Tasks: AppHeader/router cleanup, SettingsView sidebar layout, inline Users and Logs panels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
38 KiB
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>:
// 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:
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 111–123, the <!-- Gear dropdown --> comment through the closing </div>) with:
<!-- 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:
<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:
{
path: "/admin/users",
name: "admin-users",
component: () => import("@/views/UserManagementView.vue"),
},
{
path: "/admin/logs",
name: "admin-logs",
component: () => import("@/views/LogsView.vue"),
},
With:
{ path: "/admin/users", redirect: "/settings" },
{ path: "/admin/logs", redirect: "/settings" },
- Step 5: TypeScript check
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/usersredirects to/settings -
/admin/logsredirects to/settings -
No dropdown appears anywhere
-
Step 7: Commit
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 442–444):
<template>
<main class="settings-page">
<h1>Settings</h1>
Replace with:
<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:
// Old (lines 32-33):
const activeTab = ref(localStorage.getItem("settings_tab") ?? "general");
watch(activeTab, (v) => localStorage.setItem("settings_tab", v));
With:
// 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 446–460):
<!-- 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'":
<!-- 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-contentwrapper div
Before the closing </main> tag (currently at line 919), add a closing </div> for the .settings-content wrapper:
</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:
/* 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:
/* 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:
@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
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
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>):
// ── 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:
// 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:
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:
<!-- ── 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):
/* 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
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
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:
// ── 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:
<!-- ── 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>:
/* 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
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
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/usersand/admin/logsboth redirect to/settings- Gear dropdown is gone; cog icon navigates directly to
/settings - Mobile: sidebar collapses to tab row; all panels accessible
- localStorage
settings_tabpersists active panel across page reloads
git log --oneline -5
Expected: three commits visible (AppHeader/Router, sidebar layout, Users panel, Logs panel).