Compare commits
8 Commits
41387be802
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fe31925f | |||
| ef84b73cb5 | |||
| 94f665192d | |||
| c894b1dfb2 | |||
| c19124741e | |||
| 66de3b31b1 | |||
| 1adb3308be | |||
| 1f26aa7fb5 |
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>
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-17
|
||||||
|
- Added ACP user deletion end-to-end with admin/founder safeguards, self-delete protection, and backend test coverage.
|
||||||
|
- Replaced the ACP user delete browser confirm with a project-style confirmation modal and refined its header/body layout.
|
||||||
|
- Added inline clear (`x`) support to ACP user search inputs.
|
||||||
|
- Lazy-loaded major SPA routes, including ACP, to reduce the initial frontend bundle size.
|
||||||
|
- Added Vite manual vendor chunk splitting for ACP-heavy, React, router, UI, and i18n dependencies.
|
||||||
|
|
||||||
|
## 2026-02-28
|
||||||
|
- Updated ACP General to use section navigation with `Overview` as the default landing view and a dedicated `Settings` view.
|
||||||
|
- Reorganized ACP General placeholders by moving `Client communication` and `Server configuration` into the Settings area as dedicated sub-tabs.
|
||||||
|
- Added nested Settings tab grouping and bordered tab-content containers to match the ACP tabbed layout pattern.
|
||||||
|
- Refined ACP tab visual states so inactive tabs render muted and active tabs use the configured accent color.
|
||||||
|
- Standardized key ACP refresh actions with explicit icon + spacing so repeated controls render consistently.
|
||||||
|
- Added icon support to additional primary UI actions (update modal/footer actions, auth screens, and forum/thread actions).
|
||||||
|
- Synced board version/build display in stats from `composer.json` and added safe DB setting synchronization fallback logic.
|
||||||
|
- Applied global accent-based Bootstrap button variable overrides so primary button styling remains consistent across ACP and user-facing screens.
|
||||||
|
|
||||||
|
## 2026-02-27
|
||||||
|
- 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`.
|
||||||
|
- Added CLI PHP interpreter `Check` action (no persistence) plus save-time validation endpoint.
|
||||||
|
- Updated CLI PHP save UX to keep persistent inline errors and avoid duplicate danger toasts.
|
||||||
|
- Added iconized, accent-styled `Check` and `Save` actions in ACP CLI settings.
|
||||||
|
- Fixed system-status PHP detection to avoid false positives when a configured CLI binary is invalid.
|
||||||
|
- Switched `Health` PHP requirement checks to the web runtime interpreter (`PHP_BINARY`/`PHP_VERSION`) instead of configured CLI binary.
|
||||||
|
- Limited `Health` checks to runtime-relevant items (removed `tar`/`rsync` from Health view).
|
||||||
|
- Fixed `public/storage` symlink health check to correctly resolve absolute and relative symlink targets.
|
||||||
|
|
||||||
## 2026-02-24
|
## 2026-02-24
|
||||||
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
|
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
|
||||||
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.
|
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class SettingController extends Controller
|
class SettingController extends Controller
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,12 @@ class SettingController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$value = $data['value'] ?? '';
|
$value = $data['value'] ?? '';
|
||||||
|
if ($data['key'] === 'system.php_binary') {
|
||||||
|
$validationError = $this->validatePhpBinarySetting($value);
|
||||||
|
if ($validationError !== null) {
|
||||||
|
return response()->json(['message' => $validationError], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$setting = Setting::updateOrCreate(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $data['key']],
|
['key' => $data['key']],
|
||||||
@@ -67,6 +74,12 @@ class SettingController extends Controller
|
|||||||
$updated = [];
|
$updated = [];
|
||||||
|
|
||||||
foreach ($data['settings'] as $entry) {
|
foreach ($data['settings'] as $entry) {
|
||||||
|
if (($entry['key'] ?? '') === 'system.php_binary') {
|
||||||
|
$validationError = $this->validatePhpBinarySetting($entry['value'] ?? '');
|
||||||
|
if ($validationError !== null) {
|
||||||
|
return response()->json(['message' => $validationError], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
$setting = Setting::updateOrCreate(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $entry['key']],
|
['key' => $entry['key']],
|
||||||
['value' => $entry['value'] ?? '']
|
['value' => $entry['value'] ?? '']
|
||||||
@@ -80,4 +93,66 @@ class SettingController extends Controller
|
|||||||
|
|
||||||
return response()->json($updated);
|
return response()->json($updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function validateSystemPhpBinary(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'value' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validationError = $this->validatePhpBinarySetting($data['value']);
|
||||||
|
if ($validationError !== null) {
|
||||||
|
return response()->json(['message' => $validationError], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'PHP interpreter is valid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePhpBinarySetting(string $value): ?string
|
||||||
|
{
|
||||||
|
$binary = trim($value);
|
||||||
|
if ($binary === '' || $binary === 'php') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($binary === 'keyhelp-php-domain') {
|
||||||
|
return '`keyhelp-php-domain` is disabled. Use a concrete binary (e.g. keyhelp-php84).';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = null;
|
||||||
|
if (str_contains($binary, '/')) {
|
||||||
|
if (!is_executable($binary)) {
|
||||||
|
return "Configured PHP binary '{$binary}' is not executable.";
|
||||||
|
}
|
||||||
|
$resolved = $binary;
|
||||||
|
} else {
|
||||||
|
$escapedBinary = escapeshellarg($binary);
|
||||||
|
$process = new Process(['sh', '-lc', "command -v {$escapedBinary}"]);
|
||||||
|
$process->setTimeout(5);
|
||||||
|
$process->run();
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
return "Configured PHP binary '{$binary}' was not found in PATH.";
|
||||||
|
}
|
||||||
|
$resolved = trim($process->getOutput());
|
||||||
|
if ($resolved === '') {
|
||||||
|
return "Configured PHP binary '{$binary}' was not found in PATH.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$phpCheck = new Process([$resolved, '-r', 'echo PHP_VERSION;']);
|
||||||
|
$phpCheck->setTimeout(5);
|
||||||
|
$phpCheck->run();
|
||||||
|
if (!$phpCheck->isSuccessful() || trim($phpCheck->getOutput()) === '') {
|
||||||
|
return "Configured binary '{$binary}' is not a working PHP CLI executable.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ class StatsController extends Controller
|
|||||||
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
||||||
$orphanAttachments = $this->resolveOrphanAttachments();
|
$orphanAttachments = $this->resolveOrphanAttachments();
|
||||||
|
|
||||||
$version = Setting::query()->where('key', 'version')->value('value');
|
$composer = $this->readComposerMetadata();
|
||||||
$build = Setting::query()->where('key', 'build')->value('value');
|
$this->syncVersionBuildSettings($composer);
|
||||||
|
$version = $composer['version'] ?? Setting::query()->where('key', 'version')->value('value');
|
||||||
|
$build = $composer['build'] ?? Setting::query()->where('key', 'build')->value('value');
|
||||||
$boardVersion = $version
|
$boardVersion = $version
|
||||||
? ($build ? "{$version} (build {$build})" : $version)
|
? ($build ? "{$version} (build {$build})" : $version)
|
||||||
: null;
|
: null;
|
||||||
@@ -158,4 +160,59 @@ class StatsController extends Controller
|
|||||||
$value = ini_get('zlib.output_compression');
|
$value = ini_get('zlib.output_compression');
|
||||||
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
|
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function readComposerMetadata(): array
|
||||||
|
{
|
||||||
|
$path = base_path('composer.json');
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = trim((string) ($data['version'] ?? ''));
|
||||||
|
$build = trim((string) ($data['build'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $version !== '' ? $version : null,
|
||||||
|
'build' => ctype_digit($build) ? (int) $build : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncVersionBuildSettings(array $composer): void
|
||||||
|
{
|
||||||
|
$version = $composer['version'] ?? null;
|
||||||
|
$build = $composer['build'] ?? null;
|
||||||
|
|
||||||
|
if ($version === null && $build === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($version !== null) {
|
||||||
|
$currentVersion = Setting::query()->where('key', 'version')->value('value');
|
||||||
|
if ((string) $currentVersion !== (string) $version) {
|
||||||
|
Setting::updateOrCreate(['key' => 'version'], ['value' => (string) $version]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($build !== null) {
|
||||||
|
$buildString = (string) $build;
|
||||||
|
$currentBuild = Setting::query()->where('key', 'build')->value('value');
|
||||||
|
if ((string) $currentBuild !== $buildString) {
|
||||||
|
Setting::updateOrCreate(['key' => 'build'], ['value' => $buildString]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Stats endpoint should remain readable even if settings sync fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,22 @@ class SystemStatusController extends Controller
|
|||||||
$phpDefaultPath = $this->resolveBinary('php');
|
$phpDefaultPath = $this->resolveBinary('php');
|
||||||
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
||||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
$phpSelectedPath = null;
|
||||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
$phpSelectedVersion = null;
|
||||||
|
$phpSelectedOk = false;
|
||||||
|
|
||||||
|
if ($phpConfiguredPath !== '') {
|
||||||
|
$resolvedConfiguredPhpPath = $this->resolveConfiguredPhpBinaryPath($phpConfiguredPath);
|
||||||
|
$phpSelectedPath = $resolvedConfiguredPhpPath ?: $phpConfiguredPath;
|
||||||
|
$phpSelectedVersion = $resolvedConfiguredPhpPath ? $this->resolvePhpVersion($resolvedConfiguredPhpPath) : null;
|
||||||
|
$phpSelectedOk = $resolvedConfiguredPhpPath !== null && $phpSelectedVersion !== null;
|
||||||
|
} else {
|
||||||
|
$phpSelectedPath = PHP_BINARY ?: $phpDefaultPath;
|
||||||
$phpSelectedVersion = $phpSelectedPath
|
$phpSelectedVersion = $phpSelectedPath
|
||||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
? ($this->resolvePhpVersion($phpSelectedPath) ?? $phpDefaultVersion ?? PHP_VERSION)
|
||||||
: PHP_VERSION;
|
: null;
|
||||||
|
$phpSelectedOk = $phpSelectedPath !== null && $phpSelectedVersion !== null;
|
||||||
|
}
|
||||||
$minVersions = $this->resolveMinVersions();
|
$minVersions = $this->resolveMinVersions();
|
||||||
$composerPath = $this->resolveBinary('composer');
|
$composerPath = $this->resolveBinary('composer');
|
||||||
$nodePath = $this->resolveBinary('node');
|
$nodePath = $this->resolveBinary('node');
|
||||||
@@ -44,6 +55,8 @@ class SystemStatusController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'php' => PHP_VERSION,
|
'php' => PHP_VERSION,
|
||||||
|
'php_web_path' => PHP_BINARY ?: null,
|
||||||
|
'php_web_version' => PHP_VERSION ?: null,
|
||||||
'php_default' => $phpDefaultPath,
|
'php_default' => $phpDefaultPath,
|
||||||
'php_default_version' => $phpDefaultVersion,
|
'php_default_version' => $phpDefaultVersion,
|
||||||
'php_configured' => $phpConfiguredPath ?: null,
|
'php_configured' => $phpConfiguredPath ?: null,
|
||||||
@@ -82,7 +95,12 @@ class SystemStatusController extends Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedTarget = realpath(dirname($publicStorage) . DIRECTORY_SEPARATOR . $target);
|
$targetPath = $target;
|
||||||
|
if (!str_starts_with($target, DIRECTORY_SEPARATOR) && !preg_match('/^[A-Za-z]:[\\\\\\/]/', $target)) {
|
||||||
|
$targetPath = dirname($publicStorage) . DIRECTORY_SEPARATOR . $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedTarget = realpath($targetPath);
|
||||||
$expectedTarget = realpath($storagePublic);
|
$expectedTarget = realpath($storagePublic);
|
||||||
|
|
||||||
return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget;
|
return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget;
|
||||||
@@ -116,6 +134,20 @@ class SystemStatusController extends Controller
|
|||||||
return $output !== '' ? $output : null;
|
return $output !== '' ? $output : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveConfiguredPhpBinaryPath(string $binary): ?string
|
||||||
|
{
|
||||||
|
$value = trim($binary);
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($value, '/')) {
|
||||||
|
return is_executable($value) ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveBinary($value);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveBinaryVersion(?string $path, array $args): ?string
|
private function resolveBinaryVersion(?string $path, array $args): ?string
|
||||||
{
|
{
|
||||||
if (!$path) {
|
if (!$path) {
|
||||||
|
|||||||
@@ -152,38 +152,7 @@ class SystemUpdateController extends Controller
|
|||||||
|
|
||||||
$this->ensurePublicStorageLink();
|
$this->ensurePublicStorageLink();
|
||||||
|
|
||||||
$append('Installing composer dependencies...');
|
$append('Using prebuilt release package (skipping composer/npm steps).');
|
||||||
$composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path());
|
|
||||||
$composer->setTimeout(600);
|
|
||||||
$composer->run();
|
|
||||||
if (!$composer->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Composer install failed.',
|
|
||||||
'log' => array_merge($log, [$composer->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Installing npm dependencies...');
|
|
||||||
$npmInstall = new Process(['npm', 'install'], base_path());
|
|
||||||
$npmInstall->setTimeout(600);
|
|
||||||
$npmInstall->run();
|
|
||||||
if (!$npmInstall->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm install failed.',
|
|
||||||
'log' => array_merge($log, [$npmInstall->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Building assets...');
|
|
||||||
$npmBuild = new Process(['npm', 'run', 'build'], base_path());
|
|
||||||
$npmBuild->setTimeout(900);
|
|
||||||
$npmBuild->run();
|
|
||||||
if (!$npmBuild->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm run build failed.',
|
|
||||||
'log' => array_merge($log, [$npmBuild->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
if ($phpBinary === '') {
|
if ($phpBinary === '') {
|
||||||
|
|||||||
@@ -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": "104"
|
"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,
|
||||||
@@ -273,6 +274,12 @@ function AppShell() {
|
|||||||
favicon256: '',
|
favicon256: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const routeFallback = (
|
||||||
|
<Container fluid className="py-5">
|
||||||
|
<p className="bb-muted mb-0">{t('acp.loading')}</p>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchVersion()
|
fetchVersion()
|
||||||
.then((data) => setVersionInfo(data))
|
.then((data) => setVersionInfo(data))
|
||||||
@@ -535,6 +542,7 @@ function AppShell() {
|
|||||||
canAccessAcp={isAdmin}
|
canAccessAcp={isAdmin}
|
||||||
canAccessMcp={isModerator}
|
canAccessMcp={isModerator}
|
||||||
/>
|
/>
|
||||||
|
<Suspense fallback={routeFallback}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/forums" element={<BoardIndex />} />
|
<Route path="/forums" element={<BoardIndex />} />
|
||||||
@@ -557,6 +565,7 @@ function AppShell() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
<footer className="bb-footer">
|
<footer className="bb-footer">
|
||||||
<div className="ms-3 d-flex align-items-center gap-3">
|
<div className="ms-3 d-flex align-items-center gap-3">
|
||||||
<span>{t('footer.copy')}</span>
|
<span>{t('footer.copy')}</span>
|
||||||
@@ -576,6 +585,7 @@ function AppShell() {
|
|||||||
className="bb-accent-button"
|
className="bb-accent-button"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
|
||||||
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
|
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
|
||||||
{t('version.update_now')}
|
{t('version.update_now')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -591,9 +601,11 @@ function AppShell() {
|
|||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer className="justify-content-between">
|
<Modal.Footer className="justify-content-between">
|
||||||
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
|
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
|
||||||
|
<i className="bi bi-clock me-2" aria-hidden="true" />
|
||||||
{t('version.remind_later')}
|
{t('version.remind_later')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
|
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
|
||||||
|
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
|
||||||
{t('version.update_now')}
|
{t('version.update_now')}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ export async function saveSettings(settings) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function validateSystemPhpBinary(value) {
|
||||||
|
return apiFetch('/settings/system/php-binary/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadLogo(file) {
|
export async function uploadLogo(file) {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', file)
|
body.append('file', file)
|
||||||
@@ -427,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}`)
|
||||||
|
|||||||
@@ -947,7 +947,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs .nav-link {
|
.nav-tabs .nav-link {
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-ink-muted);
|
||||||
border: 1px solid var(--bb-border);
|
border: 1px solid var(--bb-border);
|
||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
border-radius: 10px 10px 0 0;
|
border-radius: 10px 10px 0 0;
|
||||||
@@ -956,7 +956,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs .nav-link.active {
|
.nav-tabs .nav-link.active {
|
||||||
color: inherit;
|
color: var(--bb-accent, #f29b3f);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border-color: var(--bb-border);
|
border-color: var(--bb-border);
|
||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
@@ -975,7 +975,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-acp-action.btn-outline-dark {
|
.bb-acp-action.btn-outline-dark {
|
||||||
--bs-btn-color: var(--bb-accent, #f29b3f);
|
--bs-btn-color: #0f1218;
|
||||||
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-hover-color: #0f1218;
|
--bs-btn-hover-color: #0f1218;
|
||||||
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
||||||
@@ -984,7 +984,7 @@ a {
|
|||||||
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-focus-shadow-rgb: 242, 155, 63;
|
--bs-btn-focus-shadow-rgb: 242, 155, 63;
|
||||||
color: var(--bb-accent, #f29b3f) !important;
|
color: #0f1218 !important;
|
||||||
border-color: var(--bb-accent, #f29b3f) !important;
|
border-color: var(--bb-accent, #f29b3f) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,7 +1072,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
|
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
|
||||||
--bs-btn-color: var(--bb-accent, #f29b3f);
|
--bs-btn-color: #0f1218;
|
||||||
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-hover-color: #0f1218;
|
--bs-btn-hover-color: #0f1218;
|
||||||
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
--bs-btn-hover-bg: var(--bb-accent, #f29b3f);
|
||||||
@@ -1080,7 +1080,7 @@ a {
|
|||||||
--bs-btn-active-color: #0f1218;
|
--bs-btn-active-color: #0f1218;
|
||||||
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
--bs-btn-active-bg: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-active-border-color: var(--bb-accent, #f29b3f);
|
||||||
color: var(--bb-accent, #f29b3f) !important;
|
color: #0f1218 !important;
|
||||||
border-color: var(--bb-accent, #f29b3f) !important;
|
border-color: var(--bb-accent, #f29b3f) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2227,6 +2227,25 @@ a {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:not(.btn-close) {
|
||||||
|
--bs-btn-bg: var(--bb-accent, #f29b3f) !important;
|
||||||
|
--bs-btn-border-color: var(--bb-accent, #f29b3f) !important;
|
||||||
|
--bs-btn-color: #0e121b !important;
|
||||||
|
--bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
|
||||||
|
--bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
|
||||||
|
--bs-btn-hover-color: #fff !important;
|
||||||
|
--bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
|
||||||
|
--bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
|
||||||
|
--bs-btn-active-color: #fff !important;
|
||||||
|
--bs-btn-disabled-bg: var(--bb-accent, #f29b3f) !important;
|
||||||
|
--bs-btn-disabled-border-color: var(--bb-accent, #f29b3f) !important;
|
||||||
|
--bs-btn-disabled-color: #0e121b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not(.btn-close):focus-visible {
|
||||||
|
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content .modal-header {
|
.modal-content .modal-header {
|
||||||
background: #0f1218;
|
background: #0f1218;
|
||||||
color: #e6e8eb;
|
color: #e6e8eb;
|
||||||
@@ -2243,6 +2262,24 @@ a {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-confirm-modal .modal-content .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-confirm-modal .modal-content .modal-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
font-size: clamp(1.1rem, 2vw, 1.9rem);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content .modal-header .btn-close {
|
.modal-content .modal-header .btn-close {
|
||||||
filter: none;
|
filter: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -2844,6 +2881,36 @@ a {
|
|||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-search-field {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-search-field-input {
|
||||||
|
padding-right: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.7rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--bb-ink-muted);
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-search-clear:hover,
|
||||||
|
.bb-search-clear:focus-visible {
|
||||||
|
color: var(--bb-accent, #f29b3f);
|
||||||
|
}
|
||||||
|
|
||||||
.bb-audit-limit {
|
.bb-audit-limit {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -397,6 +397,7 @@ export default function ForumView() {
|
|||||||
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
||||||
onClick={() => setAttachmentTab('options')}
|
onClick={() => setAttachmentTab('options')}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-sliders me-2" aria-hidden="true" />
|
||||||
{t('attachment.tab_options')}
|
{t('attachment.tab_options')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -404,6 +405,7 @@ export default function ForumView() {
|
|||||||
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||||
onClick={() => setAttachmentTab('attachments')}
|
onClick={() => setAttachmentTab('attachments')}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-paperclip me-2" aria-hidden="true" />
|
||||||
{t('attachment.tab_attachments')}
|
{t('attachment.tab_attachments')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,6 +501,7 @@ export default function ForumView() {
|
|||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-upload me-2" aria-hidden="true" />
|
||||||
{t('attachment.add_files')}
|
{t('attachment.add_files')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -635,13 +638,14 @@ export default function ForumView() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="bb-topic-pagination">
|
<div className="bb-topic-pagination">
|
||||||
<Button size="sm" variant="outline-secondary" disabled>
|
<Button size="sm" variant="outline-secondary" disabled>
|
||||||
‹
|
<i className="bi bi-chevron-left" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
|
<Button size="sm" variant="outline-secondary" className="is-active" disabled>
|
||||||
|
<i className="bi bi-dot me-1" aria-hidden="true" />
|
||||||
1
|
1
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline-secondary" disabled>
|
<Button size="sm" variant="outline-secondary" disabled>
|
||||||
›
|
<i className="bi bi-chevron-right" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,6 +759,7 @@ export default function ForumView() {
|
|||||||
document.getElementById('bb-thread-attachment-input')?.click()
|
document.getElementById('bb-thread-attachment-input')?.click()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
|
||||||
{t('attachment.drop_browse')}
|
{t('attachment.drop_browse')}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -762,6 +767,7 @@ export default function ForumView() {
|
|||||||
{renderAttachmentFooter()}
|
{renderAttachmentFooter()}
|
||||||
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
|
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
|
||||||
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
||||||
|
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
@@ -771,6 +777,7 @@ export default function ForumView() {
|
|||||||
onClick={handlePreview}
|
onClick={handlePreview}
|
||||||
disabled={!token || saving || uploading || previewLoading}
|
disabled={!token || saving || uploading || previewLoading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-eye me-2" aria-hidden="true" />
|
||||||
{t('form.preview')}
|
{t('form.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -778,6 +785,7 @@ export default function ForumView() {
|
|||||||
className="bb-accent-button"
|
className="bb-accent-button"
|
||||||
disabled={!token || saving || uploading}
|
disabled={!token || saving || uploading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
|
||||||
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ export default function Login() {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
<div className="d-flex w-100 align-items-center gap-2">
|
<div className="d-flex w-100 align-items-center gap-2">
|
||||||
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
|
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
|
||||||
|
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
||||||
|
<i className="bi bi-box-arrow-in-right me-2" aria-hidden="true" />
|
||||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function Register() {
|
|||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button type="submit" variant="dark" disabled={loading}>
|
<Button type="submit" variant="dark" disabled={loading}>
|
||||||
|
<i className="bi bi-person-plus me-2" aria-hidden="true" />
|
||||||
{loading ? t('form.registering') : t('form.create_account')}
|
{loading ? t('form.registering') : t('form.create_account')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -93,9 +93,14 @@ export default function ResetPassword() {
|
|||||||
)}
|
)}
|
||||||
<div className="d-flex w-100 align-items-center gap-2">
|
<div className="d-flex w-100 align-items-center gap-2">
|
||||||
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
|
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
|
||||||
|
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
||||||
|
<i
|
||||||
|
className={`bi ${isResetFlow ? 'bi-key-fill' : 'bi-envelope-arrow-up-fill'} me-2`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{loading
|
{loading
|
||||||
? isResetFlow
|
? isResetFlow
|
||||||
? t('auth.resetting_password')
|
? t('auth.resetting_password')
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export default function ThreadView() {
|
|||||||
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
|
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
|
||||||
onClick={() => setReplyAttachmentTab('options')}
|
onClick={() => setReplyAttachmentTab('options')}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-sliders me-2" aria-hidden="true" />
|
||||||
{t('attachment.tab_options')}
|
{t('attachment.tab_options')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -291,6 +292,7 @@ export default function ThreadView() {
|
|||||||
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
|
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
|
||||||
onClick={() => setReplyAttachmentTab('attachments')}
|
onClick={() => setReplyAttachmentTab('attachments')}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-paperclip me-2" aria-hidden="true" />
|
||||||
{t('attachment.tab_attachments')}
|
{t('attachment.tab_attachments')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,6 +376,7 @@ export default function ThreadView() {
|
|||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
|
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-upload me-2" aria-hidden="true" />
|
||||||
{t('attachment.add_files')}
|
{t('attachment.add_files')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1040,6 +1043,7 @@ export default function ThreadView() {
|
|||||||
document.getElementById('bb-reply-attachment-input')?.click()
|
document.getElementById('bb-reply-attachment-input')?.click()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
|
||||||
{t('attachment.drop_browse')}
|
{t('attachment.drop_browse')}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -1053,6 +1057,7 @@ export default function ThreadView() {
|
|||||||
onClick={handlePreview}
|
onClick={handlePreview}
|
||||||
disabled={!token || saving || replyUploading || previewLoading}
|
disabled={!token || saving || replyUploading || previewLoading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-eye me-2" aria-hidden="true" />
|
||||||
{t('form.preview')}
|
{t('form.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1060,6 +1065,7 @@ export default function ThreadView() {
|
|||||||
className="bb-accent-button"
|
className="bb-accent-button"
|
||||||
disabled={!token || saving || replyUploading}
|
disabled={!token || saving || replyUploading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-reply-fill me-2" aria-hidden="true" />
|
||||||
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
|
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1119,6 +1125,7 @@ export default function ThreadView() {
|
|||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer className="justify-content-between">
|
<Modal.Footer className="justify-content-between">
|
||||||
<Button variant="outline-secondary" onClick={() => setEditPost(null)}>
|
<Button variant="outline-secondary" onClick={() => setEditPost(null)}>
|
||||||
|
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1126,6 +1133,7 @@ export default function ThreadView() {
|
|||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
|
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||||
{editSaving ? t('form.saving') : t('acp.save')}
|
{editSaving ? t('form.saving') : t('acp.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
@@ -1180,6 +1188,7 @@ export default function ThreadView() {
|
|||||||
onClick={() => setDeleteTarget(null)}
|
onClick={() => setDeleteTarget(null)}
|
||||||
disabled={deleteLoading}
|
disabled={deleteLoading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-x-circle me-2" aria-hidden="true" />
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1187,6 +1196,7 @@ export default function ThreadView() {
|
|||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteLoading}
|
disabled={deleteLoading}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-trash me-2" aria-hidden="true" />
|
||||||
{deleteLoading ? t('form.saving') : t('acp.delete')}
|
{deleteLoading ? t('form.saving') : t('acp.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<i className="bi bi-floppy me-2" aria-hidden="true" />
|
||||||
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
|
{profileSaving ? t('form.saving') : t('ucp.save_profile')}
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Route::get('/stats', StatsController::class);
|
|||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/settings/system/php-binary/validate', [SettingController::class, 'validateSystemPhpBinary'])->middleware('auth:sanctum');
|
||||||
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
@@ -55,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