Vue Account Management & API Keys (#4753)

This commit is contained in:
Buster "Silver Eagle" Neece 2021-11-02 20:38:45 -05:00 committed by GitHub
parent 0be5c9bfc1
commit e0b0fe5a7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1490 additions and 952 deletions

View File

@ -1,122 +0,0 @@
<?php
/** @var \App\Environment $environment */
$locale_select = \App\Locale::SUPPORTED_LOCALES;
$locale_select = ['default' => __('Use Browser Default')] + $locale_select;
return [
'method' => 'post',
'groups' => [
'account_info' => [
'use_grid' => true,
'elements' => [
'name' => [
'text',
[
'label' => __('Name'),
'class' => 'half-width',
'form_group_class' => 'col-md-6',
],
],
'email' => [
'text',
[
'label' => __('E-mail Address'),
'class' => 'half-width',
'required' => true,
'autocomplete' => 'off',
'form_group_class' => 'col-md-6',
],
],
],
],
'reset_password' => [
'use_grid' => true,
'legend' => __('Reset Password'),
'description' => __('Leave these fields blank to continue using your current password.'),
'elements' => [
'password' => [
'password',
[
'label' => __('Current Password'),
'autocomplete' => 'off',
'form_group_class' => 'col-md-4',
],
],
'new_password' => [
'password',
[
'label' => __('New Password'),
'autocomplete' => 'new-password',
'class' => 'strength',
'confirm' => 'new_password_confirm',
'form_group_class' => 'col-md-4',
],
],
'new_password_confirm' => [
'password',
[
'label' => __('Confirm New Password'),
'autocomplete' => 'new-password',
'form_group_class' => 'col-md-4',
],
],
],
],
'customization' => [
'use_grid' => true,
'legend' => __('Customization'),
'elements' => [
'locale' => [
'radio',
[
'label' => __('Language'),
'options' => $locale_select,
'default' => 'default',
'form_group_class' => 'col-md-6',
],
],
'theme' => [
'radio',
[
'label' => __('Site Theme'),
'choices' => [
App\Customization::THEME_BROWSER => __('Prefer System Default'),
App\Customization::THEME_LIGHT => __('Light'),
App\Customization::THEME_DARK => __('Dark'),
],
'default' => App\Customization::DEFAULT_THEME,
'form_group_class' => 'col-md-6',
],
],
],
],
'submit' => [
'elements' => [
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'btn btn-lg btn-primary',
],
],
],
],
],
];

View File

@ -1,25 +0,0 @@
<?php
return [
'method' => 'post',
'elements' => [
'otp' => [
'text',
[
'label' => __('Code from Authenticator App'),
'description' => __('Enter the current code provided by your authenticator app to verify that it\'s working correctly.'),
'class' => 'half-width',
'required' => true,
]
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Verify Authenticator'),
'class' => 'btn btn-lg btn-primary',
],
],
],
];

View File

@ -46,6 +46,36 @@ return static function (RouteCollectorProxy $app) {
->setName('api:frontend:account:me');
$group->put('/me', Controller\Api\Frontend\Account\PutMeAction::class);
$group->put('/password', Controller\Api\Frontend\Account\PutPasswordAction::class)
->setName('api:frontend:account:password');
$group->get('/two-factor', Controller\Api\Frontend\Account\GetTwoFactorAction::class)
->setName('api:frontend:account:two-factor');
$group->put('/two-factor', Controller\Api\Frontend\Account\PutTwoFactorAction::class);
$group->delete('/two-factor', Controller\Api\Frontend\Account\DeleteTwoFactorAction::class);
$group->get(
'/api-keys',
Controller\Api\Frontend\Account\ApiKeysController::class . ':listAction'
)->setName('api:frontend:api-keys');
$group->post(
'/api-keys',
Controller\Api\Frontend\Account\ApiKeysController::class . ':createAction'
);
$group->get(
'/api-key/{id}',
Controller\Api\Frontend\Account\ApiKeysController::class . ':getAction'
)->setName('api:frontend:api-key');
$group->delete(
'/api-key/{id}',
Controller\Api\Frontend\Account\ApiKeysController::class . ':deleteAction'
);
}
);

View File

@ -27,41 +27,8 @@ return static function (RouteCollectorProxy $app) {
$group->get('/profile', Controller\Frontend\Profile\IndexAction::class)
->setName('profile:index');
$group->map(['GET', 'POST'], '/profile/edit', Controller\Frontend\Profile\EditAction::class)
->setName('profile:edit');
$group->map(
['GET', 'POST'],
'/profile/2fa/enable',
Controller\Frontend\Profile\EnableTwoFactorAction::class
)
->setName('profile:2fa:enable');
$group->map(
['GET', 'POST'],
'/profile/2fa/disable',
Controller\Frontend\Profile\DisableTwoFactorAction::class
)
->setName('profile:2fa:disable');
$group->get('/profile/theme', Controller\Frontend\Profile\ThemeAction::class)
->setName('profile:theme');
$group->get('/api_keys', Controller\Frontend\ApiKeysController::class . ':indexAction')
->setName('api_keys:index');
$group->map(
['GET', 'POST'],
'/api_keys/edit/{id}',
Controller\Frontend\ApiKeysController::class . ':editAction'
)
->setName('api_keys:edit');
$group->map(['GET', 'POST'], '/api_keys/add', Controller\Frontend\ApiKeysController::class . ':editAction')
->setName('api_keys:add');
$group->get('/api_keys/delete/{id}/{csrf}', Controller\Frontend\ApiKeysController::class . ':deleteAction')
->setName('api_keys:delete');
}
)->add(Middleware\EnableView::class)
->add(Middleware\RequireLogin::class);

View File

@ -0,0 +1,256 @@
<template>
<div>
<h2 class="outside-card-header mb-1">
<translate key="hdr">My Account</translate>
</h2>
<div class="card-deck mb-3">
<section class="card" role="region">
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title">
<translate key="lang_hdr_profile">My Profile</translate>
</h2>
</b-card-header>
<b-overlay variant="card" :show="userLoading">
<b-card-body body-class="card-padding-sm">
<b-media right-align vertical-align="center">
<template v-if="user.avatar.url" #aside>
<avatar :avatar="user.avatar.url" :avatar-service-name="user.avatar.service"
:avatar-service-url="user.avatar.serviceUrl"></avatar>
</template>
<h2 v-if="user.name" class="card-title">{{ user.name }}</h2>
<h2 v-else class="card-title">
<translate key="lang_no_username">AzuraCast User</translate>
</h2>
<h3 class="card-subtitle">{{ user.email }}</h3>
<div v-if="user.roles.length > 0" class="mt-2">
<span v-for="role in user.roles" :key="role.id"
class="badge badge-secondary">{{ role.name }}</span>
</div>
</b-media>
</b-card-body>
</b-overlay>
<div class="card-actions">
<b-button variant="outline-primary" @click.prevent="doEditProfile">
<icon icon="edit"></icon>
<translate key="lang_btn_edit_profile">Edit Profile</translate>
</b-button>
</div>
</section>
<section class="card" role="region">
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title">
<translate key="lang_hdr_security">Security</translate>
</h2>
</b-card-header>
<b-overlay variant="card" :show="securityLoading">
<b-card-body>
<h5>
<translate key="lang_two_factor">Two-Factor Authentication</translate>
<span v-if="security.twoFactorEnabled" class="badge badge-success">
<translate key="lang_enabled">Enabled</translate>
</span>
<span v-else class="badge badge-danger">
<translate key="lang_disabled">Disabled</translate>
</span>
</h5>
<p class="card-text mt-2">
<translate key="lang_two_factor_info">Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.</translate>
</p>
</b-card-body>
</b-overlay>
<div class="card-actions">
<b-button variant="outline-primary" @click.prevent="doChangePassword">
<icon icon="vpn_key"></icon>
<translate key="lang_btn_change_password">Change Password</translate>
</b-button>
<b-button v-if="security.twoFactorEnabled" variant="outline-danger"
@click.prevent="disableTwoFactor">
<icon icon="lock_open"></icon>
<translate key="lang_btn_disable_two_factor">Disable Two-Factor</translate>
</b-button>
<b-button v-else variant="outline-success" @click.prevent="enableTwoFactor">
<icon icon="lock"></icon>
<translate key="lang_btn_enable_two_factor">Enable Two-Factor</translate>
</b-button>
</div>
</section>
</div>
<b-card no-body>
<b-card-header header-bg-variant="primary-dark">
<h2 class="card-title">
<translate key="lang_hdr_api_keys">API Keys</translate>
</h2>
</b-card-header>
<b-card-body body-class="card-padding-sm">
<b-button variant="outline-primary" @click.prevent="createApiKey">
<icon icon="add"></icon>
<translate key="lang_add_btn">Add API Key</translate>
</b-button>
</b-card-body>
<data-table ref="datatable" id="account_api_keys" :show-toolbar="false" :fields="apiKeyFields"
:api-url="apiKeysApiUrl">
<template #cell(actions)="row">
<b-button-group size="sm">
<b-button size="sm" variant="danger" @click.prevent="deleteApiKey(row.item.links.self)">
<translate key="lang_btn_delete">Delete</translate>
</b-button>
</b-button-group>
</template>
</data-table>
</b-card>
<account-edit-modal ref="editModal" :user-url="userUrl" :supported-locales="supportedLocales"
@relist="relist"></account-edit-modal>
<account-change-password-modal ref="changePasswordModal" :change-password-url="changePasswordUrl"
@relist="relist"></account-change-password-modal>
<account-two-factor-modal ref="twoFactorModal" :two-factor-url="twoFactorUrl"
@relist="relist"></account-two-factor-modal>
<account-api-key-modal ref="apiKeyModal" :create-url="apiKeysApiUrl" @relist="relist"></account-api-key-modal>
</div>
</template>
<script>
import Icon from "~/components/Common/Icon";
import DataTable from "~/components/Common/DataTable";
import AccountChangePasswordModal from "./Account/ChangePasswordModal";
import AccountApiKeyModal from "./Account/ApiKeyModal";
import AccountTwoFactorModal from "./Account/TwoFactorModal";
import AccountEditModal from "./Account/EditModal";
import Avatar from "~/components/Common/Avatar";
export default {
name: 'Account',
components: {
AccountEditModal,
AccountTwoFactorModal,
AccountApiKeyModal,
AccountChangePasswordModal,
Icon,
DataTable,
Avatar
},
props: {
userUrl: String,
changePasswordUrl: String,
twoFactorUrl: String,
apiKeysApiUrl: String,
supportedLocales: Object
},
data() {
return {
userLoading: true,
user: {
name: null,
email: null,
avatar: {
url: null,
service: null,
serviceUrl: null
},
roles: [],
},
securityLoading: true,
security: {
twoFactorEnabled: false,
},
apiKeyFields: [
{
key: 'comment',
isRowHeader: true,
label: this.$gettext('API Key Description/Comments'),
sortable: false
},
{key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink'}
]
}
},
mounted() {
this.relist();
},
methods: {
relist() {
this.userLoading = true;
this.$wrapWithLoading(
this.axios.get(this.userUrl)
).then((resp) => {
this.user = {
name: resp.data.name,
email: resp.data.email,
roles: resp.data.roles,
avatar: {
url: resp.data.avatar.url_128,
service: resp.data.avatar.service_name,
serviceUrl: resp.data.avatar.service_url
}
};
this.userLoading = false;
});
this.securityLoading = true;
this.$wrapWithLoading(
this.axios.get(this.twoFactorUrl)
).then((resp) => {
this.security.twoFactorEnabled = resp.data.two_factor_enabled;
this.securityLoading = false;
});
this.$refs.datatable.relist();
},
doEditProfile() {
this.$refs.editModal.open();
},
doChangePassword() {
this.$refs.changePasswordModal.open();
},
enableTwoFactor() {
this.$refs.twoFactorModal.open();
},
disableTwoFactor() {
this.$confirmDelete({
title: this.$gettext('Disable two-factor authentication?'),
}).then((result) => {
if (result.value) {
this.$wrapWithLoading(
this.axios.delete(this.twoFactorUrl)
).then((resp) => {
this.$notifySuccess(resp.data.message);
this.relist();
});
}
});
},
createApiKey() {
this.$refs.apiKeyModal.create();
},
deleteApiKey(url) {
this.$confirmDelete({
title: this.$gettext('Delete API Key?'),
}).then((result) => {
if (result.value) {
this.$wrapWithLoading(
this.axios.delete(url)
).then((resp) => {
this.$notifySuccess(resp.data.message);
this.relist();
});
}
});
}
}
}
</script>

View File

@ -0,0 +1,120 @@
<template>
<b-modal size="md centered" id="api_keys_modal" ref="modal" :title="langTitle" @shown="focusInput"
@hidden="clearContents">
<template #default="slotProps">
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
<b-form v-if="newKey === null" class="form vue-form" @submit.prevent="doSubmit">
<b-form-fieldset>
<b-wrapped-form-group ref="firstElement" id="form_comments" :field="$v.form.comment">
<template #label="{lang}">
<translate :key="lang">API Key Description/Comments</translate>
</template>
</b-wrapped-form-group>
</b-form-fieldset>
<invisible-submit-button/>
</b-form>
<div v-else>
<account-api-key-new-key :new-key="newKey"></account-api-key-new-key>
</div>
</template>
<template #modal-footer="slotProps">
<slot name="modal-footer" v-bind="slotProps">
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<b-button v-if="newKey === null" variant="primary" type="submit" @click="doSubmit"
:disabled="$v.form.$invalid">
<translate key="lang_btn_create_key">Create New Key</translate>
</b-button>
</slot>
</template>
</b-modal>
</template>
<script>
import {validationMixin} from 'vuelidate';
import {required} from 'vuelidate/dist/validators.min.js';
import BFormFieldset from "~/components/Form/BFormFieldset";
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton";
import AccountApiKeyNewKey from "./ApiKeyNewKey";
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'AccountApiKeyModal',
components: {BWrappedFormGroup, AccountApiKeyNewKey, InvisibleSubmitButton, BFormFieldset},
mixins: [validationMixin],
props: {
createUrl: String
},
validations() {
return {
form: {
comment: {required}
}
};
},
data() {
return {
error: null,
form: {},
newKey: null,
}
},
computed: {
langTitle() {
return this.$gettext('Add API Key');
}
},
methods: {
focusInput() {
this.$refs.firstElement.focus();
},
create() {
this.resetForm();
this.error = null;
this.$refs.modal.show();
},
resetForm() {
this.newKey = null;
this.form = {
comment: ''
};
},
doSubmit() {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
this.error = null;
this.$wrapWithLoading(
this.axios({
method: 'POST',
url: this.createUrl,
data: this.form
})
).then((resp) => {
this.newKey = resp.data.key;
this.$emit('relist');
}).catch((error) => {
this.error = error.response.data.message;
});
},
close() {
this.$refs.modal.hide();
},
clearContents() {
this.$v.form.$reset();
this.error = null;
this.resetForm();
},
}
};
</script>

View File

@ -0,0 +1,45 @@
<template>
<div>
<h3 class="card-subtitle">
<translate key="lang_hdr_new_key">New Key Generated</translate>
</h3>
<p class="card-text">
<b>
<translate key="new_api_key_1">Important: copy the key below before continuing!</translate>
</b>
<translate key="new_api_key_2">You will not be able to retrieve it again.</translate>
</p>
<p class="card-text">
<translate key="new_api_key_3">Your full API key is below:</translate>
</p>
<div class="well">
<code id="api_key">{{ newKey }}</code>
<div class="buttons">
<copy-to-clipboard-button :text="newKey"></copy-to-clipboard-button>
</div>
</div>
<p class="card-text">
<translate key="new_api_key_4">When making API calls, you can pass this value in the "X-API-Key" header to authenticate as yourself.</translate>
</p>
<p class="card-text">
<translate
key="new_api_key_5">You can only perform the actions your user account is allowed to perform.</translate>
</p>
</div>
</template>
<script>
import CopyToClipboardButton from "~/components/Common/CopyToClipboardButton";
export default {
name: 'AccountApiKeyNewKey',
components: {CopyToClipboardButton},
props: {
newKey: String
}
}
</script>

View File

@ -0,0 +1,98 @@
<template>
<modal-form ref="modal" size="md" centered :title="langTitle" :disable-save-button="$v.form.$invalid"
@submit="onSubmit" @shown="focusInput" @hidden="onHidden">
<b-form-fieldset>
<b-wrapped-form-group ref="firstElement" id="form_current_password" :field="$v.form.current_password"
input-type="password">
<template #label="{lang}">
<translate :key="lang">Current Password</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group id="form_new_password" :field="$v.form.new_password" input-type="password">
<template #label="{lang}">
<translate :key="lang">New Password</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group id="form_current_password" :field="$v.form.new_password2" input-type="password">
<template #label="{lang}">
<translate :key="lang">Confirm New Password</translate>
</template>
</b-wrapped-form-group>
</b-form-fieldset>
<template #save-button-name>
{{ langTitle }}
</template>
</modal-form>
</template>
<script>
import {validationMixin} from 'vuelidate';
import {required, sameAs} from 'vuelidate/dist/validators.min.js';
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import ModalForm from "~/components/Common/ModalForm";
import BFormFieldset from "~/components/Form/BFormFieldset";
import validatePassword from "~/functions/validatePassword";
export default {
name: 'AccountChangePasswordModal',
components: {ModalForm, BWrappedFormGroup, BFormFieldset},
emits: ['relist'],
mixins: [validationMixin],
props: {
changePasswordUrl: String
},
data() {
return {
form: {
current_password: null,
new_password: null,
new_password2: null
}
};
},
validations: {
form: {
current_password: {required},
new_password: {required, validatePassword},
new_password2: {
required,
sameAs: sameAs('new_password')
}
}
},
computed: {
langTitle() {
return this.$gettext('Change Password');
}
},
methods: {
focusInput() {
this.$refs.firstElement.focus();
},
open() {
this.$refs.modal.show();
},
close() {
this.$refs.modal.hide();
},
onHidden() {
this.$v.form.$reset();
},
onSubmit() {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
this.$wrapWithLoading(
this.axios.put(this.changePasswordUrl, this.form)
).finally(() => {
this.$refs.modal.hide();
this.$emit('relist');
});
}
}
};
</script>

View File

@ -0,0 +1,93 @@
<template>
<div>
<b-form-fieldset>
<b-form-row>
<b-wrapped-form-group class="col-md-6" id="form_name" :field="form.name">
<template #label="{lang}">
<translate :key="lang">Name</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_email" :field="form.email">
<template #label="{lang}">
<translate :key="lang">E-mail Address</translate>
</template>
</b-wrapped-form-group>
</b-form-row>
</b-form-fieldset>
<b-form-fieldset>
<template #label>
<translate key="lang_hdr_customize">Customization</translate>
</template>
<b-form-row>
<b-wrapped-form-group class="col-md-6" id="edit_form_locale"
:field="form.locale">
<template #label="{lang}">
<translate :key="lang">Language</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="localeOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="edit_form_theme"
:field="form.theme">
<template #label="{lang}">
<translate :key="lang">Site Theme</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="themeOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-form-row>
</b-form-fieldset>
</div>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import BFormFieldset from "~/components/Form/BFormFieldset";
import objectToFormOptions from "~/functions/objectToFormOptions";
export default {
name: 'AccountEditForm',
props: {
form: Object,
supportedLocales: Object
},
components: {BFormFieldset, BWrappedFormGroup},
computed: {
localeOptions() {
let localeOptions = objectToFormOptions(this.supportedLocales);
localeOptions.unshift({
text: this.$gettext('Use Browser Default'),
value: 'default'
});
return localeOptions;
},
themeOptions() {
return [
{
text: this.$gettext('Prefer System Default'),
value: 'browser'
},
{
text: this.$gettext('Light'),
value: 'light'
},
{
text: this.$gettext('Dark'),
value: 'dark'
}
];
}
}
}
</script>

View File

@ -0,0 +1,109 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit" @hidden="clearContents">
<account-edit-form :form="$v.form" :supported-locales="supportedLocales"></account-edit-form>
</modal-form>
</template>
<script>
import ModalForm from "~/components/Common/ModalForm";
import {validationMixin} from "vuelidate";
import {email, required} from 'vuelidate/dist/validators.min.js';
import AccountEditForm from "./EditForm";
import mergeExisting from "~/functions/mergeExisting";
export default {
name: 'AccountEditModal',
components: {AccountEditForm, ModalForm,},
mixins: [validationMixin],
emits: ['relist'],
props: {
userUrl: String,
supportedLocales: Object
},
data() {
return {
loading: true,
error: null,
form: {}
};
},
validations() {
return {
form: {
name: {},
email: {required, email},
locale: {required},
theme: {required}
}
};
},
computed: {
langTitle() {
return this.$gettext('Edit Profile');
}
},
methods: {
resetForm() {
this.form = {
name: '',
email: '',
locale: 'default',
theme: 'browser'
};
},
open() {
this.resetForm();
this.loading = false;
this.error = null;
this.$refs.modal.show();
this.$wrapWithLoading(
this.axios.get(this.userUrl)
).then((resp) => {
this.form = mergeExisting(this.form, resp.data);
this.loading = false;
}).catch((error) => {
this.close();
});
},
doSubmit() {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
this.error = null;
this.$wrapWithLoading(
this.axios({
method: 'PUT',
url: this.userUrl,
data: this.form
})
).then((resp) => {
this.$notifySuccess();
this.$emit('relist');
this.close();
}).catch((error) => {
this.error = error.response.data.message;
});
},
close() {
this.$refs.modal.hide();
},
clearContents() {
this.$v.form.$reset();
this.loading = false;
this.error = null;
this.resetForm();
},
}
}
</script>

View File

@ -0,0 +1,166 @@
<template>
<modal-form ref="modal" :loading="loading" :title="langTitle" :error="error" :disable-save-button="$v.form.$invalid"
@submit="doSubmit" @shown="focusInput" @hidden="clearContents">
<b-row>
<b-col md="7">
<h5 class="mt-2">
<translate key="lang_2fa_hdr_1">Step 1: Scan QR Code</translate>
</h5>
<p class="card-text">
<translate key="lang_2fa_1">From your smartphone, scan the code to the right using an authentication app of your choice (FreeOTP, Authy, etc).</translate>
</p>
<h5 class="mt-0">
<translate key="lang_2fa_hdr_2">Step 2: Verify Generated Code</translate>
</h5>
<p class="card-text">
<translate key="lang_2fa_2">To verify that the code was set up correctly, enter the 6-digit code the app shows you.</translate>
</p>
<b-form-fieldset>
<b-wrapped-form-group ref="firstElement" id="form_otp" :field="$v.form.otp">
<template #label="{lang}">
<translate :key="lang">Code from Authenticator App</translate>
</template>
<template #description="{lang}">
<translate :key="lang">Enter the current code provided by your authenticator app to verify that it's working correctly.</translate>
</template>
</b-wrapped-form-group>
</b-form-fieldset>
</b-col>
<b-col md="5">
<b-img :src="totp.qr_code"></b-img>
<div v-if="totp.totp_uri" class="mt-2">
<code id="totp_uri" class="d-inline-block text-truncate" style="width: 100%;">
{{ totp.totp_uri }}
</code>
<copy-to-clipboard-button :text="totp.totp_uri"></copy-to-clipboard-button>
</div>
</b-col>
</b-row>
<template #save-button-name>
<translate key="lang_btn_submit">Submit Code</translate>
</template>
</modal-form>
</template>
<script>
import ModalForm from "~/components/Common/ModalForm";
import {validationMixin} from "vuelidate";
import {minLength, required} from 'vuelidate/dist/validators.min.js';
import CopyToClipboardButton from "~/components/Common/CopyToClipboardButton";
import BFormFieldset from "~/components/Form/BFormFieldset";
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
export default {
name: 'AccountTwoFactorModal',
components: {ModalForm, CopyToClipboardButton, BFormFieldset, BWrappedFormGroup},
mixins: [validationMixin],
emits: ['relist'],
props: {
twoFactorUrl: String
},
data() {
return {
loading: true,
error: null,
totp: {
secret: null,
totp_uri: null,
qr_code: null
},
form: {
otp: null
}
};
},
validations() {
return {
form: {
otp: {
required,
minLength: minLength(6)
}
}
};
},
computed: {
langTitle() {
return this.$gettext('Enable Two-Factor Authentication');
}
},
methods: {
focusInput() {
this.$refs.firstElement.focus();
},
resetForm() {
this.totp = {
secret: null,
totp_uri: null,
qr_code: null
};
this.form = {
otp: '',
};
},
open() {
this.resetForm();
this.loading = false;
this.error = null;
this.$refs.modal.show();
this.$wrapWithLoading(
this.axios.put(this.twoFactorUrl)
).then((resp) => {
this.totp = resp.data;
this.loading = false;
}).catch((error) => {
this.close();
});
},
doSubmit() {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
}
this.error = null;
this.$wrapWithLoading(
this.axios({
method: 'PUT',
url: this.twoFactorUrl,
data: {
secret: this.totp.secret,
otp: this.form.otp
}
})
).then((resp) => {
this.$notifySuccess();
this.$emit('relist');
this.close();
}).catch((error) => {
this.error = error.response.data.message;
});
},
close() {
this.$refs.modal.hide();
},
clearContents() {
this.$v.form.$reset();
this.loading = false;
this.error = null;
this.resetForm();
},
}
}
</script>

View File

@ -9,7 +9,11 @@ export const avatarProps = {
props: {
avatar: String,
avatarServiceUrl: String,
avatarServiceName: String
avatarServiceName: String,
width: {
type: Number,
default: 128
}
}
};

View File

@ -83,7 +83,15 @@ export default {
data: this.getSubmittableFormData()
};
},
doSubmit () {
onSubmitSuccess(response) {
this.$notifySuccess();
this.$emit('relist');
this.close();
},
onSubmitError(error) {
this.error = error.response.data.message;
},
doSubmit() {
this.$v.form.$touch();
if (this.$v.form.$anyError) {
return;
@ -94,11 +102,9 @@ export default {
this.$wrapWithLoading(
this.axios(this.buildSubmitRequest())
).then((resp) => {
this.$notifySuccess();
this.$emit('relist');
this.close();
this.onSubmitSuccess(resp);
}).catch((error) => {
this.error = error.response.data.message;
this.onSubmitError(error);
});
},
close() {

View File

@ -1,5 +1,6 @@
<template>
<b-modal :size="size" :id="id" ref="modal" :title="title" :busy="loading" @hidden="onHidden">
<b-modal :size="size" :centered="centered" :id="id" ref="modal" :title="title" :busy="loading" @shown="onShown"
@hidden="onHidden">
<template #default="slotProps">
<b-overlay variant="card" :show="loading">
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
@ -19,7 +20,9 @@
<translate key="lang_btn_close">Close</translate>
</b-button>
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="disableSaveButton">
<translate key="lang_btn_save_changes">Save Changes</translate>
<slot name="save-button-name">
<translate key="lang_btn_save_changes">Save Changes</translate>
</slot>
</b-button>
</slot>
</template>
@ -36,7 +39,7 @@ import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton";
export default {
components: {InvisibleSubmitButton},
emits: ['submit', 'hidden'],
emits: ['submit', 'shown', 'hidden'],
props: {
title: {
type: String,
@ -46,6 +49,10 @@ export default {
type: String,
default: 'lg'
},
centered: {
type: Boolean,
default: false
},
id: {
type: String,
default: 'edit-modal'
@ -75,6 +82,9 @@ export default {
doSubmit() {
this.$emit('submit');
},
onShown() {
this.$emit('shown');
},
onHidden() {
this.$emit('hidden');
},

View File

@ -2,10 +2,11 @@
<b-form-group v-bind="$attrs" :label-for="id" :state="fieldState">
<template #default>
<slot name="default" v-bind="{ id, field, state: fieldState }">
<b-form-textarea v-if="inputType === 'textarea'" :id="id" :name="name" v-model="field.$model"
<b-form-textarea v-if="inputType === 'textarea'" ref="input" :id="id" :name="name"
v-model="field.$model"
:required="isRequired" :number="isNumeric" :trim="inputTrim" v-bind="inputAttrs"
:state="fieldState"></b-form-textarea>
<b-form-input v-else :type="inputType" :id="id" :name="name" v-model="field.$model"
<b-form-input v-else ref="input" :type="inputType" :id="id" :name="name" v-model="field.$model"
:required="isRequired" :number="isNumeric" :trim="inputTrim"
v-bind="inputAttrs" :state="fieldState"></b-form-input>
</slot>
@ -95,6 +96,13 @@ export default {
isNumeric() {
return this.inputNumber || this.inputType === "number";
}
},
methods: {
focus() {
if (typeof this.$refs.input !== "undefined") {
this.$refs.input.focus();
}
}
}
}
</script>

View File

@ -0,0 +1,8 @@
import initBase from '~/base.js';
import '~/vendor/bootstrapVue.js';
import '~/vendor/sweetalert.js';
import Account from '~/components/Account';
export default initBase(Account);

View File

@ -6,6 +6,7 @@ const path = require('path');
module.exports = {
mode: (process.env.NODE_ENV === 'production') ? 'production' : 'development',
entry: {
Account: '~/pages/Account.js',
Dashboard: '~/pages/Dashboard.js',
AdminAuditLog: '~/pages/Admin/AuditLog.js',
AdminBranding: '~/pages/Admin/Branding.js',

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\AbstractApiCrudController;
use App\Entity;
use App\Exception;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Security\SplitToken;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* @template TEntity as Entity\ApiKey
* @extends AbstractApiCrudController<TEntity>
*/
class ApiKeysController extends AbstractApiCrudController
{
protected string $entityClass = Entity\ApiKey::class;
protected string $resourceRouteName = 'api:frontend:api-key';
public function listAction(ServerRequest $request, Response $response): ResponseInterface
{
$query = $this->em->createQuery(<<<'DQL'
SELECT e FROM App\Entity\ApiKey e WHERE e.user = :user
DQL)->setParameter('user', $request->getUser());
return $this->listPaginatedFromQuery($request, $response, $query);
}
/**
* @param ServerRequest $request
* @param Response $response
*
* @throws Exception
*/
public function createAction(ServerRequest $request, Response $response): ResponseInterface
{
$newKey = SplitToken::generate();
$record = new Entity\ApiKey(
$request->getUser(),
$newKey
);
/** @var TEntity $record */
$this->editRecord((array)$request->getParsedBody(), $record);
$return = $this->viewRecord($record, $request);
$return['key'] = (string)$newKey;
return $response->withJson($return);
}
/**
* @param ServerRequest $request
* @param Response $response
* @param mixed $id
*/
public function getAction(ServerRequest $request, Response $response, mixed $id): ResponseInterface
{
$record = $this->getRecord($request->getUser(), $id);
if (null === $record) {
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
$return = $this->viewRecord($record, $request);
return $response->withJson($return);
}
/**
* @param ServerRequest $request
* @param Response $response
* @param mixed $id
*/
public function deleteAction(ServerRequest $request, Response $response, mixed $id): ResponseInterface
{
$record = $this->getRecord($request->getUser(), $id);
if (null === $record) {
return $response->withStatus(404)
->withJson(Entity\Api\Error::notFound());
}
$this->deleteRecord($record);
return $response->withJson(Entity\Api\Status::deleted());
}
/**
* @param string $id
*
* @return TEntity|null
*/
protected function getRecord(Entity\User $user, string $id): ?object
{
/** @var TEntity|null $record */
$record = $this->em->getRepository(Entity\ApiKey::class)->findOneBy([
'id' => $id,
'user' => $user,
]);
return $record;
}
/**
* @inheritDoc
*/
protected function editRecord(?array $data, ?object $record = null, array $context = []): object
{
$context[AbstractNormalizer::GROUPS] = [
Entity\Interfaces\EntityGroupsInterface::GROUP_GENERAL,
];
return parent::editRecord($data, $record, $context);
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\Admin\UsersController;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DeleteTwoFactorAction extends UsersController
{
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$user = $request->getUser();
$user = $this->em->refetch($user);
$user->setTwoFactorSecret(null);
$this->em->persist($user);
$this->em->flush();
return $response->withJson(Entity\Api\Status::updated());
}
}

View File

@ -5,19 +5,50 @@ declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\Admin\UsersController;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Avatar;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class GetMeAction extends UsersController
{
/**
* @param ServerRequest $request
* @param Response $response
*/
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
public function __invoke(
ServerRequest $request,
Response $response,
Avatar $avatar
): ResponseInterface {
$user = $request->getUser();
return $response->withJson($this->viewRecord($user, $request));
$user = $this->em->refetch($user);
$return = $this->toArray($user, [
AbstractNormalizer::GROUPS => [
EntityGroupsInterface::GROUP_ID,
EntityGroupsInterface::GROUP_GENERAL,
],
]);
// Avatars
$avatarService = $avatar->getAvatarService();
$email = $user->getEmail();
$return['avatar'] = [
'url_32' => $avatar->getAvatar($email, 32),
'url_64' => $avatar->getAvatar($email, 64),
'url_128' => $avatar->getAvatar($email, 128),
'service_name' => $avatarService->getServiceName(),
'service_url' => $avatarService->getServiceUrl(),
];
foreach ($user->getRoles() as $role) {
$return['roles'][] = [
'id' => $role->getIdRequired(),
'name' => $role->getName(),
];
}
return $response->withJson($return);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\Admin\UsersController;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class GetTwoFactorAction extends UsersController
{
public function __invoke(
ServerRequest $request,
Response $response
): ResponseInterface {
$user = $request->getUser();
return $response->withJson([
'two_factor_enabled' => !empty($user->getTwoFactorSecret()),
]);
}
}

View File

@ -6,20 +6,29 @@ namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\Admin\UsersController;
use App\Entity;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class PutMeAction extends UsersController
{
/**
* @param ServerRequest $request
* @param Response $response
*/
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$user = $request->getUser();
$this->editRecord((array)$request->getParsedBody(), $user);
$user = $this->em->refetch($user);
$this->editRecord(
(array)$request->getParsedBody(),
$user,
[
AbstractNormalizer::GROUPS => [
EntityGroupsInterface::GROUP_ID,
EntityGroupsInterface::GROUP_GENERAL,
],
]
);
return $response->withJson(Entity\Api\Status::updated());
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Controller\Api\Admin\UsersController;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class PutPasswordAction extends UsersController
{
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$user = $request->getUser();
$body = (array)$request->getParsedBody();
try {
if (empty($body['current_password'])) {
throw new \InvalidArgumentException('Current password not provided (current_password).');
}
$currentPassword = $body['current_password'];
if (!$user->verifyPassword($currentPassword)) {
throw new \InvalidArgumentException('Invalid current password.');
}
if (empty($body['new_password'])) {
throw new \InvalidArgumentException('New password not provided (new_password).');
}
$user = $this->em->refetch($user);
$user->setNewPassword($body['new_password']);
$this->em->persist($user);
$this->em->flush();
return $response->withJson(Entity\Api\Status::updated());
} catch (\Throwable $e) {
return $response->withStatus(400)->withJson(Entity\Api\Error::fromException($e));
}
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Frontend\Account;
use App\Auth;
use App\Controller\Api\Admin\UsersController;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use BaconQrCode;
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;
use Psr\Http\Message\ResponseInterface;
class PutTwoFactorAction extends UsersController
{
public function __invoke(
ServerRequest $request,
Response $response
): ResponseInterface {
$params = (array)$request->getParsedBody();
try {
if (!empty($params['secret'])) {
$secret = $params['secret'];
if (64 !== strlen($secret)) {
throw new \InvalidArgumentException('Secret is not the correct length.');
}
} else {
// Generate new TOTP secret.
$secret = substr(trim(Base32::encodeUpper(random_bytes(128)), '='), 0, 64);
}
// Customize TOTP code
$user = $request->getUser();
$totp = TOTP::create($secret);
$totp->setLabel($user->getEmail());
if (!empty($params['otp'])) {
$otp = $params['otp'];
if ($totp->verify($otp, null, Auth::TOTP_WINDOW)) {
$user = $this->em->refetch($user);
$user->setTwoFactorSecret($totp->getProvisioningUri());
$this->em->persist($user);
$this->em->flush();
return $response->withJson(Entity\Api\Status::success());
}
throw new \InvalidArgumentException('Could not verify TOTP code.');
}
// Further customize TOTP code (with metadata that won't be stored in the DB)
$totp->setIssuer('AzuraCast');
$totp->setParameter('image', 'https://www.azuracast.com/img/logo.png');
// Generate QR code
$totp_uri = $totp->getProvisioningUri();
$renderer = new BaconQrCode\Renderer\ImageRenderer(
new BaconQrCode\Renderer\RendererStyle\RendererStyle(300),
new BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$qrCodeImage = (new BaconQrCode\Writer($renderer))->writeString($totp_uri);
$qrCodeBase64 = 'data:image/svg+xml;base64,' . base64_encode($qrCodeImage);
return $response->withJson([
'secret' => $secret,
'totp_uri' => $totp_uri,
'qr_code' => $qrCodeBase64,
]);
} catch (\Throwable $e) {
return $response->withStatus(400)->withJson(Entity\Api\Error::fromException($e));
}
}
}

View File

@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations;
use App\Acl;
use App\Controller\Api\Admin\StationsController;
use App\Entity;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
@ -44,12 +45,13 @@ class ProfileEditController extends StationsController
{
$context = [
AbstractNormalizer::GROUPS => [
Entity\Station::GROUP_GENERAL,
EntityGroupsInterface::GROUP_ID,
EntityGroupsInterface::GROUP_GENERAL,
],
];
if ($request->getAcl()->isAllowed(Acl::GLOBAL_STATIONS)) {
$context[AbstractNormalizer::GROUPS][] = Entity\Station::GROUP_ADMIN;
$context[AbstractNormalizer::GROUPS][] = EntityGroupsInterface::GROUP_ALL;
}
return $context;

View File

@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend;
use App\Config;
use App\Entity;
use App\Exception\NotFoundException;
use App\Form\Form;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Security\SplitToken;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class ApiKeysController
{
protected string $csrf_namespace = 'frontend_api_keys';
protected array $form_config;
public function __construct(
protected EntityManagerInterface $em,
protected Entity\Repository\ApiKeyRepository $record_repo,
Config $config
) {
$this->form_config = $config->get('forms/api_key');
}
public function indexAction(ServerRequest $request, Response $response): ResponseInterface
{
$user = $request->getUser();
return $request->getView()->renderToResponse($response, 'frontend/api_keys/index', [
'records' => $user->getApiKeys(),
'csrf' => $request->getCsrf()->generate($this->csrf_namespace),
]);
}
public function editAction(ServerRequest $request, Response $response, string $id = null): ResponseInterface
{
$user = $request->getUser();
$view = $request->getView();
$form = new Form($this->form_config);
if (!empty($id)) {
$new_record = false;
$record = $this->record_repo->getRepository()->findOneBy(['id' => $id, 'user_id' => $user->getId()]);
if (!($record instanceof Entity\ApiKey)) {
throw new NotFoundException(__('API Key not found.'));
}
$form->populate($this->record_repo->toArray($record, true, true));
} else {
$new_record = true;
$record = null;
}
if ($form->isValid($request)) {
$data = $form->getValues();
$key = null;
if ($new_record) {
$key = SplitToken::generate();
$record = new Entity\ApiKey($user, $key);
}
$this->record_repo->fromArray($record, $data);
$this->em->persist($record);
$this->em->flush();
$this->em->refresh($user);
// Render one-time display
if ($new_record) {
return $view->renderToResponse($response, 'frontend/api_keys/new_key', [
'key' => (string)$key,
]);
}
$request->getFlash()->addMessage(__('API Key updated.'), 'green');
return $response->withRedirect((string)$request->getRouter()->named('api_keys:index'));
}
return $view->renderToResponse(
$response,
'system/form_page',
[
'form' => $form,
'render_mode' => 'edit',
'title' => $id ? __('Edit API Key') : __('Add API Key'),
]
);
}
public function deleteAction(
ServerRequest $request,
Response $response,
string $id,
string $csrf
): ResponseInterface {
$request->getCsrf()->verify($csrf, $this->csrf_namespace);
/** @var Entity\User $user */
$user = $request->getAttribute('user');
$record = $this->record_repo->getRepository()->findOneBy(['id' => $id, 'user' => $user]);
if ($record instanceof Entity\ApiKey) {
$this->em->remove($record);
}
$this->em->flush();
$this->em->refresh($user);
$request->getFlash()->addMessage(__('API Key deleted.'), 'green');
return $response->withRedirect((string)$request->getRouter()->named('api_keys:index'));
}
}

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\Profile;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class DisableTwoFactorAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em
): ResponseInterface {
$user = $request->getUser();
$user->setTwoFactorSecret();
$em->persist($user);
$em->flush();
$request->getFlash()->addMessage(__('Two-factor authentication disabled.'), Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->named('profile:index'));
}
}

View File

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\Profile;
use App\Form\UserProfileForm;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use DI\FactoryInterface;
use Psr\Http\Message\ResponseInterface;
class EditAction
{
public function __invoke(
ServerRequest $request,
Response $response,
FactoryInterface $factory
): ResponseInterface {
$userProfileForm = $factory->make(UserProfileForm::class);
if ($userProfileForm->process($request)) {
$request->getFlash()->addMessage(__('Profile saved!'), Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->named('profile:index'));
}
return $request->getView()->renderToResponse(
$response,
'system/form_page',
[
'form' => $userProfileForm,
'render_mode' => 'edit',
'title' => __('Edit Profile'),
]
);
}
}

View File

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Frontend\Profile;
use App\Auth;
use App\Config;
use App\Form\Form;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use AzuraForms\Field\AbstractField;
use BaconQrCode;
use Doctrine\ORM\EntityManagerInterface;
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;
use Psr\Http\Message\ResponseInterface;
class EnableTwoFactorAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Config $config,
EntityManagerInterface $em
): ResponseInterface {
$twoFactorFormConfig = $config->get('forms/profile_two_factor');
$user = $request->getUser();
$form = new Form($twoFactorFormConfig);
$session = $request->getSession();
if ($request->isPost()) {
$secret = $session->get('totp_secret');
} else {
// Generate new TOTP secret.
$secret = substr(trim(Base32::encodeUpper(random_bytes(128)), '='), 0, 64);
$session->set('totp_secret', $secret);
}
// Customize TOTP code
$totp = TOTP::create($secret);
$totp->setLabel($user->getEmail());
$form->getField('otp')->addValidator(function ($otp, AbstractField $element) use ($totp) {
return ($totp->verify($otp, null, Auth::TOTP_WINDOW))
? true
: __('The token you supplied is invalid. Please try again.');
});
if ($form->isValid($request)) {
$user->setTwoFactorSecret($totp->getProvisioningUri());
$em->persist($user);
$em->flush();
$request->getFlash()->addMessage(__('Two-factor authentication enabled.'), Flash::SUCCESS);
return $response->withRedirect((string)$request->getRouter()->named('profile:index'));
}
// Further customize TOTP code (with metadata that won't be stored in the DB)
$totp->setIssuer('AzuraCast');
$totp->setParameter('image', 'https://www.azuracast.com/img/logo.png');
// Generate QR code
$totp_uri = $totp->getProvisioningUri();
$renderer = new BaconQrCode\Renderer\ImageRenderer(
new BaconQrCode\Renderer\RendererStyle\RendererStyle(300),
new BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$qr_code = (new BaconQrCode\Writer($renderer))->writeString($totp_uri);
return $request->getView()->renderToResponse($response, 'frontend/profile/enable_two_factor', [
'form' => $form,
'qr_code' => $qr_code,
'totp_uri' => $totp_uri,
]);
}
}

View File

@ -4,34 +4,30 @@ declare(strict_types=1);
namespace App\Controller\Frontend\Profile;
use App\Form\UserProfileForm;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Avatar;
use DI\FactoryInterface;
use App\Locale;
use Psr\Http\Message\ResponseInterface;
class IndexAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Avatar $avatar,
FactoryInterface $factory
Response $response
): ResponseInterface {
// Avatars
$avatarService = $avatar->getAvatarService();
$router = $request->getRouter();
$userProfileForm = $factory->make(UserProfileForm::class);
return $request->getView()->renderToResponse(
$response,
'frontend/profile/index',
[
'user' => $request->getUser(),
'avatar' => $avatar->getAvatar($request->getUser()->getEmail(), 64),
'avatarServiceUrl' => $avatarService->getServiceUrl(),
'profileView' => $userProfileForm->getView($request),
return $request->getView()->renderVuePage(
response: $response,
component: 'Vue_Account',
id: 'account',
title: __('My Account'),
props: [
'userUrl' => (string)$router->named('api:frontend:account:me'),
'changePasswordUrl' => (string)$router->named('api:frontend:account:password'),
'twoFactorUrl' => (string)$router->named('api:frontend:account:two-factor'),
'apiKeysApiUrl' => (string)$router->named('api:frontend:api-keys'),
'supportedLocales' => Locale::SUPPORTED_LOCALES,
]
);
}

View File

@ -4,26 +4,31 @@ declare(strict_types=1);
namespace App\Entity;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Security\SplitToken;
use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Stringable;
use Symfony\Component\Serializer\Annotation\Groups;
#[
Attributes\Auditable,
ORM\Table(name: 'api_keys'),
ORM\Entity(readOnly: true)
]
class ApiKey implements JsonSerializable, Stringable
class ApiKey implements JsonSerializable, Stringable, IdentifiableEntityInterface
{
use Traits\HasSplitTokenFields;
use Traits\TruncateStrings;
#[ORM\ManyToOne(targetEntity: User::class, fetch: 'EAGER', inversedBy: 'api_keys')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected User $user;
#[ORM\Column(length: 255, nullable: false)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected string $comment = '';
public function __construct(User $user, SplitToken $token)
@ -53,7 +58,7 @@ class ApiKey implements JsonSerializable, Stringable
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'id' => $this->id,
'comment' => $this->comment,
];
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Entity\Interfaces;
interface EntityGroupsInterface
{
public const GROUP_ID = 'id';
public const GROUP_GENERAL = 'general';
public const GROUP_ADMIN = 'admin';
public const GROUP_ALL = 'all';
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Environment;
use App\Normalizer\Attributes\DeepNormalize;
@ -38,8 +39,6 @@ class Station implements Stringable, IdentifiableEntityInterface
use Traits\TruncateStrings;
// Taxonomical groups for permission-based serialization.
public const GROUP_GENERAL = 'general';
public const GROUP_ADMIN = 'admin';
public const GROUP_AUTOMATION = 'automation';
/**
@ -50,7 +49,7 @@ class Station implements Stringable, IdentifiableEntityInterface
*/
#[ORM\Column(length: 100, nullable: false)]
#[Assert\NotBlank]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected string $name = '';
/**
@ -61,7 +60,7 @@ class Station implements Stringable, IdentifiableEntityInterface
*/
#[ORM\Column(length: 100, nullable: false)]
#[Assert\NotBlank]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected string $short_name = '';
/**
@ -71,7 +70,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_ADMIN)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected bool $is_enabled = true;
/**
@ -82,7 +81,7 @@ class Station implements Stringable, IdentifiableEntityInterface
*/
#[ORM\Column(length: 100, nullable: true)]
#[Assert\Choice(choices: [Adapters::FRONTEND_ICECAST, Adapters::FRONTEND_REMOTE, Adapters::FRONTEND_SHOUTCAST])]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $frontend_type = Adapters::FRONTEND_ICECAST;
/**
@ -93,7 +92,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column(type: 'json', nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?array $frontend_config = null;
/**
@ -104,7 +103,7 @@ class Station implements Stringable, IdentifiableEntityInterface
*/
#[ORM\Column(length: 100, nullable: true)]
#[Assert\Choice(choices: [Adapters::BACKEND_LIQUIDSOAP, Adapters::BACKEND_NONE])]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $backend_type = Adapters::BACKEND_LIQUIDSOAP;
/**
@ -115,7 +114,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column(type: 'json', nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?array $backend_config = null;
#[ORM\Column(length: 150, nullable: true)]
@ -124,22 +123,22 @@ class Station implements Stringable, IdentifiableEntityInterface
/** @OA\Property(example="A sample radio station.") */
#[ORM\Column(type: 'text', nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $description = null;
/** @OA\Property(example="https://demo.azuracast.com/") */
#[ORM\Column(length: 255, nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $url = null;
/** @OA\Property(example="Various") */
#[ORM\Column(length: 150, nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $genre = null;
/** @OA\Property(example="/var/azuracast/stations/azuratest_radio") */
#[ORM\Column(length: 255, nullable: true)]
#[Serializer\Groups(self::GROUP_ADMIN)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?string $radio_base_dir = null;
#[ORM\Column(type: 'array', nullable: true)]
@ -152,12 +151,12 @@ class Station implements Stringable, IdentifiableEntityInterface
/** @OA\Property(type="array", @OA\Items()) */
#[ORM\Column(type: 'json', nullable: true)]
#[Serializer\Groups(self::GROUP_AUTOMATION)]
#[Serializer\Groups([self::GROUP_AUTOMATION, EntityGroupsInterface::GROUP_ALL])]
protected ?array $automation_settings = null;
#[ORM\Column(nullable: true)]
#[Attributes\AuditIgnore]
#[Serializer\Groups(self::GROUP_AUTOMATION)]
#[Serializer\Groups([self::GROUP_AUTOMATION, EntityGroupsInterface::GROUP_ALL])]
protected ?int $automation_timestamp = 0;
/**
@ -167,22 +166,22 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected bool $enable_requests = false;
/** @OA\Property(example=5) */
#[ORM\Column(nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?int $request_delay = 5;
/** @OA\Property(example=15) */
#[ORM\Column(nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?int $request_threshold = 15;
/** @OA\Property(example=0) */
#[ORM\Column(nullable: true, options: ['default' => 0])]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?int $disconnect_deactivate_streamer = 0;
/**
@ -192,7 +191,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected bool $enable_streamers = false;
/**
@ -212,7 +211,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected bool $enable_public_page = true;
/**
@ -222,7 +221,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected bool $enable_on_demand = false;
/**
@ -232,7 +231,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected bool $enable_on_demand_download = true;
#[ORM\Column]
@ -250,7 +249,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column(type: 'smallint')]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected int $api_history_items = 5;
/**
@ -260,7 +259,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column(length: 100, nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $timezone = 'UTC';
/**
@ -270,7 +269,7 @@ class Station implements Stringable, IdentifiableEntityInterface
* )
*/
#[ORM\Column(length: 255, nullable: true)]
#[Serializer\Groups(self::GROUP_GENERAL)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $default_album_art_url = null;
#[ORM\OneToMany(mappedBy: 'station', targetEntity: SongHistory::class)]
@ -278,7 +277,7 @@ class Station implements Stringable, IdentifiableEntityInterface
protected Collection $history;
#[ORM\Column(nullable: true)]
#[Serializer\Groups(self::GROUP_ADMIN)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?int $media_storage_location_id = null;
#[ORM\ManyToOne]
@ -293,7 +292,7 @@ class Station implements Stringable, IdentifiableEntityInterface
protected ?StorageLocation $media_storage_location = null;
#[ORM\Column(nullable: true)]
#[Serializer\Groups(self::GROUP_ADMIN)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?int $recordings_storage_location_id = null;
#[ORM\ManyToOne]
@ -308,7 +307,7 @@ class Station implements Stringable, IdentifiableEntityInterface
protected ?StorageLocation $recordings_storage_location = null;
#[ORM\Column(nullable: true)]
#[Serializer\Groups(self::GROUP_ADMIN)]
#[Serializer\Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?int $podcasts_storage_location_id = null;
#[ORM\ManyToOne]

View File

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Entity\Traits;
use App\Entity\Interfaces\EntityGroupsInterface;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Annotations as OA;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @OA\Schema(type="object")
@ -15,6 +17,7 @@ trait HasAutoIncrementId
/** @OA\Property() */
#[ORM\Column(nullable: false)]
#[ORM\Id, ORM\GeneratedValue]
#[Groups([EntityGroupsInterface::GROUP_ID, EntityGroupsInterface::GROUP_ALL])]
protected ?int $id = null;
public function getId(): ?int

View File

@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Entity\Traits;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\SongInterface;
use App\Entity\Song;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @OA\Schema(type="object")
@ -17,18 +19,22 @@ trait HasSongFields
/** @OA\Property() */
#[ORM\Column(length: 50)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected string $song_id;
/** @OA\Property() */
#[ORM\Column(length: 303, nullable: true)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $text = null;
/** @OA\Property() */
#[ORM\Column(length: 150, nullable: true)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $artist = null;
/** @OA\Property() */
#[ORM\Column(length: 150, nullable: true)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $title = null;
public function setSong(SongInterface $song): void

View File

@ -7,11 +7,13 @@ namespace App\Entity\Traits;
use App\Entity;
use App\Security\SplitToken;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
trait HasSplitTokenFields
{
#[ORM\Column(length: 16)]
#[ORM\Id]
#[Groups([Entity\Interfaces\EntityGroupsInterface::GROUP_ID, Entity\Interfaces\EntityGroupsInterface::GROUP_ALL])]
protected string $id;
#[ORM\Column(length: 128)]
@ -29,6 +31,11 @@ trait HasSplitTokenFields
return $this->id;
}
public function getIdRequired(): string
{
return $this->id;
}
public function verify(SplitToken $userSuppliedToken): bool
{
return $userSuppliedToken->verify($this->verifier);

View File

@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Entity\Traits;
use App\Doctrine\Generator\UuidV6Generator;
use App\Entity\Interfaces\EntityGroupsInterface;
use Doctrine\ORM\Mapping as ORM;
use OpenApi\Annotations as OA;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @OA\Schema(type="object")
@ -16,6 +18,7 @@ trait HasUniqueId
/** @OA\Property() */
#[ORM\Column(type: 'guid', unique: true, nullable: false)]
#[ORM\Id, ORM\GeneratedValue(strategy: 'CUSTOM'), ORM\CustomIdGenerator(UuidV6Generator::class)]
#[Groups([EntityGroupsInterface::GROUP_ID, EntityGroupsInterface::GROUP_ALL])]
protected ?string $id = null;
public function getId(): ?string

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity;
use App\Auth;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Normalizer\Attributes\DeepNormalize;
use App\Utilities\Strings;
@ -16,6 +17,7 @@ use OpenApi\Annotations as OA;
use OTPHP\Factory;
use Stringable;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use const PASSWORD_BCRYPT;
@ -38,6 +40,7 @@ class User implements Stringable, IdentifiableEntityInterface
#[ORM\Column(length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected string $email;
#[ORM\Column(length: 255, nullable: false)]
@ -45,34 +48,41 @@ class User implements Stringable, IdentifiableEntityInterface
protected string $auth_password = '';
/** @OA\Property(example="") */
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?string $new_password = null;
/** @OA\Property(example="Demo Account") */
#[ORM\Column(length: 100, nullable: true)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $name = null;
/** @OA\Property(example="en_US") */
#[ORM\Column(length: 25, nullable: true)]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $locale = null;
/** @OA\Property(example="dark") */
#[ORM\Column(length: 25, nullable: true)]
#[Attributes\AuditIgnore]
#[Groups([EntityGroupsInterface::GROUP_GENERAL, EntityGroupsInterface::GROUP_ALL])]
protected ?string $theme = null;
/** @OA\Property(example="A1B2C3D4") */
#[ORM\Column(length: 255, nullable: true)]
#[Attributes\AuditIgnore]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected ?string $two_factor_secret = null;
/** @OA\Property(example=1609480800) */
#[ORM\Column]
#[Attributes\AuditIgnore]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected int $created_at;
/** @OA\Property(example=1609480800) */
#[ORM\Column]
#[Attributes\AuditIgnore]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
protected int $updated_at;
/**
@ -85,11 +95,13 @@ class User implements Stringable, IdentifiableEntityInterface
#[ORM\JoinTable(name: 'user_has_role')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
#[DeepNormalize(true)]
#[Serializer\MaxDepth(1)]
protected Collection $roles;
#[ORM\OneToMany(mappedBy: 'user', targetEntity: ApiKey::class)]
#[Groups([EntityGroupsInterface::GROUP_ADMIN, EntityGroupsInterface::GROUP_ALL])]
#[DeepNormalize(true)]
protected Collection $api_keys;

View File

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Config;
use App\Entity;
use App\Environment;
use App\Http\ServerRequest;
use AzuraForms\Field\AbstractField;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class UserProfileForm extends EntityForm
{
public function __construct(
EntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config,
Environment $environment
) {
$form_config = $config->get(
'forms/profile',
[
'environment' => $environment,
]
);
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\User::class;
}
/**
* @inheritDoc
*/
public function process(ServerRequest $request, $record = null): object|bool
{
$user = $request->getUser();
$this->getField('password')->addValidator(
function ($val, AbstractField $field) use ($user) {
$new_password = $field->getForm()->getField('new_password')->getValue();
if (!empty($new_password)) {
if ($user->verifyPassword($val)) {
return true;
}
return 'Current password could not be verified.';
}
return true;
}
);
return parent::process($request, $user);
}
public function getView(ServerRequest $request): string
{
$user = $request->getUser();
$viewForm = new Form($this->options['groups']['customization'], $this->normalizeRecord($user));
return $viewForm->renderView();
}
}

View File

@ -1,53 +0,0 @@
<?php
$this->layout('main', [
'title' => __('My API Keys'),
'manual' => true
]); ?>
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('My API Keys') ?></h2>
</div>
<div class="card-body">
<p>
<?=__('API keys can be used to access some system functionality without needing to log in. All of the keys
you create share your permissions in the system. For more information, see the <a href="%s">API documentation</a>.', '/static/api/index.html') ?>
</p>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" role="button" href="<?=$router->named('api_keys:add') ?>">
<i class="material-icons" aria-hidden="true">add</i>
<?=__('Add API Key') ?>
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<colgroup>
<col width="20%">
<col width="40%">
<col width="40%">
</colgroup>
<thead>
<tr>
<th>&nbsp;</th>
<th><?=__('Key Identifier') ?></th>
<th><?=__('Comments') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($records as $record): ?>
<tr class="align-middle">
<td class="center">
<div class="btn-group btn-group-sm">
<a class="btn btn-sm btn-primary" href="<?=$router->named('api_keys:edit', ['id' => $record->getId()]) ?>"><?=__('Edit') ?></a>
<a class="btn btn-sm btn-danger" href="<?=$router->named('api_keys:delete', ['id' => $record->getId(), 'csrf' => $csrf]) ?>"><?=__('Revoke') ?></a>
</div>
</td>
<td><code><?=$record->getId() ?></code></td>
<td><?=$this->e($record->getComment()) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View File

@ -1,31 +0,0 @@
<?php
$this->layout('main', [
'title' => __('My API Keys'),
'manual' => true,
]);
$assets->load('clipboard');
?>
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('New Key Generated')?></h2>
</div>
<div class="card-body">
<p><?=__('<b>Important: copy the key below before continuing!</b> You will not be able to retrieve it again.')?></p>
<p><?=__('Your full API key is below:')?></p>
<div class="well">
<code id="api_key"><?=$key?></code>
<div class="buttons">
<button class="btn btn-copy btn-default btn-sm" data-clipboard-target="#api_key"><?=__('Copy to Clipboard')?></button>
</div>
</div>
<p><?=__('When making API calls, you can pass this value in the "X-API-Key" header to authenticate as yourself. You can only perform the actions your user account is allowed to perform.')?></p>
<div class="buttons">
<a class="btn btn-lg btn-primary" href="<?=$router->named('api_keys:index')?>"><?=__('Continue')?></a>
</div>
</div>
</div>

View File

@ -1,52 +0,0 @@
<?php
$this->layout('main', ['title' => __('Enable Two-Factor Authentication'), 'manual' => true]);
/** @var \App\Assets $assets */
$assets
->load('clipboard')
?>
<div class="row">
<div class="col-8">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Enable Two-Factor Authentication') ?></h2>
</div>
<div class="card-body">
<h3 class="card-title mt-2"><?=__('Step 1: Scan QR Code') ?></h3>
<p class="card-text"><?=__('From your smartphone, scan the code to the right using an authentication app of your choice (FreeOTP, Authy, etc).') ?></p>
<h3 class="card-title mt-0"><?=__('Step 2: Verify Generated Code') ?></h3>
<p class="card-text"><?=__('To verify that the code was set up correctly, enter the 6-digit code the app shows you.') ?></p>
<?=$this->fetch('system/form_edit', ['form' => $form]) ?>
</div>
</div>
</div>
<div class="col-4">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('QR-Code') ?></h2>
</div>
<div class="card-body">
<figure>
<?=$qr_code?>
</figure>
<div class="d-flex">
<div class="flex-fill" style="max-width: 80%">
<code id="totp_uri" class="d-inline-block text-truncate" style="width: 100%;"><?=$totp_uri?></code>
</div>
<div class="flex-shrink-0">
<button class="btn btn-copy btn-link btn-xs py-1 px-2" data-clipboard-target="#totp_uri">
<i class="material-icons sm">file_copy</i><span class="sr-only"><?=__(
'Copy to Clipboard'
)?></span></button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,110 +0,0 @@
<?php
$this->layout('main', ['title' => __('My Account'), 'manual' => true]);
/** @var \App\Entity\User $user */
?>
<div class="row">
<div class="col-md-4">
<div class="card mb-4">
<div class="card-body">
<div class="media">
<?php
if ('' !== $avatarServiceUrl): ?>
<a class="align-self-center mr-3" href="<?=$avatarServiceUrl?>" target="_blank">
<img src="<?=$avatar?>" style="width: 64px; height: auto;" alt="">
</a>
<?php
endif; ?>
<div class="media-body">
<h2 class="card-title mt-2">
<?php
if (!empty($user->getName())): ?>
<?=$this->e($user->getName())?>
<?php
else: ?>
<?=__('My Account')?>
<?php
endif; ?>
</h2>
<h3 class="card-subtitle">
<?=$this->e($user->getEmail())?>
</h3>
</div>
</div>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->named('profile:edit')?>">
<i class="material-icons" aria-hidden="true">edit</i>
<?=__('Edit Profile')?>
</a>
</div>
</div>
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Roles')?></h2>
</div>
<ul class="list-group list-group-flush">
<?php
foreach ($user->getRoles() as $role): ?>
<li class="list-group-item"><?=$this->e($role->getName())?></li>
<?php
endforeach; ?>
</ul>
</div>
</div>
<div class="col-md-4 mt-4 mt-md-0">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Customization')?></h2>
</div>
<div class="card-body">
<?=$profileView?>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" href="<?=$router->named('profile:edit')?>#customization">
<i class="material-icons" aria-hidden="true">edit</i>
<?=__('Customize')?>
</a>
</div>
</div>
</div>
<div class="col-md-4 mt-4 mt-md-0">
<div class="card">
<div class="card-header bg-primary-dark mb-3">
<h2 class="card-title"><?=__('Two-Factor Authentication')?></h2>
</div>
<div class="card-body">
<?php
if (null !== $user->getTwoFactorSecret()): ?>
<h3 class="card-subtitle text-success"><?=__('Enabled')?></h3>
<?php
else: ?>
<h3 class="card-subtitle text-danger"><?=__('Disabled')?></h3>
<?php
endif; ?>
<p class="card-text mt-3"><?=__(
'Two-factor authentication improves the security of your account by requiring a second one-time access code in addition to your password when you log in.'
)?></p>
</div>
<div class="card-actions">
<?php
if (null !== $user->getTwoFactorSecret()): ?>
<a class="btn btn-outline-primary" href="<?=$router->named('profile:2fa:disable')?>">
<i class="material-icons" aria-hidden="true">lock_open</i>
<?=__('Disable Two-Factor')?>
</a>
<?php
else: ?>
<a class="btn btn-outline-primary" href="<?=$router->named('profile:2fa:enable')?>">
<i class="material-icons" aria-hidden="true">lock</i>
<?=__('Enable Two-Factor')?>
</a>
<?php
endif; ?>
</div>
</div>
</div>
</div>

View File

@ -98,12 +98,6 @@ endif; ?>">
<?=__('Switch Theme')?>
</a>
</li>
<li>
<a class="dropdown-item" href="<?=$router->named('api_keys:index')?>">
<i class="material-icons" aria-hidden="true">vpn_key</i>
<?=__('My API Keys')?>
</a>
</li>
<li>
<a class="dropdown-item" href="https://docs.azuracast.com/en/user-guide/troubleshooting" target="_blank">
<i class="material-icons" aria-hidden="true">help</i>

View File

@ -1,55 +0,0 @@
<?php
class Admin_ApiKeysCest extends CestAbstract
{
/**
* @before setupComplete
* @before login
*/
public function manageApiKeys(FunctionalTester $I): void
{
$I->wantTo('Create and administer API keys.');
// Create one API key and test its revocation within the user side.
$I->amOnPage('/api_keys');
$I->see('My API Keys');
$I->click('Add API Key', '#content');
$I->submitForm('.form', [
'comment' => 'API Key Test',
]);
$I->seeCurrentUrlEquals('/api_keys/add');
$I->see('New Key Generated');
$I->click('.btn-primary'); // Continue
$I->amOnPage('/api_keys');
$I->see('API Key Test');
/*
* TODO: Temporarily disable until new test suite is available.
$I->click(\Codeception\Util\Locator::lastElement('.btn-danger')); // Revoke
$I->seeCurrentUrlEquals('/api_keys');
$I->dontSee('API Key Test');
*/
// Create another API key and test its revocation from the admin side.
$I->click('Add API Key', '#content');
$I->submitForm('.form', [
'comment' => 'API Key Admin Test',
]);
$I->amOnPage('/admin/api');
$I->see('API Key Admin Test');
/*
$I->click(\Codeception\Util\Locator::lastElement('.btn-danger'));
$I->seeCurrentUrlEquals('/admin/api');
$I->dontSee('API Key Admin Test');
*/
}
}

View File

@ -0,0 +1,76 @@
<?php
class Api_Frontend_AccountCest extends CestAbstract
{
/**
* @before setupComplete
* @before login
*/
public function checkAccount(FunctionalTester $I): void
{
$I->wantTo('Check frontend account API functions.');
// GET me endpoint
$I->sendGet('/api/frontend/account/me');
$I->seeResponseCodeIsSuccessful();
$I->seeResponseContainsJson(
[
'email' => $this->login_username,
'name' => 'AzuraCast Test User',
]
);
// PUT me endpoint
$I->sendPut('/api/frontend/account/me', [
'name' => 'AzuraCast User with New Name',
]);
$I->seeResponseCodeIsSuccessful();
$I->sendGet('/api/frontend/account/me');
$I->seeResponseContainsJson([
'name' => 'AzuraCast User with New Name',
]);
// PUT password endpoint
$I->sendPut('/api/frontend/account/password', [
'current_password' => 'wrongpassword',
'new_password' => 'asdfasdfasdfasdf',
]);
$I->seeResponseCodeIs(400);
// GET twofactor endpoint
$I->sendGet('/api/frontend/account/two-factor');
$I->seeResponseCodeIsSuccessful();
$I->seeResponseContainsJson([
'two_factor_enabled' => false,
]);
// PUT twofactor endpoint without secret
$I->sendPut('/api/frontend/account/two-factor');
$I->seeResponseCodeIsSuccessful();
// CRUD API Keys
$createJson = [
'comment' => 'API Key Test',
];
$I->sendPost('/api/frontend/account/api-keys', $createJson);
$I->seeResponseCodeIsSuccessful();
$newRecord = $I->grabDataFromResponseByJsonPath('links.self');
$newRecordSelfLink = $newRecord[0];
$I->sendGet($newRecordSelfLink);
$I->seeResponseContainsJson($createJson);
$I->sendGet($newRecordSelfLink);
$I->seeResponseContainsJson($createJson);
$I->sendDelete($newRecordSelfLink);
$I->sendGet($newRecordSelfLink);
$I->seeResponseCodeIs(404);
}
}

View File

@ -1,47 +0,0 @@
<?php
class Frontend_ProfileCest extends CestAbstract
{
/**
* @before login
*/
public function setProfileInfo(FunctionalTester $I): void
{
$I->wantTo('Set a user profile.');
$I->amOnPage('/dashboard');
$I->see('Dashboard');
$I->amOnPage('/profile');
$I->see('Profile');
$I->see('Super Administrator');
$I->click('Edit');
$I->submitForm('.form', [
'locale' => 'fr_FR.UTF-8',
]);
$I->seeCurrentUrlEquals('/profile');
$I->see('Français');
}
/**
* @before login
*/
public function changeProfileLocale(FunctionalTester $I): void
{
$I->wantTo('Use a specific locale for a user.');
$I->amOnPage('/profile/edit');
$I->see('Edit Profile', '.card-title');
$I->submitForm('.form', [
'locale' => 'de_DE.UTF-8',
]);
$I->seeCurrentUrlEquals('/profile');
$I->see('Deutsch');
$I->seeInTitle('Mein Account');
}
}