Files
FabledScribe/frontend/src/views/SettingsView.vue
T
bvandeusen c4553d937c
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 54s
feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
Adds a daily off-hours VACUUM (ANALYZE) over the high-churn tables the
retention/purge sweeps churn (app_logs, notifications, token tables, notes,
note_versions), on top of Postgres autovacuum, to reclaim bloat left by the
nightly bulk DELETEs and keep planner stats fresh.

- services/db_maintenance.py: run_maintenance() over a closed table allowlist
  via an AUTOCOMMIT connection (VACUUM can't run in a txn); per-table summary
  persisted as the db_maintenance_last_run admin setting.
- services/db_maintenance_scheduler.py: BackgroundScheduler cron (default
  04:00 UTC, after the 03:30 trash purge); enabled-gate checked at fire time;
  live reschedule on hour change. Wired into app.py start/stop.
- routes/admin.py: admin-only GET/PUT /api/admin/db-maintenance + POST /run.
- settings.py: set_admin_setting() (write-side of get_admin_setting) for
  out-of-request writes.
- SettingsView.vue: admin 'Database maintenance' card — enable toggle, run-hour
  (UTC), Run-now, last-run summary.
- Tests: allowlist is closed, VACUUM issued per table, one failure doesn't
  abort the rest, summary persisted; route/scheduler/service surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:42:04 -04:00

3710 lines
121 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const trashRetentionDays = ref("90");
const savingRetention = ref(false);
const retentionSaved = ref(false);
// think_enabled setting removed 2026-05-23. The chat+curator architecture
// has tools=[] on the chat model; think on a no-tools conversational pass
// is pure latency cost. See generation_task.py:run_generation comment for
// the full reasoning.
function detectTimezone() {
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
async function saveTimezone() {
savingTimezone.value = true;
timezoneSaved.value = false;
try {
await apiPut('/api/settings', { user_timezone: userTimezone.value });
timezoneSaved.value = true;
setTimeout(() => (timezoneSaved.value = false), 2000);
} catch {
toastStore.show('Failed to save timezone', 'error');
} finally {
savingTimezone.value = false;
}
}
async function saveRetention() {
const n = Math.max(0, Math.floor(Number(trashRetentionDays.value) || 0));
trashRetentionDays.value = String(n);
savingRetention.value = true;
retentionSaved.value = false;
try {
await apiPut('/api/settings', { trash_retention_days: String(n) });
retentionSaved.value = true;
setTimeout(() => (retentionSaved.value = false), 2000);
} catch {
toastStore.show('Failed to save retention setting', 'error');
} finally {
savingRetention.value = false;
}
}
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const invalidatingSessions = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
function _loadTabContent(tab: string) {
if (authStore.isAdmin) {
if (tab === "users") loadUsersPanel();
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
}
watch(activeTab, (v) => {
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
_loadTabContent(v);
});
// MCP Access (API Keys are used as Bearer tokens for the in-app /mcp endpoint)
const apiKeys = ref<ApiKeyEntry[]>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
const apiKeyCopied = ref(false);
const creatingApiKey = ref(false);
const revokeConfirmId = ref<number | null>(null);
const origin = window.location.origin;
const mcpUrl = computed(() => `${origin}/mcp`);
const mcpUrlCopied = ref(false);
const mcpClientTab = ref<'claude-code' | 'claude-desktop'>('claude-code');
const copiedSnippetKey = ref<string | null>(null);
// Configurable per-browser: the local name Claude uses for this server, and the
// scope of the `claude mcp add` call. Persisted to localStorage so the user's
// preferences carry across visits without needing a server-side setting.
const _MCP_NAME_KEY = 'mcp_server_name';
const _MCP_SCOPE_KEY = 'mcp_scope';
const _DEFAULT_MCP_NAME = 'scribe';
const _isValidScope = (v: unknown): v is 'user' | 'project' | 'local' =>
v === 'user' || v === 'project' || v === 'local';
const mcpServerName = ref<string>(
localStorage.getItem(_MCP_NAME_KEY) || _DEFAULT_MCP_NAME,
);
const _storedScope = localStorage.getItem(_MCP_SCOPE_KEY);
const mcpScope = ref<'user' | 'project' | 'local'>(
_isValidScope(_storedScope) ? _storedScope : 'user',
);
watch(mcpServerName, (v) => {
const clean = (v || '').trim() || _DEFAULT_MCP_NAME;
localStorage.setItem(_MCP_NAME_KEY, clean);
});
watch(mcpScope, (v) => localStorage.setItem(_MCP_SCOPE_KEY, v));
const effectiveMcpName = computed(() => (mcpServerName.value || '').trim() || _DEFAULT_MCP_NAME);
const effectiveApiKey = computed(() => newKeyValue.value || '<your-token>');
const claudeCodeCommand = computed(() => {
// Note: `claude mcp add` takes the URL as a positional arg, not --url.
// Layout: claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]
return `claude mcp add --transport http --scope ${mcpScope.value} ${effectiveMcpName.value} \\
${mcpUrl.value} \\
--header "Authorization: Bearer ${effectiveApiKey.value}"`;
});
// Plugin install — the recommended path. The Scribe plugin bundles the MCP
// connection, a session-start hook that surfaces your rules, and the Scribe
// process-skills. The marketplace is the Scribe app's own git repo; persisted
// per-browser like the MCP fields above.
const _MKT_KEY = 'plugin_marketplace_url';
const pluginMarketplaceUrl = ref<string>(localStorage.getItem(_MKT_KEY) || '');
watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim()));
// Instance default, set by an admin (Admin tab) and loaded on mount. Used when
// the per-browser field is blank so the install command is copyable out of the box.
const serverMarketplaceUrl = ref('');
const adminMarketplaceUrl = ref('');
const savingMarketplaceUrl = ref(false);
const marketplaceUrlSaved = ref(false);
// DB maintenance (admin) — daily targeted VACUUM (ANALYZE).
interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null }
interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] }
const dbMaintEnabled = ref(true);
const dbMaintHour = ref(4);
const dbMaintLastRun = ref<DbMaintRun | null>(null);
const savingDbMaint = ref(false);
const dbMaintSaved = ref(false);
const runningDbMaint = ref(false);
const pluginInstallCommands = computed(() => {
const mkt = (pluginMarketplaceUrl.value || '').trim()
|| serverMarketplaceUrl.value
|| '<your-scribe-repo>.git';
return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`;
});
const mcpConfigSnippet = computed(() => JSON.stringify({
mcpServers: {
[effectiveMcpName.value]: {
url: mcpUrl.value,
headers: {
Authorization: `Bearer ${effectiveApiKey.value}`,
},
},
},
}, null, 2));
async function copyMcpUrl() {
await copyToClipboard(mcpUrl.value);
mcpUrlCopied.value = true;
setTimeout(() => { mcpUrlCopied.value = false; }, 2000);
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
async function copySnippet(text: string, key: string) {
await copyToClipboard(text);
copiedSnippetKey.value = key;
setTimeout(() => {
if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
}, 2000);
}
async function fetchApiKeys() {
apiKeys.value = await listApiKeys();
}
async function createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.value = true;
try {
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
} finally {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await apiRevokeApiKey(id);
revokeConfirmId.value = null;
await fetchApiKeys();
}
async function copyApiKey() {
await copyToClipboard(newKeyValue.value);
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
// Groups management
const groups = ref<GroupEntry[]>([]);
const groupsLoading = ref(false);
const newGroupName = ref("");
const newGroupDesc = ref("");
const creatingGroup = ref(false);
const expandedGroupId = ref<number | null>(null);
const groupMembers = ref<Record<number, GroupMember[]>>({});
const groupMemberSearch = ref("");
const groupMemberResults = ref<UserSearchResult[]>([]);
let groupSearchTimer: ReturnType<typeof setTimeout> | null = null;
const groupMemberRole = ref("member");
async function loadGroupsPanel() {
groupsLoading.value = true;
try {
groups.value = await listGroups();
} finally {
groupsLoading.value = false;
}
}
async function createNewGroup() {
if (!newGroupName.value.trim() || creatingGroup.value) return;
creatingGroup.value = true;
try {
await createGroup(newGroupName.value.trim(), newGroupDesc.value.trim() || undefined);
newGroupName.value = "";
newGroupDesc.value = "";
await loadGroupsPanel();
} finally {
creatingGroup.value = false;
}
}
async function deleteGroupConfirm(g: GroupEntry) {
if (!confirm(`Delete group "${g.name}"? This cannot be undone.`)) return;
await deleteGroup(g.id);
if (expandedGroupId.value === g.id) expandedGroupId.value = null;
await loadGroupsPanel();
}
async function toggleGroupExpand(g: GroupEntry) {
if (expandedGroupId.value === g.id) {
expandedGroupId.value = null;
return;
}
expandedGroupId.value = g.id;
groupMemberSearch.value = "";
groupMemberResults.value = [];
groupMembers.value[g.id] = await listGroupMembers(g.id);
}
function debounceGroupMemberSearch() {
if (groupSearchTimer) clearTimeout(groupSearchTimer);
groupSearchTimer = setTimeout(async () => {
if (groupMemberSearch.value.length < 2) { groupMemberResults.value = []; return; }
groupMemberResults.value = await searchUsers(groupMemberSearch.value);
}, 300);
}
async function addMemberToGroup(groupId: number, user: UserSearchResult) {
await addGroupMember(groupId, user.id, groupMemberRole.value);
groupMembers.value[groupId] = await listGroupMembers(groupId);
groupMemberSearch.value = "";
groupMemberResults.value = [];
await loadGroupsPanel();
}
async function removeMemberFromGroup(groupId: number, userId: number) {
await removeGroupMember(groupId, userId);
groupMembers.value[groupId] = await listGroupMembers(groupId);
await loadGroupsPanel();
}
// Notification preferences (email notifications only — push surface removed)
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
caldav_username: "",
caldav_password: "",
caldav_calendar_name: "",
});
const savingCaldav = ref(false);
const caldavSaved = ref(false);
const testingCaldav = ref(false);
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
// SMTP settings (admin only)
const smtp = ref({
smtp_host: "",
smtp_port: "587",
smtp_username: "",
smtp_password: "",
smtp_from_address: "",
smtp_from_name: "Fabled Scribe",
smtp_use_tls: "true",
});
const savingSmtp = ref(false);
const smtpSaved = ref(false);
const testRecipient = ref("");
const sendingTest = ref(false);
// Base URL setting (admin only)
const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Search test (SearXNG)
const searxngConfigured = ref(false);
const searxngUrl = ref("");
const searchQuery = ref("");
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
const searchLoading = ref(false);
const searchError = ref("");
// ── Profile ──────────────────────────────────────────────────────────────────
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const profile = ref<UserProfile>({
display_name: '', job_title: '', industry: '',
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
interests: [], work_schedule: {},
})
const profileSaving = ref(false)
const profileSaved = ref(false)
async function loadProfile() {
try { profile.value = await getProfile() } catch { /* non-critical */ }
}
async function saveProfile() {
profileSaving.value = true
profileSaved.value = false
try {
profile.value = await updateProfile({
display_name: profile.value.display_name,
job_title: profile.value.job_title,
industry: profile.value.industry,
expertise_level: profile.value.expertise_level,
response_style: profile.value.response_style,
tone: profile.value.tone,
interests: profile.value.interests,
work_schedule: profile.value.work_schedule,
})
profileSaved.value = true
setTimeout(() => { profileSaved.value = false }, 2000)
} catch { toastStore.show('Failed to save profile', 'error') }
finally { profileSaving.value = false }
}
function toggleProfileWorkDay(day: string) {
const days = [...(profile.value.work_schedule.days ?? [])]
const idx = days.indexOf(day)
if (idx >= 0) days.splice(idx, 1)
else days.push(day)
profile.value.work_schedule = { ...profile.value.work_schedule, days }
}
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings();
newEmail.value = authStore.user?.email ?? "";
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
userTimezone.value = allSettings.user_timezone ?? "";
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
if (allSettings.notify_security_alerts !== undefined) {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load user profile
await loadProfile();
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
// Load CalDAV settings
try {
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
caldav.value = { ...caldav.value, ...caldavConfig };
} catch {
// CalDAV not configured yet
}
// Check SearXNG status
try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
searxngConfigured.value = sr.configured;
searxngUrl.value = sr.searxng_url;
} catch {
searxngConfigured.value = false;
}
// Plugin marketplace URL (instance default; readable by all users so the
// install command in MCP Access is copyable).
try {
const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url");
serverMarketplaceUrl.value = mk.marketplace_url || "";
adminMarketplaceUrl.value = mk.marketplace_url || "";
if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || "";
} catch {
// not configured yet
}
// DB maintenance config (admin only — endpoint is admin-gated).
if (authStore.isAdmin) {
try {
const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance");
dbMaintEnabled.value = dm.enabled;
dbMaintHour.value = dm.hour;
dbMaintLastRun.value = dm.last_run;
} catch {
// leave defaults
}
}
// Load admin settings
if (authStore.isAdmin) {
try {
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
smtp.value = { ...smtp.value, ...smtpConfig };
} catch {
// SMTP not configured yet
}
try {
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
baseUrl.value = urlConfig.base_url;
} catch {
// base URL not configured yet
}
}
_loadTabContent(activeTab.value);
});
async function changeEmail() {
changingEmail.value = true;
try {
const body: Record<string, string> = { email: newEmail.value.trim() };
if (authStore.user?.has_password) {
body.password = emailPassword.value;
}
const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
authStore.user = updated;
emailPassword.value = "";
toastStore.show("Email updated successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
} finally {
changingEmail.value = false;
}
}
async function invalidateSessions() {
invalidatingSessions.value = true;
try {
await apiPost("/api/auth/invalidate-sessions", {});
toastStore.show("All other sessions have been invalidated");
} catch {
toastStore.show("Failed to invalidate sessions", "error");
} finally {
invalidatingSessions.value = false;
}
}
async function changePassword() {
if (newPassword.value !== confirmNewPassword.value) {
toastStore.show("New passwords do not match", "error");
return;
}
changingPassword.value = true;
try {
await apiPut("/api/auth/password", {
current_password: currentPassword.value,
new_password: newPassword.value,
});
toastStore.show("Password changed successfully");
currentPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
} finally {
changingPassword.value = false;
}
}
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exporting.value = false;
}
}
const exportingNotes = ref(false);
async function exportNotes(format: "markdown" | "json") {
exportingNotes.value = true;
try {
const res = await fetch(`/api/export?format=${format}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const blob = await res.blob();
const ext = format === "json" ? "json" : "zip";
const stamp = new Date().toISOString().slice(0, 10);
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `scribe-${stamp}.${ext}`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exportingNotes.value = false;
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function saveNotifications() {
savingNotifications.value = true;
notificationsSaved.value = false;
try {
await apiPut("/api/settings", {
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
});
notificationsSaved.value = true;
setTimeout(() => (notificationsSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save notification preferences", "error");
} finally {
savingNotifications.value = false;
}
}
async function saveCaldav() {
savingCaldav.value = true;
caldavSaved.value = false;
try {
await apiPut("/api/settings/caldav", caldav.value);
caldavSaved.value = true;
setTimeout(() => (caldavSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save CalDAV settings", "error");
} finally {
savingCaldav.value = false;
}
}
async function testCaldav() {
testingCaldav.value = true;
caldavTestResult.value = null;
try {
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
caldavTestResult.value = result;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
} else {
caldavTestResult.value = { success: false, error: "Connection test failed" };
}
} finally {
testingCaldav.value = false;
}
}
async function saveSmtp() {
savingSmtp.value = true;
smtpSaved.value = false;
try {
await apiPut("/api/admin/smtp", smtp.value);
smtpSaved.value = true;
setTimeout(() => (smtpSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save SMTP settings", "error");
} finally {
savingSmtp.value = false;
}
}
async function sendTestEmail() {
if (!testRecipient.value.trim()) {
toastStore.show("Enter a recipient email address", "error");
return;
}
sendingTest.value = true;
try {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
} finally {
sendingTest.value = false;
}
}
async function saveBaseUrl() {
savingBaseUrl.value = true;
baseUrlSaved.value = false;
try {
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
baseUrlSaved.value = true;
setTimeout(() => (baseUrlSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save application URL", "error");
} finally {
savingBaseUrl.value = false;
}
}
async function saveMarketplaceUrl() {
savingMarketplaceUrl.value = true;
marketplaceUrlSaved.value = false;
try {
const url = adminMarketplaceUrl.value.trim();
await apiPut("/api/plugin/marketplace-url", { marketplace_url: url });
serverMarketplaceUrl.value = url;
// Reflect the new instance default in the copyable field unless the user
// set their own per-browser override.
if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url;
marketplaceUrlSaved.value = true;
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
} finally {
savingMarketplaceUrl.value = false;
}
}
async function saveDbMaintenance() {
savingDbMaint.value = true;
dbMaintSaved.value = false;
try {
await apiPut("/api/admin/db-maintenance", {
enabled: dbMaintEnabled.value,
hour: dbMaintHour.value,
});
dbMaintSaved.value = true;
setTimeout(() => (dbMaintSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
} finally {
savingDbMaint.value = false;
}
}
async function runDbMaintenanceNow() {
runningDbMaint.value = true;
try {
const summary = await apiPost<DbMaintRun>("/api/admin/db-maintenance/run", {});
dbMaintLastRun.value = summary;
const failed = summary.tables.filter((t) => !t.ok).length;
toastStore.show(
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
failed ? "error" : "success",
);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Maintenance run failed", "error");
} finally {
runningDbMaint.value = false;
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
restoring.value = true;
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch("/api/admin/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const result = await res.json();
toastStore.show(
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
);
} catch (e) {
toastStore.show("Restore failed: " + (e as Error).message, "error");
} finally {
restoring.value = false;
if (restoreFileInput.value) restoreFileInput.value.value = "";
}
}
async function testSearch() {
const q = searchQuery.value.trim();
if (!q) return;
searchLoading.value = true;
searchError.value = "";
searchResults.value = [];
try {
const data = await apiGet<{ configured: boolean; results: { url: string; title: string; snippet: string }[]; error?: string }>(
`/api/settings/search?q=${encodeURIComponent(q)}`
);
if (!data.configured) {
searchError.value = "SearXNG is not configured — set SEARXNG_URL in docker-compose.";
} else {
searchResults.value = data.results;
if (!data.results.length) searchError.value = "No results found.";
}
} catch {
searchError.value = "Search request failed.";
} finally {
searchLoading.value = false;
}
}
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === "Enter") testSearch();
}
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<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;
}
// ── Logs panel ──
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;
}
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",
});
}
</script>
<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', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab === 'apikeys' ? 'MCP Access' : 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', 'groups', '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">
<!-- ── General ── -->
<div v-show="activeTab === 'general'" class="settings-grid">
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">Used to schedule the daily journal prep and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
<div class="field">
<label for="user-timezone">Your timezone</label>
<div style="display:flex; gap:0.5rem; align-items:center">
<input
id="user-timezone"
v-model="userTimezone"
type="text"
class="input"
placeholder="e.g. America/New_York"
/>
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
</div>
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
{{ savingTimezone ? 'Saving…' : 'Save' }}
</button>
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- Trash retention -->
<section class="settings-section full-width">
<h2>Trash retention</h2>
<p class="section-desc">Deleted items move to <router-link to="/trash">Trash</router-link> and can be restored. They're permanently purged after this many days.</p>
<div class="field">
<label for="trash-retention">Retention period (days)</label>
<input
id="trash-retention"
v-model="trashRetentionDays"
type="number"
min="0"
step="1"
class="input"
style="max-width: 8rem"
/>
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveRetention" :disabled="savingRetention">
{{ savingRetention ? 'Saving' : 'Save' }}
</button>
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<!-- ── Account ── -->
<div v-show="activeTab === 'account'" class="settings-grid">
<!-- SSO accounts: no local credential management -->
<section v-if="!authStore.user?.has_password" class="settings-section">
<h2>Account</h2>
<p class="section-desc">
Your account is managed by an external identity provider.
Email and password changes are made through your provider, not here.
</p>
</section>
<template v-if="authStore.user?.has_password">
<section class="settings-section">
<h2>Email Address</h2>
<p class="section-desc">Used for password resets and notifications.</p>
<div class="field">
<label for="new-email">Email</label>
<input
id="new-email"
v-model="newEmail"
type="email"
placeholder="you@example.com"
class="input"
/>
</div>
<div class="field">
<label for="email-password">Current Password</label>
<input
id="email-password"
v-model="emailPassword"
type="password"
autocomplete="current-password"
class="input"
/>
<p class="field-hint">Required to confirm the change.</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changeEmail"
:disabled="changingEmail || !emailPassword"
>
{{ changingEmail ? "Saving..." : "Save Email" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Change Password</h2>
<div class="field">
<label for="current-password">Current Password</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="input"
/>
</div>
<div class="field">
<label for="new-password">New Password</label>
<input
id="new-password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-new-password">Confirm New Password</label>
<input
id="confirm-new-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="input"
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
/>
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
Passwords do not match
</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changePassword"
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
>
{{ changingPassword ? "Changing..." : "Change Password" }}
</button>
</div>
</section>
</template>
<section class="settings-section">
<h2>Active Sessions</h2>
<p class="section-desc">
Sign out all other devices and sessions. Use this after a password change
to ensure stale sessions are revoked.
</p>
<div class="actions">
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
</button>
</div>
</section>
</div>
<!-- ── Notifications ── -->
<!-- ── Profile ── -->
<div v-show="activeTab === 'profile'" class="settings-grid">
<section class="settings-section full-width">
<h2>About You</h2>
<p class="section-desc">This information is used by the assistant to personalise responses in chat and the daily journal.</p>
<div class="assistant-grid">
<div class="field">
<label>Display Name</label>
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
<p class="field-hint">How the assistant addresses you.</p>
</div>
<div class="field">
<label>Job Title</label>
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
</div>
<div class="field">
<label>Industry</label>
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
</div>
<div class="field">
<label>Expertise Level</label>
<select v-model="profile.expertise_level" class="input">
<option value="novice">Novice — explain things simply</option>
<option value="intermediate">Intermediate — balanced explanations</option>
<option value="expert">Expert — assume deep knowledge</option>
</select>
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Response Preferences</h2>
<div class="assistant-grid">
<div class="field">
<label>Response Style</label>
<select v-model="profile.response_style" class="input">
<option value="concise">Concise — short and direct</option>
<option value="balanced">Balanced — default</option>
<option value="detailed">Detailed — thorough explanations</option>
</select>
</div>
<div class="field">
<label>Tone</label>
<select v-model="profile.tone" class="input">
<option value="casual">Casual — friendly and relaxed</option>
<option value="professional">Professional — formal and precise</option>
<option value="technical">Technical — jargon-friendly</option>
</select>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Interests</h2>
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Work Schedule</h2>
<p class="section-desc">Helps the journal understand when you're working and what's relevant each morning.</p>
<div class="field">
<label>Work Days</label>
<div class="day-picker">
<button
v-for="day in WORK_DAYS"
:key="day"
class="day-btn"
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
@click="toggleProfileWorkDay(day)"
type="button"
>{{ day }}</button>
</div>
</div>
<div class="assistant-grid" style="margin-top:0.75rem">
<div class="field">
<label>Start Time</label>
<input v-model="profile.work_schedule.start" type="time" class="input" />
</div>
<div class="field">
<label>End Time</label>
<input v-model="profile.work_schedule.end" type="time" class="input" />
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<div v-show="activeTab === 'notifications'" class="settings-grid">
<section class="settings-section">
<h2>Email Notifications</h2>
<p class="section-desc">
Email notifications when SMTP is configured by an admin.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifyTaskReminders" />
Task due date reminders
</label>
<p class="field-hint">Daily email for tasks due or overdue.</p>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifySecurityAlerts" />
Security alerts
</label>
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
{{ savingNotifications ? "Saving..." : "Save" }}
</button>
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<!-- ── Integrations ── -->
<div v-show="activeTab === 'integrations'" class="settings-grid">
<section class="settings-section full-width">
<h2>Calendar (CalDAV)</h2>
<p class="section-desc">
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
</p>
<div class="caldav-grid">
<div class="field">
<label for="caldav-url">CalDAV URL</label>
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
</div>
<div class="field">
<label for="caldav-username">Username</label>
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
</div>
<div class="field">
<label for="caldav-password">Password</label>
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
</div>
<div class="field">
<label for="caldav-calendar-name">Calendar Name</label>
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
<p class="field-hint">Optional. Exact calendar name to use.</p>
</div>
</div>
<div class="actions" style="margin-bottom: 1rem;">
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
{{ savingCaldav ? "Saving..." : "Save" }}
</button>
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
{{ testingCaldav ? "Testing..." : "Test Connection" }}
</button>
</div>
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
<template v-if="caldavTestResult.success">
{{ caldavTestResult.message }}
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
Calendars: {{ caldavTestResult.calendars.join(", ") }}
</div>
</template>
<template v-else>{{ caldavTestResult.error }}</template>
</div>
</section>
<section class="settings-section full-width">
<h2>Web Search (SearXNG)</h2>
<template v-if="searxngConfigured">
<p class="section-desc">
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
Test a query below to verify results and rate limiting.
</p>
<div class="search-row">
<input
v-model="searchQuery"
type="text"
class="input"
placeholder="Enter a search query..."
@keydown="onSearchKeydown"
/>
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
{{ searchLoading ? "Searching..." : "Search" }}
</button>
</div>
<p v-if="searchError" class="search-error">{{ searchError }}</p>
<ul v-if="searchResults.length" class="search-results">
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
<div class="result-header">
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
<span class="result-host">{{ hostname(r.url) }}</span>
</div>
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
</li>
</ul>
</template>
<template v-else>
<p class="section-desc not-configured">
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
</p>
</template>
</section>
</div>
<!-- ── Data ── -->
<div v-show="activeTab === 'data'" class="settings-grid">
<section class="settings-section full-width">
<h2>Export</h2>
<p class="section-desc">Download your notes and tasks in portable formats.</p>
<div class="data-actions">
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
</button>
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
</button>
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
</div>
</section>
<template v-if="authStore.isAdmin">
<section class="settings-section full-width">
<h2>Backup &amp; Restore</h2>
<p class="section-desc">Full application backup includes all users and their data.</p>
<div class="data-actions">
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Full Backup" }}
</button>
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
{{ restoring ? "Restoring..." : "Restore from Backup" }}
</button>
<input
ref="restoreFileInput"
type="file"
accept=".json"
class="hidden-file-input"
@change="handleRestoreFile"
/>
</div>
</section>
</template>
</div>
<!-- ── MCP Access ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<!-- Endpoint URL -->
<section class="settings-section full-width">
<h2>MCP Access</h2>
<p class="settings-description">
Connect Claude (Code or Desktop) to this Scribe instance. Claude reads and writes your notes,
tasks, projects, events, and people via the built-in MCP endpoint below.
</p>
<div class="mcp-url-block">
<label class="mcp-url-label">MCP endpoint</label>
<div class="mcp-code-row">
<pre class="mcp-code">{{ mcpUrl }}</pre>
<button class="btn btn-secondary btn-sm" @click="copyMcpUrl">
{{ mcpUrlCopied ? 'Copied' : 'Copy URL' }}
</button>
</div>
</div>
</section>
<!-- Tokens -->
<section class="settings-section full-width">
<h2>Personal access tokens</h2>
<p class="settings-description">
Tokens are sent as <code>Authorization: Bearer </code> on every MCP request and resolve to
your user account. Write-scoped tokens can create and update content; read-only tokens can
only query. A token is shown <strong>once</strong> at creation keep it safe.
</p>
<!-- Create form -->
<div class="api-key-create-form">
<input v-model="newKeyName" placeholder="Token name (e.g. claude-laptop)" class="settings-input" />
<div class="api-key-scope-select">
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
</div>
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
{{ creatingApiKey ? 'Generating…' : 'Generate token' }}
</button>
</div>
<!-- One-time token reveal -->
<div v-if="newKeyValue" class="api-key-reveal">
<p><strong>Copy this token now it will not be shown again.</strong></p>
<div class="api-key-value-row">
<code class="api-key-value">{{ newKeyValue }}</code>
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
</div>
<p class="mcp-hint" style="margin-top:0.5rem;">
The snippets in <strong>Connect Claude</strong> below are pre-filled with this token until you click Done.
</p>
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
</div>
</div>
<!-- Tokens table -->
<table v-if="apiKeys.length > 0" class="api-keys-table">
<thead>
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
</thead>
<tbody>
<tr v-for="key in apiKeys" :key="key.id">
<td>{{ key.name }}</td>
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
<td><code>{{ key.key_prefix }}</code></td>
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
<td>
<span v-if="revokeConfirmId !== key.id">
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
</span>
<span v-else>
Sure?
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
</span>
</td>
</tr>
</tbody>
</table>
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No tokens yet.</p>
</section>
<!-- Connect Claude -->
<section class="settings-section full-width">
<h2>Connect Claude</h2>
<p class="settings-description">
Run the appropriate snippet for your client. Generate a token above first to pre-fill the
token value, or copy the snippet now and paste your own where it says <code>&lt;your-token&gt;</code>.
</p>
<div class="mcp-client-tabs" role="tablist">
<button
type="button"
role="tab"
:aria-selected="mcpClientTab === 'claude-code'"
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
@click="mcpClientTab = 'claude-code'"
>Claude Code</button>
<button
type="button"
role="tab"
:aria-selected="mcpClientTab === 'claude-desktop'"
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
@click="mcpClientTab = 'claude-desktop'"
>Claude Desktop</button>
</div>
<!-- Claude Code tab -->
<div v-if="mcpClientTab === 'claude-code'">
<p class="settings-description">
<strong>Recommended install the Scribe plugin.</strong> One install wires up the MCP
connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
</p>
<ol>
<li>
Add the marketplace and install the plugin:
<div class="mcp-code-row">
<pre class="mcp-code">{{ pluginInstallCommands }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(pluginInstallCommands, 'plugin-install')">
{{ copiedSnippetKey === 'plugin-install' ? 'Copied' : 'Copy' }}
</button>
</div>
</li>
<li>
When prompted, enter your <strong>Scribe base URL</strong> (<code>{{ origin }}</code>),
an <strong>API key</strong> (generate one above), and optionally a
<strong>project id</strong> to scope the session-start context.
</li>
<li>
Restart Claude Code <code>/mcp</code> shows <code>scribe</code> connected and your
standing rules load at session start.
</li>
</ol>
<details class="mcp-advanced">
<summary>Customize</summary>
<label class="mcp-config-field">
<span class="mcp-config-label">Plugin marketplace (git URL)</span>
<input
v-model="pluginMarketplaceUrl"
type="text"
:placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Scribe.git'"
class="settings-input"
spellcheck="false"
/>
<span class="mcp-hint">
Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
</span>
</label>
<div class="mcp-client-config">
<label class="mcp-config-field">
<span class="mcp-config-label">Server name</span>
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
<span class="mcp-hint">
Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
</span>
</label>
<label class="mcp-config-field">
<span class="mcp-config-label">Scope</span>
<select v-model="mcpScope" class="settings-input">
<option value="user">user — available across all projects</option>
<option value="project">project — write to current repo's .mcp.json</option>
<option value="local">local this machine + repo only</option>
</select>
<span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
</label>
</div>
<p class="mcp-hint" style="margin-top: 0.75rem;">
<strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
push or the Scribe skills this way.
</p>
<div class="mcp-code-row">
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="mcp-hint">
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
</p>
</details>
</div>
<!-- Claude Desktop tab -->
<div v-else-if="mcpClientTab === 'claude-desktop'">
<label class="mcp-config-field">
<span class="mcp-config-label">Server name</span>
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
</label>
<ol>
<li>
Add this block to your Claude Desktop MCP config file
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
<code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
<div class="mcp-code-row">
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
</button>
</div>
</li>
<li>
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
</li>
</ol>
</div>
</section>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
<section class="settings-section full-width">
<h2>Application URL</h2>
<p class="section-desc">
Public URL used in email links (invitations, password resets). Example: https://notes.example.com
</p>
<div class="field url-field">
<label for="base-url">Base URL</label>
<input
id="base-url"
v-model="baseUrl"
type="url"
placeholder="https://notes.example.com"
class="input"
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
{{ savingBaseUrl ? "Saving..." : "Save" }}
</button>
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Plugin marketplace</h2>
<p class="section-desc">
Git URL of the repo that ships this Scribe plugin (usually this app's own repo).
Shown to every user in <strong>MCP Access</strong> so the install command is
copyable. Example: https://git.example.com/you/Scribe.git
</p>
<div class="field url-field">
<label for="marketplace-url">Marketplace git URL</label>
<input
id="marketplace-url"
v-model="adminMarketplaceUrl"
type="url"
placeholder="https://git.example.com/you/Scribe.git"
class="input"
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
</button>
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Database maintenance</h2>
<p class="section-desc">
A daily <code>VACUUM (ANALYZE)</code> over the high-churn tables (logs, notifications,
tokens, notes, version history) on top of Postgres autovacuum to reclaim space left
by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC),
just after trash purge.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="dbMaintEnabled" />
Run scheduled maintenance daily
</label>
</div>
<div class="field url-field">
<label for="db-maint-hour">Run hour (UTC)</label>
<select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
</select>
</div>
<div class="actions">
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
{{ savingDbMaint ? "Saving..." : "Save" }}
</button>
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
{{ runningDbMaint ? "Running..." : "Run now" }}
</button>
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
</div>
<div v-if="dbMaintLastRun" class="db-maint-last">
<span class="db-maint-last-label">
Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
· {{ dbMaintLastRun.elapsed_ms }}ms
</span>
<ul class="db-maint-table-list">
<li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
<code>{{ t.table }}</code>
<span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
</li>
</ul>
</div>
</section>
<section class="settings-section full-width">
<h2>Email / SMTP</h2>
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
<div class="smtp-grid">
<div class="field">
<label for="smtp-host">SMTP Host</label>
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
</div>
<div class="field">
<label for="smtp-port">Port</label>
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
</div>
<div class="field">
<label for="smtp-username">Username</label>
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
</div>
<div class="field">
<label for="smtp-password">Password</label>
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
</div>
<div class="field">
<label for="smtp-from-address">From Address</label>
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
</div>
<div class="field">
<label for="smtp-from-name">From Name</label>
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
</div>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
Use STARTTLS
</label>
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
</button>
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
</div>
<div class="test-email-section">
<h3 class="subsection-label">Test Email</h3>
<div class="test-email-row">
<input
v-model="testRecipient"
type="email"
placeholder="test@example.com"
class="input"
/>
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
{{ sendingTest ? "Sending..." : "Send Test" }}
</button>
</div>
</div>
</section>
</div>
<!-- ── 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>
<!-- ── 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>
<!-- ── Groups ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'groups'" class="settings-grid">
<section class="settings-section full-width">
<h2>Groups</h2>
<p class="section-desc">Manage platform-wide groups for sharing projects and notes.</p>
<!-- Create group form -->
<div class="group-create-form">
<input v-model="newGroupName" class="input-field" placeholder="Group name" maxlength="100" @keydown.enter="createNewGroup" />
<input v-model="newGroupDesc" class="input-field" placeholder="Description (optional)" maxlength="255" @keydown.enter="createNewGroup" />
<button class="btn-primary" @click="createNewGroup" :disabled="creatingGroup || !newGroupName.trim()">
{{ creatingGroup ? 'Creating…' : 'Create Group' }}
</button>
</div>
<!-- Groups list -->
<div v-if="groupsLoading" class="loading-msg">Loading groups</div>
<div v-else-if="!groups.length" class="empty-msg">No groups yet.</div>
<div v-else class="groups-list">
<div v-for="g in groups" :key="g.id" class="group-card">
<div class="group-card-header">
<div class="group-card-info">
<span class="group-name">{{ g.name }}</span>
<span class="group-meta">{{ g.member_count }} member{{ g.member_count !== 1 ? 's' : '' }}</span>
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
</div>
<div class="group-card-actions">
<button class="btn-sm" @click="toggleGroupExpand(g)">
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
</button>
<button class="btn-sm btn-danger-sm" @click="deleteGroupConfirm(g)">Delete</button>
</div>
</div>
<!-- Members panel -->
<div v-if="expandedGroupId === g.id" class="group-members-panel">
<div class="members-search">
<div class="member-search-wrap">
<input
v-model="groupMemberSearch"
class="input-field"
placeholder="Search user to add…"
@input="debounceGroupMemberSearch"
autocomplete="off"
/>
<ul v-if="groupMemberResults.length" class="member-results">
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
<span class="member-result-name">{{ u.username }}</span>
</li>
</ul>
</div>
<select v-model="groupMemberRole" class="role-select">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
</div>
<ul class="members-list">
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
<span class="member-name">{{ m.username }}</span>
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
<button class="btn-sm btn-danger-sm" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
</li>
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</div><!-- end .settings-content -->
</main>
</template>
<style scoped>
/* 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: 500;
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(91, 74, 138, 0.08);
border-left-color: var(--color-primary);
font-weight: 500;
}
/* Content panel */
.settings-content {
flex: 1;
min-width: 0;
}
/* Two-column grid — small sections pair up, full-width sections span both */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
}
.settings-section.full-width {
grid-column: 1 / -1;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
}
.section-desc {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 500; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
.model-delete-btn {
background: none; border: none; cursor: pointer; color: var(--color-text-muted);
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--color-action-destructive); background: color-mix(in srgb, var(--color-action-destructive) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
.suggestion-chip {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
border: 1px solid var(--color-border); background: var(--color-bg-card);
cursor: pointer; font-family: monospace; color: var(--color-text);
transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
.pull-bar-track {
height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
height: 100%; background: var(--color-primary); border-radius: 2px;
transition: width 0.3s ease;
}
.pull-bar-indeterminate {
height: 4px; background: var(--color-border); border-radius: 2px;
position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
content: ""; position: absolute; top: 0; left: -40%;
width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { left: -40%; } 100% { left: 100%; }
}
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.assistant-grid {
grid-template-columns: 1fr;
}
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.35rem;
color: var(--color-text);
}
.input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.field-hint-warn {
display: block;
margin-top: 0.3rem;
color: #f59e0b;
}
.actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
/* Save: Moss action-primary per Hybrid */
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Danger outline (Invalidate sessions, Clear observations, etc.):
Oxblood action-destructive ghost — fills on hover */
.btn-danger-outline {
padding: 0.4rem 0.9rem;
background: none;
color: var(--color-action-destructive);
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
/* Filled destructive: Oxblood action-destructive */
.btn-danger {
padding: 0.4rem 0.9rem;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: default; }
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
Refresh, Add slot, etc.). Outline form for visual lightness. */
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-action-secondary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
/* DB maintenance last-run summary */
.db-maint-last { margin-top: 1rem; }
.db-maint-last-label {
display: block;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.db-maint-table-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.db-maint-table-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.82rem;
padding: 0.2rem 0;
}
.db-maint-table-list .dm-status { color: var(--color-text-muted); }
.db-maint-table-list li.dm-failed .dm-status { color: var(--color-danger); }
.btn-warn:hover:not(:disabled) {
background: var(--color-warning);
color: #fff;
}
.saved-msg {
color: var(--color-success);
font-size: 0.875rem;
font-weight: 500;
}
.input-error { border-color: var(--color-danger); }
.input-error:focus { border-color: var(--color-danger); }
.error-hint {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--color-danger);
}
.retention-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
/* Data buttons */
.data-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.hidden-file-input { display: none; }
/* Checkboxes */
.checkbox-field { margin-bottom: 1rem; }
.checkbox-field label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
/* CalDAV grid */
.caldav-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.caldav-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.caldav-grid { grid-template-columns: 1fr; }
}
.test-result {
padding: 0.65rem 0.9rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
line-height: 1.4;
}
.test-result.success {
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
border: 1px solid var(--color-success);
color: var(--color-success);
}
.test-result.error {
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
.test-calendars {
margin-top: 0.3rem;
font-size: 0.82rem;
opacity: 0.85;
}
/* Search test */
.url-chip {
font-size: 0.8rem;
padding: 0.1rem 0.4rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
}
.not-configured {
opacity: 0.75;
}
.search-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.search-row .input { flex: 1; }
.search-error {
font-size: 0.875rem;
color: var(--color-danger);
margin: 0 0 0.5rem;
}
.search-results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.search-result {
padding: 0.65rem 0.85rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.result-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.result-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-primary);
text-decoration: none;
word-break: break-word;
}
.result-title:hover { text-decoration: underline; }
.result-host {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.result-snippet {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Application URL — constrain input width */
.url-field { max-width: 480px; }
/* SMTP grid */
.smtp-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.smtp-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.smtp-grid { grid-template-columns: 1fr; }
}
/* Test email */
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.test-email-section {
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.test-email-row {
display: flex;
gap: 0.5rem;
align-items: center;
max-width: 480px;
}
/* Push notifications */
.push-unsupported {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.push-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.875rem;
}
.push-status-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.push-error {
font-size: 0.82rem;
color: var(--color-danger, #e74c3c);
margin: 0.25rem 0 0;
}
@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);
background: var(--color-primary-tint);
}
.settings-grid {
grid-template-columns: 1fr;
}
.settings-section.full-width {
grid-column: auto;
}
.assistant-grid {
grid-template-columns: 1fr;
}
.smtp-grid {
grid-template-columns: 1fr;
}
.caldav-grid {
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: 500;
color: var(--color-text-secondary);
}
.users-table { width: 100%; border-collapse: collapse; }
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 500;
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: 500; }
.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: 500;
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); }
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
.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-action-destructive); color: var(--color-action-destructive); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
margin-right: 0.25rem;
transition: background 0.15s;
}
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.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); }
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.15s;
}
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.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;
}
/* 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: 500; color: var(--color-text); }
.stat-label {
font-size: 0.75rem;
font-weight: 500;
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: 500;
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: 500;
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: 500; 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;
}
/* ── About / version ─────────────────────────────────────────── */
.version-line {
margin: 0;
font-size: 0.9rem;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.6rem;
}
.version-badge {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-primary);
border-radius: 4px;
color: var(--color-primary);
}
/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */
.btn-primary {
padding: 0.4rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.input-field {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.input-field:focus { border-color: var(--color-primary); }
.group-create-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
align-items: center;
}
.group-create-form .input-field { flex: 1; min-width: 160px; }
.loading-msg, .empty-msg {
color: var(--color-text-muted);
font-size: 0.88rem;
padding: 0.5rem 0;
}
.groups-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.group-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.group-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-bg-secondary);
}
.group-card-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.group-name {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
}
.group-meta {
font-size: 0.78rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.group-desc {
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-card-actions {
display: flex;
gap: 0.35rem;
flex-shrink: 0;
}
.btn-sm {
padding: 0.25rem 0.6rem;
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.group-members-panel {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.members-search {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.member-search-wrap {
flex: 1;
position: relative;
}
.member-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
margin-top: 2px;
list-style: none;
padding: 0.25rem 0;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 160px;
overflow-y: auto;
}
.member-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.member-result-item:hover { background: var(--color-hover); }
.member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--color-text-muted); font-size: 0.78rem; }
.role-select {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.members-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.member-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
background: var(--color-hover);
}
.member-name { flex: 1; font-size: 0.88rem; font-weight: 500; color: var(--color-text); }
.member-role-badge {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.role-owner { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.members-empty {
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
/* Profile — Locations + Journal sections */
.location-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.geo-msg {
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
.geo-ok { color: var(--color-success); }
.geo-error { color: var(--color-danger); }
.geo-pending { color: var(--color-text-muted); }
.unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.unit-btn {
padding: 0.4rem 1rem;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.unit-btn:not(:last-child) {
border-right: 1px solid var(--color-border);
}
.unit-btn.active {
background: var(--color-action-primary);
color: #fff;
}
.unit-btn:hover:not(.active) {
color: var(--color-text);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.time-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.time-input {
width: 4rem;
text-align: center;
}
.time-sep {
font-size: 1.1rem;
color: var(--color-text-muted);
}
.time-sep--quiet { font-size: 0.9rem; }
/* API Keys tab */
.api-key-create-form {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.api-key-scope-select {
display: flex;
gap: 1rem;
}
.api-key-scope-select label {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.api-key-reveal {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 1.5rem;
}
.api-key-value-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.api-key-value {
flex: 1;
background: var(--color-surface-2, var(--color-surface));
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
word-break: break-all;
}
.api-keys-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-table th, .api-keys-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.api-keys-table th { font-weight: 500; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 500;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
/* Scribe MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
.mcp-pkg-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.65rem 0.9rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code {
margin-top: 0.4rem;
padding: 0.55rem 0.75rem;
background: color-mix(in srgb, var(--color-text) 6%, transparent);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.82rem;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
flex: 1;
}
.mcp-client-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.mcp-client-tab {
background: transparent;
border: none;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.mcp-client-tab:hover { color: var(--color-text); }
.mcp-client-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 500;
}
.mcp-url-block { margin-top: 0.25rem; }
.mcp-url-label {
display: block;
font-size: 0.85rem;
opacity: 0.7;
margin-bottom: 0.25rem;
}
.mcp-client-config {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0 1.5rem;
}
@media (max-width: 640px) {
.mcp-client-config { grid-template-columns: 1fr; }
}
.mcp-config-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mcp-config-label {
font-size: 0.85rem;
opacity: 0.7;
}
.mcp-code-row {
display: flex;
align-items: stretch;
gap: 0.4rem;
margin-top: 0.4rem;
}
.mcp-code-row .mcp-code { margin-top: 0; }
.mcp-code-row .btn-sm { white-space: nowrap; }
.mcp-advanced {
margin-top: 1.25rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
padding-top: 0.75rem;
}
.mcp-advanced summary {
cursor: pointer;
font-size: 0.85rem;
opacity: 0.75;
user-select: none;
}
.mcp-advanced summary:hover { opacity: 1; }
.mcp-advanced[open] summary { margin-bottom: 0.5rem; }
.mcp-hint {
margin-top: 0.5rem;
font-size: 0.8rem;
opacity: 0.7;
line-height: 1.5;
}
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
.mcp-plain-list {
list-style: disc;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.6;
}
/* Voice tab */
.voice-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Voice Library (admin) */
.voice-library-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.voice-library-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 1rem 0;
}
.voice-library-toolbar .input {
flex: 1;
}
.voice-library-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--color-border);
max-height: 480px;
overflow-y: auto;
}
.voice-library-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.voice-library-meta {
min-width: 0;
flex: 1;
}
.voice-library-id {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
font-weight: 500;
}
.voice-library-sub {
color: var(--color-text-muted);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.voice-library-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.voice-library-empty {
padding: 1rem 0;
color: var(--color-text-muted);
text-align: center;
font-style: italic;
}
.voice-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.voice-badge--bundled {
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.status-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.status-on {
background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent);
color: var(--color-success, #22c55e);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
}
.status-off {
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.35rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
}
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
.radio-option strong { font-size: 0.875rem; }
.range-input {
width: 100%;
max-width: 360px;
accent-color: var(--color-primary);
margin: 0.35rem 0 0.2rem;
}
.range-labels {
display: flex;
justify-content: space-between;
max-width: 360px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.form-actions {
margin-top: 1rem;
}
.voice-admin-spinner {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 0.4rem;
vertical-align: middle;
}
.voice-admin-spinner span {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--color-text-muted);
animation: va-dot-bounce 1.2s ease-in-out infinite;
}
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
/* ── Voice blend ────────────────────────────────────────────────────────── */
.blend-slots {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.blend-slot {
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.blend-voice-select {
width: 100%;
}
.blend-weight-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.blend-weight-slider {
flex: 1;
}
.blend-weight-label {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
text-align: right;
color: var(--color-text-secondary);
}
.btn-remove-slot {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.45rem;
line-height: 1;
transition: color 0.15s, border-color 0.15s;
}
.btn-remove-slot:hover {
color: var(--color-danger, #e05555);
border-color: var(--color-danger, #e05555);
}
.blend-actions {
margin-top: 0.25rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
/* ── Profile tab ─────────────────────────────────────────────────────────── */
.day-picker {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.35rem;
}
.day-btn {
padding: 0.3rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.day-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.day-btn.active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 500;
}
.learned-summary {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
border-radius: 8px;
padding: 0.85rem 1rem;
font-size: 0.9rem;
line-height: 1.55;
color: var(--color-text);
white-space: pre-wrap;
margin-bottom: 0.75rem;
}
.learned-empty {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
.btn-danger-outline {
padding: 0.45rem 1rem;
background: none;
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
color: var(--color-action-destructive);
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
@keyframes va-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
</style>