feat(t-paliad-054): /admin landing page indexing admin sub-pages

`/admin` was 404 — only `/admin/team` existed. Add a browseable index so
the admin area has a root, with the existing Team-Verwaltung tile alongside
greyed-out roadmap placeholders (Departments, Audit-Log, Email-Templates,
Feature-Flags) so admins see what's coming.

- internal/handlers/admin_users.go: handleAdminIndexPage serves
  dist/admin.html. Same RequireAdminFunc gate as /admin/team — non-admins
  get the standard 302 to /dashboard?forbidden=admin.
- internal/handlers/handlers.go: register GET /admin under the existing
  admin-conditional block.
- frontend/src/admin.tsx + client/admin.ts: card grid built from the
  shared .grid + .card landing-page pattern. .admin-card-soon dims the
  placeholders + adds a "Kommt bald" badge so they read as roadmap, not
  broken links.
- frontend/src/components/Sidebar.tsx: add Admin-Bereich (/admin) above
  Team-Verwaltung in the existing admin group. Both items live in the
  same display:none group that sidebar.ts reveals after /api/me confirms
  global_role='global_admin'.
- frontend/src/client/i18n.ts: nav.admin.bereich + admin.title /
  .heading / .subtitle / .section.{available,planned} / .coming_soon
  plus per-card title+desc, DE+EN.
- frontend/src/styles/global.css: .admin-section-planned spacing,
  .admin-card-soon dimming, .admin-soon-badge pill.
- frontend/build.ts: register the renderAdmin entrypoint and admin.ts
  client bundle.
This commit is contained in:
m
2026-04-27 15:13:46 +02:00
parent d2777be931
commit c2eb23aa5b
8 changed files with 199 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderAdminTeam } from "./src/admin-team";
import { renderNotFound } from "./src/notfound";
@@ -75,6 +76,7 @@ async function build() {
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
join(import.meta.dir, "src/client/admin.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
@@ -153,6 +155,7 @@ async function build() {
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
await Bun.write(join(DIST, "admin.html"), renderAdmin());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());

108
frontend/src/admin.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
interface PlannedCard {
icon: string;
i18nTitle: string;
i18nDesc: string;
fallbackTitle: string;
fallbackDesc: string;
}
const PLANNED: PlannedCard[] = [
{
icon: ICON_BUILDING,
i18nTitle: "admin.card.departments.title",
i18nDesc: "admin.card.departments.desc",
fallbackTitle: "Departments / Dezernate",
fallbackDesc: "Dezernate anlegen und Mitglieder verwalten.",
},
{
icon: ICON_LOG,
i18nTitle: "admin.card.audit.title",
i18nDesc: "admin.card.audit.desc",
fallbackTitle: "Audit-Log",
fallbackDesc: "Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.",
},
{
icon: ICON_MAIL,
i18nTitle: "admin.card.email_templates.title",
i18nDesc: "admin.card.email_templates.desc",
fallbackTitle: "Email-Templates",
fallbackDesc: "Vorlagen für Einladungen, Erinnerungen und Benachrichtigungen anpassen.",
},
{
icon: ICON_FLAG,
i18nTitle: "admin.card.feature_flags.title",
i18nDesc: "admin.card.feature_flags.desc",
fallbackTitle: "Feature-Flags",
fallbackDesc: "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
},
];
export function renderAdmin(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#65a30d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.title">Admin-Bereich &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.heading">Admin-Bereich</h1>
<p className="tool-subtitle" data-i18n="admin.subtitle">
Werkzeuge zur Verwaltung von Paliad. Nur f&uuml;r Administrator:innen sichtbar.
</p>
</div>
<h3 className="section-heading" data-i18n="admin.section.available">Verf&uuml;gbar</h3>
<div className="grid grid-2">
<a href="/admin/team" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_USERS }} />
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, l&ouml;schen.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
<div className="grid grid-2">
{PLANNED.map((c) => (
<div className="card admin-card-soon" title="Kommt bald" data-i18n-title="admin.coming_soon">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: c.icon }} />
<h2 data-i18n={c.i18nTitle}>{c.fallbackTitle}</h2>
<p data-i18n={c.i18nDesc}>{c.fallbackDesc}</p>
<span className="admin-soon-badge" data-i18n="admin.coming_soon">Kommt bald</span>
</div>
))}
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,7 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
});

View File

@@ -1180,7 +1180,24 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
"nav.admin.bereich": "Admin-Bereich",
"nav.admin.team": "Team-Verwaltung",
"admin.title": "Admin-Bereich — Paliad",
"admin.heading": "Admin-Bereich",
"admin.subtitle": "Werkzeuge zur Verwaltung von Paliad. Nur für Administrator:innen sichtbar.",
"admin.section.available": "Verfügbar",
"admin.section.planned": "Geplant",
"admin.coming_soon": "Kommt bald",
"admin.card.team.title": "Team-Verwaltung",
"admin.card.team.desc": "Benutzer:innen anlegen, bearbeiten, löschen.",
"admin.card.departments.title": "Departments / Dezernate",
"admin.card.departments.desc": "Dezernate anlegen und Mitglieder verwalten.",
"admin.card.audit.title": "Audit-Log",
"admin.card.audit.desc": "Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.",
"admin.card.email_templates.title": "Email-Templates",
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Benachrichtigungen anpassen.",
"admin.card.feature_flags.title": "Feature-Flags",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
"admin.team.title": "Team-Verwaltung — Paliad",
"admin.team.heading": "Team-Verwaltung",
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
@@ -2400,7 +2417,24 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
"nav.admin.bereich": "Admin Area",
"nav.admin.team": "Team Management",
"admin.title": "Admin Area — Paliad",
"admin.heading": "Admin Area",
"admin.subtitle": "Tools for managing Paliad. Visible only to administrators.",
"admin.section.available": "Available",
"admin.section.planned": "Planned",
"admin.coming_soon": "Coming soon",
"admin.card.team.title": "Team Management",
"admin.card.team.desc": "Create, edit and delete user accounts.",
"admin.card.departments.title": "Departments / Dezernate",
"admin.card.departments.desc": "Create departments and manage their members.",
"admin.card.audit.title": "Audit Log",
"admin.card.audit.desc": "Who changed what, and when. Traceability for security-relevant actions.",
"admin.card.email_templates.title": "Email Templates",
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and notifications.",
"admin.card.feature_flags.title": "Feature Flags",
"admin.card.feature_flags.desc": "Enable features per office, department or role.",
"admin.team.title": "Team Management — Paliad",
"admin.team.heading": "Team Management",
"admin.team.subtitle": "View, edit and add Paliad accounts.",

View File

@@ -139,7 +139,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
role is known — only the first visit waits for /api/me. */}
<div className="sidebar-group sidebar-admin-group" id="sidebar-admin-group" style="display:none">
<div className="sidebar-group-label" data-i18n="nav.group.admin">Admin</div>
{navItem("/admin/team", ICON_SHIELD, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin", ICON_SHIELD, "nav.admin.bereich", "Admin-Bereich", currentPath)}
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
</div>
</nav>

View File

@@ -7073,3 +7073,38 @@ dialog.quick-add-sheet::backdrop {
line-height: 1;
text-decoration: none;
}
/* --- Admin landing page (t-paliad-054) ------------------------------
/admin index reuses the standard .grid + .card pattern from the
public landing page. The "Geplant" section uses .admin-card-soon
to dim placeholder cards and add a "Kommt bald" badge so they
read as a roadmap rather than broken links. */
.admin-section-planned {
margin-top: 2.5rem;
}
.admin-card-soon {
position: relative;
opacity: 0.6;
cursor: default;
}
.admin-card-soon:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-sm);
}
.admin-soon-badge {
position: absolute;
top: 0.85rem;
right: 0.85rem;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
background: var(--color-bg-muted, #f3f4f6);
border: 1px solid var(--color-border);
padding: 0.2rem 0.5rem;
border-radius: 999px;
}

View File

@@ -152,3 +152,12 @@ func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-team.html")
}
// handleAdminIndexPage serves the SPA shell for /admin — the admin landing
// page that lists current and planned admin sub-pages so the area is
// browseable. Like /admin/team, the route is gated through RequireAdminFunc
// at registration in handlers.go; non-admins get the standard 302 to
// /dashboard?forbidden=admin from rejectAdmin.
func handleAdminIndexPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin.html")
}

View File

@@ -293,6 +293,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
if svc != nil && svc.Users != nil {
adminGate := auth.RequireAdminFunc
users := svc.Users
protected.HandleFunc("GET /admin", adminGate(users, gateOnboarded(handleAdminIndexPage)))
protected.HandleFunc("GET /admin/team", adminGate(users, gateOnboarded(handleAdminTeamPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))