diff --git a/CHANGELOG.md b/CHANGELOG.md index 658baf5..00ee983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-17 +- Added ACP user deletion end-to-end with admin/founder safeguards, self-delete protection, and backend test coverage. +- Replaced the ACP user delete browser confirm with a project-style confirmation modal and refined its header/body layout. +- Added inline clear (`x`) support to ACP user search inputs. +- Lazy-loaded major SPA routes, including ACP, to reduce the initial frontend bundle size. +- Added Vite manual vendor chunk splitting for ACP-heavy, React, router, UI, and i18n dependencies. + ## 2026-02-28 - Updated ACP General to use section navigation with `Overview` as the default landing view and a dedicated `Settings` view. - Reorganized ACP General placeholders by moving `Client communication` and `Server configuration` into the Settings area as dedicated sub-tabs. diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3221aa1..8412f76 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Role; use App\Models\User; +use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -232,6 +233,29 @@ class UserController extends Controller ]); } + public function destroy(Request $request, User $user): JsonResponse + { + $actor = $request->user(); + if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + if ($this->isFounder($user) && !$this->isFounder($actor)) { + return response()->json(['message' => 'Forbidden'], 403); + } + if ($actor->is($user)) { + return response()->json(['message' => 'You cannot delete your own account.'], 422); + } + + app(AuditLogger::class)->log($request, 'user.deleted', $user, [ + 'email' => $user->email, + 'name' => $user->name, + ], $actor); + + $user->delete(); + + return response()->json(null, 204); + } + private function resolveAvatarUrl(User $user): ?string { if (!$user->avatar_path) { diff --git a/composer.json b/composer.json index 74ae9b9..7616f7a 100644 --- a/composer.json +++ b/composer.json @@ -98,5 +98,5 @@ "minimum-stability": "stable", "prefer-stable": true, "version": "26.0.3", - "build": "111" + "build": "112" } diff --git a/resources/js/App.jsx b/resources/js/App.jsx index 26de9ad..b79a5db 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -1,20 +1,21 @@ -import { useEffect, useRef, useState } from 'react' +import { Suspense, lazy, useEffect, useRef, useState } from 'react' import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom' import { Button, Container, Modal, NavDropdown } from 'react-bootstrap' import { AuthProvider, useAuth } from './context/AuthContext' -import Home from './pages/Home' -import ForumView from './pages/ForumView' -import ThreadView from './pages/ThreadView' -import Login from './pages/Login' -import Register from './pages/Register' -import ResetPassword from './pages/ResetPassword' -import { Acp } from './pages/Acp' -import BoardIndex from './pages/BoardIndex' -import Ucp from './pages/Ucp' -import Profile from './pages/Profile' import { useTranslation } from 'react-i18next' import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client' +const Home = lazy(() => import('./pages/Home')) +const ForumView = lazy(() => import('./pages/ForumView')) +const ThreadView = lazy(() => import('./pages/ThreadView')) +const Login = lazy(() => import('./pages/Login')) +const Register = lazy(() => import('./pages/Register')) +const ResetPassword = lazy(() => import('./pages/ResetPassword')) +const Acp = lazy(() => import('./pages/Acp').then((module) => ({ default: module.Acp ?? module.default }))) +const BoardIndex = lazy(() => import('./pages/BoardIndex')) +const Ucp = lazy(() => import('./pages/Ucp')) +const Profile = lazy(() => import('./pages/Profile')) + function PortalHeader({ userMenu, isAuthenticated, @@ -271,7 +272,13 @@ function AppShell() { favicon64: '', favicon128: '', favicon256: '', - }) + }) + + const routeFallback = ( + + {t('acp.loading')} + + ) useEffect(() => { fetchVersion() @@ -535,28 +542,30 @@ function AppShell() { canAccessAcp={isAdmin} canAccessMcp={isModerator} /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + +
{t('acp.loading')}