Files
FabledScribe/frontend/src/views/SettingsView.vue
T
bvandeusen 2b2e5c666a fix: load all pre-selected settings tabs correctly on mount
The watch(activeTab) handler loads tab data on navigation, but not on
initial mount when localStorage restores a tab. Three more gaps:

- briefing: was inside the isAdmin guard — non-admin users who last
  visited the Briefing tab would see an empty form
- users: no onMounted equivalent — admin user list never loaded
- logs: no onMounted equivalent — admin log viewer never loaded

Moves briefing outside the admin guard and adds users/logs inside it.
2026-03-26 22:32:28 -04:00

3542 lines
116 KiB
Vue

<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
import { usePushStore } from "@/stores/push";
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 pushStore = usePushStore();
const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
const ollamaModels = ref<OllamaModel[]>([]);
const pullModelName = ref("");
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
const pulling = ref(false);
const deletingModel = ref<string | null>(null);
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 saving = ref(false);
const saved = 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", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => {
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
if (v === "users" && authStore.isAdmin) loadUsersPanel();
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
});
// API Keys
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
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 mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null);
const mcpInfoLoading = ref(false);
const origin = window.location.origin;
const mcpConfigSnippet = JSON.stringify({
mcpServers: {
fable: {
command: "fable-mcp",
env: {
FABLE_URL: window.location.origin,
FABLE_API_KEY: "<your-api-key>",
},
},
},
}, null, 2);
async function loadMcpInfo() {
if (mcpInfo.value !== null) return;
mcpInfoLoading.value = true;
try {
mcpInfo.value = await getFableMcpInfo();
} catch {
mcpInfo.value = { available: false, filename: null };
} finally {
mcpInfoLoading.value = false;
}
}
async function fetchApiKeys() {
const res = await fetch('/api/api-keys', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
apiKeys.value = data.api_keys;
}
}
async function createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.value = true;
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
});
if (res.ok) {
const data = await res.json();
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
}
} finally {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
revokeConfirmId.value = null;
await fetchApiKeys();
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = newKeyValue.value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadEnvFile() {
const baseUrl = window.location.origin;
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
_downloadFile('fable-mcp.env', content);
}
function downloadMcpConfig() {
const baseUrl = window.location.origin;
const config = {
mcpServers: {
fable: {
command: 'fable-mcp',
env: {
FABLE_URL: baseUrl,
FABLE_API_KEY: newKeyValue.value,
},
},
},
};
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
}
// 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();
}
// Briefing settings
const briefingConfig = ref<BriefingConfig>({
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
});
const briefingFeeds = ref<BriefingFeed[]>([]);
const briefingSaving = ref(false);
const briefingSaved = ref(false);
const briefingGeocoding = ref<Record<string, boolean>>({});
const briefingGeoError = ref<Record<string, string>>({});
const newFeedUrl = ref('');
const newFeedCategory = ref('');
const addingFeed = ref(false);
const refreshingFeeds = ref(false);
const briefingIncludeTopics = ref<string[]>([]);
const briefingExcludeTopics = ref<string[]>([]);
function _parseTopics(raw: unknown): string[] {
try {
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(val) ? val.map(String) : [];
} catch {
return [];
}
}
async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds();
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
}
async function saveIncludeTopics(topics: string[]) {
briefingIncludeTopics.value = topics;
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
}
async function saveExcludeTopics(topics: string[]) {
briefingExcludeTopics.value = topics;
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
}
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
async function fetchTopicSuggestions(q: string): Promise<string[]> {
if (!q) return _STANDARD_TOPICS;
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
}
async function geocodeLocation(key: 'home' | 'work') {
const loc = briefingConfig.value.locations[key];
if (!loc?.address?.trim()) return;
briefingGeocoding.value[key] = true;
briefingGeoError.value[key] = '';
try {
const result = await geocodeAddress(loc.address.trim());
if (result) {
briefingConfig.value.locations[key] = {
...loc,
lat: result.lat,
lon: result.lon,
label: key.charAt(0).toUpperCase() + key.slice(1),
};
briefingGeoError.value[key] = '';
} else {
briefingGeoError.value[key] = 'Location not found — check the address';
}
} catch {
briefingGeoError.value[key] = 'Geocoding failed';
} finally {
briefingGeocoding.value[key] = false;
}
}
function toggleWorkDay(day: number) {
const days = briefingConfig.value.work_days;
const idx = days.indexOf(day);
if (idx === -1) days.push(day);
else days.splice(idx, 1);
days.sort();
}
async function saveBriefingSettings() {
briefingSaving.value = true;
briefingSaved.value = false;
try {
await saveBriefingConfig(briefingConfig.value);
briefingSaved.value = true;
setTimeout(() => (briefingSaved.value = false), 2000);
} catch {
toastStore.show('Failed to save briefing settings', 'error');
} finally {
briefingSaving.value = false;
}
}
async function addFeed() {
if (!newFeedUrl.value.trim() || addingFeed.value) return;
addingFeed.value = true;
try {
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
briefingFeeds.value.push(feed);
newFeedUrl.value = '';
newFeedCategory.value = '';
} catch (err: any) {
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
} finally {
addingFeed.value = false;
}
}
async function removeFeed(id: number) {
await deleteBriefingFeed(id);
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
}
async function refreshFeeds() {
if (refreshingFeeds.value) return;
refreshingFeeds.value = true;
try {
const result = await refreshBriefingFeeds();
// Reload feed list so last_fetched_at updates
briefingFeeds.value = await getBriefingFeeds();
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''}${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
} catch {
toastStore.show('Failed to refresh feeds', 'error');
} finally {
refreshingFeeds.value = false;
}
}
function feedAge(isoStr: string | null): string {
if (!isoStr) return 'never fetched';
const diff = Date.now() - new Date(isoStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
async function saveRetention() {
savingRetention.value = true;
try {
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
} finally {
savingRetention.value = false;
}
}
// Notification preferences
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 Assistant",
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("");
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings();
pushStore.checkSubscription();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
// Load installed models and configured defaults
try {
const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
installedModels.value = modelsData.models;
defaultChatModel.value = modelsData.default_chat_model;
} catch {
// Ollama unreachable — dropdowns will be empty
}
await loadOllamaModels();
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? "";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_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 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;
}
// 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
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "users") loadUsersPanel();
if (activeTab.value === "logs") loadLogsPanel();
}
if (activeTab.value === "briefing") loadBriefingTab();
if (activeTab.value === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
});
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;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
return `${(bytes / 1e6).toFixed(0)} MB`;
}
async function loadOllamaModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
ollamaModels.value = data.models ?? [];
installedModels.value = ollamaModels.value.map((m) => m.name);
} catch {
// Ollama unreachable
}
}
async function pullModel() {
const name = pullModelName.value.trim();
if (!name || pulling.value) return;
pulling.value = true;
pullProgress.value = { status: "Connecting…", pct: null };
try {
const resp = await fetch("/api/chat/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
credentials: "include",
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed);
if (msg.error) throw new Error(msg.error);
if (msg.status === "success") {
pullProgress.value = { status: "Complete", pct: 100 };
pullModelName.value = "";
} else if (msg.total && msg.completed) {
const pct = Math.round((msg.completed / msg.total) * 100);
pullProgress.value = { status: msg.status || "Downloading…", pct };
} else {
pullProgress.value = { status: msg.status || "Working…", pct: null };
}
} catch (parseErr: any) {
if (parseErr.message !== "JSON parse error") throw parseErr;
}
}
}
await loadOllamaModels();
toastStore.show(`Model ${name} ready`);
} catch (err: any) {
toastStore.show(`Pull failed: ${err.message}`, "error");
} finally {
pulling.value = false;
setTimeout(() => { pullProgress.value = null; }, 3000);
}
}
async function deleteModel(name: string) {
deletingModel.value = name;
try {
await apiPost("/api/chat/models/delete", { model: name });
await loadOllamaModels();
if (defaultModel.value === name) defaultModel.value = "";
toastStore.show(`Deleted ${name}`);
} catch {
toastStore.show(`Failed to delete ${name}`, "error");
} finally {
deletingModel.value = null;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
try {
await store.updateSettings({
assistant_name: assistantName.value.trim() || "Fable",
default_model: defaultModel.value,
});
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} finally {
saving.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 = `fabledassistant-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 = `fabledassistant-${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 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', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab === 'apikeys' ? 'API Keys' : 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">
<section class="settings-section full-width">
<h2>Assistant</h2>
<div class="assistant-grid">
<div class="field">
<label for="assistant-name">Assistant Name</label>
<input
id="assistant-name"
v-model="assistantName"
type="text"
placeholder="Fable"
class="input"
/>
<p class="field-hint">The name used in chat messages and LLM context.</p>
</div>
<div class="field">
<label for="default-model">Chat Model</label>
<select id="default-model" v-model="defaultModel" class="input">
<option value="">Default ({{ defaultChatModel || "qwen3:latest" }})</option>
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">Model used for new conversations.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveAssistant" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- Model Management -->
<section class="settings-section full-width">
<div class="model-mgmt-header">
<div>
<h2>Model Management</h2>
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
</div>
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list"> Refresh</button>
</div>
<!-- Installed models -->
<div v-if="ollamaModels.length" class="model-list">
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
<div class="model-row-info">
<span class="model-name">{{ m.name }}</span>
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
</div>
<div class="model-row-right">
<span class="model-size">{{ formatBytes(m.size) }}</span>
<button
class="model-delete-btn"
:disabled="deletingModel === m.name"
@click="deleteModel(m.name)"
:title="`Remove ${m.name}`"
>{{ deletingModel === m.name ? '…' : '✕' }}</button>
</div>
</div>
</div>
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
<!-- Pull a model -->
<div class="model-pull-form">
<input
v-model="pullModelName"
class="input"
placeholder="e.g. qwen3:7b"
:disabled="pulling"
@keydown.enter="pullModel"
/>
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
{{ pulling ? 'Pulling…' : 'Pull' }}
</button>
</div>
<div class="model-suggestions">
<span class="suggestions-label">Suggestions:</span>
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
:key="s" class="suggestion-chip"
:disabled="pulling || ollamaModels.some(m => m.name === s)"
@click="pullModelName = s"
>{{ s }}</button>
</div>
<!-- Pull progress -->
<div v-if="pullProgress" class="model-pull-progress">
<div class="pull-status">{{ pullProgress.status }}</div>
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
</div>
<div v-else class="pull-bar-indeterminate"></div>
</div>
</section>
</div>
<!-- ── Account ── -->
<div v-show="activeTab === 'account'" class="settings-grid">
<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 v-if="authStore.user?.has_password" 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 || (authStore.user?.has_password && !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>
<section class="settings-section">
<h2>Active Sessions</h2>
<p class="section-desc">
Sign out all other devices and sessions. Use this after an SSO 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 ── -->
<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>
<section class="settings-section">
<h2>Push Notifications</h2>
<p class="section-desc">
Receive browser push notifications when a response is ready. Requires HTTPS.
</p>
<template v-if="!pushStore.isSupported">
<p class="push-unsupported">Push notifications are not supported in this browser or connection (requires HTTPS).</p>
</template>
<template v-else>
<div class="push-status-row">
<span class="push-status-label">Permission:</span>
<span
:class="['push-permission-badge', {
'perm-granted': pushStore.permission === 'granted',
'perm-denied': pushStore.permission === 'denied',
'perm-default': pushStore.permission === 'default',
}]"
>
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
</span>
</div>
<div class="push-status-row">
<span class="push-status-label">Status:</span>
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
</span>
</div>
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
<div class="actions" style="margin-top: 0.75rem;">
<button
v-if="!pushStore.isSubscribed"
class="btn-save"
@click="pushStore.subscribe()"
:disabled="pushStore.loading || pushStore.permission === 'denied'"
>
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
</button>
<button
v-else
class="btn-secondary"
@click="pushStore.unsubscribe()"
:disabled="pushStore.loading"
>
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
</button>
</div>
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
Notifications are blocked. Allow them in your browser site settings to re-enable.
</p>
</template>
</section>
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</section>
<section class="settings-section full-width">
<h2>About</h2>
<p class="version-line">Fabled Assistant <span class="version-badge">{{ appVersion }}</span></p>
</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>
<!-- ── Briefing ── -->
<div v-show="activeTab === 'briefing'" class="settings-grid">
<section class="settings-section full-width">
<h2>Daily Briefing</h2>
<p class="section-desc">
Configure your daily briefing a conversation that summarises your day,
weather, and RSS feeds on a schedule.
</p>
<!-- Enable -->
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.enabled" />
Enable Daily Briefing
</label>
<p class="field-hint">Start daily briefings at the configured slots.</p>
</div>
</section>
<!-- Locations -->
<section class="settings-section full-width">
<h2>Locations</h2>
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
<div class="briefing-input-group">
<input
type="text"
class="input"
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
:value="briefingConfig.locations[key]?.address ?? ''"
@input="(e) => {
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
}"
/>
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
</button>
</div>
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
{{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
</div>
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
</div>
<div class="checkbox-field" style="margin-top: 0.75rem">
<label>
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
Also check CalDAV event locations
</label>
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
</div>
<div class="field" style="margin-top: 1rem">
<label class="field-label">Temperature unit</label>
<div class="briefing-unit-toggle">
<button
type="button"
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
@click="briefingConfig.temp_unit = 'C'"
>°C</button>
<button
type="button"
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
@click="briefingConfig.temp_unit = 'F'"
>°F</button>
</div>
</div>
</section>
<!-- Work schedule -->
<section class="settings-section full-width">
<h2>Office Days</h2>
<p class="section-desc">Which days do you go into the office? Used to decide whether to include the 8am check-in slot.</p>
<div class="briefing-day-toggles">
<button
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="idx"
:class="['briefing-day-btn', { active: briefingConfig.work_days.includes(idx) }]"
@click="toggleWorkDay(idx)"
type="button"
>{{ day }}</button>
</div>
</section>
<!-- Slots -->
<section class="settings-section full-width">
<h2>Scheduled Slots</h2>
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
<div class="briefing-slot-list">
<div
v-for="[key, label, localTime] in ([
['compilation', 'Morning briefing', '4:00 am'],
['morning', 'Office check-in', '8:00 am'],
['midday', 'Midday update', '12:00 pm'],
['afternoon', 'End of day', '4:00 pm'],
] as const)"
:key="key"
class="briefing-slot-row"
>
<div class="briefing-slot-info">
<span class="briefing-slot-label">{{ label }}</span>
<span class="briefing-slot-time">{{ localTime }}</span>
</div>
<label>
<input type="checkbox" v-model="briefingConfig.slots[key]" />
</label>
</div>
</div>
<p class="field-hint" style="margin-top: 0.5rem">
Firing in timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
</p>
</section>
<!-- RSS Feeds -->
<section class="settings-section full-width">
<div class="briefing-feeds-header">
<div>
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
</div>
<button
v-if="briefingFeeds.length"
class="btn-secondary briefing-refresh-btn"
@click="refreshFeeds"
:disabled="refreshingFeeds"
title="Fetch latest items from all feeds"
>{{ refreshingFeeds ? 'Refreshing' : 'Refresh all' }}</button>
</div>
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
<div class="briefing-feed-info">
<div class="briefing-feed-title-row">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
</div>
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
</div>
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
</div>
</div>
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
<div class="briefing-add-feed-form">
<div class="briefing-add-feed-inputs">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
</div>
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
{{ addingFeed ? 'Adding' : 'Add Feed' }}
</button>
</div>
</section>
<!-- News Preferences -->
<section class="settings-section full-width">
<h2>News Preferences</h2>
<p class="section-desc">
Tell the briefing what topics you care about. Topics are matched against
classified RSS items before each briefing runs.
</p>
<div class="settings-field">
<label class="field-label">Interested in</label>
<TagInput
:model-value="briefingIncludeTopics"
placeholder="e.g. technology, science, local"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveIncludeTopics"
/>
</div>
<div class="settings-field" style="margin-top: 0.75rem">
<label class="field-label">Not interested in</label>
<TagInput
:model-value="briefingExcludeTopics"
placeholder="e.g. sports, celebrity"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveExcludeTopics"
/>
</div>
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
<p class="field-hint" style="margin-top: 0.35rem">
technology · science · politics · business · health · environment ·
local · entertainment · sports · other
</p>
<p class="field-hint">Custom terms are also accepted.</p>
</details>
</section>
<!-- Notifications -->
<section class="settings-section full-width">
<h2>Notifications</h2>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.notifications" />
Push notification when each briefing slot is ready
</label>
</div>
</section>
<section class="settings-section full-width">
<div class="actions">
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
{{ briefingSaved ? 'Saved ' : briefingSaving ? 'Saving' : 'Save Briefing Settings' }}
</button>
</div>
</section>
</div>
<!-- ── API Keys ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<section class="settings-section full-width">
<h2>API Keys</h2>
<p class="settings-description">
API keys let external tools (like the Fable MCP server) access your data without a browser session.
Write-scoped keys can create and update content; read-only keys can only query.
</p>
<!-- Create form -->
<div class="api-key-create-form">
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" 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 Key' }}
</button>
</div>
<!-- One-time key reveal -->
<div v-if="newKeyValue" class="api-key-reveal">
<p><strong>Copy this key 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>
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
</div>
</div>
<!-- Keys 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 API keys yet.</p>
</section>
<!-- Fable MCP install section -->
<section class="settings-section full-width">
<h2>Fable MCP</h2>
<p class="settings-description">
The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects.
Install it locally, then point it at this Fable instance with an API key above.
</p>
<div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
<template v-else-if="mcpInfo">
<div v-if="mcpInfo.available" class="mcp-available">
<div class="mcp-pkg-row">
<span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
<a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
</div>
<div class="mcp-install-steps">
<h3>Installation</h3>
<ol>
<li>
Download the wheel above and install it:
<pre class="mcp-code">pip install {{ mcpInfo.filename }}</pre>
</li>
<li>
Create a <code>.env</code> file (or set environment variables):
<pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY=&lt;your-api-key&gt;</pre>
</li>
<li>
Add to your Claude MCP config (<code>~/.claude.json</code> or the Claude Desktop config):
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
</li>
</ol>
</div>
</div>
<div v-else class="mcp-unavailable">
<p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
</div>
</template>
</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>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 Assistant" 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>
<span class="member-result-email">{{ u.email }}</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: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
padding: 0.25rem 0.75rem 0.2rem;
}
.sidebar-item {
display: block;
width: 100%;
text-align: left;
padding: 0.4rem 0.75rem;
border: none;
border-left: 2px solid transparent;
background: none;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
font-family: inherit;
}
.sidebar-item:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
.sidebar-item.active {
color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
border-left-color: var(--color-primary);
font-weight: 600;
}
/* Content panel */
.settings-content {
flex: 1;
min-width: 0;
}
/* 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: 700;
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: 600; 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-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 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: 600;
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);
}
.actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { opacity: 0.9; }
.btn-danger-outline {
padding: 0.4rem 0.9rem;
background: none;
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-danger, #e74c3c);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-danger, #e74c3c);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.btn-warn:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
}
.saved-msg {
color: var(--color-success);
font-size: 0.875rem;
font-weight: 600;
}
.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 {
font-style: italic;
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: 600;
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: 600;
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);
font-style: italic;
}
.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: 700;
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: rgba(99, 102, 241, 0.08);
}
.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: 600;
color: var(--color-text-secondary);
}
.users-table { width: 100%; border-collapse: collapse; }
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td { border-bottom: none; }
.cell-username { font-weight: 600; }
.cell-email { color: var(--color-text-secondary); }
.cell-date { color: var(--color-text-muted); font-size: 0.85rem; }
.cell-actions { white-space: nowrap; }
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
}
.role-admin {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.role-user {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
}
.you-label { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; }
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) { border-color: var(--color-danger); color: var(--color-danger); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.25rem;
}
.btn-confirm-delete:hover:not(:disabled) { filter: brightness(0.9); }
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
}
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
.btn-toggle-open { background: var(--color-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { opacity: 0.9; }
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
.loading-msg, .empty-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
/* Logs panel */
.stats-section { padding: 1rem 1.25rem; }
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
.stat-card {
flex: 1;
min-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-count { font-size: 1.5rem; font-weight: 700; color: var(--color-text); }
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.stat-audit { color: var(--color-primary); }
.stat-usage { color: var(--color-success); }
.stat-error { color: var(--color-danger); }
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.filter-select { min-width: 140px; }
.filter-input { flex: 1; min-width: 150px; }
.filter-date { width: 140px; }
.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td { border-bottom: none; }
.log-row { cursor: pointer; transition: background 0.1s; }
.log-row:hover { background: var(--color-bg-secondary); }
.row-expanded { background: var(--color-bg-secondary); }
.cell-time { white-space: nowrap; color: var(--color-text-muted); font-size: 0.8rem; }
.cell-user { color: var(--color-text-secondary); }
.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--color-danger); }
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
.detail-json {
margin: 0; padding: 0.75rem;
background: var(--color-bg); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); font-size: 0.8rem;
overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
}
.category-badge {
display: inline-block;
font-size: 0.65rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.35rem; border-radius: var(--radius-sm);
}
.cat-audit { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.cat-usage { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); }
.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 15%, transparent); }
.method-tag {
display: inline-block;
font-size: 0.65rem; font-weight: 700; font-family: monospace;
padding: 0.05rem 0.25rem; border-radius: 3px;
background: var(--color-bg-secondary); color: var(--color-text-muted);
margin-right: 0.25rem;
}
/* ── About / version ─────────────────────────────────────────── */
.version-line {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
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-border);
border-radius: 4px;
color: var(--color-text-muted);
}
/* ── Groups tab ──────────────────────────────────────────────── */
.btn-primary {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.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-style: italic;
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: 600;
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-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
.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: 600; 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: 700;
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-style: italic;
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
/* Briefing tab */
.briefing-location-row {
margin-bottom: 1rem;
}
.briefing-input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.briefing-geo-confirmed {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.2rem;
}
.briefing-geo-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.2rem;
}
.briefing-day-toggles {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.briefing-unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.briefing-unit-btn {
padding: 0.35rem 1rem;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: all 0.15s;
}
.briefing-unit-btn:first-child {
border-right: 1px solid var(--color-border);
}
.briefing-unit-btn.active {
background: var(--color-primary);
color: #fff;
}
.briefing-day-btn {
padding: 0.35rem 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;
}
.briefing-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.briefing-slot-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.briefing-slot-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
border-radius: 8px;
background: var(--color-bg-secondary);
}
.briefing-slot-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.briefing-slot-label {
font-size: 0.88rem;
font-weight: 500;
}
.briefing-slot-time {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.briefing-feeds-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.briefing-feed-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--color-bg-secondary);
}
.briefing-feed-info {
flex: 1;
min-width: 0;
}
.briefing-feed-title {
font-size: 0.85rem;
font-weight: 500;
display: block;
}
.briefing-feed-url {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.briefing-btn-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s;
}
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
.briefing-feeds-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.briefing-feeds-header h2 { margin: 0; }
.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; }
.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; }
.briefing-feed-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.briefing-feed-cat {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
font-weight: 500;
}
.briefing-feed-age {
font-size: 0.72rem;
color: var(--color-text-muted);
display: block;
margin-top: 0.1rem;
}
.briefing-add-feed-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 0.75rem;
}
.briefing-add-feed-inputs {
display: flex;
gap: 0.5rem;
flex: 1;
flex-wrap: wrap;
}
.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; }
.briefing-cat-input { max-width: 180px; }
.briefing-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* 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: 600; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
}
.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; }
/* Fable 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: 600; 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;
}
</style>