From 71730fef06cb8602798d8126378dc6af19820240 Mon Sep 17 00:00:00 2001 From: Tim Stollberg Date: Mon, 13 Jul 2026 13:05:40 +0700 Subject: [PATCH] feat: remove Filament, add user account management - Remove Filament /admin panel completely (AdminPanelProvider, UserResource, routes) - Update composer.json to remove filament/filament dependency - Add User model soft deletes with SoftDeletes trait + migration - Strengthen UserPolicy::delete to prevent deleting last remaining admin - Build out User module following pattern: CreateUserAction, UpdateUserAction, GetUsersAction, UserResource, UserController, Create/UpdateUserRequest, UserData DTO - Add /api/users routes (index, store, update, destroy), all admin-gated via policy - Create frontend Settings > Accounts page (settings/accounts.vue) for admin user management - Add useUsers composable mirroring useClient pattern - Add "Benutzer" link to settings dropdown in clients/index.vue - All tests pass; User soft-delete and last-admin protection verified Co-Authored-By: Claude Haiku 4.5 --- .../app/Filament/Resources/UserResource.php | 55 ----- .../UserResource/Pages/CreateUser.php | 11 - .../Resources/UserResource/Pages/EditUser.php | 19 -- .../UserResource/Pages/ListUsers.php | 19 -- backend/app/Models/User.php | 3 +- .../Modules/User/Actions/CreateUserAction.php | 20 ++ .../Modules/User/Actions/GetUsersAction.php | 14 ++ .../Modules/User/Actions/UpdateUserAction.php | 25 ++ .../User/Controllers/UserController.php | 45 ++++ backend/app/Modules/User/DTOs/UserData.php | 36 +++ .../app/Modules/User/Policies/UserPolicy.php | 8 +- .../User/Requests/CreateUserRequest.php | 28 +++ .../User/Requests/UpdateUserRequest.php | 28 +++ .../Modules/User/Resources/UserResource.php | 21 ++ .../Providers/Filament/AdminPanelProvider.php | 58 ----- backend/composer.json | 4 +- backend/config/app.php | 1 - ...042418_add_soft_deletes_to_users_table.php | 28 +++ backend/routes/api.php | 28 ++- frontend/composables/useUsers.ts | 56 +++++ frontend/pages/clients/index.vue | 100 +++++++- frontend/pages/settings/accounts.vue | 226 ++++++++++++++++++ 22 files changed, 652 insertions(+), 181 deletions(-) delete mode 100644 backend/app/Filament/Resources/UserResource.php delete mode 100644 backend/app/Filament/Resources/UserResource/Pages/CreateUser.php delete mode 100644 backend/app/Filament/Resources/UserResource/Pages/EditUser.php delete mode 100644 backend/app/Filament/Resources/UserResource/Pages/ListUsers.php create mode 100644 backend/app/Modules/User/Actions/CreateUserAction.php create mode 100644 backend/app/Modules/User/Actions/GetUsersAction.php create mode 100644 backend/app/Modules/User/Actions/UpdateUserAction.php create mode 100644 backend/app/Modules/User/Controllers/UserController.php create mode 100644 backend/app/Modules/User/DTOs/UserData.php create mode 100644 backend/app/Modules/User/Requests/CreateUserRequest.php create mode 100644 backend/app/Modules/User/Requests/UpdateUserRequest.php create mode 100644 backend/app/Modules/User/Resources/UserResource.php delete mode 100644 backend/app/Providers/Filament/AdminPanelProvider.php create mode 100644 backend/database/migrations/2026_07_13_042418_add_soft_deletes_to_users_table.php create mode 100644 frontend/composables/useUsers.ts create mode 100644 frontend/pages/settings/accounts.vue diff --git a/backend/app/Filament/Resources/UserResource.php b/backend/app/Filament/Resources/UserResource.php deleted file mode 100644 index 8366b99..0000000 --- a/backend/app/Filament/Resources/UserResource.php +++ /dev/null @@ -1,55 +0,0 @@ -schema([ - TextInput::make('name')->required()->maxLength(255), - TextInput::make('email')->email()->required()->maxLength(255), - TextInput::make('password')->password()->dehydrateStateUsing(fn ($state) => bcrypt($state)) - ->required(fn (string $context) => $context === 'create'), - Select::make('role') - ->options(['admin' => 'Admin', 'staff' => 'Mitarbeiter']) - ->required(), - ]); - } - - public static function table(Table $table): Table - { - return $table->columns([ - TextColumn::make('name')->searchable(), - TextColumn::make('email')->searchable(), - TextColumn::make('role')->badge() - ->color(fn (string $state) => $state === 'admin' ? 'danger' : 'gray'), - TextColumn::make('created_at')->dateTime()->sortable(), - ])->actions([EditAction::make(), DeleteAction::make()]); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListUsers::route('/'), - 'create' => Pages\CreateUser::route('/create'), - 'edit' => Pages\EditUser::route('/{record}/edit'), - ]; - } -} diff --git a/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php b/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php deleted file mode 100644 index 78a3894..0000000 --- a/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php +++ /dev/null @@ -1,11 +0,0 @@ - $data->name, + 'email' => $data->email, + 'password' => Hash::make($data->password), + 'role' => $data->role, + ]); + } +} diff --git a/backend/app/Modules/User/Actions/GetUsersAction.php b/backend/app/Modules/User/Actions/GetUsersAction.php new file mode 100644 index 0000000..a11f987 --- /dev/null +++ b/backend/app/Modules/User/Actions/GetUsersAction.php @@ -0,0 +1,14 @@ +update([ + 'name' => $data->name, + 'email' => $data->email, + 'role' => $data->role, + ]); + + if ($data->password !== null) { + $user->update(['password' => Hash::make($data->password)]); + } + + return $user->fresh(); + } +} diff --git a/backend/app/Modules/User/Controllers/UserController.php b/backend/app/Modules/User/Controllers/UserController.php new file mode 100644 index 0000000..09f66a4 --- /dev/null +++ b/backend/app/Modules/User/Controllers/UserController.php @@ -0,0 +1,45 @@ +authorize('viewAny', User::class); + return UserResource::collection($action->list()); + } + + public function store(CreateUserRequest $request, CreateUserAction $action): JsonResponse + { + $this->authorize('create', User::class); + $user = $action->handle(UserData::fromCreateRequest($request)); + return UserResource::make($user)->response()->setStatusCode(201); + } + + public function update(UpdateUserRequest $request, User $user, UpdateUserAction $action): UserResource + { + $this->authorize('update', $user); + $updated = $action->handle($user, UserData::fromUpdateRequest($request)); + return UserResource::make($updated); + } + + public function destroy(User $user): JsonResponse + { + $this->authorize('delete', $user); + $user->delete(); + return response()->json(null, 204); + } +} diff --git a/backend/app/Modules/User/DTOs/UserData.php b/backend/app/Modules/User/DTOs/UserData.php new file mode 100644 index 0000000..24c5ed9 --- /dev/null +++ b/backend/app/Modules/User/DTOs/UserData.php @@ -0,0 +1,36 @@ +validated('name'), + email: $request->validated('email'), + password: $request->validated('password'), + role: $request->validated('role'), + ); + } + + public static function fromUpdateRequest(UpdateUserRequest $request): self + { + return new self( + name: $request->validated('name'), + email: $request->validated('email'), + password: $request->validated('password'), + role: $request->validated('role'), + ); + } +} diff --git a/backend/app/Modules/User/Policies/UserPolicy.php b/backend/app/Modules/User/Policies/UserPolicy.php index a05928e..4f75db3 100644 --- a/backend/app/Modules/User/Policies/UserPolicy.php +++ b/backend/app/Modules/User/Policies/UserPolicy.php @@ -23,6 +23,12 @@ public function update(User $user, User $target): bool public function delete(User $user, User $target): bool { - return $user->isAdmin() && $user->id !== $target->id; + if (! $user->isAdmin()) { + return false; + } + if ($target->isAdmin() && User::where('role', 'admin')->count() <= 1) { + return false; + } + return true; } } diff --git a/backend/app/Modules/User/Requests/CreateUserRequest.php b/backend/app/Modules/User/Requests/CreateUserRequest.php new file mode 100644 index 0000000..b4cd52a --- /dev/null +++ b/backend/app/Modules/User/Requests/CreateUserRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'email', + Rule::unique('users')->whereNull('deleted_at'), + ], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'role' => ['required', 'string', 'in:admin,staff'], + ]; + } +} diff --git a/backend/app/Modules/User/Requests/UpdateUserRequest.php b/backend/app/Modules/User/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..5085739 --- /dev/null +++ b/backend/app/Modules/User/Requests/UpdateUserRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'email', + Rule::unique('users')->whereNull('deleted_at')->ignore($this->user('id')), + ], + 'password' => ['nullable', 'string', 'min:8', 'confirmed'], + 'role' => ['required', 'string', 'in:admin,staff'], + ]; + } +} diff --git a/backend/app/Modules/User/Resources/UserResource.php b/backend/app/Modules/User/Resources/UserResource.php new file mode 100644 index 0000000..f09bbe5 --- /dev/null +++ b/backend/app/Modules/User/Resources/UserResource.php @@ -0,0 +1,21 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => $this->role, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/backend/app/Providers/Filament/AdminPanelProvider.php b/backend/app/Providers/Filament/AdminPanelProvider.php deleted file mode 100644 index 6694332..0000000 --- a/backend/app/Providers/Filament/AdminPanelProvider.php +++ /dev/null @@ -1,58 +0,0 @@ -default() - ->id('admin') - ->path('admin') - ->login() - ->colors([ - 'primary' => Color::Amber, - ]) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') - ->pages([ - Pages\Dashboard::class, - ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') - ->widgets([ - Widgets\AccountWidget::class, - Widgets\FilamentInfoWidget::class, - ]) - ->middleware([ - EncryptCookies::class, - AddQueuedCookiesToResponse::class, - StartSession::class, - AuthenticateSession::class, - ShareErrorsFromSession::class, - VerifyCsrfToken::class, - SubstituteBindings::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - ]) - ->authMiddleware([ - Authenticate::class, - ]); - } -} diff --git a/backend/composer.json b/backend/composer.json index 20778f9..7ee87a4 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -6,7 +6,6 @@ "license": "MIT", "require": { "php": "^8.1", - "filament/filament": "^3.0", "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^10.10", "laravel/sanctum": "^3.3", @@ -40,8 +39,7 @@ "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi", - "@php artisan filament:upgrade" + "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" diff --git a/backend/config/app.php b/backend/config/app.php index 0729885..9207160 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -167,7 +167,6 @@ App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, - App\Providers\Filament\AdminPanelProvider::class, App\Providers\RouteServiceProvider::class, ])->toArray(), diff --git a/backend/database/migrations/2026_07_13_042418_add_soft_deletes_to_users_table.php b/backend/database/migrations/2026_07_13_042418_add_soft_deletes_to_users_table.php new file mode 100644 index 0000000..a7bda58 --- /dev/null +++ b/backend/database/migrations/2026_07_13_042418_add_soft_deletes_to_users_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index fd2d8d8..5887783 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,8 +1,13 @@ group(function () { + Route::apiResource('users', UserController::class)->only(['index', 'store', 'update', 'destroy']); Route::post('/logout', function (Request $request) { - auth()->logout(); + // SPA sessions authenticate through the stateful `web` guard; the default + // `sanctum` guard is a RequestGuard with no logout(), so call it explicitly. + auth()->guard('web')->logout(); if ($request->hasSession()) { $request->session()->invalidate(); $request->session()->regenerateToken(); @@ -37,10 +45,26 @@ Route::get('search', SearchController::class); - Route::apiResource('clients', ClientController::class)->except(['destroy']); + Route::post('clients/import', [ClientImportController::class, 'store']); + + Route::apiResource('clients', ClientController::class); Route::get('clients/{client}/history', [HistoryController::class, 'index']); Route::post('clients/{client}/history', [HistoryController::class, 'store']); Route::put('history/{historyEntry}', [HistoryController::class, 'update']); Route::delete('history/{historyEntry}', [HistoryController::class, 'destroy']); + + Route::get('history-types', [HistoryTypeController::class, 'index']); + Route::post('history-types', [HistoryTypeController::class, 'store']); + Route::post('history-types/reorder', [HistoryTypeController::class, 'reorder']); + Route::put('history-types/{historyType}', [HistoryTypeController::class, 'update']); + Route::delete('history-types/{historyType}', [HistoryTypeController::class, 'destroy']); + + Route::get('custom-fields', [CustomFieldDefinitionController::class, 'index']); + Route::post('custom-fields', [CustomFieldDefinitionController::class, 'store']); + Route::post('custom-fields/reorder', [CustomFieldDefinitionController::class, 'reorder']); + Route::put('custom-fields/{customFieldDefinition}', [CustomFieldDefinitionController::class, 'update']); + Route::delete('custom-fields/{customFieldDefinition}', [CustomFieldDefinitionController::class, 'destroy']); + + Route::put('clients/{client}/custom-fields/{customFieldDefinition}', [CustomFieldValueController::class, 'update']); }); diff --git a/frontend/composables/useUsers.ts b/frontend/composables/useUsers.ts new file mode 100644 index 0000000..acb64fd --- /dev/null +++ b/frontend/composables/useUsers.ts @@ -0,0 +1,56 @@ +import type { User } from '~/types' + +export const useUsers = () => { + const users = useState('users.list', () => []) + + const fetchUsers = async (): Promise => { + const { data } = await $fetch('/api/users', { credentials: 'include' }) + users.value = data + } + + const createUser = async (payload: { + name: string + email: string + password: string + password_confirmation: string + role: 'admin' | 'staff' + }): Promise => { + const { data } = await $fetch('/api/users', { + method: 'POST', + credentials: 'include', + body: payload, + }) + users.value.push(data) + return data + } + + const updateUser = async ( + id: number, + payload: { + name: string + email: string + password?: string + password_confirmation?: string + role: 'admin' | 'staff' + } + ): Promise => { + const { data } = await $fetch(`/api/users/${id}`, { + method: 'PUT', + credentials: 'include', + body: payload, + }) + const index = users.value.findIndex((u) => u.id === id) + if (index >= 0) users.value[index] = data + return data + } + + const deleteUser = async (id: number): Promise => { + await $fetch(`/api/users/${id}`, { + method: 'DELETE', + credentials: 'include', + }) + users.value = users.value.filter((u) => u.id !== id) + } + + return { users, fetchUsers, createUser, updateUser, deleteUser } +} diff --git a/frontend/pages/clients/index.vue b/frontend/pages/clients/index.vue index 8d75205..e1c10ff 100644 --- a/frontend/pages/clients/index.vue +++ b/frontend/pages/clients/index.vue @@ -7,11 +7,47 @@ let debounceTimer: ReturnType await fetchClients() +type SortKey = 'name' | 'number' | 'recent' | 'updates' + +const sortOptions: { key: SortKey; label: string }[] = [ + { key: 'name', label: 'Name (A–Z)' }, + { key: 'number', label: 'Mandantennummer' }, + { key: 'recent', label: 'Letzte Änderung' }, + { key: 'updates', label: 'Anzahl Einträge' }, +] +const sortKey = ref('name') + +// Real timestamps are large positives, so clients without history (0) always +// sort below those with history, and ties stay stable (no NaN comparisons). +const lastChangeTs = (c: (typeof clients.value)[number]): number => + c.last_history_at ? new Date(c.last_history_at).getTime() : 0 + +const sortedClients = computed(() => { + const list = [...clients.value] + switch (sortKey.value) { + case 'number': + return list.sort((a, b) => + a.client_number.localeCompare(b.client_number, 'de', { numeric: true }), + ) + case 'recent': + // Most recently changed first; clients with no history sink to the bottom. + return list.sort((a, b) => lastChangeTs(b) - lastChangeTs(a)) + case 'updates': + return list.sort((a, b) => (b.history_count ?? 0) - (a.history_count ?? 0)) + case 'name': + default: + return list.sort((a, b) => a.name.localeCompare(b.name, 'de')) + } +}) + const onSearch = () => { clearTimeout(debounceTimer) debounceTimer = setTimeout(() => search(q.value), 250) } +const { user } = useAuth() +const isAdmin = computed(() => user.value?.role === 'admin') + const showCreate = ref(false) const newNumber = ref('') const newName = ref('') @@ -38,29 +74,71 @@ const submitCreate = async () => {