Compare commits
6 Commits
1adb3308be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fe31925f | |||
| ef84b73cb5 | |||
| 94f665192d | |||
| c894b1dfb2 | |||
| c19124741e | |||
| 66de3b31b1 |
3
.mailmap
Normal file
3
.mailmap
Normal file
@@ -0,0 +1,3 @@
|
||||
tracer <tracer@24unix.net> Micha <tracer@24unix.net>
|
||||
tracer <tracer@24unix.net> Micha <espey@smart-q.de>
|
||||
tracer <tracer@24unix.net> speedbb-ci <ci@24unix.net>
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# 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
|
||||
- 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`.
|
||||
|
||||
32
README.md
32
README.md
@@ -1,7 +1,31 @@
|
||||
# SpeedBB Forum
|
||||
# speedBB
|
||||
|
||||
Placeholder README for the forum application.
|
||||
speedBB is a modern forum application with a built-in Admin Control Panel (ACP), customizable branding, user/rank management, attachment support, and integrated update tooling.
|
||||
|
||||
## Status
|
||||
## What It Does
|
||||
|
||||
Work in progress.
|
||||
- Hosts classic forum discussions with categories, forums, topics, and replies.
|
||||
- Provides an ACP for everyday operations (settings, users, groups, ranks, attachments, and audit log).
|
||||
- Supports brand customization (name, theme, accents, logos, favicons).
|
||||
- Manages user media (avatars, rank badges, logos) with public delivery.
|
||||
- Includes built-in update and system-check workflows so admins can verify server health and apply updates from the ACP.
|
||||
|
||||
## ACP Areas
|
||||
|
||||
The ACP is organized into practical sections for day-to-day forum operations:
|
||||
|
||||
- `General`: core board identity and visual setup (name, theme defaults, accents, logos, favicons).
|
||||
- `Forums`: structure and ordering of categories/forums.
|
||||
- `Users`: account overview and moderation/admin user management actions.
|
||||
- `Groups`: role and permission group administration.
|
||||
- `Ranks`: rank definitions and badge management.
|
||||
- `Attachments`: attachment policy and extension/group controls.
|
||||
- `Audit log`: activity trail for administrative actions.
|
||||
- `System`: split into `Health` (live website health checks) and `Updates` (update-readiness checks and update actions, including CLI interpreter validation).
|
||||
- `Custom`: space for project-specific custom assets/overrides.
|
||||
|
||||
## Current Product Status
|
||||
|
||||
- Active version: `26.0.3`
|
||||
- Forum + ACP features are in active use.
|
||||
- Health and update checks are integrated directly into ACP System.
|
||||
|
||||
@@ -32,8 +32,10 @@ class StatsController extends Controller
|
||||
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
||||
$orphanAttachments = $this->resolveOrphanAttachments();
|
||||
|
||||
$version = Setting::query()->where('key', 'version')->value('value');
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
$composer = $this->readComposerMetadata();
|
||||
$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
|
||||
? ($build ? "{$version} (build {$build})" : $version)
|
||||
: null;
|
||||
@@ -158,4 +160,59 @@ class StatsController extends Controller
|
||||
$value = ini_get('zlib.output_compression');
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -98,5 +98,5 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"version": "26.0.3",
|
||||
"build": "106"
|
||||
"build": "112"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -273,6 +274,12 @@ function AppShell() {
|
||||
favicon256: '',
|
||||
})
|
||||
|
||||
const routeFallback = (
|
||||
<Container fluid className="py-5">
|
||||
<p className="bb-muted mb-0">{t('acp.loading')}</p>
|
||||
</Container>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
.then((data) => setVersionInfo(data))
|
||||
@@ -535,6 +542,7 @@ function AppShell() {
|
||||
canAccessAcp={isAdmin}
|
||||
canAccessMcp={isModerator}
|
||||
/>
|
||||
<Suspense fallback={routeFallback}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/forums" element={<BoardIndex />} />
|
||||
@@ -557,6 +565,7 @@ function AppShell() {
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<footer className="bb-footer">
|
||||
<div className="ms-3 d-flex align-items-center gap-3">
|
||||
<span>{t('footer.copy')}</span>
|
||||
@@ -576,6 +585,7 @@ function AppShell() {
|
||||
className="bb-accent-button"
|
||||
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_now')}
|
||||
</Button>
|
||||
@@ -591,9 +601,11 @@ function AppShell() {
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
|
||||
<i className="bi bi-clock me-2" aria-hidden="true" />
|
||||
{t('version.remind_later')}
|
||||
</Button>
|
||||
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
|
||||
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
|
||||
{t('version.update_now')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -947,7 +947,7 @@ a {
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
color: var(--bb-ink-muted);
|
||||
border: 1px solid var(--bb-border);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 10px 10px 0 0;
|
||||
@@ -956,7 +956,7 @@ a {
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: inherit;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--bb-border);
|
||||
border-bottom-color: transparent;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2227,6 +2227,25 @@ a {
|
||||
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 {
|
||||
background: #0f1218;
|
||||
color: #e6e8eb;
|
||||
@@ -2243,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;
|
||||
@@ -2844,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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listRanks,
|
||||
listRoles,
|
||||
listUsers,
|
||||
deleteUser,
|
||||
listAuditLogs,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
@@ -97,6 +98,7 @@ function Acp({ isAdmin }) {
|
||||
const [systemStatus, setSystemStatus] = useState(null)
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [generalSection, setGeneralSection] = useState('overview')
|
||||
const [systemSection, setSystemSection] = useState('overview')
|
||||
const [systemUpdateSection, setSystemUpdateSection] = useState('insite')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
@@ -125,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)
|
||||
@@ -203,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: '',
|
||||
@@ -997,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>
|
||||
@@ -1115,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) => {
|
||||
@@ -1187,6 +1220,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1229,6 +1263,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
@@ -1864,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({
|
||||
@@ -2896,70 +2960,46 @@ function Acp({ isAdmin }) {
|
||||
return (
|
||||
<Container fluid className="bb-acp py-4">
|
||||
<h2 className="mb-4">{t('acp.title')}</h2>
|
||||
<Tabs defaultActiveKey="general" className="mb-3">
|
||||
<Tabs
|
||||
defaultActiveKey="general"
|
||||
className="mb-0"
|
||||
contentClassName="pt-2"
|
||||
>
|
||||
<Tab eventKey="general" title={t('acp.general')}>
|
||||
<div className="border border-1 border-dark border-top-0 rounded-bottom p-3">
|
||||
<Row className="g-4">
|
||||
<Col xs={12} lg="auto">
|
||||
<div className="bb-acp-sidebar">
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
|
||||
<div className="bb-acp-sidebar-title">{t('acp.general')}</div>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.users')}
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
generalSection === 'overview' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setGeneralSection('overview')}
|
||||
>
|
||||
<i className="bi bi-speedometer2 me-2" aria-hidden="true" />
|
||||
Overview
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.groups')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.forums')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.ranks')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.attachments')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.board_configuration')}</div>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action is-active">
|
||||
{t('acp.general')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.forums')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.users')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.client_communication')}</div>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.authentication')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.email_settings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.server_configuration')}</div>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.security_settings')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
{t('acp.search_settings')}
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
generalSection === 'settings' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setGeneralSection('settings')}
|
||||
>
|
||||
<i className="bi bi-sliders2 me-2" aria-hidden="true" />
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} lg>
|
||||
{generalSection === 'overview' && (
|
||||
<>
|
||||
<div className="bb-acp-panel mb-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
@@ -2968,9 +3008,11 @@ function Acp({ isAdmin }) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
className="d-inline-flex align-items-center gap-2 px-3"
|
||||
onClick={refreshBoardStats}
|
||||
disabled={boardStatsLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise" aria-hidden="true" />
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3016,7 +3058,86 @@ function Acp({ isAdmin }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel mt-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h5 className="mb-1">{t('acp.admin_log_title')}</h5>
|
||||
<p className="bb-muted mb-0">{t('acp.admin_log_hint')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
className="d-inline-flex align-items-center gap-2 px-3"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise" aria-hidden="true" />
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{auditLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!auditLoading && recentAdminLogs.length === 0 && (
|
||||
<p className="bb-muted mb-0">{t('admin_log.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && recentAdminLogs.length > 0 && (
|
||||
<div className="bb-acp-admin-log">
|
||||
<table className="bb-acp-admin-log__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin_log.username')}</th>
|
||||
<th>{t('admin_log.user_ip')}</th>
|
||||
<th>{t('admin_log.time')}</th>
|
||||
<th>{t('admin_log.action')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentAdminLogs.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.user?.name || entry.user?.email || '—'}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
<td>{formatDateTime(entry.created_at)}</td>
|
||||
<td>{formatAuditAction(entry.action)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0"
|
||||
onClick={() => {
|
||||
const target = document.querySelector('[data-rb-event-key="audit"]')
|
||||
if (target) target.click()
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-box-arrow-up-right me-2" aria-hidden="true" />
|
||||
{t('acp.view_admin_log')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{generalSection === 'settings' && (
|
||||
<>
|
||||
{generalError && <p className="text-danger">{generalError}</p>}
|
||||
<Tabs
|
||||
defaultActiveKey="general"
|
||||
className="mb-0"
|
||||
contentClassName="pt-2"
|
||||
>
|
||||
<Tab eventKey="general" title={t('acp.general')}>
|
||||
<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">{t('acp.general_settings')}</h5>
|
||||
@@ -3309,6 +3430,7 @@ function Acp({ isAdmin }) {
|
||||
className="bb-accent-button"
|
||||
disabled={generalSaving || generalUploading}
|
||||
>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{generalSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</Col>
|
||||
@@ -3316,76 +3438,148 @@ function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel mt-4">
|
||||
</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">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h5 className="mb-1">{t('acp.admin_log_title')}</h5>
|
||||
<p className="bb-muted mb-0">{t('acp.admin_log_hint')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<h5 className="mb-0">Units</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{auditLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!auditLoading && recentAdminLogs.length === 0 && (
|
||||
<p className="bb-muted mb-0">{t('admin_log.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && recentAdminLogs.length > 0 && (
|
||||
<div className="bb-acp-admin-log">
|
||||
<table className="bb-acp-admin-log__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin_log.username')}</th>
|
||||
<th>{t('admin_log.user_ip')}</th>
|
||||
<th>{t('admin_log.time')}</th>
|
||||
<th>{t('admin_log.action')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentAdminLogs.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.user?.name || entry.user?.email || '—'}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
<td>{formatDateTime(entry.created_at)}</td>
|
||||
<td>{formatAuditAction(entry.action)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0"
|
||||
onClick={() => {
|
||||
const target = document.querySelector('[data-rb-event-key="audit"]')
|
||||
if (target) target.click()
|
||||
}}
|
||||
<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 }))
|
||||
}
|
||||
>
|
||||
{t('acp.view_admin_log')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<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 & 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">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">{t('acp.client_communication')}</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-3">Placeholder</p>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
<i className="bi bi-shield-lock me-2" aria-hidden="true" />
|
||||
{t('acp.authentication')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
<i className="bi bi-envelope me-2" aria-hidden="true" />
|
||||
{t('acp.email_settings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="server-configuration" title={t('acp.server_configuration')}>
|
||||
<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">{t('acp.server_configuration')}</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-3">Placeholder</p>
|
||||
<div className="list-group">
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
<i className="bi bi-shield-check me-2" aria-hidden="true" />
|
||||
{t('acp.security_settings')}
|
||||
</button>
|
||||
<button type="button" className="list-group-item list-group-item-action">
|
||||
<i className="bi bi-search me-2" aria-hidden="true" />
|
||||
{t('acp.search_settings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</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}>
|
||||
@@ -3464,12 +3658,25 @@ function Acp({ isAdmin }) {
|
||||
paginationComponent={UsersPagination}
|
||||
subHeader
|
||||
subHeaderComponent={
|
||||
<div className="bb-search-field">
|
||||
<Form.Control
|
||||
className="bb-user-search"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -3482,6 +3689,7 @@ function Acp({ isAdmin }) {
|
||||
className="bb-accent-button"
|
||||
onClick={() => setShowRoleCreate(true)}
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
|
||||
{t('group.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3573,6 +3781,7 @@ function Acp({ isAdmin }) {
|
||||
className="bb-accent-button"
|
||||
onClick={() => setShowRankCreate(true)}
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
|
||||
{t('rank.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3735,6 +3944,7 @@ function Acp({ isAdmin }) {
|
||||
className="bb-accent-button"
|
||||
disabled={attachmentSettingsSaving}
|
||||
>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{attachmentSettingsSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3750,6 +3960,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleSeedAttachmentDefaults}
|
||||
disabled={attachmentSeedSaving}
|
||||
>
|
||||
<i className="bi bi-database-add me-2" aria-hidden="true" />
|
||||
{attachmentSeedSaving
|
||||
? t('attachment.seed_in_progress')
|
||||
: t('attachment.seed_defaults')}
|
||||
@@ -3759,6 +3970,7 @@ function Acp({ isAdmin }) {
|
||||
variant="outline-secondary"
|
||||
onClick={handleAttachmentGroupExpandAll}
|
||||
>
|
||||
<i className="bi bi-arrows-expand me-2" aria-hidden="true" />
|
||||
{t('acp.expand_all')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -3766,6 +3978,7 @@ function Acp({ isAdmin }) {
|
||||
variant="outline-secondary"
|
||||
onClick={handleAttachmentGroupCollapseAll}
|
||||
>
|
||||
<i className="bi bi-arrows-collapse me-2" aria-hidden="true" />
|
||||
{t('acp.collapse_all')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -3773,6 +3986,7 @@ function Acp({ isAdmin }) {
|
||||
className="bb-accent-button"
|
||||
onClick={() => openAttachmentGroupModal()}
|
||||
>
|
||||
<i className="bi bi-folder-plus me-2" aria-hidden="true" />
|
||||
{t('attachment.group_create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3787,6 +4001,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleSeedAttachmentDefaults}
|
||||
disabled={attachmentSeedSaving}
|
||||
>
|
||||
<i className="bi bi-database-add me-2" aria-hidden="true" />
|
||||
{attachmentSeedSaving
|
||||
? t('attachment.seed_in_progress')
|
||||
: t('attachment.seed_defaults')}
|
||||
@@ -3805,6 +4020,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleSeedAttachmentDefaults}
|
||||
disabled={attachmentSeedSaving}
|
||||
>
|
||||
<i className="bi bi-database-add me-2" aria-hidden="true" />
|
||||
{attachmentSeedSaving
|
||||
? t('attachment.seed_in_progress')
|
||||
: t('attachment.seed_defaults')}
|
||||
@@ -3815,6 +4031,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleAttachmentGroupAutoNest}
|
||||
disabled={attachmentSeedSaving}
|
||||
>
|
||||
<i className="bi bi-diagram-2 me-2" aria-hidden="true" />
|
||||
{attachmentSeedSaving
|
||||
? t('attachment.seed_in_progress')
|
||||
: t('attachment.group_auto_nest')}
|
||||
@@ -3856,6 +4073,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3898,6 +4116,7 @@ function Acp({ isAdmin }) {
|
||||
}`}
|
||||
onClick={() => setSystemSection('overview')}
|
||||
>
|
||||
<i className="bi bi-heart-pulse me-2" aria-hidden="true" />
|
||||
Health
|
||||
</button>
|
||||
<button
|
||||
@@ -3907,6 +4126,7 @@ function Acp({ isAdmin }) {
|
||||
}`}
|
||||
onClick={() => setSystemSection('updates')}
|
||||
>
|
||||
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
|
||||
Updates
|
||||
</button>
|
||||
</div>
|
||||
@@ -3930,6 +4150,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleVersionCheck}
|
||||
disabled={versionChecking}
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
|
||||
{t('version.recheck')}
|
||||
</Button>
|
||||
{systemUpdateAvailable && (
|
||||
@@ -3939,6 +4160,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setUpdateModalOpen(true)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
<i className="bi bi-download me-2" aria-hidden="true" />
|
||||
{t('version.update_now')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -3953,6 +4175,7 @@ function Acp({ isAdmin }) {
|
||||
variant={systemUpdateSection === 'insite' ? 'primary' : 'dark'}
|
||||
onClick={() => setSystemUpdateSection('insite')}
|
||||
>
|
||||
<i className="bi bi-activity me-2" aria-hidden="true" />
|
||||
Live Update
|
||||
</Button>
|
||||
<Button
|
||||
@@ -3960,6 +4183,7 @@ function Acp({ isAdmin }) {
|
||||
variant={systemUpdateSection === 'cli' ? 'primary' : 'dark'}
|
||||
onClick={() => setSystemUpdateSection('cli')}
|
||||
>
|
||||
<i className="bi bi-terminal me-2" aria-hidden="true" />
|
||||
CLI
|
||||
</Button>
|
||||
<Button
|
||||
@@ -3967,6 +4191,7 @@ function Acp({ isAdmin }) {
|
||||
variant={systemUpdateSection === 'ci' ? 'primary' : 'dark'}
|
||||
onClick={() => setSystemUpdateSection('ci')}
|
||||
>
|
||||
<i className="bi bi-diagram-3 me-2" aria-hidden="true" />
|
||||
CI/CD
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
@@ -4190,9 +4415,11 @@ function Acp({ isAdmin }) {
|
||||
</Form.Group>
|
||||
<div className="d-flex gap-2 justify-content-between">
|
||||
<Button type="button" variant="outline-secondary" onClick={handleReset}>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button">
|
||||
<i className="bi bi-check2-circle me-2" aria-hidden="true" />
|
||||
{selectedId ? t('acp.save') : t('acp.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -4405,15 +4632,61 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowUserModal(false)}
|
||||
disabled={userSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" variant="dark" disabled={userSaving}>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{userSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</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)}
|
||||
@@ -4495,9 +4768,11 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowRoleModal(false)}
|
||||
disabled={roleSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" variant="dark" disabled={roleSaving}>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{roleSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -4548,6 +4823,7 @@ function Acp({ isAdmin }) {
|
||||
variant="dark"
|
||||
disabled={roleSaving || !roleFormName.trim()}
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
|
||||
{roleSaving ? t('form.saving') : t('group.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -4717,9 +4993,11 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowRankModal(false)}
|
||||
disabled={rankSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bb-accent-button" variant="dark" disabled={rankSaving}>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{rankSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -4745,6 +5023,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setUpdateModalOpen(false)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -4752,6 +5031,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={handleRunUpdate}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
<i className="bi bi-download me-2" aria-hidden="true" />
|
||||
{updateRunning ? t('version.updating') : t('version.update_now')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
@@ -4863,6 +5143,7 @@ function Acp({ isAdmin }) {
|
||||
variant="dark"
|
||||
disabled={rankSaving || !rankFormName.trim()}
|
||||
>
|
||||
<i className="bi bi-award me-2" aria-hidden="true" />
|
||||
{rankSaving ? t('form.saving') : t('rank.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -4951,6 +5232,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowAttachmentGroupModal(false)}
|
||||
disabled={attachmentGroupSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -4959,6 +5241,7 @@ function Acp({ isAdmin }) {
|
||||
variant="dark"
|
||||
disabled={attachmentGroupSaving}
|
||||
>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{attachmentGroupSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -5038,6 +5321,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowAttachmentExtensionModal(false)}
|
||||
disabled={attachmentExtensionSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -5046,6 +5330,7 @@ function Acp({ isAdmin }) {
|
||||
variant="dark"
|
||||
disabled={attachmentExtensionSaving || !newAttachmentExtension.extension.trim()}
|
||||
>
|
||||
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||
{attachmentExtensionSaving
|
||||
? t('form.saving')
|
||||
: attachmentExtensionEdit
|
||||
@@ -5075,6 +5360,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={() => setShowAttachmentExtensionDelete(false)}
|
||||
disabled={attachmentExtensionSaving}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -5084,6 +5370,7 @@ function Acp({ isAdmin }) {
|
||||
onClick={confirmAttachmentExtensionDelete}
|
||||
disabled={attachmentExtensionSaving}
|
||||
>
|
||||
<i className="bi bi-trash me-2" aria-hidden="true" />
|
||||
{attachmentExtensionSaving ? t('form.saving') : t('acp.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +397,7 @@ export default function ForumView() {
|
||||
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
||||
onClick={() => setAttachmentTab('options')}
|
||||
>
|
||||
<i className="bi bi-sliders me-2" aria-hidden="true" />
|
||||
{t('attachment.tab_options')}
|
||||
</button>
|
||||
<button
|
||||
@@ -404,6 +405,7 @@ export default function ForumView() {
|
||||
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||
onClick={() => setAttachmentTab('attachments')}
|
||||
>
|
||||
<i className="bi bi-paperclip me-2" aria-hidden="true" />
|
||||
{t('attachment.tab_attachments')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -499,6 +501,7 @@ export default function ForumView() {
|
||||
variant="outline-secondary"
|
||||
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||
>
|
||||
<i className="bi bi-upload me-2" aria-hidden="true" />
|
||||
{t('attachment.add_files')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -635,13 +638,14 @@ export default function ForumView() {
|
||||
</span>
|
||||
<div className="bb-topic-pagination">
|
||||
<Button size="sm" variant="outline-secondary" disabled>
|
||||
‹
|
||||
<i className="bi bi-chevron-left" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
|
||||
<i className="bi bi-dot me-1" aria-hidden="true" />
|
||||
1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline-secondary" disabled>
|
||||
›
|
||||
<i className="bi bi-chevron-right" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,6 +759,7 @@ export default function ForumView() {
|
||||
document.getElementById('bb-thread-attachment-input')?.click()
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
|
||||
{t('attachment.drop_browse')}
|
||||
</button>
|
||||
</span>
|
||||
@@ -762,6 +767,7 @@ export default function ForumView() {
|
||||
{renderAttachmentFooter()}
|
||||
<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)}>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<div className="d-flex gap-2">
|
||||
@@ -771,6 +777,7 @@ export default function ForumView() {
|
||||
onClick={handlePreview}
|
||||
disabled={!token || saving || uploading || previewLoading}
|
||||
>
|
||||
<i className="bi bi-eye me-2" aria-hidden="true" />
|
||||
{t('form.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -778,6 +785,7 @@ export default function ForumView() {
|
||||
className="bb-accent-button"
|
||||
disabled={!token || saving || uploading}
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
|
||||
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -59,9 +59,11 @@ export default function Login() {
|
||||
</Form.Group>
|
||||
<div className="d-flex w-100 align-items-center gap-2">
|
||||
<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')}
|
||||
</Button>
|
||||
<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')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<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')}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
@@ -93,9 +93,14 @@ export default function ResetPassword() {
|
||||
)}
|
||||
<div className="d-flex w-100 align-items-center gap-2">
|
||||
<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')}
|
||||
</Button>
|
||||
<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
|
||||
? isResetFlow
|
||||
? t('auth.resetting_password')
|
||||
|
||||
@@ -284,6 +284,7 @@ export default function ThreadView() {
|
||||
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
|
||||
onClick={() => setReplyAttachmentTab('options')}
|
||||
>
|
||||
<i className="bi bi-sliders me-2" aria-hidden="true" />
|
||||
{t('attachment.tab_options')}
|
||||
</button>
|
||||
<button
|
||||
@@ -291,6 +292,7 @@ export default function ThreadView() {
|
||||
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||
onClick={() => setReplyAttachmentTab('attachments')}
|
||||
>
|
||||
<i className="bi bi-paperclip me-2" aria-hidden="true" />
|
||||
{t('attachment.tab_attachments')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -374,6 +376,7 @@ export default function ThreadView() {
|
||||
variant="outline-secondary"
|
||||
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
|
||||
>
|
||||
<i className="bi bi-upload me-2" aria-hidden="true" />
|
||||
{t('attachment.add_files')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1040,6 +1043,7 @@ export default function ThreadView() {
|
||||
document.getElementById('bb-reply-attachment-input')?.click()
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
|
||||
{t('attachment.drop_browse')}
|
||||
</button>
|
||||
</span>
|
||||
@@ -1053,6 +1057,7 @@ export default function ThreadView() {
|
||||
onClick={handlePreview}
|
||||
disabled={!token || saving || replyUploading || previewLoading}
|
||||
>
|
||||
<i className="bi bi-eye me-2" aria-hidden="true" />
|
||||
{t('form.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1060,6 +1065,7 @@ export default function ThreadView() {
|
||||
className="bb-accent-button"
|
||||
disabled={!token || saving || replyUploading}
|
||||
>
|
||||
<i className="bi bi-reply-fill me-2" aria-hidden="true" />
|
||||
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1119,6 +1125,7 @@ export default function ThreadView() {
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button variant="outline-secondary" onClick={() => setEditPost(null)}>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1126,6 +1133,7 @@ export default function ThreadView() {
|
||||
onClick={handleEditSave}
|
||||
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')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
@@ -1180,6 +1188,7 @@ export default function ThreadView() {
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1187,6 +1196,7 @@ export default function ThreadView() {
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
<i className="bi bi-trash me-2" aria-hidden="true" />
|
||||
{deleteLoading ? t('form.saving') : t('acp.delete')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
|
||||
@@ -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')}
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/**'],
|
||||
|
||||
Reference in New Issue
Block a user