Vue Account Management & API Keys (#4753)
This commit is contained in:
parent
0be5c9bfc1
commit
e0b0fe5a7b
|
@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
];
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
];
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -9,7 +9,11 @@ export const avatarProps = {
|
|||
props: {
|
||||
avatar: String,
|
||||
avatarServiceUrl: String,
|
||||
avatarServiceName: String
|
||||
avatarServiceName: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 128
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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> </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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
*/
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue