Add ACP user deletion and split frontend bundles
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 30s
CI/CD Pipeline / promote_stable (push) Successful in 2s

This commit is contained in:
2026-03-17 16:49:11 +01:00
parent ef84b73cb5
commit a2fe31925f
12 changed files with 442 additions and 51 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -98,5 +98,5 @@
"minimum-stability": "stable",
"prefer-stable": true,
"version": "26.0.3",
"build": "111"
"build": "112"
}

View File

@@ -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 = (
<Container fluid className="py-5">
<p className="bb-muted mb-0">{t('acp.loading')}</p>
</Container>
)
useEffect(() => {
fetchVersion()
@@ -535,28 +542,30 @@ function AppShell() {
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"
element={
<Ucp
theme={theme}
setTheme={setTheme}
accentOverride={accentOverride}
setAccentOverride={setAccentOverride}
/>
}
/>
</Routes>
<Suspense fallback={routeFallback}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"
element={
<Ucp
theme={theme}
setTheme={setTheme}
accentOverride={accentOverride}
setAccentOverride={setAccentOverride}
/>
}
/>
</Routes>
</Suspense>
<footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3">
<span>{t('footer.copy')}</span>

View File

@@ -434,6 +434,12 @@ export async function listUsers() {
return getCollection('/users')
}
export async function deleteUser(userId) {
return apiFetch(`/users/${userId}`, {
method: 'DELETE',
})
}
export async function listAuditLogs(limit = 200) {
const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
return getCollection(`/audit-logs${query}`)

View File

@@ -975,7 +975,7 @@ a {
}
.bb-acp-action.btn-outline-dark {
--bs-btn-color: var(--bb-accent, #f29b3f);
--bs-btn-color: #0f1218;
--bs-btn-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -984,7 +984,7 @@ a {
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
--bs-btn-focus-shadow-rgb: 242, 155, 63;
color: var(--bb-accent, #f29b3f) !important;
color: #0f1218 !important;
border-color: var(--bb-accent, #f29b3f) !important;
}
@@ -1072,7 +1072,7 @@ a {
}
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
--bs-btn-color: var(--bb-accent, #f29b3f);
--bs-btn-color: #0f1218;
--bs-btn-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -1080,7 +1080,7 @@ a {
--bs-btn-active-color: #0f1218;
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
color: var(--bb-accent, #f29b3f) !important;
color: #0f1218 !important;
border-color: var(--bb-accent, #f29b3f) !important;
}
@@ -2262,6 +2262,24 @@ a {
margin: 0;
}
.bb-confirm-modal .modal-content .modal-header {
display: flex;
align-items: center;
justify-content: flex-start;
}
.bb-confirm-modal .modal-content .modal-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
max-width: none;
font-size: clamp(1.1rem, 2vw, 1.9rem);
line-height: 1.2;
text-align: center;
white-space: nowrap;
}
.modal-content .modal-header .btn-close {
filter: none;
opacity: 1;
@@ -2863,6 +2881,36 @@ a {
max-width: 320px;
}
.bb-search-field {
position: relative;
width: 100%;
max-width: 320px;
}
.bb-search-field-input {
padding-right: 2.75rem;
}
.bb-search-clear {
position: absolute;
top: 50%;
right: 0.7rem;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--bb-ink-muted);
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bb-search-clear:hover,
.bb-search-clear:focus-visible {
color: var(--bb-accent, #f29b3f);
}
.bb-audit-limit {
max-width: 120px;
}

View File

@@ -16,6 +16,7 @@ import {
listRanks,
listRoles,
listUsers,
deleteUser,
listAuditLogs,
reorderForums,
saveSetting,
@@ -126,6 +127,9 @@ function Acp({ isAdmin }) {
const [rankEditImage, setRankEditImage] = useState(null)
const [showUserModal, setShowUserModal] = useState(false)
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
const [showUserDelete, setShowUserDelete] = useState(false)
const [userDeleteTarget, setUserDeleteTarget] = useState(null)
const [userDeleting, setUserDeleting] = useState(false)
const [roleQuery, setRoleQuery] = useState('')
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const roleMenuRef = useRef(null)
@@ -204,6 +208,12 @@ function Acp({ isAdmin }) {
favicon_128: '',
favicon_256: '',
})
const [unitsSettings, setUnitsSettings] = useState({
date: 'dd.mm.yyyy',
datetime: 'dd.mm.yyyy hh:mm',
byte_calculation_base: 'decimal',
byte_prefixes: 'decimal',
})
const [systemCliSettings, setSystemCliSettings] = useState({
php_mode: 'php',
php_custom: '',
@@ -998,7 +1008,7 @@ function Acp({ isAdmin }) {
<Button
variant="dark"
title={t('user.delete')}
onClick={() => console.log('delete user', row)}
onClick={() => handleUserDelete(row)}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
@@ -1116,17 +1126,39 @@ function Acp({ isAdmin }) {
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return '—'
if (bytes === 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
const value = bytes / 1024 ** idx
const base = unitsSettings.byte_calculation_base === 'decimal' ? 1000 : 1024
const units = unitsSettings.byte_prefixes === 'binary'
? ['B', 'KiB', 'MiB', 'GiB', 'TiB']
: ['B', 'KB', 'MB', 'GB', 'TB']
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(base)))
const value = bytes / base ** idx
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`
}
const pad2 = (value) => String(value).padStart(2, '0')
const formatDateTime = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleString()
const day = pad2(date.getDate())
const month = pad2(date.getMonth() + 1)
const year = date.getFullYear()
const hours24 = pad2(date.getHours())
const minutes = pad2(date.getMinutes())
switch (unitsSettings.datetime) {
case 'yyyy-mm-dd hh:mm':
return `${year}-${month}-${day} ${hours24}:${minutes}`
case 'mm/dd/yyyy h:mm a': {
const hour = date.getHours() % 12 || 12
const meridiem = date.getHours() >= 12 ? 'PM' : 'AM'
return `${month}/${day}/${year} ${hour}:${minutes} ${meridiem}`
}
case 'dd.mm.yyyy hh:mm':
default:
return `${day}.${month}.${year} ${hours24}:${minutes}`
}
}
const formatBool = (value) => {
@@ -1867,6 +1899,35 @@ function Acp({ isAdmin }) {
}
}
const handleUserDelete = (user) => {
const deleteLocked = (user.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
if (deleteLocked) {
setUsersError(t('user.founder_locked'))
return
}
setUsersError('')
setUserDeleteTarget(user)
setShowUserDelete(true)
}
const confirmUserDelete = async () => {
if (!userDeleteTarget) return
setUserDeleting(true)
setUsersError('')
try {
await deleteUser(userDeleteTarget.id)
setUsers((prev) => prev.filter((user) => user.id !== userDeleteTarget.id))
setShowUserDelete(false)
setUserDeleteTarget(null)
} catch (err) {
setUsersError(err.message)
} finally {
setUserDeleting(false)
}
}
const openAttachmentExtensionEdit = (extension) => {
setAttachmentExtensionEdit(extension)
setNewAttachmentExtension({
@@ -3379,6 +3440,94 @@ function Acp({ isAdmin }) {
</div>
</div>
</Tab>
<Tab eventKey="units" title="Units">
<div className="border border-1 border-dark border-top-0 rounded-bottom p-3">
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">Units</h5>
</div>
<div className="bb-acp-panel-body">
<Row className="g-3">
<Col lg={4}>
<Form.Group>
<Form.Label>Date</Form.Label>
<Form.Select
value={unitsSettings.date}
onChange={(event) =>
setUnitsSettings((prev) => ({ ...prev, date: event.target.value }))
}
>
<option value="dd.mm.yyyy">DD.MM.YYYY</option>
<option value="yyyy-mm-dd">YYYY-MM-DD</option>
<option value="mm/dd/yyyy">MM/DD/YYYY</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>Date &amp; time</Form.Label>
<Form.Select
value={unitsSettings.datetime}
onChange={(event) =>
setUnitsSettings((prev) => ({ ...prev, datetime: event.target.value }))
}
>
<option value="dd.mm.yyyy hh:mm">DD.MM.YYYY HH:mm</option>
<option value="yyyy-mm-dd hh:mm">YYYY-MM-DD HH:mm</option>
<option value="mm/dd/yyyy h:mm a">MM/DD/YYYY h:mm A</option>
</Form.Select>
</Form.Group>
</Col>
<Col xs={12}>
<div className="bb-acp-panel mt-2">
<div className="bb-acp-panel-header">
<h6 className="mb-0">Byte values</h6>
</div>
<div className="bb-acp-panel-body">
<Row className="g-3">
<Col lg={6}>
<Form.Group>
<Form.Label>Calculation base</Form.Label>
<Form.Select
value={unitsSettings.byte_calculation_base}
onChange={(event) =>
setUnitsSettings((prev) => ({
...prev,
byte_calculation_base: event.target.value,
}))
}
>
<option value="binary">Binary | One KB/KiB corresponds to 1024 bytes.</option>
<option value="decimal">Decimal | One KB/KiB corresponds to 1000 bytes.</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>Prefixes</Form.Label>
<Form.Select
value={unitsSettings.byte_prefixes}
onChange={(event) =>
setUnitsSettings((prev) => ({
...prev,
byte_prefixes: event.target.value,
}))
}
>
<option value="binary">Binary | KiB, MiB, GiB, TiB</option>
<option value="decimal">Decimal | KB, MB, GB, TB</option>
</Form.Select>
</Form.Group>
</Col>
</Row>
</div>
</div>
</Col>
</Row>
</div>
</div>
</div>
</Tab>
<Tab eventKey="client-communication" title={t('acp.client_communication')}>
<div className="border border-1 border-dark border-top-0 rounded-bottom p-3">
<div className="bb-acp-panel">
@@ -3431,7 +3580,6 @@ function Acp({ isAdmin }) {
</div>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
{error && <p className="text-danger">{error}</p>}
<Row className="g-4">
<Col lg={12}>
@@ -3510,12 +3658,25 @@ function Acp({ isAdmin }) {
paginationComponent={UsersPagination}
subHeader
subHeaderComponent={
<Form.Control
className="bb-user-search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/>
<div className="bb-search-field">
<Form.Control
className="bb-user-search bb-search-field-input"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder={t('user.search')}
/>
{userSearch && (
<button
type="button"
className="bb-search-clear"
onClick={() => setUserSearch('')}
aria-label={t('acp.clear')}
title={t('acp.clear')}
>
<i className="bi bi-x-lg" aria-hidden="true" />
</button>
)}
</div>
}
/>
)}
@@ -4482,6 +4643,50 @@ function Acp({ isAdmin }) {
</Form>
</Modal.Body>
</Modal>
<Modal
show={showUserDelete}
onHide={() => {
if (userDeleting) return
setShowUserDelete(false)
setUserDeleteTarget(null)
}}
dialogClassName="bb-confirm-modal"
centered
>
<Modal.Header closeButton={!userDeleting}>
<Modal.Title>{t('user.delete_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{t('user.delete_confirm')}</p>
<p className="bb-muted">
{userDeleteTarget?.email || userDeleteTarget?.name}
</p>
<div className="d-flex justify-content-between gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={() => {
setShowUserDelete(false)
setUserDeleteTarget(null)
}}
disabled={userDeleting}
>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')}
</Button>
<Button
type="button"
className="bb-accent-button"
variant="dark"
onClick={confirmUserDelete}
disabled={userDeleting}
>
<i className="bi bi-trash me-2" aria-hidden="true" />
{userDeleting ? t('form.saving') : t('acp.delete')}
</Button>
</div>
</Modal.Body>
</Modal>
<Modal
show={showRoleModal}
onHide={() => setShowRoleModal(false)}

View File

@@ -1,6 +1,7 @@
{
"acp.cancel": "Abbrechen",
"acp.collapse_all": "Alle einklappen",
"acp.clear": "Leeren",
"acp.create": "Erstellen",
"acp.delete": "Löschen",
"acp.drag_handle": "Zum Sortieren ziehen",
@@ -234,6 +235,8 @@
"user.impersonate": "Imitieren",
"user.edit": "Bearbeiten",
"user.delete": "Löschen",
"user.delete_title": "Benutzer löschen",
"user.delete_confirm": "Diesen Benutzer löschen? Das kann nicht rückgängig gemacht werden.",
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
"group.create": "Gruppe erstellen",
"group.create_title": "Gruppe erstellen",

View File

@@ -1,6 +1,7 @@
{
"acp.cancel": "Cancel",
"acp.collapse_all": "Collapse all",
"acp.clear": "Clear",
"acp.create": "Create",
"acp.delete": "Delete",
"acp.drag_handle": "Drag to reorder",
@@ -234,6 +235,8 @@
"user.impersonate": "Impersonate",
"user.edit": "Edit",
"user.delete": "Delete",
"user.delete_title": "Delete User",
"user.delete_confirm": "Delete this user? This cannot be undone.",
"user.founder_locked": "Only founders can edit or assign the Founder role.",
"group.create": "Create group",
"group.create_title": "Create group",

View File

@@ -56,6 +56,7 @@ Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middlewar
Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');

View File

@@ -164,6 +164,39 @@ it('allows admins to update user rank', function (): void {
expect($target->rank_id)->toBe($rank->id);
});
it('allows admins to delete users', function (): void {
$admin = makeAdmin();
$target = User::factory()->create();
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$target->id}");
$response->assertStatus(204);
$this->assertDatabaseMissing('users', ['id' => $target->id]);
});
it('forbids deleting founder user when actor is not founder', function (): void {
$admin = makeAdmin();
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
$founder = User::factory()->create();
$founder->roles()->attach($founderRole);
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$founder->id}");
$response->assertStatus(403);
});
it('prevents admins from deleting their own account', function (): void {
$admin = makeAdmin();
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$admin->id}");
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'You cannot delete your own account.']);
});
it('rejects update without admin role', function (): void {
$user = User::factory()->create();
$target = User::factory()->create();

View File

@@ -10,6 +10,58 @@ export default defineConfig({
}),
react(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('/node_modules/')) {
return undefined;
}
if (
id.includes('/react-data-table-component/') ||
id.includes('/react-dropzone/')
) {
return 'acp-vendor';
}
if (
id.includes('/react-router/') ||
id.includes('/react-router-dom/') ||
id.includes('/@remix-run/')
) {
return 'router-vendor';
}
if (
id.includes('/react/') ||
id.includes('/react-dom/') ||
id.includes('/scheduler/')
) {
return 'react-vendor';
}
if (
id.includes('/react-bootstrap/') ||
id.includes('/bootstrap/') ||
id.includes('/bootstrap-icons/')
) {
return 'ui-vendor';
}
if (
id.includes('/i18next/') ||
id.includes('/react-i18next/') ||
id.includes('/i18next-http-backend/')
) {
return 'i18n-vendor';
}
return 'vendor';
},
},
},
},
server: {
watch: {
ignored: ['**/storage/framework/views/**'],