Files
FabledScribe/docs/superpowers/plans/2026-03-10-settings-rework.md
T
bvandeusen 89f3d94895 docs: settings rework implementation plan
Tasks: AppHeader/router cleanup, SettingsView sidebar layout,
inline Users and Logs panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 21:11:12 -04:00

1204 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<script setup>`:
```typescript
// DELETE these lines:
const gearOpen = ref(false);
const gearMenuRef = ref<HTMLElement | null>(null);
function toggleGear() {
gearOpen.value = !gearOpen.value;
}
function handleClickOutside(e: MouseEvent) {
if (gearMenuRef.value && !gearMenuRef.value.contains(e.target as Node)) {
gearOpen.value = false;
}
}
onMounted(() => document.addEventListener("click", handleClickOutside));
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
```
After deletion, `onMounted` and `onUnmounted` are no longer needed — remove the imports of both from the `import { ref, computed, onMounted, onUnmounted }` line, changing it to:
```typescript
import { ref, computed } from "vue";
```
- [ ] **Step 2: Replace gear dropdown with router-link in AppHeader template**
In `frontend/src/components/AppHeader.vue`, replace the entire gear-menu `<div>` block (lines 111123, the `<!-- Gear dropdown -->` comment through the closing `</div>`) with:
```html
<!-- Settings link -->
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</router-link>
```
Also remove the CSS for `.gear-menu`, `.btn-gear`, `.gear-dropdown`, `.gear-item` from the `<style scoped>` section — find and delete those rule blocks.
- [ ] **Step 3: Update mobile menu in AppHeader**
In the mobile menu section (inside `<div v-if="mobileMenuOpen" class="mobile-menu">`), find and delete the two admin router-links — they look like this:
```html
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
```
Leave the existing `<router-link to="/settings" class="nav-link">Settings</router-link>` line in place — it is already there and provides the Settings entry for mobile users.
- [ ] **Step 4: Replace /admin/users and /admin/logs routes with redirects**
In `frontend/src/router/index.ts`, replace:
```typescript
{
path: "/admin/users",
name: "admin-users",
component: () => import("@/views/UserManagementView.vue"),
},
{
path: "/admin/logs",
name: "admin-logs",
component: () => import("@/views/LogsView.vue"),
},
```
With:
```typescript
{ path: "/admin/users", redirect: "/settings" },
{ path: "/admin/logs", redirect: "/settings" },
```
- [ ] **Step 5: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend && npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 6: Manual verification**
Start the dev server (`docker compose up`) and verify:
- Clicking the cog icon navigates to `/settings`
- `/admin/users` redirects to `/settings`
- `/admin/logs` redirects to `/settings`
- No dropdown appears anywhere
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/AppHeader.vue frontend/src/router/index.ts
git commit -m "feat: replace gear dropdown with direct settings link"
```
---
## Chunk 2: SettingsView Layout (Sidebar + Card Headers)
### Task 2: Convert tab bar to sidebar layout
**Files:**
- Modify: `frontend/src/views/SettingsView.vue`
This is a template + CSS change only — all script logic stays identical.
- [ ] **Step 1: Replace the `<main>` open tag and page header**
Current (line 442444):
```html
<template>
<main class="settings-page">
<h1>Settings</h1>
```
Replace with:
```html
<template>
<main class="settings-root">
<aside class="settings-sidebar" role="navigation" aria-label="Settings navigation">
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'users', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
</aside>
<div class="settings-content">
```
Note: the existing "admin" tab is renamed to "config". Update the script so that old stored "admin" values are migrated and normalized on every write. Replace the two lines:
```typescript
// Old (lines 32-33):
const activeTab = ref(localStorage.getItem("settings_tab") ?? "general");
watch(activeTab, (v) => localStorage.setItem("settings_tab", v));
```
With:
```typescript
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "config", "users", "logs"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => localStorage.setItem("settings_tab", v === "admin" ? "config" : v));
```
- [ ] **Step 2: Remove the old tab bar block**
Delete the entire `<!-- Tab bar -->` block (lines 446460):
```html
<!-- Tab bar -->
<div class="settings-tabs" role="tablist">
<button
v-for="tab in (authStore.isAdmin
? ['general', 'account', 'notifications', 'integrations', 'data', 'admin']
: ['general', 'account', 'notifications', 'integrations', 'data'])"
:key="tab"
role="tab"
:aria-selected="activeTab === tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
```
- [ ] **Step 3: Rename the Admin panel v-show condition**
The admin panel currently uses `v-show="activeTab === 'admin'"`. Change it to `v-show="activeTab === 'config'"`:
```html
<!-- Old: -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'admin'" class="settings-grid">
<!-- New: -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
```
- [ ] **Step 4: Close the `.settings-content` wrapper div**
Before the closing `</main>` tag (currently at line 919), add a closing `</div>` for the `.settings-content` wrapper:
```html
</div><!-- end .settings-content -->
</main>
```
- [ ] **Step 5: Update the CSS — add sidebar layout, restyle card headers**
Replace the entire `/* Tab bar */` CSS block (`.settings-tabs`, `.tab-btn`, `.tab-btn:hover`, `.tab-btn.active`) with the new sidebar CSS:
```css
/* Settings root layout */
.settings-root {
display: flex;
gap: 0;
max-width: 1100px;
margin: 2rem auto;
padding: 0 1.5rem;
align-items: flex-start;
min-height: 0;
}
/* Sidebar */
.settings-sidebar {
width: 175px;
flex-shrink: 0;
position: sticky;
top: 1.5rem;
padding-right: 1rem;
}
.sidebar-group {
margin-bottom: 1rem;
}
.sidebar-group-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
padding: 0.25rem 0.75rem 0.2rem;
}
.sidebar-item {
display: block;
width: 100%;
text-align: left;
padding: 0.4rem 0.75rem;
border: none;
border-left: 2px solid transparent;
background: none;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
font-family: inherit;
}
.sidebar-item:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
.sidebar-item.active {
color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
border-left-color: var(--color-primary);
font-weight: 600;
}
/* Content panel */
.settings-content {
flex: 1;
min-width: 0;
}
```
- [ ] **Step 5b: Remove orphaned CSS rules**
Explicitly find and delete the following blocks from `<style scoped>` — they are no longer used:
- `.settings-page { ... }` (the old max-width wrapper)
- `.settings-page h1 { ... }` (the old heading margin)
- `.settings-tabs { ... }`, `.tab-btn { ... }`, `.tab-btn:hover { ... }`, `.tab-btn.active { ... }` (all tab bar rules)
Update `.settings-section h2` to use the small-caps Option-A card header style:
```css
/* Old: */
.settings-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
/* New: */
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
}
```
- [ ] **Step 6: Update the responsive CSS at the bottom**
The existing `@media (max-width: 768px)` block references `.settings-tabs` and `.tab-btn`. Replace those with sidebar mobile behaviour:
```css
@media (max-width: 768px) {
.settings-root {
flex-direction: column;
padding: 0 1rem;
}
.settings-sidebar {
width: 100%;
position: static;
padding-right: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.sidebar-group {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
.sidebar-group-label {
width: 100%;
}
.sidebar-item {
width: auto;
border-left: none;
border-bottom: 2px solid transparent;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
font-size: 0.82rem;
padding: 0.35rem 0.7rem;
}
.sidebar-item.active {
border-bottom-color: var(--color-primary);
}
.settings-grid {
grid-template-columns: 1fr;
}
.settings-section.full-width {
grid-column: auto;
}
/* keep remaining media rules unchanged */
}
```
- [ ] **Step 7: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend && npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 8: Manual verification**
- Navigate to `/settings` → sidebar shows User and Admin groups (admin user only)
- Clicking each sidebar item shows the correct panel
- Active item has indigo left border + tint
- On mobile (≤768px) sidebar collapses to tab-like row
- All existing panels (General, Account, Notifications, Integrations, Data, Config/Admin) load correctly
- Refreshing on each panel restores to correct tab (localStorage key preserved)
- [ ] **Step 9: Commit**
```bash
git add frontend/src/views/SettingsView.vue
git commit -m "feat: settings sidebar layout + card header style"
```
---
## Chunk 3: Inline Users and Logs Panels
### Task 3: Add Users panel to SettingsView
**Files:**
- Modify: `frontend/src/views/SettingsView.vue`
Source: `frontend/src/views/UserManagementView.vue` (read this file — do not modify it)
- [ ] **Step 1: Add UserManagement script logic to SettingsView**
In `frontend/src/views/SettingsView.vue` `<script setup>`, add the following imports and reactive state **after** the existing imports/state (around line 430, before the closing `</script>`):
```typescript
// ── Users panel ──
import type { User } from "@/types/auth";
interface Invitation {
id: number;
email: string;
created_at: string;
expires_at: string;
}
const users = ref<User[]>([]);
const registrationOpen = ref(false);
const usersLoading = ref(false);
const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(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 `<!-- ── Admin ── -->` panel closing `</div>` (currently at line 917) and before `</div><!-- end .settings-content -->`, add:
```html
<!-- ── Users ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
<h2>Registration</h2>
<div class="registration-row">
<div class="registration-info">
<p class="registration-status">
Registration is currently
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
{{ registrationOpen ? "open" : "closed" }}
</strong>
</p>
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
</div>
<button
class="btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
>
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
</button>
</div>
</section>
<section class="settings-section full-width">
<h2>Invite User</h2>
<form class="invite-form" @submit.prevent="sendInvite">
<input
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
required
:disabled="sendingInvite"
/>
<button type="submit" class="btn-save" :disabled="sendingInvite || !inviteEmail.trim()">
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
<div v-if="invitations.length > 0" class="invite-list">
<h3 class="subsection-label">Pending Invitations</h3>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th class="hide-mobile">Sent</th>
<th class="hide-mobile">Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button class="btn-delete" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="settings-section full-width">
<h2>Users</h2>
<div v-if="usersLoading" class="loading-msg">Loading users...</div>
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
<table v-else class="users-table">
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Role</th>
<th class="hide-mobile">Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="cell-username">{{ u.username }}</td>
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
<td>
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ formatUserDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
</template>
<template v-else-if="confirmDeleteId === u.id">
<button class="btn-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button class="btn-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
</template>
</td>
</tr>
</tbody>
</table>
</section>
</div>
```
- [ ] **Step 3: Add Users panel CSS to SettingsView**
Copy the following CSS from `UserManagementView.vue` into `SettingsView.vue`'s `<style scoped>` section (these classes don't exist yet in SettingsView):
```css
/* Users panel */
.registration-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.registration-info { flex: 1; }
.registration-status { margin: 0; font-size: 0.95rem; }
.text-success { color: var(--color-success); }
.text-muted { color: var(--color-text-muted); }
.invite-form {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-input { flex: 1; }
.invite-list { margin-top: 1rem; }
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.users-table { width: 100%; border-collapse: collapse; }
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td { border-bottom: none; }
.cell-username { font-weight: 600; }
.cell-email { color: var(--color-text-secondary); }
.cell-date { color: var(--color-text-muted); font-size: 0.85rem; }
.cell-actions { white-space: nowrap; }
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
}
.role-admin {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.role-user {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
}
.you-label { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; }
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) { border-color: var(--color-danger); color: var(--color-danger); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.25rem;
}
.btn-confirm-delete:hover:not(:disabled) { filter: brightness(0.9); }
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
.btn-toggle-open { background: var(--color-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { opacity: 0.9; }
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
.loading-msg, .empty-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
```
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend && npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 5: Manual verification**
- Navigate to `/settings`, click "Users" in Admin sidebar section
- Registration toggle appears and works
- Invite form sends invitation (toast appears)
- Pending invitations list appears after sending
- Users table shows all users with roles
- Delete user flow works (two-click confirm)
- Non-admin users don't see the Admin sidebar section at all
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/SettingsView.vue
git commit -m "feat: inline Users panel into Settings"
```
---
### Task 4: Add Logs panel to SettingsView
**Files:**
- Modify: `frontend/src/views/SettingsView.vue`
Source: `frontend/src/views/LogsView.vue` (read this file — do not modify it)
- [ ] **Step 1: Add Logs script logic to SettingsView**
Note: The consolidated `watch(activeTab, ...)` watcher was already updated in Task 3 Step 1 to include the Logs panel lazy-load. No additional watcher is needed here.
In `frontend/src/views/SettingsView.vue` `<script setup>`, add after the Users panel logic:
```typescript
// ── Logs panel ──
import PaginationBar from "@/components/PaginationBar.vue";
interface LogEntry {
id: number;
category: string;
user_id: number | null;
username: string | null;
action: string | null;
endpoint: string | null;
method: string | null;
status_code: number | null;
duration_ms: number | null;
ip_address: string | null;
details: string | null;
created_at: string;
}
interface LogStats {
audit: number;
usage: number;
error: number;
total: number;
}
const logs = ref<LogEntry[]>([]);
const logStats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
const logTotal = ref(0);
const logsLoading = ref(false);
const logsLoaded = ref(false);
const expandedLogId = ref<number | null>(null);
const logCategory = ref("");
const logSearch = ref("");
const logDateFrom = ref("");
const logDateTo = ref("");
const logLimit = 50;
const logOffset = ref(0);
let logSearchTimeout: ReturnType<typeof setTimeout> | null = null;
watch([logCategory, logDateFrom, logDateTo], () => {
logOffset.value = 0;
if (logsLoaded.value) fetchLogs();
});
watch(logSearch, () => {
if (logSearchTimeout) clearTimeout(logSearchTimeout);
logSearchTimeout = setTimeout(() => {
logOffset.value = 0;
if (logsLoaded.value) fetchLogs();
}, 300);
});
watch(logOffset, () => {
if (logsLoaded.value) fetchLogs();
});
async function fetchLogs() {
try {
const params = new URLSearchParams();
if (logCategory.value) params.set("category", logCategory.value);
if (logSearch.value) params.set("search", logSearch.value);
if (logDateFrom.value) params.set("date_from", logDateFrom.value);
if (logDateTo.value) params.set("date_to", logDateTo.value);
params.set("limit", String(logLimit));
params.set("offset", String(logOffset.value));
const data = await apiGet<{ logs: LogEntry[]; total: number }>(`/api/admin/logs?${params}`);
logs.value = data.logs;
logTotal.value = data.total;
} catch {
toastStore.show("Failed to load logs", "error");
}
}
async function fetchLogStats() {
try {
logStats.value = await apiGet<LogStats>("/api/admin/logs/stats");
} catch { /* ignore */ }
}
async function loadLogsPanel() {
if (logsLoaded.value) return;
logsLoading.value = true;
await Promise.all([fetchLogs(), fetchLogStats()]);
logsLoaded.value = true;
logsLoading.value = false;
}
function toggleLogExpand(id: number) {
expandedLogId.value = expandedLogId.value === id ? null : id;
}
function formatLogTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function formatLogDetails(details: string | null): string {
if (!details) return "";
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
}
function logDisplayLabel(entry: LogEntry): string {
if (entry.category === "audit" && entry.action) return entry.action;
if (entry.endpoint) return entry.endpoint;
return "—";
}
function clearLogFilters() {
logCategory.value = "";
logSearch.value = "";
logDateFrom.value = "";
logDateTo.value = "";
logOffset.value = 0;
}
```
(No separate watcher needed — the consolidated `watch(activeTab, ...)` updated in Task 3 Step 1 already includes both `loadUsersPanel()` and `loadLogsPanel()` calls.)
- [ ] **Step 2: Add Logs panel template**
After the Users panel closing `</div>` in the template, add:
```html
<!-- ── Logs ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'logs'" class="settings-grid">
<section class="settings-section full-width stats-section">
<h2>Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-count">{{ logStats.total.toLocaleString() }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-count stat-audit">{{ logStats.audit.toLocaleString() }}</span>
<span class="stat-label">Audit</span>
</div>
<div class="stat-card">
<span class="stat-count stat-usage">{{ logStats.usage.toLocaleString() }}</span>
<span class="stat-label">Usage</span>
</div>
<div class="stat-card">
<span class="stat-count stat-error">{{ logStats.error.toLocaleString() }}</span>
<span class="stat-label">Error</span>
</div>
</div>
</section>
<section class="settings-section full-width">
<h2>Log Entries</h2>
<div class="filter-bar">
<select v-model="logCategory" class="filter-select input">
<option value="">All categories</option>
<option value="audit">Audit</option>
<option value="usage">Usage</option>
<option value="error">Error</option>
</select>
<input v-model="logSearch" type="text" placeholder="Search logs..." class="filter-input input" />
<input v-model="logDateFrom" type="date" class="filter-date input" title="From date" />
<input v-model="logDateTo" type="date" class="filter-date input" title="To date" />
<button
v-if="logCategory || logSearch || logDateFrom || logDateTo"
class="btn-secondary"
@click="clearLogFilters"
>Clear</button>
</div>
<div v-if="logsLoading" class="loading-msg">Loading logs...</div>
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
<template v-else>
<table class="users-table logs-table">
<thead>
<tr>
<th>Time</th>
<th>Category</th>
<th class="hide-mobile">User</th>
<th>Action / Endpoint</th>
<th class="hide-mobile">Status</th>
<th class="hide-mobile">Duration</th>
</tr>
</thead>
<tbody>
<template v-for="entry in logs" :key="entry.id">
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
<td class="cell-time">{{ formatLogTime(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
</td>
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
<td class="cell-action">
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
{{ logDisplayLabel(entry) }}
</td>
<td class="hide-mobile cell-status">
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
{{ entry.status_code }}
</span>
<span v-else></span>
</td>
<td class="hide-mobile cell-duration">
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
</td>
</tr>
<tr v-if="expandedLogId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
<td colspan="6">
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
<pre v-if="entry.details" class="detail-json">{{ formatLogDetails(entry.details) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
<PaginationBar
:total="logTotal"
:limit="logLimit"
:offset="logOffset"
@update:offset="logOffset = $event"
/>
</template>
</section>
</div>
```
- [ ] **Step 3: Add Logs panel CSS**
Add to `SettingsView.vue` `<style scoped>`:
```css
/* Logs panel */
.stats-section { padding: 1rem 1.25rem; }
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
.stat-card {
flex: 1;
min-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-count { font-size: 1.5rem; font-weight: 700; color: var(--color-text); }
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.stat-audit { color: var(--color-primary); }
.stat-usage { color: var(--color-success); }
.stat-error { color: var(--color-danger); }
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.filter-select { min-width: 140px; }
.filter-input { flex: 1; min-width: 150px; }
.filter-date { width: 140px; }
.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td { border-bottom: none; }
.log-row { cursor: pointer; transition: background 0.1s; }
.log-row:hover { background: var(--color-bg-secondary); }
.row-expanded { background: var(--color-bg-secondary); }
.cell-time { white-space: nowrap; color: var(--color-text-muted); font-size: 0.8rem; }
.cell-user { color: var(--color-text-secondary); }
.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--color-danger); }
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
.detail-json {
margin: 0; padding: 0.75rem;
background: var(--color-bg); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); font-size: 0.8rem;
overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
}
.category-badge {
display: inline-block;
font-size: 0.65rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.35rem; border-radius: var(--radius-sm);
}
.cat-audit { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.cat-usage { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); }
.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 15%, transparent); }
.method-tag {
display: inline-block;
font-size: 0.65rem; font-weight: 700; font-family: monospace;
padding: 0.05rem 0.25rem; border-radius: 3px;
background: var(--color-bg-secondary); color: var(--color-text-muted);
margin-right: 0.25rem;
}
```
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend && npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 5: Manual verification**
- Click "Logs" in Admin sidebar section
- Stats cards show total/audit/usage/error counts
- Log table renders with category badges, method tags
- Filters (category, search, date range) work; clear button appears/disappears
- Clicking a row expands detail (IP, JSON details)
- Pagination works (if enough logs exist)
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/SettingsView.vue
git commit -m "feat: inline Logs panel into Settings"
```
---
## Final Verification
- [ ] Full TypeScript check: `cd frontend && npx tsc --noEmit`
- [ ] Docker build: `docker compose build && docker compose up`
- [ ] Navigate through all 8 sidebar panels as admin — each panel loads without errors
- [ ] Navigate through 5 user panels as non-admin — Admin section not visible
- [ ] `/admin/users` and `/admin/logs` both redirect to `/settings`
- [ ] Gear dropdown is gone; cog icon navigates directly to `/settings`
- [ ] Mobile: sidebar collapses to tab row; all panels accessible
- [ ] localStorage `settings_tab` persists active panel across page reloads
```bash
git log --oneline -5
```
Expected: three commits visible (AppHeader/Router, sidebar layout, Users panel, Logs panel).