2 Commits

Author SHA1 Message Date
a2fe31925f 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
2026-03-17 16:49:11 +01:00
ef84b73cb5 Refine ACP general settings navigation and tabbed layout
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 31s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-28 19:13:33 +01:00
19 changed files with 850 additions and 261 deletions

View File

@@ -1,5 +1,22 @@
# Changelog # 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.
- Added nested Settings tab grouping and bordered tab-content containers to match the ACP tabbed layout pattern.
- Refined ACP tab visual states so inactive tabs render muted and active tabs use the configured accent color.
- Standardized key ACP refresh actions with explicit icon + spacing so repeated controls render consistently.
- Added icon support to additional primary UI actions (update modal/footer actions, auth screens, and forum/thread actions).
- Synced board version/build display in stats from `composer.json` and added safe DB setting synchronization fallback logic.
- Applied global accent-based Bootstrap button variable overrides so primary button styling remains consistent across ACP and user-facing screens.
## 2026-02-27 ## 2026-02-27
- Reworked ACP System navigation into `Health` and `Updates`. - Reworked ACP System navigation into `Health` and `Updates`.
- Moved update/version actions into the new `Updates` area and grouped update checks under `Live Update`, `CLI`, and `CI/CD`. - Moved update/version actions into the new `Updates` area and grouped update checks under `Live Update`, `CLI`, and `CI/CD`.

View File

@@ -32,8 +32,10 @@ class StatsController extends Controller
$avatarSizeBytes = $this->resolveAvatarDirectorySize(); $avatarSizeBytes = $this->resolveAvatarDirectorySize();
$orphanAttachments = $this->resolveOrphanAttachments(); $orphanAttachments = $this->resolveOrphanAttachments();
$version = Setting::query()->where('key', 'version')->value('value'); $composer = $this->readComposerMetadata();
$build = Setting::query()->where('key', 'build')->value('value'); $this->syncVersionBuildSettings($composer);
$version = $composer['version'] ?? Setting::query()->where('key', 'version')->value('value');
$build = $composer['build'] ?? Setting::query()->where('key', 'build')->value('value');
$boardVersion = $version $boardVersion = $version
? ($build ? "{$version} (build {$build})" : $version) ? ($build ? "{$version} (build {$build})" : $version)
: null; : null;
@@ -158,4 +160,59 @@ class StatsController extends Controller
$value = ini_get('zlib.output_compression'); $value = ini_get('zlib.output_compression');
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true); return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
} }
private function readComposerMetadata(): array
{
$path = base_path('composer.json');
if (!is_file($path) || !is_readable($path)) {
return [];
}
$raw = file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return [];
}
$version = trim((string) ($data['version'] ?? ''));
$build = trim((string) ($data['build'] ?? ''));
return [
'version' => $version !== '' ? $version : null,
'build' => ctype_digit($build) ? (int) $build : null,
];
}
private function syncVersionBuildSettings(array $composer): void
{
$version = $composer['version'] ?? null;
$build = $composer['build'] ?? null;
if ($version === null && $build === null) {
return;
}
try {
if ($version !== null) {
$currentVersion = Setting::query()->where('key', 'version')->value('value');
if ((string) $currentVersion !== (string) $version) {
Setting::updateOrCreate(['key' => 'version'], ['value' => (string) $version]);
}
}
if ($build !== null) {
$buildString = (string) $build;
$currentBuild = Setting::query()->where('key', 'build')->value('value');
if ((string) $currentBuild !== $buildString) {
Setting::updateOrCreate(['key' => 'build'], ['value' => $buildString]);
}
}
} catch (\Throwable) {
// Stats endpoint should remain readable even if settings sync fails.
}
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; 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 private function resolveAvatarUrl(User $user): ?string
{ {
if (!$user->avatar_path) { if (!$user->avatar_path) {

View File

@@ -98,5 +98,5 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"version": "26.0.3", "version": "26.0.3",
"build": "110" "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 { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Button, Container, Modal, NavDropdown } from 'react-bootstrap' import { Button, Container, Modal, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext' 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 { useTranslation } from 'react-i18next'
import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client' 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({ function PortalHeader({
userMenu, userMenu,
isAuthenticated, isAuthenticated,
@@ -271,7 +272,13 @@ function AppShell() {
favicon64: '', favicon64: '',
favicon128: '', favicon128: '',
favicon256: '', favicon256: '',
}) })
const routeFallback = (
<Container fluid className="py-5">
<p className="bb-muted mb-0">{t('acp.loading')}</p>
</Container>
)
useEffect(() => { useEffect(() => {
fetchVersion() fetchVersion()
@@ -535,28 +542,30 @@ function AppShell() {
canAccessAcp={isAdmin} canAccessAcp={isAdmin}
canAccessMcp={isModerator} canAccessMcp={isModerator}
/> />
<Routes> <Suspense fallback={routeFallback}>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/forums" element={<BoardIndex />} /> <Route path="/" element={<Home />} />
<Route path="/forum/:id" element={<ForumView />} /> <Route path="/forums" element={<BoardIndex />} />
<Route path="/thread/:id" element={<ThreadView />} /> <Route path="/forum/:id" element={<ForumView />} />
<Route path="/login" element={<Login />} /> <Route path="/thread/:id" element={<ThreadView />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/profile/:id" element={<Profile />} /> <Route path="/register" element={<Register />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} /> <Route path="/profile/:id" element={<Profile />} />
<Route <Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
path="/ucp" <Route
element={ path="/ucp"
<Ucp element={
theme={theme} <Ucp
setTheme={setTheme} theme={theme}
accentOverride={accentOverride} setTheme={setTheme}
setAccentOverride={setAccentOverride} accentOverride={accentOverride}
/> setAccentOverride={setAccentOverride}
} />
/> }
</Routes> />
</Routes>
</Suspense>
<footer className="bb-footer"> <footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3"> <div className="ms-3 d-flex align-items-center gap-3">
<span>{t('footer.copy')}</span> <span>{t('footer.copy')}</span>
@@ -576,6 +585,7 @@ function AppShell() {
className="bb-accent-button" className="bb-accent-button"
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
> >
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
{t('version.update_available_short')} (build {availableBuild}) ·{' '} {t('version.update_available_short')} (build {availableBuild}) ·{' '}
{t('version.update_now')} {t('version.update_now')}
</Button> </Button>
@@ -591,9 +601,11 @@ function AppShell() {
</Modal.Body> </Modal.Body>
<Modal.Footer className="justify-content-between"> <Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}> <Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
<i className="bi bi-clock me-2" aria-hidden="true" />
{t('version.remind_later')} {t('version.remind_later')}
</Button> </Button>
<Button className="bb-accent-button" onClick={() => window.location.reload()}> <Button className="bb-accent-button" onClick={() => window.location.reload()}>
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
{t('version.update_now')} {t('version.update_now')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

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

View File

@@ -947,7 +947,7 @@ a {
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
color: var(--bb-accent, #f29b3f); color: var(--bb-ink-muted);
border: 1px solid var(--bb-border); border: 1px solid var(--bb-border);
border-bottom-color: transparent; border-bottom-color: transparent;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
@@ -956,7 +956,7 @@ a {
} }
.nav-tabs .nav-link.active { .nav-tabs .nav-link.active {
color: inherit; color: var(--bb-accent, #f29b3f);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border-color: var(--bb-border); border-color: var(--bb-border);
border-bottom-color: transparent; border-bottom-color: transparent;
@@ -975,7 +975,7 @@ a {
} }
.bb-acp-action.btn-outline-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-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218; --bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f); --bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -984,7 +984,7 @@ a {
--bs-btn-active-bg: var(--bb-accent, #f29b3f); --bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: var(--bb-accent, #f29b3f); --bs-btn-active-border-color: var(--bb-accent, #f29b3f);
--bs-btn-focus-shadow-rgb: 242, 155, 63; --bs-btn-focus-shadow-rgb: 242, 155, 63;
color: var(--bb-accent, #f29b3f) !important; color: #0f1218 !important;
border-color: var(--bb-accent, #f29b3f) !important; border-color: var(--bb-accent, #f29b3f) !important;
} }
@@ -1072,7 +1072,7 @@ a {
} }
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark { [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-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218; --bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f); --bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -1080,7 +1080,7 @@ a {
--bs-btn-active-color: #0f1218; --bs-btn-active-color: #0f1218;
--bs-btn-active-bg: var(--bb-accent, #f29b3f); --bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: 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; border-color: var(--bb-accent, #f29b3f) !important;
} }
@@ -2227,6 +2227,25 @@ a {
opacity: 0.6; opacity: 0.6;
} }
.btn:not(.btn-close) {
--bs-btn-bg: var(--bb-accent, #f29b3f) !important;
--bs-btn-border-color: var(--bb-accent, #f29b3f) !important;
--bs-btn-color: #0e121b !important;
--bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
--bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
--bs-btn-hover-color: #fff !important;
--bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
--bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
--bs-btn-active-color: #fff !important;
--bs-btn-disabled-bg: var(--bb-accent, #f29b3f) !important;
--bs-btn-disabled-border-color: var(--bb-accent, #f29b3f) !important;
--bs-btn-disabled-color: #0e121b !important;
}
.btn:not(.btn-close):focus-visible {
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
}
.modal-content .modal-header { .modal-content .modal-header {
background: #0f1218; background: #0f1218;
color: #e6e8eb; color: #e6e8eb;
@@ -2243,6 +2262,24 @@ a {
margin: 0; 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 { .modal-content .modal-header .btn-close {
filter: none; filter: none;
opacity: 1; opacity: 1;
@@ -2844,6 +2881,36 @@ a {
max-width: 320px; 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 { .bb-audit-limit {
max-width: 120px; max-width: 120px;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -397,6 +397,7 @@ export default function ForumView() {
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`} className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('options')} onClick={() => setAttachmentTab('options')}
> >
<i className="bi bi-sliders me-2" aria-hidden="true" />
{t('attachment.tab_options')} {t('attachment.tab_options')}
</button> </button>
<button <button
@@ -404,6 +405,7 @@ export default function ForumView() {
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`} className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('attachments')} onClick={() => setAttachmentTab('attachments')}
> >
<i className="bi bi-paperclip me-2" aria-hidden="true" />
{t('attachment.tab_attachments')} {t('attachment.tab_attachments')}
</button> </button>
</div> </div>
@@ -499,6 +501,7 @@ export default function ForumView() {
variant="outline-secondary" variant="outline-secondary"
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()} onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
> >
<i className="bi bi-upload me-2" aria-hidden="true" />
{t('attachment.add_files')} {t('attachment.add_files')}
</Button> </Button>
</div> </div>
@@ -635,13 +638,14 @@ export default function ForumView() {
</span> </span>
<div className="bb-topic-pagination"> <div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled> <Button size="sm" variant="outline-secondary" disabled>
<i className="bi bi-chevron-left" aria-hidden="true" />
</Button> </Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled> <Button size="sm" variant="outline-secondary" className="is-active" disabled>
<i className="bi bi-dot me-1" aria-hidden="true" />
1 1
</Button> </Button>
<Button size="sm" variant="outline-secondary" disabled> <Button size="sm" variant="outline-secondary" disabled>
<i className="bi bi-chevron-right" aria-hidden="true" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -755,6 +759,7 @@ export default function ForumView() {
document.getElementById('bb-thread-attachment-input')?.click() document.getElementById('bb-thread-attachment-input')?.click()
}} }}
> >
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
{t('attachment.drop_browse')} {t('attachment.drop_browse')}
</button> </button>
</span> </span>
@@ -762,6 +767,7 @@ export default function ForumView() {
{renderAttachmentFooter()} {renderAttachmentFooter()}
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0"> <Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}> <Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
@@ -771,6 +777,7 @@ export default function ForumView() {
onClick={handlePreview} onClick={handlePreview}
disabled={!token || saving || uploading || previewLoading} disabled={!token || saving || uploading || previewLoading}
> >
<i className="bi bi-eye me-2" aria-hidden="true" />
{t('form.preview')} {t('form.preview')}
</Button> </Button>
<Button <Button
@@ -778,6 +785,7 @@ export default function ForumView() {
className="bb-accent-button" className="bb-accent-button"
disabled={!token || saving || uploading} disabled={!token || saving || uploading}
> >
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
{saving || uploading ? t('form.posting') : t('form.create_thread')} {saving || uploading ? t('form.posting') : t('form.create_thread')}
</Button> </Button>
</div> </div>

View File

@@ -59,9 +59,11 @@ export default function Login() {
</Form.Group> </Form.Group>
<div className="d-flex w-100 align-items-center gap-2"> <div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}> <Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}> <Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
<i className="bi bi-box-arrow-in-right me-2" aria-hidden="true" />
{loading ? t('form.signing_in') : t('form.sign_in')} {loading ? t('form.signing_in') : t('form.sign_in')}
</Button> </Button>
</div> </div>

View File

@@ -70,6 +70,7 @@ export default function Register() {
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
<i className="bi bi-person-plus me-2" aria-hidden="true" />
{loading ? t('form.registering') : t('form.create_account')} {loading ? t('form.registering') : t('form.create_account')}
</Button> </Button>
</Form> </Form>

View File

@@ -93,9 +93,14 @@ export default function ResetPassword() {
)} )}
<div className="d-flex w-100 align-items-center gap-2"> <div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}> <Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}> <Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
<i
className={`bi ${isResetFlow ? 'bi-key-fill' : 'bi-envelope-arrow-up-fill'} me-2`}
aria-hidden="true"
/>
{loading {loading
? isResetFlow ? isResetFlow
? t('auth.resetting_password') ? t('auth.resetting_password')

View File

@@ -284,6 +284,7 @@ export default function ThreadView() {
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`} className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('options')} onClick={() => setReplyAttachmentTab('options')}
> >
<i className="bi bi-sliders me-2" aria-hidden="true" />
{t('attachment.tab_options')} {t('attachment.tab_options')}
</button> </button>
<button <button
@@ -291,6 +292,7 @@ export default function ThreadView() {
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`} className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('attachments')} onClick={() => setReplyAttachmentTab('attachments')}
> >
<i className="bi bi-paperclip me-2" aria-hidden="true" />
{t('attachment.tab_attachments')} {t('attachment.tab_attachments')}
</button> </button>
</div> </div>
@@ -374,6 +376,7 @@ export default function ThreadView() {
variant="outline-secondary" variant="outline-secondary"
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()} onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
> >
<i className="bi bi-upload me-2" aria-hidden="true" />
{t('attachment.add_files')} {t('attachment.add_files')}
</Button> </Button>
</div> </div>
@@ -1040,6 +1043,7 @@ export default function ThreadView() {
document.getElementById('bb-reply-attachment-input')?.click() document.getElementById('bb-reply-attachment-input')?.click()
}} }}
> >
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
{t('attachment.drop_browse')} {t('attachment.drop_browse')}
</button> </button>
</span> </span>
@@ -1053,6 +1057,7 @@ export default function ThreadView() {
onClick={handlePreview} onClick={handlePreview}
disabled={!token || saving || replyUploading || previewLoading} disabled={!token || saving || replyUploading || previewLoading}
> >
<i className="bi bi-eye me-2" aria-hidden="true" />
{t('form.preview')} {t('form.preview')}
</Button> </Button>
<Button <Button
@@ -1060,6 +1065,7 @@ export default function ThreadView() {
className="bb-accent-button" className="bb-accent-button"
disabled={!token || saving || replyUploading} disabled={!token || saving || replyUploading}
> >
<i className="bi bi-reply-fill me-2" aria-hidden="true" />
{saving || replyUploading ? t('form.posting') : t('form.post_reply')} {saving || replyUploading ? t('form.posting') : t('form.post_reply')}
</Button> </Button>
</div> </div>
@@ -1119,6 +1125,7 @@ export default function ThreadView() {
</Modal.Body> </Modal.Body>
<Modal.Footer className="justify-content-between"> <Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setEditPost(null)}> <Button variant="outline-secondary" onClick={() => setEditPost(null)}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button <Button
@@ -1126,6 +1133,7 @@ export default function ThreadView() {
onClick={handleEditSave} onClick={handleEditSave}
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())} disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
> >
<i className="bi bi-floppy me-2" aria-hidden="true" />
{editSaving ? t('form.saving') : t('acp.save')} {editSaving ? t('form.saving') : t('acp.save')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
@@ -1180,6 +1188,7 @@ export default function ThreadView() {
onClick={() => setDeleteTarget(null)} onClick={() => setDeleteTarget(null)}
disabled={deleteLoading} disabled={deleteLoading}
> >
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button <Button
@@ -1187,6 +1196,7 @@ export default function ThreadView() {
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
disabled={deleteLoading} disabled={deleteLoading}
> >
<i className="bi bi-trash me-2" aria-hidden="true" />
{deleteLoading ? t('form.saving') : t('acp.delete')} {deleteLoading ? t('form.saving') : t('acp.delete')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

@@ -116,6 +116,7 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
} }
}} }}
> >
<i className="bi bi-floppy me-2" aria-hidden="true" />
{profileSaving ? t('form.saving') : t('ucp.save_profile')} {profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button> </Button>
</Col> </Col>

View File

@@ -1,6 +1,7 @@
{ {
"acp.cancel": "Abbrechen", "acp.cancel": "Abbrechen",
"acp.collapse_all": "Alle einklappen", "acp.collapse_all": "Alle einklappen",
"acp.clear": "Leeren",
"acp.create": "Erstellen", "acp.create": "Erstellen",
"acp.delete": "Löschen", "acp.delete": "Löschen",
"acp.drag_handle": "Zum Sortieren ziehen", "acp.drag_handle": "Zum Sortieren ziehen",
@@ -234,6 +235,8 @@
"user.impersonate": "Imitieren", "user.impersonate": "Imitieren",
"user.edit": "Bearbeiten", "user.edit": "Bearbeiten",
"user.delete": "Löschen", "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.", "user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
"group.create": "Gruppe erstellen", "group.create": "Gruppe erstellen",
"group.create_title": "Gruppe erstellen", "group.create_title": "Gruppe erstellen",

View File

@@ -1,6 +1,7 @@
{ {
"acp.cancel": "Cancel", "acp.cancel": "Cancel",
"acp.collapse_all": "Collapse all", "acp.collapse_all": "Collapse all",
"acp.clear": "Clear",
"acp.create": "Create", "acp.create": "Create",
"acp.delete": "Delete", "acp.delete": "Delete",
"acp.drag_handle": "Drag to reorder", "acp.drag_handle": "Drag to reorder",
@@ -234,6 +235,8 @@
"user.impersonate": "Impersonate", "user.impersonate": "Impersonate",
"user.edit": "Edit", "user.edit": "Edit",
"user.delete": "Delete", "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.", "user.founder_locked": "Only founders can edit or assign the Founder role.",
"group.create": "Create group", "group.create": "Create group",
"group.create_title": "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('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum'); Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::patch('/users/{user}', [UserController::class, 'update'])->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::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum'); Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->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); 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 { it('rejects update without admin role', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$target = User::factory()->create(); $target = User::factory()->create();

View File

@@ -10,6 +10,58 @@ export default defineConfig({
}), }),
react(), 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: { server: {
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],