From 79b7fe0b42b6120fe7bf02627e2bc2086925f074 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 15 Apr 2026 22:18:16 +0200 Subject: [PATCH] feat: account settings with Vuexy tab pattern and MFA banner fix Restructures account/profile pages to match Vuexy's account-settings tab pattern (Account, Security, Notifications) and fixes the MFA enforcement banner that stayed visible after successful setup. Backend: - Add phone column to users table with migration - Add PUT /me/profile endpoint for profile updates - Create UpdateProfileRequest form request - Update MeResource to include phone field Organizer app: - Rewrite account-settings as tabbed page (VTabs pill style + VWindow) - Create AccountTab: avatar, profile form, email change, danger zone - Create SecurityTab: password change, MFA method cards, backup codes, trusted devices, disable MFA danger zone - Create NotificationsTab: placeholder with disabled toggles - Fix MFA banner: set authStore.mfaSetupRequired = false on setup complete - Update router guard to redirect to ?tab=security for MFA enforcement - Update UserProfile menu links to use tab query params Portal: - Restructure profiel.vue with VTabs (Mijn profiel + Beveiliging) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/Api/V1/AccountController.php | 20 + .../Requests/Api/V1/UpdateProfileRequest.php | 27 + api/app/Http/Resources/Api/V1/MeResource.php | 1 + api/app/Models/User.php | 1 + ..._04_16_000000_add_phone_to_users_table.php | 24 + api/routes/api.php | 1 + apps/app/components.d.ts | 3 + .../account-settings/AccountTab.vue | 442 +++++++ .../account-settings/NotificationsTab.vue | 60 + .../account-settings/SecurityTab.vue | 639 ++++++++++ apps/app/src/composables/api/useAccount.ts | 28 +- .../src/layouts/components/UserProfile.vue | 8 +- apps/app/src/pages/account-settings/index.vue | 257 +--- .../src/pages/account-settings/security.vue | 398 ------- apps/app/src/pages/login.vue | 2 +- apps/app/src/plugins/1.router/guards.ts | 4 +- apps/app/src/stores/useAuthStore.ts | 2 + apps/app/src/types/auth.ts | 4 + apps/app/src/types/member.ts | 2 + apps/app/typed-router.d.ts | 1 - apps/portal/auto-imports.d.ts | 4 + apps/portal/src/pages/profiel.vue | 1033 +++++++++-------- 22 files changed, 1860 insertions(+), 1101 deletions(-) create mode 100644 api/app/Http/Requests/Api/V1/UpdateProfileRequest.php create mode 100644 api/database/migrations/2026_04_16_000000_add_phone_to_users_table.php create mode 100644 apps/app/src/components/account-settings/AccountTab.vue create mode 100644 apps/app/src/components/account-settings/NotificationsTab.vue create mode 100644 apps/app/src/components/account-settings/SecurityTab.vue delete mode 100644 apps/app/src/pages/account-settings/security.vue diff --git a/api/app/Http/Controllers/Api/V1/AccountController.php b/api/app/Http/Controllers/Api/V1/AccountController.php index c67a6c60..a5cdb5ac 100644 --- a/api/app/Http/Controllers/Api/V1/AccountController.php +++ b/api/app/Http/Controllers/Api/V1/AccountController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\V1\UpdateProfileRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,25 @@ use Illuminate\Validation\ValidationException; final class AccountController extends Controller { + /** + * PUT /api/v1/me/profile + * Authenticated user updates their profile. + */ + public function updateProfile(UpdateProfileRequest $request): JsonResponse + { + $user = $request->user(); + + $user->update($request->validated()); + + activity() + ->causedBy($user) + ->performedOn($user) + ->log('user.profile.updated'); + + return $this->success(message: 'Je profiel is bijgewerkt.'); + } + + /** * POST /api/v1/me/change-password * Authenticated user changes their own password. diff --git a/api/app/Http/Requests/Api/V1/UpdateProfileRequest.php b/api/app/Http/Requests/Api/V1/UpdateProfileRequest.php new file mode 100644 index 00000000..67d19de7 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateProfileRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'phone' => ['nullable', 'string', 'max:20'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], + 'timezone' => ['required', 'string', 'timezone:all'], + 'locale' => ['required', 'string', 'in:nl,en'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php index fe4559a6..76c1328e 100644 --- a/api/app/Http/Resources/Api/V1/MeResource.php +++ b/api/app/Http/Resources/Api/V1/MeResource.php @@ -20,6 +20,7 @@ final class MeResource extends JsonResource 'full_name' => $this->full_name, 'date_of_birth' => $this->date_of_birth?->toDateString(), 'email' => $this->email, + 'phone' => $this->phone, 'timezone' => $this->timezone, 'locale' => $this->locale, 'avatar' => $this->avatar, diff --git a/api/app/Models/User.php b/api/app/Models/User.php index 9b2abe93..100da495 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -28,6 +28,7 @@ final class User extends Authenticatable 'last_name', 'date_of_birth', 'email', + 'phone', 'password', 'timezone', 'locale', diff --git a/api/database/migrations/2026_04_16_000000_add_phone_to_users_table.php b/api/database/migrations/2026_04_16_000000_add_phone_to_users_table.php new file mode 100644 index 00000000..4888b368 --- /dev/null +++ b/api/database/migrations/2026_04_16_000000_add_phone_to_users_table.php @@ -0,0 +1,24 @@ +string('phone', 20)->nullable()->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('phone'); + }); + } +}; diff --git a/api/routes/api.php b/api/routes/api.php index 171b0e12..8fe034c4 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -144,6 +144,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::delete('auth/trusted-devices', [TrustedDeviceController::class, 'destroyAll']); // Account management (self-service) + Route::put('me/profile', [AccountController::class, 'updateProfile']); Route::post('me/change-password', [AccountController::class, 'changePassword']); Route::post('me/change-email', [EmailChangeController::class, 'request']); diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index a9583049..564ce4de 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -7,6 +7,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AccountTab: typeof import('./src/components/account-settings/AccountTab.vue')['default'] AddEditAddressDialog: typeof import('./src/components/dialogs/AddEditAddressDialog.vue')['default'] AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default'] AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default'] @@ -76,6 +77,7 @@ declare module 'vue' { MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] + NotificationsTab: typeof import('./src/components/account-settings/NotificationsTab.vue')['default'] OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default'] PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default'] PersonDetailPanel: typeof import('./src/components/persons/PersonDetailPanel.vue')['default'] @@ -88,6 +90,7 @@ declare module 'vue' { RouterView: typeof import('vue-router')['RouterView'] ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default'] SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default'] + SecurityTab: typeof import('./src/components/account-settings/SecurityTab.vue')['default'] ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default'] ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default'] Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default'] diff --git a/apps/app/src/components/account-settings/AccountTab.vue b/apps/app/src/components/account-settings/AccountTab.vue new file mode 100644 index 00000000..d55a1bfc --- /dev/null +++ b/apps/app/src/components/account-settings/AccountTab.vue @@ -0,0 +1,442 @@ + + + diff --git a/apps/app/src/components/account-settings/NotificationsTab.vue b/apps/app/src/components/account-settings/NotificationsTab.vue new file mode 100644 index 00000000..cef0df21 --- /dev/null +++ b/apps/app/src/components/account-settings/NotificationsTab.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/app/src/components/account-settings/SecurityTab.vue b/apps/app/src/components/account-settings/SecurityTab.vue new file mode 100644 index 00000000..d14abccf --- /dev/null +++ b/apps/app/src/components/account-settings/SecurityTab.vue @@ -0,0 +1,639 @@ + + + + + diff --git a/apps/app/src/composables/api/useAccount.ts b/apps/app/src/composables/api/useAccount.ts index c8096788..75cbd3f2 100644 --- a/apps/app/src/composables/api/useAccount.ts +++ b/apps/app/src/composables/api/useAccount.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/vue-query' +import { useMutation, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' @@ -8,6 +8,15 @@ interface ApiResponse { message: string } +export interface UpdateProfilePayload { + first_name: string + last_name: string + phone?: string | null + date_of_birth?: string | null + timezone: string + locale: string +} + export interface ChangePasswordPayload { current_password: string password: string @@ -24,6 +33,23 @@ export interface AdminChangeEmailPayload { new_email: string } +export function useUpdateProfile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: UpdateProfilePayload) => { + const { data } = await apiClient.put>( + '/me/profile', + payload, + ) + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + export function useChangePassword() { return useMutation({ mutationFn: async (payload: ChangePasswordPayload) => { diff --git a/apps/app/src/layouts/components/UserProfile.vue b/apps/app/src/layouts/components/UserProfile.vue index 2d53703e..c818ca3c 100644 --- a/apps/app/src/layouts/components/UserProfile.vue +++ b/apps/app/src/layouts/components/UserProfile.vue @@ -76,18 +76,18 @@ function handleLogout() { - + - Accountinstellingen + Mijn profiel - +