Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 72 additions & 101 deletions AGENTS.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ web/
│ ├── Auth/openid.php LightOpenID — third-party, intentionally global ns
│ ├── Security/CSRF.php Sbpp\Security\CSRF — token helpers
│ ├── Security/Crypto.php Sbpp\Security\Crypto — password / token crypto
│ ├── View/AdminTabs.php Sbpp\View\AdminTabs — Pattern A admin sub-section nav
│ ├── View/AdminNavCatalog.php Sbpp\View\AdminNavCatalog — Pattern A section catalogs for the main-sidebar accordion (#1490)
│ ├── View/AdminTabs.php Sbpp\View\AdminTabs — back-link chrome for edit-* admin pages (non-empty tabs are a no-op post-#1490)
│ ├── View/ Sbpp\View\* — typed Smarty view-model DTOs
│ ├── View/Install/ Sbpp\View\Install\* — install-wizard step DTOs (#1332)
│ ├── Markup/ Sbpp\Markup\IntroRenderer — admin Markdown -> safe HTML
Expand Down
1 change: 0 additions & 1 deletion web/includes/View/AdminAdminsListView.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ final class AdminAdminsListView extends View
*/
public function __construct(
public readonly bool $can_list_admins,
public readonly bool $can_add_admins,
public readonly bool $can_edit_admins,
public readonly bool $can_delete_admins,
public readonly int $admin_count,
Expand Down
225 changes: 225 additions & 0 deletions web/includes/View/AdminNavCatalog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);

namespace Sbpp\View;

use Sbpp\Auth\UserManager;
use Sbpp\Config;

/**
* Section catalogs for Pattern A admin routes (`?section=<slug>`).
*
* Single source for the nested children the main sidebar accordion
* renders (#1490) and for the `$sections` arrays page handlers use
* for routing defaults. Permission and feature-toggle gates match
* the pre-#1490 AdminTabs filter so a link either appears in the
* accordion and resolves, or is absent from both.
*
* @phpstan-type TabSpec array{
* name: string,
* permission: int|string,
* url: string,
* slug: string,
* icon?: string,
* config?: bool,
* }
*/
final class AdminNavCatalog
{
private function __construct()
{
}

/**
* Unfiltered section catalog for one admin endpoint (`c=` value).
* Empty list means the category has no accordion children (comms,
* export) or is unknown.
*
* @return list<TabSpec>
*/
public static function sectionsFor(string $endpoint): array
{
return match ($endpoint) {
'admins' => [
[
'slug' => 'admins',
'name' => 'Admins',
'permission' => ADMIN_OWNER | ADMIN_LIST_ADMINS,
'url' => 'index.php?p=admin&c=admins&section=admins',
'icon' => 'users',
],
[
'slug' => 'add-admin',
'name' => 'Add admin',
'permission' => ADMIN_OWNER | ADMIN_ADD_ADMINS,
'url' => 'index.php?p=admin&c=admins&section=add-admin',
'icon' => 'user-plus',
],
[
'slug' => 'overrides',
'name' => 'Overrides',
'permission' => ADMIN_OWNER | ADMIN_ADD_ADMINS,
'url' => 'index.php?p=admin&c=admins&section=overrides',
'icon' => 'shield',
],
],
'servers' => [
[
'slug' => 'list',
'name' => 'List servers',
'permission' => ADMIN_OWNER | ADMIN_LIST_SERVERS,
'url' => 'index.php?p=admin&c=servers&section=list',
'icon' => 'server',
],
[
'slug' => 'add',
'name' => 'Add new server',
'permission' => ADMIN_OWNER | ADMIN_ADD_SERVER,
'url' => 'index.php?p=admin&c=servers&section=add',
'icon' => 'plus',
],
],
'bans' => self::bansSections(),
'groups' => [
[
'slug' => 'list',
'name' => 'List groups',
'permission' => ADMIN_OWNER | ADMIN_LIST_GROUPS,
'url' => 'index.php?p=admin&c=groups&section=list',
'icon' => 'users',
],
[
'slug' => 'add',
'name' => 'Add a group',
'permission' => ADMIN_OWNER | ADMIN_ADD_GROUP,
'url' => 'index.php?p=admin&c=groups&section=add',
'icon' => 'plus',
],
],
'settings' => [
[
'slug' => 'settings',
'name' => 'Main',
'permission' => ADMIN_OWNER | ADMIN_WEB_SETTINGS,
'url' => 'index.php?p=admin&c=settings&section=settings',
'icon' => 'settings',
],
[
'slug' => 'features',
'name' => 'Features',
'permission' => ADMIN_OWNER | ADMIN_WEB_SETTINGS,
'url' => 'index.php?p=admin&c=settings&section=features',
'icon' => 'toggle-right',
],
[
'slug' => 'logs',
'name' => 'System Log',
'permission' => ADMIN_OWNER | ADMIN_WEB_SETTINGS,
'url' => 'index.php?p=admin&c=settings&section=logs',
'icon' => 'scroll-text',
],
[
'slug' => 'themes',
'name' => 'Themes',
'permission' => ADMIN_OWNER | ADMIN_WEB_SETTINGS,
'url' => 'index.php?p=admin&c=settings&section=themes',
'icon' => 'palette',
],
],
'mods' => [
[
'slug' => 'list',
'name' => 'List MODs',
'permission' => ADMIN_OWNER | ADMIN_LIST_MODS,
'url' => 'index.php?p=admin&c=mods&section=list',
'icon' => 'puzzle',
],
[
'slug' => 'add',
'name' => 'Add new MOD',
'permission' => ADMIN_OWNER | ADMIN_ADD_MODS,
'url' => 'index.php?p=admin&c=mods&section=add',
'icon' => 'plus',
],
],
default => [],
};
}

/**
* Filter a section list the same way AdminTabs did: drop entries
* the user cannot reach and honour optional `config` toggles.
*
* @param list<TabSpec> $sections
* @return list<TabSpec>
*/
public static function filterForUser(array $sections, UserManager $userbank): array
{
$out = [];
foreach ($sections as $tab) {
if (!$userbank->HasAccess($tab['permission'])) {
continue;
}
if (isset($tab['config']) && !$tab['config']) {
continue;
}
$out[] = $tab;
}
return $out;
}

/**
* @return list<TabSpec>
*/
private static function bansSections(): array
{
$sections = [
[
'slug' => 'add-ban',
'name' => 'Add a ban',
'permission' => ADMIN_OWNER | ADMIN_ADD_BAN,
'url' => 'index.php?p=admin&c=bans&section=add-ban',
'icon' => 'plus',
],
];

if (Config::getBool('config.enableprotest')) {
$sections[] = [
'slug' => 'protests',
'name' => 'Ban protests',
'permission' => ADMIN_OWNER | ADMIN_BAN_PROTESTS,
'url' => 'index.php?p=admin&c=bans&section=protests',
'icon' => 'flag',
];
}
if (Config::getBool('config.enablesubmit')) {
$sections[] = [
'slug' => 'submissions',
'name' => 'Ban submissions',
'permission' => ADMIN_OWNER | ADMIN_BAN_SUBMISSIONS,
'url' => 'index.php?p=admin&c=bans&section=submissions',
'icon' => 'clipboard-list',
];
}

$sections[] = [
'slug' => 'import',
'name' => 'Import bans',
'permission' => ADMIN_OWNER | ADMIN_BAN_IMPORT,
'url' => 'index.php?p=admin&c=bans&section=import',
'icon' => 'upload',
];

if (Config::getBool('config.enablegroupbanning')) {
$sections[] = [
'slug' => 'group-ban',
'name' => 'Group ban',
'permission' => ADMIN_OWNER | ADMIN_ADD_BAN,
'url' => 'index.php?p=admin&c=bans&section=group-ban',
'icon' => 'users',
];
}

return $sections;
}
}
112 changes: 18 additions & 94 deletions web/includes/View/AdminTabs.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,77 +5,23 @@
/**
* Class AdminTabs
*
* Drives the intra-page section nav for Pattern A admin routes — the
* routes that subdivide via `?section=<slug>` URLs (servers, mods,
* groups, settings, comms, admins, bans; see AGENTS.md "Sub-paged
* admin routes").
* Two render shapes remain after #1490:
*
* #1259 unified the chrome
* ------------------------
* Pre-#1259 there were two visuals:
* - settings rendered a vertical 14rem sidebar inline in every
* `page_admin_settings_*.tpl`,
* - servers / mods / groups rendered a horizontal pill strip via
* `core/admin_tabs.tpl`.
* The horizontal strip read as "tabs into one document" rather than
* "navigation between sibling pages" even after #1239 routed each
* section to its own URL, and stacked badly on dense routes (mods has
* a 3-section strip already; admins family hits 4+). #1259 lifted the
* settings sidebar into the parameterized `core/admin_sidebar.tpl`
* partial and pointed every Pattern A handler at it via this class.
*
* #1239 — anchor links, no JS toggle
* ----------------------------------
* Pre-#1239 the partial emitted `<button onclick="openTab(this, …)">`
* elements that called a JS function from the v1.x sourcebans.js bulk
* file (removed at #1123 D1). Clicks were silent no-ops — every pane
* was visible permanently — until the broken chrome was repaired in
* #1239 by routing each section through its own `?section=<slug>` URL.
*
* Two render shapes
* -----------------
* 1. `$tabs === []` (edit-* pages: admin.edit.ban.php, admin.rcon.php,
* admin.email.php, …):
* admin.email.php, admin.export.php, …):
* Emits `core/admin_tabs.tpl` which renders just the trailing
* "Back" anchor — there's no sub-section nav for these surfaces,
* only the affordance to leave. This shape is unchanged by #1259.
*
* 2. `$tabs !== []` (Pattern A pages: admin.servers, admin.mods,
* admin.groups, admin.settings, admin.comms, admin.admins,
* admin.bans):
* Opens the sidebar shell (`<div class="admin-sidebar-shell">`),
* emits `core/admin_sidebar.tpl` (the <aside> + link list), then
* opens the content column (`<div class="admin-sidebar-content">`).
* The page handler is responsible for closing both wrappers AFTER
* `Renderer::render(...)` runs:
* only the affordance to leave.
*
* ```php
* new AdminTabs($sections, $userbank, $theme, $section, 'Settings sections');
* Renderer::render($theme, new AdminSettingsView(...));
* echo '</div></div><!-- /.admin-sidebar-content + /.admin-sidebar-shell -->';
* ```
* 2. `$tabs !== []` (legacy Pattern A callers):
* No-op. Section links live in the main sidebar accordion
* (`AdminNavCatalog` + `core/navbar.tpl`, #1490). Page handlers
* may still construct AdminTabs with a non-empty list during
* migration; the constructor accepts the call and renders
* nothing so content is not wrapped in a second rail.
*
* The wrapper opens before the View and closes after, so each
* `Renderer::render` call slots into the content column without
* per-View structural changes.
*
* Each tab in the `$tabs` array carries:
* - `name` Display label rendered as the link text.
* - `permission` Bitmask for `CUserManager::HasAccess()`. The tab
* is omitted entirely when the current user lacks
* the flag.
* - `url` (required for non-empty tabs) The link target. The
* page handler is responsible for building it
* (typically `index.php?p=admin&c=<page>&section=<slug>`)
* so the partial doesn't need to know about routing.
* - `slug` (required for non-empty tabs) Short identifier used
* for the `data-testid` and active-tab matching.
* - `icon` (optional) Lucide icon name (e.g. `server`,
* `puzzle`). When omitted the partial falls back to
* a generic `circle-dot` so every row has matching
* visual weight.
* - `config` (optional) Feature-toggle gate; the tab is omitted
* when `config` is set and falsy.
* Prefer dropping non-empty `new AdminTabs(...)` calls from Pattern A
* handlers entirely. Keep the empty-tabs Back-link shape.
*
* @phpstan-type TabSpec array{
* name: string,
Expand All @@ -93,17 +39,10 @@ final class AdminTabs

/**
* @param list<TabSpec> $tabs
* @param string|null $activeSlug Slug of the section to mark with
* `aria-current="page"`. When null, the first accessible tab's
* slug wins. When the value doesn't match any visible tab no
* tab is marked active (and the sidebar falls back to its
* all-inactive look — still better than every entry looking
* identical).
* @param string|null $sidebarLabel aria-label for the sidebar
* <aside>. Screen readers announce the navigation by this
* label ("Settings sections" / "Server sections" / …). Only
* consumed when `$tabs` is non-empty (the empty-tabs Back-link
* shape has no sidebar).
* @param string|null $activeSlug Kept for call-site compatibility;
* unused when `$tabs` is non-empty (main sidebar owns active state).
* @param string|null $sidebarLabel Kept for call-site compatibility;
* unused when `$tabs` is non-empty.
*/
public function __construct(
array $tabs,
Expand All @@ -128,30 +67,15 @@ public function __construct(
}

if ($this->tabs === []) {
// Edit-* shape: just the trailing Back anchor. The legacy
// partial still owns this surface — there's no sidebar to
// unify, only the "leave this page" affordance.
$theme->assign('tabs', $this->tabs);
$theme->assign('active_tab', $resolvedActive);
$theme->display('core/admin_tabs.tpl');
return;
}

// Sidebar shape (#1259). Open the shell + render the <aside> +
// open the content column. Closing tags live in the calling
// page handler — see the class docblock for the contract.
$sidebarId = 'admin-sidebar';
$sidebarLabel = $sidebarLabel !== null && $sidebarLabel !== ''
? $sidebarLabel
: 'Page sections';

echo '<div class="admin-sidebar-shell" data-testid="admin-sidebar-shell">';
$theme->assign('tabs', $this->tabs);
$theme->assign('active_tab', $resolvedActive);
$theme->assign('sidebar_id', $sidebarId);
$theme->assign('sidebar_label', $sidebarLabel);
$theme->display('core/admin_sidebar.tpl');
echo '<div class="admin-sidebar-content">';
// #1490 — section nav moved into the main sidebar accordion.
// Non-empty AdminTabs no longer opens admin-sidebar-shell.
unset($sidebarLabel);
}
}

Expand Down
Loading
Loading