From c2565fb81807938ba2241b3032fb799d798f6d4a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 10 Mar 2026 22:22:18 -0400 Subject: [PATCH] feat: inline Users panel into Settings Ports UserManagementView logic (users list, invitations, registration toggle) directly into SettingsView as a lazy-loaded 'users' admin tab. Adds loadLogsPanel stub for Task 4. Adds apiDelete import and User type import at script top. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 372 +++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ef18451..80b7fde 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,8 +3,9 @@ import { ref, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { usePushStore } from "@/stores/push"; +import type { User } from "@/types/auth"; const store = useSettingsStore(); const authStore = useAuthStore(); @@ -32,7 +33,11 @@ const restoreFileInput = ref(null); 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)); +watch(activeTab, (v) => { + localStorage.setItem("settings_tab", v === "admin" ? "config" : v); + if (v === "users" && authStore.isAdmin) loadUsersPanel(); + if (v === "logs" && authStore.isAdmin) loadLogsPanel(); +}); // Chat retention const chatRetentionDays = ref(90); @@ -439,6 +444,133 @@ function onSearchKeydown(e: KeyboardEvent) { function hostname(url: string): string { try { return new URL(url).hostname; } catch { return url; } } + +// ── Users panel ── + +interface Invitation { + id: number; + email: string; + created_at: string; + expires_at: string; +} + +const users = ref([]); +const registrationOpen = ref(false); +const usersLoading = ref(false); +const toggling = ref(false); +const confirmDeleteId = ref(null); +const deleting = ref(null); +const inviteEmail = ref(""); +const sendingInvite = ref(false); +const invitations = ref([]); +const revokingId = ref(null); + +async function fetchUsers() { + try { + const data = await apiGet<{ users: User[] }>("/api/admin/users"); + users.value = data.users; + } catch { + toastStore.show("Failed to load users", "error"); + } +} + +async function fetchRegistration() { + try { + const data = await apiGet<{ open: boolean }>("/api/admin/registration"); + registrationOpen.value = data.open; + } catch { /* ignore */ } +} + +async function fetchInvitations() { + try { + const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations"); + invitations.value = data.invitations; + } catch { /* ignore */ } +} + +async function loadUsersPanel() { + if (users.value.length > 0) return; // already loaded + usersLoading.value = true; + await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]); + usersLoading.value = false; +} + +async function loadLogsPanel() { /* implemented in Task 4 */ } + +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", + }); +} @@ -1396,4 +1642,126 @@ function hostname(url: string): string { grid-template-columns: 1fr; } } + +/* 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; +}