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 <noreply@anthropic.com>
This commit is contained in:
parent
e5e0be57da
commit
71730fef06
22 changed files with 652 additions and 181 deletions
|
|
@ -1,55 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Models\User;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||
protected static ?string $label = 'Benutzer';
|
||||
protected static ?string $pluralLabel = 'Benutzer';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $fillable = ['name', 'email', 'password', 'role'];
|
||||
|
||||
|
|
|
|||
20
backend/app/Modules/User/Actions/CreateUserAction.php
Normal file
20
backend/app/Modules/User/Actions/CreateUserAction.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\User\DTOs\UserData;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class CreateUserAction
|
||||
{
|
||||
public function handle(UserData $data): User
|
||||
{
|
||||
return User::create([
|
||||
'name' => $data->name,
|
||||
'email' => $data->email,
|
||||
'password' => Hash::make($data->password),
|
||||
'role' => $data->role,
|
||||
]);
|
||||
}
|
||||
}
|
||||
14
backend/app/Modules/User/Actions/GetUsersAction.php
Normal file
14
backend/app/Modules/User/Actions/GetUsersAction.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class GetUsersAction
|
||||
{
|
||||
public function list(): Collection
|
||||
{
|
||||
return User::all();
|
||||
}
|
||||
}
|
||||
25
backend/app/Modules/User/Actions/UpdateUserAction.php
Normal file
25
backend/app/Modules/User/Actions/UpdateUserAction.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\User\DTOs\UserData;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UpdateUserAction
|
||||
{
|
||||
public function handle(User $user, UserData $data): User
|
||||
{
|
||||
$user->update([
|
||||
'name' => $data->name,
|
||||
'email' => $data->email,
|
||||
'role' => $data->role,
|
||||
]);
|
||||
|
||||
if ($data->password !== null) {
|
||||
$user->update(['password' => Hash::make($data->password)]);
|
||||
}
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
45
backend/app/Modules/User/Controllers/UserController.php
Normal file
45
backend/app/Modules/User/Controllers/UserController.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\User\Actions\CreateUserAction;
|
||||
use App\Modules\User\Actions\GetUsersAction;
|
||||
use App\Modules\User\Actions\UpdateUserAction;
|
||||
use App\Modules\User\DTOs\UserData;
|
||||
use App\Modules\User\Requests\CreateUserRequest;
|
||||
use App\Modules\User\Requests\UpdateUserRequest;
|
||||
use App\Modules\User\Resources\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(GetUsersAction $action): AnonymousResourceCollection
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
36
backend/app/Modules/User/DTOs/UserData.php
Normal file
36
backend/app/Modules/User/DTOs/UserData.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\DTOs;
|
||||
|
||||
use App\Modules\User\Requests\CreateUserRequest;
|
||||
use App\Modules\User\Requests\UpdateUserRequest;
|
||||
|
||||
final class UserData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $email,
|
||||
public readonly ?string $password,
|
||||
public readonly string $role,
|
||||
) {}
|
||||
|
||||
public static function fromCreateRequest(CreateUserRequest $request): self
|
||||
{
|
||||
return new self(
|
||||
name: $request->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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
backend/app/Modules/User/Requests/CreateUserRequest.php
Normal file
28
backend/app/Modules/User/Requests/CreateUserRequest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CreateUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
backend/app/Modules/User/Requests/UpdateUserRequest.php
Normal file
28
backend/app/Modules/User/Requests/UpdateUserRequest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
21
backend/app/Modules/User/Resources/UserResource.php
Normal file
21
backend/app/Modules/User/Resources/UserResource.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\User\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'role' => $this->role,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Modules\Client\Controllers\ClientController;
|
||||
use App\Modules\CustomField\Controllers\CustomFieldDefinitionController;
|
||||
use App\Modules\CustomField\Controllers\CustomFieldValueController;
|
||||
use App\Modules\History\Controllers\HistoryController;
|
||||
use App\Modules\History\Controllers\HistoryTypeController;
|
||||
use App\Modules\Import\Controllers\ClientImportController;
|
||||
use App\Modules\Search\Controllers\SearchController;
|
||||
use App\Modules\User\Controllers\UserController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -24,8 +29,11 @@
|
|||
});
|
||||
|
||||
Route::middleware('auth:sanctum')->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']);
|
||||
});
|
||||
|
|
|
|||
56
frontend/composables/useUsers.ts
Normal file
56
frontend/composables/useUsers.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { User } from '~/types'
|
||||
|
||||
export const useUsers = () => {
|
||||
const users = useState<User[]>('users.list', () => [])
|
||||
|
||||
const fetchUsers = async (): Promise<void> => {
|
||||
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<User> => {
|
||||
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<User> => {
|
||||
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<void> => {
|
||||
await $fetch(`/api/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
users.value = users.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
return { users, fetchUsers, createUser, updateUser, deleteUser }
|
||||
}
|
||||
|
|
@ -7,11 +7,47 @@ let debounceTimer: ReturnType<typeof setTimeout>
|
|||
|
||||
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<SortKey>('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 () => {
|
|||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
|
||||
<h1 class="text-base font-semibold text-gray-800">Mandant CRM</h1>
|
||||
<header class="grid grid-cols-3 items-center bg-white border-b border-gray-200 px-6 py-3">
|
||||
<h1 class="text-base font-semibold text-gray-800">Markus Grote CRM</h1>
|
||||
|
||||
<input
|
||||
v-model="q"
|
||||
@input="onSearch"
|
||||
type="search"
|
||||
placeholder="Mandant suchen (Name oder Nr.)…"
|
||||
class="flex-1 border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-md"
|
||||
class="w-full max-w-md justify-self-center border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
@click="showCreate = true"
|
||||
class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700"
|
||||
>
|
||||
+ Neuer Mandant
|
||||
</button>
|
||||
<LogoutButton />
|
||||
|
||||
<div class="justify-self-end flex items-center gap-3">
|
||||
<!-- Settings: hover-/focus-revealed menu -->
|
||||
<div v-if="isAdmin" class="relative group/settings">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Einstellungen"
|
||||
title="Einstellungen"
|
||||
class="p-1.5 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<IconSettings />
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-0 top-full pt-1 z-20 hidden group-hover/settings:block group-focus-within/settings:block"
|
||||
>
|
||||
<div class="bg-white border border-gray-200 shadow-md py-1 min-w-[150px]">
|
||||
<NuxtLink to="/settings/accounts" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
Benutzer
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/settings/fields" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
Felder
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/settings/history-types" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
Verlaufstypen
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/settings/import" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
Mandanten-Import
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-4xl mx-auto py-4 px-6 space-y-1">
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<span class="text-xs text-gray-500">{{ clients.length }} Mandanten</span>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
Sortieren
|
||||
<select
|
||||
v-model="sortKey"
|
||||
class="border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="clients.length === 0" class="text-sm text-gray-500 py-8 text-center">
|
||||
Keine Mandanten gefunden.
|
||||
</p>
|
||||
<ClientCard v-for="client in clients" :key="client.id" :client="client" />
|
||||
<ClientCard v-for="client in sortedClients" :key="client.id" :client="client" />
|
||||
</main>
|
||||
|
||||
<!-- Create modal -->
|
||||
|
|
|
|||
226
frontend/pages/settings/accounts.vue
Normal file
226
frontend/pages/settings/accounts.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import type { User } from '~/types'
|
||||
|
||||
definePageMeta({ middleware: ['auth', 'admin'] })
|
||||
|
||||
const { users, fetchUsers, createUser, updateUser, deleteUser } = useUsers()
|
||||
|
||||
await fetchUsers()
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref<{
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
role: 'admin' | 'staff'
|
||||
}>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
role: 'staff',
|
||||
})
|
||||
const error = ref('')
|
||||
|
||||
const resetForm = () => {
|
||||
editingId.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
role: 'staff',
|
||||
}
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const startEdit = (user: User) => {
|
||||
editingId.value = user.id
|
||||
error.value = ''
|
||||
form.value = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
error.value = ''
|
||||
|
||||
if (!form.value.name.trim()) {
|
||||
error.value = 'Bitte einen Namen angeben.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.email.trim()) {
|
||||
error.value = 'Bitte eine E-Mail angeben.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const payload: any = {
|
||||
name: form.value.name.trim(),
|
||||
email: form.value.email.trim(),
|
||||
role: form.value.role,
|
||||
}
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password
|
||||
payload.password_confirmation = form.value.password_confirmation
|
||||
}
|
||||
await updateUser(editingId.value, payload)
|
||||
} else {
|
||||
if (!form.value.password) {
|
||||
error.value = 'Bitte ein Passwort angeben.'
|
||||
return
|
||||
}
|
||||
await createUser({
|
||||
name: form.value.name.trim(),
|
||||
email: form.value.email.trim(),
|
||||
password: form.value.password,
|
||||
password_confirmation: form.value.password_confirmation,
|
||||
role: form.value.role,
|
||||
})
|
||||
}
|
||||
resetForm()
|
||||
} catch (e: any) {
|
||||
error.value = e.data?.message || 'Fehler beim Speichern.'
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (user: User) => {
|
||||
if (confirm(`Benutzer "${user.name}" wirklich löschen?`)) {
|
||||
deleteUser(user.id)
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
admin: 'Admin',
|
||||
staff: 'Mitarbeiter',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
|
||||
<NuxtLink
|
||||
to="/clients"
|
||||
title="Alle Mandanten"
|
||||
aria-label="Alle Mandanten"
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<IconArrowLeft />
|
||||
</NuxtLink>
|
||||
<h1 class="text-base font-semibold text-gray-800">Benutzer verwalten</h1>
|
||||
</header>
|
||||
|
||||
<div class="max-w-3xl mx-auto py-6 px-6 space-y-6">
|
||||
<!-- Existing users -->
|
||||
<section class="bg-white border border-gray-200">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-2 font-medium">Name</th>
|
||||
<th class="px-4 py-2 font-medium">E-Mail</th>
|
||||
<th class="px-4 py-2 font-medium">Rolle</th>
|
||||
<th class="px-4 py-2 font-medium text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" class="border-b border-gray-100">
|
||||
<td class="px-4 py-2 text-gray-800">{{ user.name }}</td>
|
||||
<td class="px-4 py-2 text-gray-600">{{ user.email }}</td>
|
||||
<td class="px-4 py-2 text-gray-600">{{ roleLabels[user.role] }}</td>
|
||||
<td class="px-4 py-2 text-right space-x-2">
|
||||
<button
|
||||
@click="startEdit(user)"
|
||||
title="Bearbeiten"
|
||||
class="inline-flex items-center text-gray-400 hover:text-blue-600"
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(user)"
|
||||
title="Löschen"
|
||||
class="inline-flex items-center text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Create/Edit form -->
|
||||
<section class="bg-white border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-sm font-semibold text-gray-800">
|
||||
{{ editingId ? 'Benutzer bearbeiten' : 'Neuer Benutzer' }}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">E-Mail</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="w-full border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">
|
||||
{{ editingId ? 'Passwort (leer = nicht ändern)' : 'Passwort' }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="w-full border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.password">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Passwort wiederholen</label>
|
||||
<input
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="w-full border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Rolle</label>
|
||||
<select v-model="form.role" class="w-full border border-gray-300 px-3 py-2 text-sm">
|
||||
<option value="staff">Mitarbeiter</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="submit"
|
||||
class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700"
|
||||
>
|
||||
{{ editingId ? 'Änderungen speichern' : 'Benutzer anlegen' }}
|
||||
</button>
|
||||
<button v-if="editingId" @click="resetForm" class="text-sm text-gray-500 hover:underline px-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Reference in a new issue