89f3d94895
Tasks: AppHeader/router cleanup, SettingsView sidebar layout, inline Users and Logs panels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1204 lines
38 KiB
Markdown
1204 lines
38 KiB
Markdown
# 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 111–123, 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 442–444):
|
||
```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 446–460):
|
||
```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).
|