From 2742816b88d001ea9dd21ccc2ff4d19117598397 Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Sun, 19 Jan 2025 21:23:50 -0500 Subject: [PATCH] Implement Account Settings pages --- .../skeleton/app/assets/components/NavBar.vue | 8 +- .../app/assets/composables/index.ts | 3 + .../assets/composables/useUserEmailEditApi.ts | 45 +++++ .../composables/useUserPasswordEditApi.ts | 45 +++++ .../composables/useUserProfileEditApi.ts | 45 +++++ .../app/assets/interfaces/EmailEditApi.ts | 14 ++ .../app/assets/interfaces/PasswordEditApi.ts | 15 ++ .../app/assets/interfaces/ProfileEditApi.ts | 14 ++ .../app/assets/interfaces/index.ts | 3 + .../app/assets/routes/index.ts | 30 ++++ .../app/assets/views/UserSettings.vue | 3 + .../app/assets/views/UserSettingsEmail.vue | 3 + .../app/assets/views/UserSettingsPassword.vue | 3 + .../app/assets/views/UserSettingsProfile.vue | 3 + .../app/schema/requests/account-email.yaml | 17 ++ .../app/schema/requests/account-settings.yaml | 12 -- ...rofileAction.php => ProfileEditAction.php} | 17 +- .../src/Controller/ProfileEmailEditAction.php | 164 ++++++++++++++++++ ...tingsAction.php => SettingsEditAction.php} | 26 +-- .../app/src/Routes/AuthRoutes.php | 10 +- ...tionTest.php => ProfileEditActionTest.php} | 19 +- ...est.php => ProfileEmailEditActionTest.php} | 64 ++----- .../Controller/SettingsEditActionTest.php | 110 ++++++++++++ .../assets/composables/useRoleUpdateApi.ts | 4 +- .../assets/composables/useUserUpdateApi.ts | 4 +- .../app/assets/interfaces/index.ts | 3 - .../app/assets/interfaces/ApiResponse.ts | 2 + .../app/assets/interfaces/index.ts | 3 + .../Pages/Account/FormUserEmail.vue | 100 +++++++++++ .../Pages/Account/FormUserPassword.vue | 117 +++++++++++++ .../Pages/Account/FormUserProfile.vue | 122 +++++++++++++ .../Pages/Admin/User/UserPasswordForm.vue | 2 +- .../theme-pink-cupcake/src/plugins/account.ts | 14 +- .../src/views/Account/PageUserSettings.vue | 26 +++ .../views/Account/PageUserSettingsEmail.vue | 9 + .../Account/PageUserSettingsPassword.vue | 9 + .../views/Account/PageUserSettingsProfile.vue | 9 + .../src/views/Account/index.ts | 15 +- 38 files changed, 988 insertions(+), 124 deletions(-) create mode 100644 packages/sprinkle-account/app/assets/composables/useUserEmailEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/composables/useUserPasswordEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/composables/useUserProfileEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/interfaces/EmailEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/interfaces/PasswordEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/interfaces/ProfileEditApi.ts create mode 100644 packages/sprinkle-account/app/assets/views/UserSettings.vue create mode 100644 packages/sprinkle-account/app/assets/views/UserSettingsEmail.vue create mode 100644 packages/sprinkle-account/app/assets/views/UserSettingsPassword.vue create mode 100644 packages/sprinkle-account/app/assets/views/UserSettingsProfile.vue create mode 100644 packages/sprinkle-account/app/schema/requests/account-email.yaml rename packages/sprinkle-account/app/src/Controller/{ProfileAction.php => ProfileEditAction.php} (91%) create mode 100644 packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php rename packages/sprinkle-account/app/src/Controller/{SettingsAction.php => SettingsEditAction.php} (88%) rename packages/sprinkle-account/app/tests/Controller/{ProfileActionTest.php => ProfileEditActionTest.php} (89%) rename packages/sprinkle-account/app/tests/Controller/{SettingsActionTest.php => ProfileEmailEditActionTest.php} (66%) create mode 100644 packages/sprinkle-account/app/tests/Controller/SettingsEditActionTest.php rename packages/{sprinkle-admin => sprinkle-core}/app/assets/interfaces/ApiResponse.ts (75%) create mode 100644 packages/theme-pink-cupcake/src/components/Pages/Account/FormUserEmail.vue create mode 100644 packages/theme-pink-cupcake/src/components/Pages/Account/FormUserPassword.vue create mode 100644 packages/theme-pink-cupcake/src/components/Pages/Account/FormUserProfile.vue create mode 100644 packages/theme-pink-cupcake/src/views/Account/PageUserSettings.vue create mode 100644 packages/theme-pink-cupcake/src/views/Account/PageUserSettingsEmail.vue create mode 100644 packages/theme-pink-cupcake/src/views/Account/PageUserSettingsPassword.vue create mode 100644 packages/theme-pink-cupcake/src/views/Account/PageUserSettingsProfile.vue diff --git a/packages/skeleton/app/assets/components/NavBar.vue b/packages/skeleton/app/assets/components/NavBar.vue index baf1dffa7..3c414008d 100644 --- a/packages/skeleton/app/assets/components/NavBar.vue +++ b/packages/skeleton/app/assets/components/NavBar.vue @@ -22,12 +22,8 @@ const auth = useAuthStore() :username="auth.user.full_name" :avatar="auth.user.avatar" :meta="auth.user.user_name"> - - + + diff --git a/packages/sprinkle-account/app/assets/composables/index.ts b/packages/sprinkle-account/app/assets/composables/index.ts index 26b06b160..e8ed9b271 100644 --- a/packages/sprinkle-account/app/assets/composables/index.ts +++ b/packages/sprinkle-account/app/assets/composables/index.ts @@ -1,3 +1,6 @@ export * as Register from './register' export { forgotPassword } from './forgotPassword' export { resendVerification } from './resendVerification' +export { useUserProfileEditApi } from './useUserProfileEditApi' +export { useUserPasswordEditApi } from './useUserPasswordEditApi' +export { useUserEmailEditApi } from './useUserEmailEditApi' diff --git a/packages/sprinkle-account/app/assets/composables/useUserEmailEditApi.ts b/packages/sprinkle-account/app/assets/composables/useUserEmailEditApi.ts new file mode 100644 index 000000000..04c15b2ea --- /dev/null +++ b/packages/sprinkle-account/app/assets/composables/useUserEmailEditApi.ts @@ -0,0 +1,45 @@ +import { ref } from 'vue' +import axios from 'axios' +import { Severity } from '@userfrosting/sprinkle-core/interfaces' +import type { ApiResponse, AlertInterface } from '@userfrosting/sprinkle-core/interfaces' +import type { EmailEditRequest } from '../interfaces' + +// TODO : Add validation +// 'schema://requests/account-email.yaml' + +/** + * API Composable + */ +export function useUserEmailEditApi() { + const apiLoading = ref(false) + const apiError = ref(null) + + async function submitEmailEdit(data: EmailEditRequest) { + apiLoading.value = true + apiError.value = null + return axios + .post('/account/settings/email', data) + .then((response) => { + return { + message: response.data.message + } + }) + .catch((err) => { + apiError.value = { + ...{ + description: 'An error as occurred', + style: Severity.Danger, + closeBtn: true + }, + ...err.response.data + } + + throw apiError.value + }) + .finally(() => { + apiLoading.value = false + }) + } + + return { submitEmailEdit, apiLoading, apiError } +} diff --git a/packages/sprinkle-account/app/assets/composables/useUserPasswordEditApi.ts b/packages/sprinkle-account/app/assets/composables/useUserPasswordEditApi.ts new file mode 100644 index 000000000..d68f2fa2c --- /dev/null +++ b/packages/sprinkle-account/app/assets/composables/useUserPasswordEditApi.ts @@ -0,0 +1,45 @@ +import { ref } from 'vue' +import axios from 'axios' +import { Severity } from '@userfrosting/sprinkle-core/interfaces' +import type { ApiResponse, AlertInterface } from '@userfrosting/sprinkle-core/interfaces' +import type { PasswordEditRequest } from '../interfaces' + +// TODO : Add validation +// 'schema://requests/account-settings.yaml' + +/** + * API Composable + */ +export function useUserPasswordEditApi() { + const apiLoading = ref(false) + const apiError = ref(null) + + async function submitPasswordEdit(data: PasswordEditRequest) { + apiLoading.value = true + apiError.value = null + return axios + .post('/account/settings', data) + .then((response) => { + return { + message: response.data.message + } + }) + .catch((err) => { + apiError.value = { + ...{ + description: 'An error as occurred', + style: Severity.Danger, + closeBtn: true + }, + ...err.response.data + } + + throw apiError.value + }) + .finally(() => { + apiLoading.value = false + }) + } + + return { submitPasswordEdit, apiLoading, apiError } +} diff --git a/packages/sprinkle-account/app/assets/composables/useUserProfileEditApi.ts b/packages/sprinkle-account/app/assets/composables/useUserProfileEditApi.ts new file mode 100644 index 000000000..65d55ea5c --- /dev/null +++ b/packages/sprinkle-account/app/assets/composables/useUserProfileEditApi.ts @@ -0,0 +1,45 @@ +import { ref } from 'vue' +import axios from 'axios' +import { Severity } from '@userfrosting/sprinkle-core/interfaces' +import type { ApiResponse, AlertInterface } from '@userfrosting/sprinkle-core/interfaces' +import type { ProfileEditRequest } from '../interfaces' + +// TODO : Add validation +// 'schema://requests/profile-settings.yaml' + +/** + * API Composable + */ +export function useUserProfileEditApi() { + const apiLoading = ref(false) + const apiError = ref(null) + + async function submitProfileEdit(data: ProfileEditRequest) { + apiLoading.value = true + apiError.value = null + return axios + .post('/account/settings/profile', data) + .then((response) => { + return { + message: response.data.message + } + }) + .catch((err) => { + apiError.value = { + ...{ + description: 'An error as occurred', + style: Severity.Danger, + closeBtn: true + }, + ...err.response.data + } + + throw apiError.value + }) + .finally(() => { + apiLoading.value = false + }) + } + + return { submitProfileEdit, apiLoading, apiError } +} diff --git a/packages/sprinkle-account/app/assets/interfaces/EmailEditApi.ts b/packages/sprinkle-account/app/assets/interfaces/EmailEditApi.ts new file mode 100644 index 000000000..015147522 --- /dev/null +++ b/packages/sprinkle-account/app/assets/interfaces/EmailEditApi.ts @@ -0,0 +1,14 @@ +/** + * API Interfaces - What the API expects and what it returns + * + * This interface is tied to the `ProfileEmailEditAction` API, accessed at the + * POST `/account/settings/email` endpoint. + * + * This api doesn't have a corresponding Response data interface. + * The General API Response interface is used. + */ +// TODO : Email should be it's own form +export interface EmailEditRequest { + email: string + passwordcheck: string +} diff --git a/packages/sprinkle-account/app/assets/interfaces/PasswordEditApi.ts b/packages/sprinkle-account/app/assets/interfaces/PasswordEditApi.ts new file mode 100644 index 000000000..e41db3239 --- /dev/null +++ b/packages/sprinkle-account/app/assets/interfaces/PasswordEditApi.ts @@ -0,0 +1,15 @@ +/** + * API Interfaces - What the API expects and what it returns + * + * This interface is tied to the `SettingsEditAction` API, accessed at the + * POST `/account/settings` endpoint. + * + * This api doesn't have a corresponding Response data interface. + * The General API Response interface is used. + */ +// TODO : Email should be it's own form +export interface PasswordEditRequest { + passwordcheck: string + password: string + passwordc: string +} diff --git a/packages/sprinkle-account/app/assets/interfaces/ProfileEditApi.ts b/packages/sprinkle-account/app/assets/interfaces/ProfileEditApi.ts new file mode 100644 index 000000000..196db8e85 --- /dev/null +++ b/packages/sprinkle-account/app/assets/interfaces/ProfileEditApi.ts @@ -0,0 +1,14 @@ +/** + * API Interfaces - What the API expects and what it returns + * + * This interface is tied to the `ProfileEditAction` API, accessed at the + * POST `/account/settings/profile` endpoint. + * + * This api doesn't have a corresponding Response data interface. + * The General API Response interface is used. + */ +export interface ProfileEditRequest { + first_name: string + last_name: string + locale: string +} diff --git a/packages/sprinkle-account/app/assets/interfaces/index.ts b/packages/sprinkle-account/app/assets/interfaces/index.ts index 24d6d7c6e..4f8b062c7 100644 --- a/packages/sprinkle-account/app/assets/interfaces/index.ts +++ b/packages/sprinkle-account/app/assets/interfaces/index.ts @@ -5,3 +5,6 @@ export type { GroupInterface } from './models/groupInterface' export type { RoleInterface } from './models/roleInterface' export type { PermissionInterface } from './models/permissionInterface' export type { RouteGuard } from './routes' +export type { ProfileEditRequest } from './ProfileEditApi' +export type { PasswordEditRequest } from './PasswordEditApi' +export type { EmailEditRequest } from './EmailEditApi' diff --git a/packages/sprinkle-account/app/assets/routes/index.ts b/packages/sprinkle-account/app/assets/routes/index.ts index 0481b1b94..caaeeffc4 100644 --- a/packages/sprinkle-account/app/assets/routes/index.ts +++ b/packages/sprinkle-account/app/assets/routes/index.ts @@ -38,5 +38,35 @@ export default [ } }, component: () => import('../views/ResendVerificationView.vue') + }, + { + path: '/account/settings', + name: 'account.settings', + redirect: { name: 'account.settings.profile' }, + meta: { + auth: { + redirect: { name: 'account.login' } + }, + title: 'Account settings', + description: 'Update your account settings, including email, name, and password.' + }, + component: () => import('../views/UserSettings.vue'), + children: [ + { + path: 'profile', + name: 'account.settings.profile', + component: () => import('../views/UserSettingsProfile.vue') + }, + { + path: 'password', + name: 'account.settings.password', + component: () => import('../views/UserSettingsPassword.vue') + }, + { + path: 'email', + name: 'account.settings.email', + component: () => import('../views/UserSettingsEmail.vue') + } + ] } ] diff --git a/packages/sprinkle-account/app/assets/views/UserSettings.vue b/packages/sprinkle-account/app/assets/views/UserSettings.vue new file mode 100644 index 000000000..a76cca971 --- /dev/null +++ b/packages/sprinkle-account/app/assets/views/UserSettings.vue @@ -0,0 +1,3 @@ + diff --git a/packages/sprinkle-account/app/assets/views/UserSettingsEmail.vue b/packages/sprinkle-account/app/assets/views/UserSettingsEmail.vue new file mode 100644 index 000000000..7ffa80774 --- /dev/null +++ b/packages/sprinkle-account/app/assets/views/UserSettingsEmail.vue @@ -0,0 +1,3 @@ + diff --git a/packages/sprinkle-account/app/assets/views/UserSettingsPassword.vue b/packages/sprinkle-account/app/assets/views/UserSettingsPassword.vue new file mode 100644 index 000000000..a7c932d1b --- /dev/null +++ b/packages/sprinkle-account/app/assets/views/UserSettingsPassword.vue @@ -0,0 +1,3 @@ + diff --git a/packages/sprinkle-account/app/assets/views/UserSettingsProfile.vue b/packages/sprinkle-account/app/assets/views/UserSettingsProfile.vue new file mode 100644 index 000000000..5e0f40c27 --- /dev/null +++ b/packages/sprinkle-account/app/assets/views/UserSettingsProfile.vue @@ -0,0 +1,3 @@ + diff --git a/packages/sprinkle-account/app/schema/requests/account-email.yaml b/packages/sprinkle-account/app/schema/requests/account-email.yaml new file mode 100644 index 000000000..140be4d69 --- /dev/null +++ b/packages/sprinkle-account/app/schema/requests/account-email.yaml @@ -0,0 +1,17 @@ +--- +passwordcheck: + validators: + required: + message: PASSWORD.CONFIRM_CURRENT +email: + validators: + required: + label: "&EMAIL" + message: VALIDATE.REQUIRED + length: + label: "&EMAIL" + min: 1 + max: 150 + message: VALIDATE.LENGTH_RANGE + email: + message: VALIDATE.INVALID_EMAIL diff --git a/packages/sprinkle-account/app/schema/requests/account-settings.yaml b/packages/sprinkle-account/app/schema/requests/account-settings.yaml index 4a2d36870..b1e8ff13b 100644 --- a/packages/sprinkle-account/app/schema/requests/account-settings.yaml +++ b/packages/sprinkle-account/app/schema/requests/account-settings.yaml @@ -3,18 +3,6 @@ passwordcheck: validators: required: message: PASSWORD.CONFIRM_CURRENT -email: - validators: - required: - label: "&EMAIL" - message: VALIDATE.REQUIRED - length: - label: "&EMAIL" - min: 1 - max: 150 - message: VALIDATE.LENGTH_RANGE - email: - message: VALIDATE.INVALID_EMAIL password: validators: length: diff --git a/packages/sprinkle-account/app/src/Controller/ProfileAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php similarity index 91% rename from packages/sprinkle-account/app/src/Controller/ProfileAction.php rename to packages/sprinkle-account/app/src/Controller/ProfileEditAction.php index 56cceea27..14ccd6a66 100644 --- a/packages/sprinkle-account/app/src/Controller/ProfileAction.php +++ b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php @@ -14,11 +14,11 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use UserFrosting\Alert\AlertStream; use UserFrosting\Fortress\RequestSchema; use UserFrosting\Fortress\RequestSchema\RequestSchemaInterface; use UserFrosting\Fortress\Transformer\RequestDataTransformer; use UserFrosting\Fortress\Validator\ServerSideValidator; +use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Authenticate\Authenticator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; @@ -40,7 +40,7 @@ * Route Name: settings.profile * Request type: POST */ -class ProfileAction +class ProfileEditAction { // Request schema to use to validate data. protected string $schema = 'schema://requests/profile-settings.yaml'; @@ -49,7 +49,7 @@ class ProfileAction * Inject dependencies. */ public function __construct( - protected AlertStream $alert, + protected Translator $translator, protected Authenticator $authenticator, protected SiteLocale $locale, protected UserActivityLoggerInterface $logger, @@ -69,7 +69,14 @@ public function __invoke(Request $request, Response $response): Response { $this->handle($request); - return $response; + $payload = json_encode([ + // TODO : The message won't be in the right locale if the user + // changed it. We need to find a way to handle this. + 'message' => $this->translator->translate('PROFILE.UPDATED') + ], JSON_THROW_ON_ERROR); + $response->getBody()->write($payload); + + return $response->withHeader('Content-Type', 'application/json'); } /** @@ -126,8 +133,6 @@ protected function handle(Request $request): void 'type' => 'update_profile_settings', 'user_id' => $currentUser->id, ]); - - $this->alert->addMessage('success', 'PROFILE.UPDATED'); } /** diff --git a/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php new file mode 100644 index 000000000..b79f306b6 --- /dev/null +++ b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php @@ -0,0 +1,164 @@ +handle($request); + + $payload = json_encode([ + 'message' => $this->translator->translate('ACCOUNT.SETTINGS.UPDATED') + ], JSON_THROW_ON_ERROR); + $response->getBody()->write($payload); + + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * Handle the request and return the payload. + * + * @param Request $request + */ + protected function handle(Request $request): void + { + // Access control for entire resource - check that the current user has permission to modify themselves + // See recipe "per-field access control" for dynamic fine-grained control over which properties a user can modify. + if (!$this->authenticator->checkAccess('update_account_settings')) { + throw new ForbiddenException(); + } + + // Get POST parameters + $params = (array) $request->getParsedBody(); + + // Load the request schema + $schema = $this->getSchema(); + + // Whitelist and set parameter defaults + $data = $this->transformer->transform($schema, $params); + + // Get current user. Won't be null, as AuthGuard prevent it. + /** @var UserInterface */ + $currentUser = $this->authenticator->user(); + + // Validate request data + $this->validateData($schema, $data); + + // Confirm current password + if ($currentUser->comparePassword($data['passwordcheck']) === false) { + throw new PasswordInvalidException(); + } + + // Remove password check from object data after validation + unset($data['passwordcheck']); + + // If new email was submitted, check that the email address is not in use + if ($data['email'] !== $currentUser->email && $this->userModel::findUnique($data['email'], 'email') !== null) { + throw new EmailNotUniqueException(); + } + + // Looks good, let's update with new values! + // Note that only fields listed in `account-email.yaml` will be + // permitted in $data, so this prevents the user from updating all columns in the DB + $currentUser->fill($data); + $currentUser->save(); + + // Create activity record + $this->logger->info("User {$currentUser->user_name} updated their account settings.", [ + 'type' => 'update_account_settings', + 'user_id' => $currentUser->id, + ]); + } + + /** + * Load the request schema. + * + * @return RequestSchemaInterface + */ + protected function getSchema(): RequestSchemaInterface + { + return new RequestSchema($this->schema); + } + + /** + * Validate request POST data. + * + * @param RequestSchemaInterface $schema + * @param mixed[] $data + */ + protected function validateData(RequestSchemaInterface $schema, array $data): void + { + $errors = $this->validator->validate($schema, $data); + if (count($errors) !== 0) { + $e = new ValidationException(); + $e->addErrors($errors); + + throw $e; + } + } +} diff --git a/packages/sprinkle-account/app/src/Controller/SettingsAction.php b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php similarity index 88% rename from packages/sprinkle-account/app/src/Controller/SettingsAction.php rename to packages/sprinkle-account/app/src/Controller/SettingsEditAction.php index 5bb9cb0c3..1368d606f 100644 --- a/packages/sprinkle-account/app/src/Controller/SettingsAction.php +++ b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php @@ -14,15 +14,14 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use UserFrosting\Alert\AlertStream; use UserFrosting\Config\Config; use UserFrosting\Fortress\RequestSchema; use UserFrosting\Fortress\RequestSchema\RequestSchemaInterface; use UserFrosting\Fortress\Transformer\RequestDataTransformer; use UserFrosting\Fortress\Validator\ServerSideValidator; +use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Authenticate\Authenticator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; -use UserFrosting\Sprinkle\Account\Exceptions\EmailNotUniqueException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\PasswordInvalidException; use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; @@ -42,7 +41,7 @@ * Route Name: settings * Request type: POST */ -class SettingsAction +class SettingsEditAction { // Request schema to use to validate data. protected string $schema = 'schema://requests/account-settings.yaml'; @@ -51,7 +50,7 @@ class SettingsAction * Inject dependencies. */ public function __construct( - protected AlertStream $alert, + protected Translator $translator, protected Authenticator $authenticator, protected Config $config, protected UserActivityLoggerInterface $logger, @@ -72,7 +71,12 @@ public function __invoke(Request $request, Response $response): Response { $this->handle($request); - return $response; + $payload = json_encode([ + 'message' => $this->translator->translate('ACCOUNT.SETTINGS.UPDATED') + ], JSON_THROW_ON_ERROR); + $response->getBody()->write($payload); + + return $response->withHeader('Content-Type', 'application/json'); } /** @@ -113,16 +117,6 @@ protected function handle(Request $request): void unset($data['passwordcheck']); unset($data['passwordc']); - // If new email was submitted, check that the email address is not in use - if ($data['email'] !== $currentUser->email && $this->userModel::findUnique($data['email'], 'email') !== null) { - throw new EmailNotUniqueException(); - } - - // If password is empty, remove it from the data array - if ($data['password'] === '') { - unset($data['password']); - } - // Looks good, let's update with new values! // Note that only fields listed in `account-settings.yaml` will be // permitted in $data, so this prevents the user from updating all columns in the DB @@ -134,8 +128,6 @@ protected function handle(Request $request): void 'type' => 'update_account_settings', 'user_id' => $currentUser->id, ]); - - $this->alert->addMessage('success', 'ACCOUNT.SETTINGS.UPDATED'); } /** diff --git a/packages/sprinkle-account/app/src/Routes/AuthRoutes.php b/packages/sprinkle-account/app/src/Routes/AuthRoutes.php index 3e8b8bad5..a17ffe006 100644 --- a/packages/sprinkle-account/app/src/Routes/AuthRoutes.php +++ b/packages/sprinkle-account/app/src/Routes/AuthRoutes.php @@ -24,11 +24,12 @@ use UserFrosting\Sprinkle\Account\Controller\ForgetPasswordAction; use UserFrosting\Sprinkle\Account\Controller\LoginAction; use UserFrosting\Sprinkle\Account\Controller\LogoutAction; -use UserFrosting\Sprinkle\Account\Controller\ProfileAction; +use UserFrosting\Sprinkle\Account\Controller\ProfileEditAction; +use UserFrosting\Sprinkle\Account\Controller\ProfileEmailEditAction; use UserFrosting\Sprinkle\Account\Controller\RegisterAction; use UserFrosting\Sprinkle\Account\Controller\ResendVerificationAction; use UserFrosting\Sprinkle\Account\Controller\SetPasswordAction; -use UserFrosting\Sprinkle\Account\Controller\SettingsAction; +use UserFrosting\Sprinkle\Account\Controller\SettingsEditAction; use UserFrosting\Sprinkle\Account\Controller\SuggestUsernameAction; use UserFrosting\Sprinkle\Account\Controller\VerifyAction; use UserFrosting\Sprinkle\Core\Middlewares\NoCache; @@ -51,8 +52,9 @@ public function register(App $app): void // Auth Guard $app->group('/account', function (RouteCollectorProxy $group) { $group->get('/logout', LogoutAction::class)->setName('account.logout'); - $group->post('/settings', SettingsAction::class)->setName('settings'); - $group->post('/settings/profile', ProfileAction::class)->setName('settings.profile'); + $group->post('/settings', SettingsEditAction::class)->setName('settings'); + $group->post('/settings/profile', ProfileEditAction::class)->setName('settings.profile'); + $group->post('/settings/email', ProfileEmailEditAction::class)->setName('settings.email'); })->add(AuthGuard::class)->add(NoCache::class); // No guard diff --git a/packages/sprinkle-account/app/tests/Controller/ProfileActionTest.php b/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php similarity index 89% rename from packages/sprinkle-account/app/tests/Controller/ProfileActionTest.php rename to packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php index 5c693d083..54ba61d80 100644 --- a/packages/sprinkle-account/app/tests/Controller/ProfileActionTest.php +++ b/packages/sprinkle-account/app/tests/Controller/ProfileEditActionTest.php @@ -12,14 +12,13 @@ namespace UserFrosting\Sprinkle\Account\Tests\Controller; -use UserFrosting\Alert\AlertStream; use UserFrosting\Config\Config; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; -class ProfileActionTest extends AccountTestCase +class ProfileEditActionTest extends AccountTestCase { use RefreshDatabase; use WithTestUser; @@ -48,7 +47,9 @@ public function testProfile(): void $response = $this->handleRequest($request); // Assert response status & body - $this->assertResponse('', $response); + $this->assertJsonResponse([ + 'message' => 'Profile settings updated', + ], $response); $this->assertResponseStatus(200, $response); // Make sure user was update @@ -57,12 +58,6 @@ public function testProfile(): void $this->assertSame('foo', $editedUser->first_name); $this->assertSame($user->last_name, $editedUser->last_name); $this->assertSame($user->locale, $editedUser->locale); - - // Test message - /** @var AlertStream */ - $ms = $this->ci->get(AlertStream::class); - $messages = $ms->getAndClearMessages(); - $this->assertSame('success', array_reverse($messages)[0]['type']); } public function testProfileWithNoPermissions(): void @@ -78,12 +73,6 @@ public function testProfileWithNoPermissions(): void // Assert response status & body $this->assertJsonResponse('Access Denied', $response, 'title'); $this->assertResponseStatus(403, $response); - - // Test message - /** @var AlertStream */ - $ms = $this->ci->get(AlertStream::class); - $messages = $ms->getAndClearMessages(); - $this->assertSame('danger', array_reverse($messages)[0]['type']); } public function testProfileWithOneLocale(): void diff --git a/packages/sprinkle-account/app/tests/Controller/SettingsActionTest.php b/packages/sprinkle-account/app/tests/Controller/ProfileEmailEditActionTest.php similarity index 66% rename from packages/sprinkle-account/app/tests/Controller/SettingsActionTest.php rename to packages/sprinkle-account/app/tests/Controller/ProfileEmailEditActionTest.php index 5916cb321..339854c52 100644 --- a/packages/sprinkle-account/app/tests/Controller/SettingsActionTest.php +++ b/packages/sprinkle-account/app/tests/Controller/ProfileEmailEditActionTest.php @@ -12,13 +12,12 @@ namespace UserFrosting\Sprinkle\Account\Tests\Controller; -use UserFrosting\Alert\AlertStream; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Testing\WithTestUser; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; -class SettingsActionTest extends AccountTestCase +class ProfileEmailEditActionTest extends AccountTestCase { use RefreshDatabase; use WithTestUser; @@ -39,29 +38,22 @@ public function testSettings(): void $this->actAsUser($user, true); // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings', [ + $request = $this->createJsonRequest('POST', '/account/settings/email', [ 'passwordcheck' => 'potato', 'email' => 'testSettings@test.com', - 'password' => 'testrSetPassword', - 'passwordc' => 'testrSetPassword', ]); $response = $this->handleRequest($request); // Assert response status & body - $this->assertResponse('', $response); + $this->assertJsonResponse([ + 'message' => 'Account settings updated', + ], $response); $this->assertResponseStatus(200, $response); - // Test message - /** @var AlertStream */ - $ms = $this->ci->get(AlertStream::class); - $messages = $ms->getAndClearMessages(); - $this->assertSame('success', array_reverse($messages)[0]['type']); - // Refresh user, make sure password was hashed, and it actually changed. /** @var User */ $freshUser = User::find($user->id); - $this->assertNotSame('testrSetPassword', $freshUser->password); - $this->assertTrue($freshUser->comparePassword('testrSetPassword')); + $this->assertSame('testSettings@test.com', $freshUser->email); } public function testSettingsWithNoPermissions(): void @@ -71,44 +63,12 @@ public function testSettingsWithNoPermissions(): void $this->actAsUser($user); // No permissions ! // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings'); + $request = $this->createJsonRequest('POST', '/account/settings/email'); $response = $this->handleRequest($request); // Assert response status & body $this->assertJsonResponse('Access Denied', $response, 'title'); $this->assertResponseStatus(403, $response); - - // Test message - /** @var AlertStream */ - $ms = $this->ci->get(AlertStream::class); - $messages = $ms->getAndClearMessages(); - $this->assertSame('danger', array_reverse($messages)[0]['type']); - } - - public function testSettingsOnlyEmailNoLocale(): void - { - /** @var User */ - $user = User::factory(['password' => 'potato'])->create(); - $this->actAsUser($user, permissions: ['update_account_settings']); // Assert specific permission while at it - - // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings', [ - 'passwordcheck' => 'potato', - 'email' => 'testSettings@test.com', - 'password' => '', - 'passwordc' => '', - ]); - $response = $this->handleRequest($request); - - // Assert response status & body - $this->assertResponse('', $response); - $this->assertResponseStatus(200, $response); - - // Test message - /** @var AlertStream */ - $ms = $this->ci->get(AlertStream::class); - $messages = $ms->getAndClearMessages(); - $this->assertSame('success', array_reverse($messages)[0]['type']); } public function testSettingsWithFailedValidation(): void @@ -118,7 +78,7 @@ public function testSettingsWithFailedValidation(): void $this->actAsUser($user, true); // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings'); + $request = $this->createJsonRequest('POST', '/account/settings/email'); $response = $this->handleRequest($request); // Assert response status & body @@ -133,11 +93,9 @@ public function testSettingsWithFailedPasswordCheck(): void $this->actAsUser($user, true); // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings', [ + $request = $this->createJsonRequest('POST', '/account/settings/email', [ 'passwordcheck' => 'foo', //<-- Not potato 'email' => 'testSettings@test.com', - 'password' => 'testrSetPassword', - 'passwordc' => 'testrSetPassword', ]); $response = $this->handleRequest($request); @@ -157,11 +115,9 @@ public function testSettingsWithEmailInUse(): void $firstUser = User::factory()->create(); // Create request with method and url and fetch response - $request = $this->createJsonRequest('POST', '/account/settings', [ + $request = $this->createJsonRequest('POST', '/account/settings/email', [ 'passwordcheck' => 'potato', 'email' => $firstUser->email, - 'password' => 'testrSetPassword', - 'passwordc' => 'testrSetPassword', ]); $response = $this->handleRequest($request); diff --git a/packages/sprinkle-account/app/tests/Controller/SettingsEditActionTest.php b/packages/sprinkle-account/app/tests/Controller/SettingsEditActionTest.php new file mode 100644 index 000000000..7a7fe765b --- /dev/null +++ b/packages/sprinkle-account/app/tests/Controller/SettingsEditActionTest.php @@ -0,0 +1,110 @@ +refreshDatabase(); + } + + public function testSettings(): void + { + /** @var User */ + $user = User::factory(['password' => 'potato'])->create(); + $this->actAsUser($user, true); + + // Create request with method and url and fetch response + $request = $this->createJsonRequest('POST', '/account/settings', [ + 'passwordcheck' => 'potato', + 'password' => 'testrSetPassword', + 'passwordc' => 'testrSetPassword', + ]); + $response = $this->handleRequest($request); + + // Assert response status & body + $this->assertJsonResponse([ + 'message' => 'Account settings updated', + ], $response); + $this->assertResponseStatus(200, $response); + + // Refresh user, make sure password was hashed, and it actually changed. + /** @var User */ + $freshUser = User::find($user->id); + $this->assertNotSame('testrSetPassword', $freshUser->password); + $this->assertTrue($freshUser->comparePassword('testrSetPassword')); + } + + public function testSettingsWithNoPermissions(): void + { + /** @var User */ + $user = User::factory()->create(); + $this->actAsUser($user); // No permissions ! + + // Create request with method and url and fetch response + $request = $this->createJsonRequest('POST', '/account/settings'); + $response = $this->handleRequest($request); + + // Assert response status & body + $this->assertJsonResponse('Access Denied', $response, 'title'); + $this->assertResponseStatus(403, $response); + } + + public function testSettingsWithFailedValidation(): void + { + /** @var User */ + $user = User::factory(['password' => 'potato'])->create(); + $this->actAsUser($user, true); + + // Create request with method and url and fetch response + $request = $this->createJsonRequest('POST', '/account/settings'); + $response = $this->handleRequest($request); + + // Assert response status & body + $this->assertJsonResponse('Validation error', $response, 'title'); + $this->assertResponseStatus(400, $response); + } + + public function testSettingsWithFailedPasswordCheck(): void + { + /** @var User */ + $user = User::factory(['password' => 'potato'])->create(); + $this->actAsUser($user, true); + + // Create request with method and url and fetch response + $request = $this->createJsonRequest('POST', '/account/settings', [ + 'passwordcheck' => 'foo', //<-- Not potato + 'password' => 'testrSetPassword', + 'passwordc' => 'testrSetPassword', + ]); + $response = $this->handleRequest($request); + + // Assert response status & body + $this->assertJsonResponse('Account Exception', $response, 'title'); + $this->assertJsonResponse("Current password doesn't match the one we have on record", $response, 'description'); + $this->assertResponseStatus(400, $response); + } +} diff --git a/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts b/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts index 63a92f2f8..29c6b82e8 100644 --- a/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts +++ b/packages/sprinkle-admin/app/assets/composables/useRoleUpdateApi.ts @@ -1,7 +1,7 @@ import { ref } from 'vue' import axios from 'axios' -import { Severity, type AlertInterface } from '@userfrosting/sprinkle-core/interfaces' -import type { ApiResponse } from '../interfaces' +import { Severity } from '@userfrosting/sprinkle-core/interfaces' +import type { ApiResponse, AlertInterface } from '@userfrosting/sprinkle-core/interfaces' // TODO : Add validation // 'schema://requests/role/edit-field.yaml' diff --git a/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts b/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts index 325e65ee6..432addb5b 100644 --- a/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts +++ b/packages/sprinkle-admin/app/assets/composables/useUserUpdateApi.ts @@ -1,7 +1,7 @@ import { ref } from 'vue' import axios from 'axios' -import { Severity, type AlertInterface } from '@userfrosting/sprinkle-core/interfaces' -import type { ApiResponse } from '../interfaces' +import { Severity } from '@userfrosting/sprinkle-core/interfaces' +import type { AlertInterface, ApiResponse } from '@userfrosting/sprinkle-core/interfaces' // TODO : Add validation // 'schema://requests/user/edit-field.yaml' diff --git a/packages/sprinkle-admin/app/assets/interfaces/index.ts b/packages/sprinkle-admin/app/assets/interfaces/index.ts index 833c63681..138877b4c 100644 --- a/packages/sprinkle-admin/app/assets/interfaces/index.ts +++ b/packages/sprinkle-admin/app/assets/interfaces/index.ts @@ -28,6 +28,3 @@ export type { UserEditRequest, UserEditResponse } from './UserEditApi' export type { UserPasswordRequest } from './UserPasswordApi' export type { UsersSprunjerResponse } from './UsersApi' export type { UserRoleSprunjeResponse } from './UserRolesApi' - -// Misc -export type { ApiResponse } from './ApiResponse' diff --git a/packages/sprinkle-admin/app/assets/interfaces/ApiResponse.ts b/packages/sprinkle-core/app/assets/interfaces/ApiResponse.ts similarity index 75% rename from packages/sprinkle-admin/app/assets/interfaces/ApiResponse.ts rename to packages/sprinkle-core/app/assets/interfaces/ApiResponse.ts index ca6d05bcb..1a354ffb5 100644 --- a/packages/sprinkle-admin/app/assets/interfaces/ApiResponse.ts +++ b/packages/sprinkle-core/app/assets/interfaces/ApiResponse.ts @@ -1,5 +1,7 @@ /** * Interfaces - What the API expects and what it returns + * + * Generic API Response interface. */ export interface ApiResponse { message: string diff --git a/packages/sprinkle-core/app/assets/interfaces/index.ts b/packages/sprinkle-core/app/assets/interfaces/index.ts index 77783e06c..cabb54b26 100644 --- a/packages/sprinkle-core/app/assets/interfaces/index.ts +++ b/packages/sprinkle-core/app/assets/interfaces/index.ts @@ -24,3 +24,6 @@ export type { AssociativeArray } from './common' export { Severity } from './severity' export type { Sprunjer, SprunjerData, SprunjerListable, SprunjerListableOption } from './sprunjer' export type { SprunjerRequest, SprunjerResponse } from './sprunjerApi' + +// Misc +export type { ApiResponse } from './ApiResponse' diff --git a/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserEmail.vue b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserEmail.vue new file mode 100644 index 000000000..6e4b407a3 --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserEmail.vue @@ -0,0 +1,100 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserPassword.vue b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserPassword.vue new file mode 100644 index 000000000..6be11f480 --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserPassword.vue @@ -0,0 +1,117 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserProfile.vue b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserProfile.vue new file mode 100644 index 000000000..af1dba671 --- /dev/null +++ b/packages/theme-pink-cupcake/src/components/Pages/Account/FormUserProfile.vue @@ -0,0 +1,122 @@ + + + diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordForm.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordForm.vue index 7a14308c9..e500ae86d 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordForm.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserPasswordForm.vue @@ -30,7 +30,7 @@ const emits = defineEmits(['submit']) +
+
+ +
    + + + + +
+
+
+
+ +
+
+ diff --git a/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsEmail.vue b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsEmail.vue new file mode 100644 index 000000000..f9bd0abce --- /dev/null +++ b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsEmail.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsPassword.vue b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsPassword.vue new file mode 100644 index 000000000..38cf712e9 --- /dev/null +++ b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsPassword.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsProfile.vue b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsProfile.vue new file mode 100644 index 000000000..f2afd696c --- /dev/null +++ b/packages/theme-pink-cupcake/src/views/Account/PageUserSettingsProfile.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/theme-pink-cupcake/src/views/Account/index.ts b/packages/theme-pink-cupcake/src/views/Account/index.ts index abcbdb51b..c1cc95c45 100644 --- a/packages/theme-pink-cupcake/src/views/Account/index.ts +++ b/packages/theme-pink-cupcake/src/views/Account/index.ts @@ -2,5 +2,18 @@ import PageLogin from './PageLogin.vue' import PageRegister from './PageRegister.vue' import PageForgotPassword from './PageForgotPassword.vue' import PageResendVerification from './PageResendVerification.vue' +import PageUserSettings from './PageUserSettings.vue' +import PageUserSettingsPassword from './PageUserSettingsPassword.vue' +import PageUserSettingsProfile from './PageUserSettingsProfile.vue' +import PageUserSettingsEmail from './PageUserSettingsEmail.vue' -export { PageLogin, PageRegister, PageForgotPassword, PageResendVerification } +export { + PageLogin, + PageRegister, + PageForgotPassword, + PageResendVerification, + PageUserSettings, + PageUserSettingsPassword, + PageUserSettingsProfile, + PageUserSettingsEmail +}