From 89f3d9489588d657646171522ddd77f815bcdd5b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 10 Mar 2026 21:11:12 -0400 Subject: [PATCH] docs: settings rework implementation plan Tasks: AppHeader/router cleanup, SettingsView sidebar layout, inline Users and Logs panels. Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-10-settings-rework.md | 1203 +++++++++++++++++ 1 file changed, 1203 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-10-settings-rework.md diff --git a/docs/superpowers/plans/2026-03-10-settings-rework.md b/docs/superpowers/plans/2026-03-10-settings-rework.md new file mode 100644 index 0000000..0d1f1d5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-settings-rework.md @@ -0,0 +1,1203 @@ +# Settings Rework Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the horizontal tab bar in SettingsView with a sidebar, apply Option-A grouped-card content style, inline Users/Logs views as sidebar panels, and convert the header cog from a dropdown to a direct link. + +**Architecture:** Three sequential tasks — (1) AppHeader + Router cleanup removes the dropdown and old routes, (2) SettingsView layout converts tab bar to sidebar with card-style panel headers, (3) SettingsView panels folds in UserManagement and Logs script/template logic as new Admin panels. + +**Tech Stack:** Vue 3, TypeScript, Vue Router, Pinia, scoped CSS — no new dependencies. + +--- + +## Chunk 1: AppHeader + Router + +### Task 1: Remove gear dropdown; cog becomes router-link + +**Files:** +- Modify: `frontend/src/components/AppHeader.vue` +- Modify: `frontend/src/router/index.ts` + +No automated tests exist for navigation — verify manually after each step. + +- [ ] **Step 1: Remove gear state/logic from AppHeader script** + +In `frontend/src/components/AppHeader.vue`, delete these lines from ``): + +```typescript +// ── Users panel ── +import type { User } from "@/types/auth"; + +interface Invitation { + id: number; + email: string; + created_at: string; + expires_at: string; +} + +const users = ref([]); +const registrationOpen = ref(false); +const usersLoading = ref(false); +const toggling = ref(false); +const confirmDeleteId = ref(null); +const deleting = ref(null); +const inviteEmail = ref(""); +const sendingInvite = ref(false); +const invitations = ref([]); +const revokingId = ref(null); + +async function fetchUsers() { + try { + const data = await apiGet<{ users: User[] }>("/api/admin/users"); + users.value = data.users; + } catch { + toastStore.show("Failed to load users", "error"); + } +} + +async function fetchRegistration() { + try { + const data = await apiGet<{ open: boolean }>("/api/admin/registration"); + registrationOpen.value = data.open; + } catch { /* ignore */ } +} + +async function fetchInvitations() { + try { + const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations"); + invitations.value = data.invitations; + } catch { /* ignore */ } +} + +async function loadUsersPanel() { + if (users.value.length > 0) return; // already loaded + usersLoading.value = true; + await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]); + usersLoading.value = false; +} + +async function sendInvite() { + const email = inviteEmail.value.trim().toLowerCase(); + if (!email) return; + sendingInvite.value = true; + try { + await apiPost("/api/admin/invitations", { email }); + toastStore.show(`Invitation sent to ${email}`); + inviteEmail.value = ""; + await fetchInvitations(); + } catch (e: unknown) { + const body = (e as { body?: { error?: string } })?.body; + toastStore.show(body?.error || "Failed to send invitation", "error"); + } finally { + sendingInvite.value = false; + } +} + +async function revokeInvitation(id: number) { + revokingId.value = id; + try { + await apiDelete(`/api/admin/invitations/${id}`); + invitations.value = invitations.value.filter((inv) => inv.id !== id); + toastStore.show("Invitation revoked"); + } catch { + toastStore.show("Failed to revoke invitation", "error"); + } finally { + revokingId.value = null; + } +} + +async function toggleRegistration() { + toggling.value = true; + try { + const data = await apiPut<{ open: boolean }>("/api/admin/registration", { + open: !registrationOpen.value, + }); + registrationOpen.value = data.open; + toastStore.show(data.open ? "Registration opened" : "Registration closed"); + } catch { + toastStore.show("Failed to update registration setting", "error"); + } finally { + toggling.value = false; + } +} + +function confirmDelete(userId: number) { + if (confirmDeleteId.value === userId) { + deleteUser(userId); + } else { + confirmDeleteId.value = userId; + } +} +function cancelDelete() { confirmDeleteId.value = null; } + +async function deleteUser(userId: number) { + confirmDeleteId.value = null; + deleting.value = userId; + try { + await apiDelete(`/api/admin/users/${userId}`); + users.value = users.value.filter((u) => u.id !== userId); + toastStore.show("User deleted"); + } catch (e: unknown) { + const body = (e as { body?: { error?: string } })?.body; + toastStore.show(body?.error || "Failed to delete user", "error"); + } finally { + deleting.value = null; + } +} + +function formatUserDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", + }); +} +``` + +Note: `apiDelete` must be imported — add it to the existing import line: +```typescript +// Change: +import { apiGet, apiPost, apiPut } from "@/api/client"; +// To: +import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; +``` + +Replace the existing `watch(activeTab, ...)` that only saves to localStorage (already updated in Task 2) so it now also handles lazy panel loading. The single consolidated watcher should be: +```typescript +watch(activeTab, (v) => { + localStorage.setItem("settings_tab", v === "admin" ? "config" : v); + if (v === "users" && authStore.isAdmin) loadUsersPanel(); + if (v === "logs" && authStore.isAdmin) loadLogsPanel(); +}); +``` +(The `v === "admin"` normalization is kept here as a safety net but should never fire after the migration in Task 2.) + +- [ ] **Step 2: Add Users panel template** + +In `frontend/src/views/SettingsView.vue` template, after the `` panel closing `` (currently at line 917) and before ``, add: + +```html + +
+ +
+

Registration

+
+
+

+ Registration is currently + + {{ registrationOpen ? "open" : "closed" }} + +

+

When closed, new users can only be added by an administrator.

+
+ +
+
+ +
+

Invite User

+
+ + +
+

Send an invitation link to allow someone to register, even when public registration is closed.

+
+

Pending Invitations

+ + + + + + + + + + + + + + + + + +
EmailSentExpiresActions
{{ formatUserDate(inv.created_at) }}{{ formatUserDate(inv.expires_at) }} + +
+
+
+ +
+

Users

+
Loading users...
+
No users found.
+ + + + + + + + + + + + + + + + + + + +
UsernameEmailRoleJoinedActions
{{ u.username }} + + {{ u.role }} + + {{ formatUserDate(u.created_at) }} + + + +
+
+ +
+``` + +- [ ] **Step 3: Add Users panel CSS to SettingsView** + +Copy the following CSS from `UserManagementView.vue` into `SettingsView.vue`'s `