Add ACP user deletion and split frontend bundles
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# 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
|
## 2026-02-28
|
||||||
- Updated ACP General to use section navigation with `Overview` as the default landing view and a dedicated `Settings` view.
|
- 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.
|
- Reorganized ACP General placeholders by moving `Client communication` and `Server configuration` into the Settings area as dedicated sub-tabs.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": "111"
|
"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 { 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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2262,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;
|
||||||
@@ -2863,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
listRanks,
|
listRanks,
|
||||||
listRoles,
|
listRoles,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
deleteUser,
|
||||||
listAuditLogs,
|
listAuditLogs,
|
||||||
reorderForums,
|
reorderForums,
|
||||||
saveSetting,
|
saveSetting,
|
||||||
@@ -126,6 +127,9 @@ function Acp({ isAdmin }) {
|
|||||||
const [rankEditImage, setRankEditImage] = useState(null)
|
const [rankEditImage, setRankEditImage] = useState(null)
|
||||||
const [showUserModal, setShowUserModal] = useState(false)
|
const [showUserModal, setShowUserModal] = useState(false)
|
||||||
const [userForm, setUserForm] = useState({ id: null, name: '', email: '', rankId: '', roles: [] })
|
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 [roleQuery, setRoleQuery] = useState('')
|
||||||
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
|
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
|
||||||
const roleMenuRef = useRef(null)
|
const roleMenuRef = useRef(null)
|
||||||
@@ -204,6 +208,12 @@ function Acp({ isAdmin }) {
|
|||||||
favicon_128: '',
|
favicon_128: '',
|
||||||
favicon_256: '',
|
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({
|
const [systemCliSettings, setSystemCliSettings] = useState({
|
||||||
php_mode: 'php',
|
php_mode: 'php',
|
||||||
php_custom: '',
|
php_custom: '',
|
||||||
@@ -998,7 +1008,7 @@ function Acp({ isAdmin }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="dark"
|
variant="dark"
|
||||||
title={t('user.delete')}
|
title={t('user.delete')}
|
||||||
onClick={() => console.log('delete user', row)}
|
onClick={() => handleUserDelete(row)}
|
||||||
>
|
>
|
||||||
<i className="bi bi-trash" aria-hidden="true" />
|
<i className="bi bi-trash" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1116,17 +1126,39 @@ function Acp({ isAdmin }) {
|
|||||||
const formatBytes = (bytes) => {
|
const formatBytes = (bytes) => {
|
||||||
if (bytes === null || bytes === undefined) return '—'
|
if (bytes === null || bytes === undefined) return '—'
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
const base = unitsSettings.byte_calculation_base === 'decimal' ? 1000 : 1024
|
||||||
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
|
const units = unitsSettings.byte_prefixes === 'binary'
|
||||||
const value = bytes / 1024 ** idx
|
? ['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]}`
|
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pad2 = (value) => String(value).padStart(2, '0')
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
const formatDateTime = (value) => {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return '—'
|
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) => {
|
const formatBool = (value) => {
|
||||||
@@ -1867,6 +1899,35 @@ function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUserDelete = (user) => {
|
||||||
|
const deleteLocked = (user.roles || []).includes('ROLE_FOUNDER') && !canManageFounder
|
||||||
|
if (deleteLocked) {
|
||||||
|
setUsersError(t('user.founder_locked'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsersError('')
|
||||||
|
setUserDeleteTarget(user)
|
||||||
|
setShowUserDelete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmUserDelete = async () => {
|
||||||
|
if (!userDeleteTarget) return
|
||||||
|
|
||||||
|
setUserDeleting(true)
|
||||||
|
setUsersError('')
|
||||||
|
try {
|
||||||
|
await deleteUser(userDeleteTarget.id)
|
||||||
|
setUsers((prev) => prev.filter((user) => user.id !== userDeleteTarget.id))
|
||||||
|
setShowUserDelete(false)
|
||||||
|
setUserDeleteTarget(null)
|
||||||
|
} catch (err) {
|
||||||
|
setUsersError(err.message)
|
||||||
|
} finally {
|
||||||
|
setUserDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAttachmentExtensionEdit = (extension) => {
|
const openAttachmentExtensionEdit = (extension) => {
|
||||||
setAttachmentExtensionEdit(extension)
|
setAttachmentExtensionEdit(extension)
|
||||||
setNewAttachmentExtension({
|
setNewAttachmentExtension({
|
||||||
@@ -3379,6 +3440,94 @@ function Acp({ isAdmin }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab eventKey="units" title="Units">
|
||||||
|
<div className="border border-1 border-dark border-top-0 rounded-bottom p-3">
|
||||||
|
<div className="bb-acp-panel">
|
||||||
|
<div className="bb-acp-panel-header">
|
||||||
|
<h5 className="mb-0">Units</h5>
|
||||||
|
</div>
|
||||||
|
<div className="bb-acp-panel-body">
|
||||||
|
<Row className="g-3">
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Date</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={unitsSettings.date}
|
||||||
|
onChange={(event) =>
|
||||||
|
setUnitsSettings((prev) => ({ ...prev, date: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="dd.mm.yyyy">DD.MM.YYYY</option>
|
||||||
|
<option value="yyyy-mm-dd">YYYY-MM-DD</option>
|
||||||
|
<option value="mm/dd/yyyy">MM/DD/YYYY</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={4}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Date & 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')}>
|
<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="border border-1 border-dark border-top-0 rounded-bottom p-3">
|
||||||
<div className="bb-acp-panel">
|
<div className="bb-acp-panel">
|
||||||
@@ -3431,7 +3580,6 @@ function Acp({ isAdmin }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab eventKey="forums" title={t('acp.forums')}>
|
<Tab eventKey="forums" title={t('acp.forums')}>
|
||||||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
|
||||||
{error && <p className="text-danger">{error}</p>}
|
{error && <p className="text-danger">{error}</p>}
|
||||||
<Row className="g-4">
|
<Row className="g-4">
|
||||||
<Col lg={12}>
|
<Col lg={12}>
|
||||||
@@ -3510,12 +3658,25 @@ function Acp({ isAdmin }) {
|
|||||||
paginationComponent={UsersPagination}
|
paginationComponent={UsersPagination}
|
||||||
subHeader
|
subHeader
|
||||||
subHeaderComponent={
|
subHeaderComponent={
|
||||||
<Form.Control
|
<div className="bb-search-field">
|
||||||
className="bb-user-search"
|
<Form.Control
|
||||||
value={userSearch}
|
className="bb-user-search bb-search-field-input"
|
||||||
onChange={(event) => setUserSearch(event.target.value)}
|
value={userSearch}
|
||||||
placeholder={t('user.search')}
|
onChange={(event) => setUserSearch(event.target.value)}
|
||||||
/>
|
placeholder={t('user.search')}
|
||||||
|
/>
|
||||||
|
{userSearch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bb-search-clear"
|
||||||
|
onClick={() => setUserSearch('')}
|
||||||
|
aria-label={t('acp.clear')}
|
||||||
|
title={t('acp.clear')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -4482,6 +4643,50 @@ function Acp({ isAdmin }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
show={showRoleModal}
|
show={showRoleModal}
|
||||||
onHide={() => setShowRoleModal(false)}
|
onHide={() => setShowRoleModal(false)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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/**'],
|
||||||
|
|||||||
Reference in New Issue
Block a user