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

38 KiB
Raw Blame History

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

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 111123, 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/users redirects to /settings

  • /admin/logs redirects 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 442444):

<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 446460):

<!-- 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-content wrapper 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/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
git log --oneline -5

Expected: three commits visible (AppHeader/Router, sidebar layout, Users panel, Logs panel).