CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 34s
Rule 156, across the whole client. `apiGet`, `apiPost`, `apiPut`, `apiPatch` and `apiDelete` each called bare `fetch`, whose default is to wait as long as the browser will — not a long timeout but the absence of one. The only AbortController in the frontend belonged to the SSE stream and was for cancellation. So every request in the app could hang forever, and there is no state a surface can render for "pending forever" that is not a lie: the spinner that never resolves looks exactly like work still in progress. Found while building the version readout (#3329), which had to tell "the fetch failed" apart from "still loading" and could not. ONE REQUEST PATH. The five verbs were near-identical bodies; they now delegate to a single `request()` that owns the deadline, so a sixth verb cannot be added without one. 30s by default — long enough to clear a cold embedding call and a list view under pool contention (#2384), so tripping it means something is wrong rather than merely busy. Overridable per call via `timeoutMs`. EXPIRY IS AN ApiError, which is the half of rule 156 that is easy to skip. A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an object with no `body`, so all ~330 existing catch sites would have printed their generic fallback and the timeout would have been invisible in exactly the situation it exists to expose. Rethrown as `ApiError` with a 408 — a status no Scribe route returns, so it unambiguously means the client gave up — every one of those call sites now reports it correctly, untouched. Only TimeoutError is converted. A deliberate cancellation aborts with AbortError and passes through: a caller that cancelled its own request does not want that surfaced as a server failure. Pinned by a test, because collapsing the two is the obvious "simplification". STREAMS RELOCATE THE DEADLINE RATHER THAN ESCAPING IT. A wall-clock timeout would kill a long-lived SSE connection mid-flight, but two different waits are involved and only one of them is the stream: the CONNECT can fail to answer and now carries a 15s deadline, cleared the moment headers arrive; the BODY stays unbounded on purpose, since its failure mode is going quiet, which a timeout cannot distinguish from being idle — that is what reconnection and Last-Event-ID are for. Reading the connect as exempt because "the stream is long-lived" leaves an unreachable server looking like a quiet one. BULK TRANSFERS get their own value, not the default. Backup, notes export and admin restore walk the whole store and 30s would cut them off mid-work; they carry 10 minutes. Bounded, not unbounded — rule 156 asks for a deadline, not a short one, and no ceiling at all is what leaves a restore that died server-side spinning forever. Four source-inspection guards in the unit lane (no frontend test runner): no bare fetch anywhere; the default is actually applied — pinning the specific regression, since #3329's opt-in shape would pass every other check while leaving 330 callers unbounded; expiry converts to ApiError; and cancellation does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
3685 lines
130 KiB
Vue
3685 lines
130 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted } from "vue";
|
||
import { useSettingsStore } from "@/stores/settings";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import { useToastStore } from "@/stores/toast";
|
||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
||
import type { User } from "@/types/auth";
|
||
import PaginationBar from "@/components/PaginationBar.vue";
|
||
import TagInput from "@/components/TagInput.vue";
|
||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||
|
||
const store = useSettingsStore();
|
||
const authStore = useAuthStore();
|
||
const toastStore = useToastStore();
|
||
|
||
// ── Shared areas (milestone 307) ────────────────────────────────────────
|
||
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
|
||
// a global list anyone can extend stops being a shared list — but every user
|
||
// reads it, which is why the catalog lives in a store rather than here.
|
||
const canonStore = useCanonicalSystemsStore();
|
||
const newAreaName = ref("");
|
||
const newAreaDescription = ref("");
|
||
const creatingArea = ref(false);
|
||
const editingAreaId = ref<number | null>(null);
|
||
const editAreaName = ref("");
|
||
const editAreaDescription = ref("");
|
||
const savingArea = ref(false);
|
||
|
||
async function createArea() {
|
||
const name = newAreaName.value.trim();
|
||
if (!name || creatingArea.value) return;
|
||
creatingArea.value = true;
|
||
try {
|
||
await canonStore.createEntry({
|
||
name,
|
||
description: newAreaDescription.value.trim() || undefined,
|
||
});
|
||
newAreaName.value = "";
|
||
newAreaDescription.value = "";
|
||
toastStore.show("Area added");
|
||
} catch (e) {
|
||
// A 409 means an area with the same match key already exists — say which,
|
||
// because "CI and Release" vs "CI & Release" looks like a different name.
|
||
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
|
||
} finally {
|
||
creatingArea.value = false;
|
||
}
|
||
}
|
||
|
||
function startEditArea(id: number, name: string, description: string | null) {
|
||
editingAreaId.value = id;
|
||
editAreaName.value = name;
|
||
editAreaDescription.value = description ?? "";
|
||
}
|
||
|
||
async function saveArea() {
|
||
const id = editingAreaId.value;
|
||
const name = editAreaName.value.trim();
|
||
if (id == null || !name || savingArea.value) return;
|
||
savingArea.value = true;
|
||
try {
|
||
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
|
||
editingAreaId.value = null;
|
||
toastStore.show("Area updated");
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
|
||
} finally {
|
||
savingArea.value = false;
|
||
}
|
||
}
|
||
const userTimezone = ref("");
|
||
const savingTimezone = ref(false);
|
||
const timezoneSaved = ref(false);
|
||
const trashRetentionDays = ref("90");
|
||
const savingRetention = ref(false);
|
||
const retentionSaved = ref(false);
|
||
// Knowledge auto-inject (per-user). Defaults mirror the backend
|
||
// (services/plugin_context: enabled, threshold 0.55, top-k 3).
|
||
const kbInjectEnabled = ref(true);
|
||
const kbInjectThreshold = ref("0.55");
|
||
const kbInjectTopK = ref("3");
|
||
const kbWritePathEnabled = ref(true);
|
||
// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above:
|
||
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
|
||
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||
const kbWritePathThreshold = ref("0.68");
|
||
// Near-duplicate report floors, one per record kind (services/dedup.py).
|
||
// Snippets are single-chunk, so their floor sits below the 0.90 write-time
|
||
// gate and catches what it lets through. Notes/tasks are scored at chunk
|
||
// grain (#280) — related families clear 0.90 easily — so their floor sits
|
||
// above the gate to keep the report pointed at genuinely-alike records.
|
||
const kbDupThresholdSnippet = ref("0.82");
|
||
const kbDupThresholdNote = ref("0.93");
|
||
const kbDupThresholdTask = ref("0.93");
|
||
const savingKbInject = ref(false);
|
||
const kbInjectSaved = ref(false);
|
||
|
||
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
||
// has tools=[] on the chat model; think on a no-tools conversational pass
|
||
// is pure latency cost. See generation_task.py:run_generation comment for
|
||
// the full reasoning.
|
||
|
||
function detectTimezone() {
|
||
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
|
||
async function saveTimezone() {
|
||
savingTimezone.value = true;
|
||
timezoneSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', { user_timezone: userTimezone.value });
|
||
timezoneSaved.value = true;
|
||
setTimeout(() => (timezoneSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save timezone', 'error');
|
||
} finally {
|
||
savingTimezone.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveRetention() {
|
||
const n = Math.max(0, Math.floor(Number(trashRetentionDays.value) || 0));
|
||
trashRetentionDays.value = String(n);
|
||
savingRetention.value = true;
|
||
retentionSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', { trash_retention_days: String(n) });
|
||
retentionSaved.value = true;
|
||
setTimeout(() => (retentionSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save retention setting', 'error');
|
||
} finally {
|
||
savingRetention.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveKbInject() {
|
||
const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
|
||
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
|
||
// `|| default` not `|| 0`: an unparseable value here should fall back to the
|
||
// per-kind default, not to 0 — a 0 floor would report every record as a
|
||
// duplicate of every other one.
|
||
const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82));
|
||
const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93));
|
||
const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93));
|
||
// Same `|| default` reasoning: falling back to 0 would surface every
|
||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
||
const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
|
||
kbInjectThreshold.value = String(t);
|
||
kbInjectTopK.value = String(k);
|
||
kbDupThresholdSnippet.value = String(dupSnip);
|
||
kbDupThresholdNote.value = String(dupNote);
|
||
kbDupThresholdTask.value = String(dupTask);
|
||
kbWritePathThreshold.value = String(wpT);
|
||
savingKbInject.value = true;
|
||
kbInjectSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', {
|
||
kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
|
||
kb_autoinject_threshold: String(t),
|
||
kb_autoinject_top_k: String(k),
|
||
// Its own switch AND its own threshold (shares only the ceiling) — see
|
||
// WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the
|
||
// measurements that split them.
|
||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||
kb_writepath_threshold: String(wpT),
|
||
kb_duplicate_threshold_snippet: String(dupSnip),
|
||
kb_duplicate_threshold_note: String(dupNote),
|
||
kb_duplicate_threshold_task: String(dupTask),
|
||
});
|
||
kbInjectSaved.value = true;
|
||
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save auto-inject settings', 'error');
|
||
} finally {
|
||
savingKbInject.value = false;
|
||
}
|
||
}
|
||
const newEmail = ref("");
|
||
const emailPassword = ref("");
|
||
const changingEmail = ref(false);
|
||
const currentPassword = ref("");
|
||
const newPassword = ref("");
|
||
const confirmNewPassword = ref("");
|
||
const changingPassword = ref(false);
|
||
const invalidatingSessions = ref(false);
|
||
const exporting = ref(false);
|
||
const restoring = ref(false);
|
||
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
|
||
// and the client's ordinary 30s default would cut them off mid-work. They are
|
||
// still bounded: rule 156 asks for a deadline, not a short one, and "no ceiling
|
||
// at all" is what leaves a restore that died server-side spinning forever.
|
||
const BULK_TRANSFER_TIMEOUT_MS = 10 * 60 * 1000;
|
||
|
||
function bulkDeadline(): AbortSignal {
|
||
return AbortSignal.timeout(BULK_TRANSFER_TIMEOUT_MS);
|
||
}
|
||
|
||
// ── What's running (#3127 checklist 12) ─────────────────────────────────
|
||
// Three states kept apart, because collapsing any two of them is the defect
|
||
// this readout exists to remove: `null` + no error = not asked yet (the Config
|
||
// tab has not been opened); a payload = answered, with each ABSENT field shown
|
||
// as "unknown"; `versionError` = the fetch itself failed, which is its own
|
||
// thing and must never render as a blank or as a plausible-looking value.
|
||
const versionInfo = ref<VersionPayload | null>(null);
|
||
const versionLoading = ref(false);
|
||
const versionError = ref("");
|
||
const commitCopied = ref(false);
|
||
|
||
async function loadVersionPanel() {
|
||
if (versionLoading.value) return;
|
||
versionLoading.value = true;
|
||
versionError.value = "";
|
||
try {
|
||
versionInfo.value = await fetchVersion();
|
||
} catch (e) {
|
||
versionInfo.value = null;
|
||
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
|
||
} finally {
|
||
versionLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function copyCommit() {
|
||
if (!versionInfo.value?.commit) return;
|
||
await copyToClipboard(versionInfo.value.commit);
|
||
commitCopied.value = true;
|
||
setTimeout(() => { commitCopied.value = false; }, 2000);
|
||
}
|
||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||
|
||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
|
||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||
|
||
function _loadTabContent(tab: string) {
|
||
if (authStore.isAdmin) {
|
||
if (tab === "users") loadUsersPanel();
|
||
else if (tab === "logs") loadLogsPanel();
|
||
else if (tab === "groups") loadGroupsPanel();
|
||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||
}
|
||
if (tab === "apikeys") { fetchApiKeys(); }
|
||
}
|
||
|
||
watch(activeTab, (v) => {
|
||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||
_loadTabContent(v);
|
||
});
|
||
|
||
// MCP Access (API Keys are used as Bearer tokens for the in-app /mcp endpoint)
|
||
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||
const newKeyName = ref('');
|
||
const newKeyScope = ref<'read' | 'write'>('write');
|
||
const newKeyValue = ref('');
|
||
const apiKeyCopied = ref(false);
|
||
const creatingApiKey = ref(false);
|
||
const revokeConfirmId = ref<number | null>(null);
|
||
|
||
const origin = window.location.origin;
|
||
const mcpUrl = computed(() => `${origin}/mcp`);
|
||
const mcpUrlCopied = ref(false);
|
||
const mcpClientTab = ref<'claude-code' | 'claude-desktop'>('claude-code');
|
||
const copiedSnippetKey = ref<string | null>(null);
|
||
|
||
// Configurable per-browser: the local name Claude uses for this server, and the
|
||
// scope of the `claude mcp add` call. Persisted to localStorage so the user's
|
||
// preferences carry across visits without needing a server-side setting.
|
||
const _MCP_NAME_KEY = 'mcp_server_name';
|
||
const _MCP_SCOPE_KEY = 'mcp_scope';
|
||
const _DEFAULT_MCP_NAME = 'scribe';
|
||
const _isValidScope = (v: unknown): v is 'user' | 'project' | 'local' =>
|
||
v === 'user' || v === 'project' || v === 'local';
|
||
|
||
const mcpServerName = ref<string>(
|
||
localStorage.getItem(_MCP_NAME_KEY) || _DEFAULT_MCP_NAME,
|
||
);
|
||
const _storedScope = localStorage.getItem(_MCP_SCOPE_KEY);
|
||
const mcpScope = ref<'user' | 'project' | 'local'>(
|
||
_isValidScope(_storedScope) ? _storedScope : 'user',
|
||
);
|
||
|
||
watch(mcpServerName, (v) => {
|
||
const clean = (v || '').trim() || _DEFAULT_MCP_NAME;
|
||
localStorage.setItem(_MCP_NAME_KEY, clean);
|
||
});
|
||
watch(mcpScope, (v) => localStorage.setItem(_MCP_SCOPE_KEY, v));
|
||
|
||
const effectiveMcpName = computed(() => (mcpServerName.value || '').trim() || _DEFAULT_MCP_NAME);
|
||
const effectiveApiKey = computed(() => newKeyValue.value || '<your-token>');
|
||
|
||
const claudeCodeCommand = computed(() => {
|
||
// Note: `claude mcp add` takes the URL as a positional arg, not --url.
|
||
// Layout: claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]
|
||
return `claude mcp add --transport http --scope ${mcpScope.value} ${effectiveMcpName.value} \\
|
||
${mcpUrl.value} \\
|
||
--header "Authorization: Bearer ${effectiveApiKey.value}"`;
|
||
});
|
||
|
||
// Plugin install — the recommended path. The Scribe plugin bundles the MCP
|
||
// connection, a session-start hook that surfaces your rules, and the Scribe
|
||
// process-skills. The marketplace is the Scribe app's own git repo; persisted
|
||
// per-browser like the MCP fields above.
|
||
const _MKT_KEY = 'plugin_marketplace_url';
|
||
const pluginMarketplaceUrl = ref<string>(localStorage.getItem(_MKT_KEY) || '');
|
||
watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim()));
|
||
// Instance default, set by an admin (Admin tab) and loaded on mount. Used when
|
||
// the per-browser field is blank so the install command is copyable out of the box.
|
||
const serverMarketplaceUrl = ref('');
|
||
const adminMarketplaceUrl = ref('');
|
||
const savingMarketplaceUrl = ref(false);
|
||
const marketplaceUrlSaved = ref(false);
|
||
|
||
// DB maintenance (admin) — daily targeted VACUUM (ANALYZE).
|
||
interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null }
|
||
interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] }
|
||
const dbMaintEnabled = ref(true);
|
||
const dbMaintHour = ref(4);
|
||
const dbMaintLastRun = ref<DbMaintRun | null>(null);
|
||
const savingDbMaint = ref(false);
|
||
const dbMaintSaved = ref(false);
|
||
const runningDbMaint = ref(false);
|
||
interface DbTableHealth {
|
||
table: string; live: number; dead: number; dead_pct: number;
|
||
total_bytes: number; mod_since_analyze: number;
|
||
last_vacuum: string | null; last_analyze: string | null;
|
||
}
|
||
interface DbHealth { db_bytes: number; tables: DbTableHealth[] }
|
||
const dbHealth = ref<DbHealth | null>(null);
|
||
const loadingHealth = ref(false);
|
||
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
||
|
||
function formatBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`;
|
||
const units = ["KB", "MB", "GB", "TB"];
|
||
let v = n / 1024, i = 0;
|
||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||
}
|
||
const pluginInstallCommands = computed(() => {
|
||
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||
|| serverMarketplaceUrl.value
|
||
|| '<your-scribe-repo>.git';
|
||
return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`;
|
||
});
|
||
|
||
const mcpConfigSnippet = computed(() => JSON.stringify({
|
||
mcpServers: {
|
||
[effectiveMcpName.value]: {
|
||
url: mcpUrl.value,
|
||
headers: {
|
||
Authorization: `Bearer ${effectiveApiKey.value}`,
|
||
},
|
||
},
|
||
},
|
||
}, null, 2));
|
||
|
||
async function copyMcpUrl() {
|
||
await copyToClipboard(mcpUrl.value);
|
||
mcpUrlCopied.value = true;
|
||
setTimeout(() => { mcpUrlCopied.value = false; }, 2000);
|
||
}
|
||
|
||
async function copyToClipboard(text: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch {
|
||
// Fallback for http (non-secure) contexts where clipboard API is unavailable
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
}
|
||
|
||
async function copySnippet(text: string, key: string) {
|
||
await copyToClipboard(text);
|
||
copiedSnippetKey.value = key;
|
||
setTimeout(() => {
|
||
if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
|
||
}, 2000);
|
||
}
|
||
|
||
async function fetchApiKeys() {
|
||
apiKeys.value = await listApiKeys();
|
||
}
|
||
|
||
async function createApiKey() {
|
||
if (!newKeyName.value) return;
|
||
creatingApiKey.value = true;
|
||
try {
|
||
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||
newKeyValue.value = data.key;
|
||
newKeyName.value = '';
|
||
await fetchApiKeys();
|
||
} finally {
|
||
creatingApiKey.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokeApiKey(id: number) {
|
||
await apiRevokeApiKey(id);
|
||
revokeConfirmId.value = null;
|
||
await fetchApiKeys();
|
||
}
|
||
|
||
async function copyApiKey() {
|
||
await copyToClipboard(newKeyValue.value);
|
||
apiKeyCopied.value = true;
|
||
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
|
||
}
|
||
|
||
// Groups management
|
||
const groups = ref<GroupEntry[]>([]);
|
||
const groupsLoading = ref(false);
|
||
const newGroupName = ref("");
|
||
const newGroupDesc = ref("");
|
||
const creatingGroup = ref(false);
|
||
const expandedGroupId = ref<number | null>(null);
|
||
const groupMembers = ref<Record<number, GroupMember[]>>({});
|
||
const groupMemberSearch = ref("");
|
||
const groupMemberResults = ref<UserSearchResult[]>([]);
|
||
let groupSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const groupMemberRole = ref("member");
|
||
|
||
async function loadGroupsPanel() {
|
||
groupsLoading.value = true;
|
||
try {
|
||
groups.value = await listGroups();
|
||
} finally {
|
||
groupsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function createNewGroup() {
|
||
if (!newGroupName.value.trim() || creatingGroup.value) return;
|
||
creatingGroup.value = true;
|
||
try {
|
||
await createGroup(newGroupName.value.trim(), newGroupDesc.value.trim() || undefined);
|
||
newGroupName.value = "";
|
||
newGroupDesc.value = "";
|
||
await loadGroupsPanel();
|
||
} finally {
|
||
creatingGroup.value = false;
|
||
}
|
||
}
|
||
|
||
async function deleteGroupConfirm(g: GroupEntry) {
|
||
if (!confirm(`Delete group "${g.name}"? This cannot be undone.`)) return;
|
||
await deleteGroup(g.id);
|
||
if (expandedGroupId.value === g.id) expandedGroupId.value = null;
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function toggleGroupExpand(g: GroupEntry) {
|
||
if (expandedGroupId.value === g.id) {
|
||
expandedGroupId.value = null;
|
||
return;
|
||
}
|
||
expandedGroupId.value = g.id;
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
groupMembers.value[g.id] = await listGroupMembers(g.id);
|
||
}
|
||
|
||
function debounceGroupMemberSearch() {
|
||
if (groupSearchTimer) clearTimeout(groupSearchTimer);
|
||
groupSearchTimer = setTimeout(async () => {
|
||
if (groupMemberSearch.value.length < 2) { groupMemberResults.value = []; return; }
|
||
groupMemberResults.value = await searchUsers(groupMemberSearch.value);
|
||
}, 300);
|
||
}
|
||
|
||
async function addMemberToGroup(groupId: number, user: UserSearchResult) {
|
||
await addGroupMember(groupId, user.id, groupMemberRole.value);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function removeMemberFromGroup(groupId: number, userId: number) {
|
||
await removeGroupMember(groupId, userId);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
|
||
// Notification preferences (email notifications only — push surface removed)
|
||
const notifyTaskReminders = ref(true);
|
||
const notifySecurityAlerts = ref(true);
|
||
const savingNotifications = ref(false);
|
||
const notificationsSaved = ref(false);
|
||
|
||
// SMTP settings (admin only)
|
||
const smtp = ref({
|
||
smtp_host: "",
|
||
smtp_port: "587",
|
||
smtp_username: "",
|
||
smtp_password: "",
|
||
smtp_from_address: "",
|
||
smtp_from_name: "Fabled Scribe",
|
||
smtp_use_tls: "true",
|
||
});
|
||
const savingSmtp = ref(false);
|
||
const smtpSaved = ref(false);
|
||
const testRecipient = ref("");
|
||
const sendingTest = ref(false);
|
||
|
||
// Base URL setting (admin only)
|
||
const baseUrl = ref("");
|
||
const savingBaseUrl = ref(false);
|
||
const baseUrlSaved = ref(false);
|
||
|
||
// Git forge connections (#2778) — the user's keyring: one read-only
|
||
// credential per forge host, used server-side for every forge read on
|
||
// projects this user owns. The token round-trips masked; the server treats
|
||
// the mask as "unchanged".
|
||
interface ForgeConnectionEntry {
|
||
id: number; kind: string; base_url: string; host: string;
|
||
}
|
||
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
|
||
const forgeKinds = ref<string[]>(["gitea", "github"]);
|
||
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
|
||
const connFormOpen = ref(false);
|
||
const savingConn = ref(false);
|
||
const testingConnId = ref(0);
|
||
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
|
||
// The webhook secret stays admin: the push endpoint is one URL per instance
|
||
// and authenticates deliveries, not users.
|
||
const forgeWebhookSecret = ref("");
|
||
const savingForgeWebhook = ref(false);
|
||
const forgeWebhookSaved = ref(false);
|
||
|
||
|
||
// Search test (SearXNG)
|
||
const searxngConfigured = ref(false);
|
||
const searxngUrl = ref("");
|
||
const searchQuery = ref("");
|
||
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
|
||
const searchLoading = ref(false);
|
||
const searchError = ref("");
|
||
|
||
|
||
// ── Profile ──────────────────────────────────────────────────────────────────
|
||
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
const profile = ref<UserProfile>({
|
||
display_name: '', job_title: '', industry: '',
|
||
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
|
||
interests: [], work_schedule: {},
|
||
})
|
||
const profileSaving = ref(false)
|
||
const profileSaved = ref(false)
|
||
|
||
async function loadProfile() {
|
||
try { profile.value = await getProfile() } catch { /* non-critical */ }
|
||
}
|
||
|
||
async function saveProfile() {
|
||
profileSaving.value = true
|
||
profileSaved.value = false
|
||
try {
|
||
profile.value = await updateProfile({
|
||
display_name: profile.value.display_name,
|
||
job_title: profile.value.job_title,
|
||
industry: profile.value.industry,
|
||
expertise_level: profile.value.expertise_level,
|
||
response_style: profile.value.response_style,
|
||
tone: profile.value.tone,
|
||
interests: profile.value.interests,
|
||
work_schedule: profile.value.work_schedule,
|
||
})
|
||
profileSaved.value = true
|
||
setTimeout(() => { profileSaved.value = false }, 2000)
|
||
} catch { toastStore.show('Failed to save profile', 'error') }
|
||
finally { profileSaving.value = false }
|
||
}
|
||
|
||
function toggleProfileWorkDay(day: string) {
|
||
const days = [...(profile.value.work_schedule.days ?? [])]
|
||
const idx = days.indexOf(day)
|
||
if (idx >= 0) days.splice(idx, 1)
|
||
else days.push(day)
|
||
profile.value.work_schedule = { ...profile.value.work_schedule, days }
|
||
}
|
||
|
||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||
|
||
onMounted(async () => {
|
||
await store.fetchSettings();
|
||
newEmail.value = authStore.user?.email ?? "";
|
||
|
||
// Load notification preferences from user settings
|
||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||
userTimezone.value = allSettings.user_timezone ?? "";
|
||
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
||
kbInjectEnabled.value = allSettings.kb_autoinject_enabled !== "false";
|
||
if (allSettings.kb_autoinject_threshold !== undefined) {
|
||
kbInjectThreshold.value = allSettings.kb_autoinject_threshold;
|
||
}
|
||
if (allSettings.kb_autoinject_top_k !== undefined) {
|
||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||
}
|
||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
||
if (allSettings.kb_writepath_threshold !== undefined) {
|
||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||
}
|
||
if (allSettings.kb_duplicate_threshold_snippet !== undefined) {
|
||
kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet;
|
||
}
|
||
if (allSettings.kb_duplicate_threshold_note !== undefined) {
|
||
kbDupThresholdNote.value = allSettings.kb_duplicate_threshold_note;
|
||
}
|
||
if (allSettings.kb_duplicate_threshold_task !== undefined) {
|
||
kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
|
||
}
|
||
if (allSettings.notify_task_reminders !== undefined) {
|
||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||
}
|
||
if (allSettings.notify_security_alerts !== undefined) {
|
||
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
||
}
|
||
|
||
// Load user profile
|
||
await loadProfile();
|
||
|
||
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
||
|
||
// Check SearXNG status
|
||
try {
|
||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
||
searxngConfigured.value = sr.configured;
|
||
searxngUrl.value = sr.searxng_url;
|
||
} catch {
|
||
searxngConfigured.value = false;
|
||
}
|
||
|
||
// Plugin marketplace URL (instance default; readable by all users so the
|
||
// install command in MCP Access is copyable).
|
||
try {
|
||
const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url");
|
||
serverMarketplaceUrl.value = mk.marketplace_url || "";
|
||
adminMarketplaceUrl.value = mk.marketplace_url || "";
|
||
if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || "";
|
||
} catch {
|
||
// not configured yet
|
||
}
|
||
|
||
// DB maintenance config (admin only — endpoint is admin-gated).
|
||
if (authStore.isAdmin) {
|
||
try {
|
||
const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance");
|
||
dbMaintEnabled.value = dm.enabled;
|
||
dbMaintHour.value = dm.hour;
|
||
dbMaintLastRun.value = dm.last_run;
|
||
} catch {
|
||
// leave defaults
|
||
}
|
||
await loadDbHealth();
|
||
}
|
||
|
||
// 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
|
||
}
|
||
try {
|
||
await loadForgeWebhook();
|
||
} catch {
|
||
// webhook secret not configured yet
|
||
}
|
||
}
|
||
try {
|
||
await loadForgeConnections();
|
||
} catch {
|
||
// no keyring yet — the ordinary state
|
||
}
|
||
_loadTabContent(activeTab.value);
|
||
});
|
||
|
||
async function loadForgeConnections() {
|
||
const res = await apiGet<{
|
||
connections: ForgeConnectionEntry[]; kinds: string[];
|
||
}>("/api/settings/forge-connections");
|
||
forgeConnections.value = res.connections;
|
||
if (res.kinds?.length) forgeKinds.value = res.kinds;
|
||
}
|
||
|
||
async function loadForgeWebhook() {
|
||
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
|
||
forgeWebhookSecret.value = cfg.webhook_secret;
|
||
}
|
||
|
||
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) {
|
||
toastStore.show(apiErrorMessage(e, "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) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
|
||
} finally {
|
||
changingPassword.value = false;
|
||
}
|
||
}
|
||
|
||
async function exportData(scope: "user" | "full") {
|
||
exporting.value = true;
|
||
try {
|
||
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
||
const res = await fetch(url, { signal: bulkDeadline() });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||
}
|
||
const data = await res.json();
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toastStore.show("Backup downloaded");
|
||
} catch (e) {
|
||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
exporting.value = false;
|
||
}
|
||
}
|
||
|
||
const exportingNotes = ref(false);
|
||
|
||
async function exportNotes(format: "markdown" | "json") {
|
||
exportingNotes.value = true;
|
||
try {
|
||
const res = await fetch(`/api/export?format=${format}`, { signal: bulkDeadline() });
|
||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||
const blob = await res.blob();
|
||
const ext = format === "json" ? "json" : "zip";
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `scribe-${stamp}.${ext}`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toastStore.show("Export downloaded");
|
||
} catch (e) {
|
||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
exportingNotes.value = false;
|
||
}
|
||
}
|
||
|
||
function triggerRestoreUpload() {
|
||
restoreFileInput.value?.click();
|
||
}
|
||
|
||
async function saveNotifications() {
|
||
savingNotifications.value = true;
|
||
notificationsSaved.value = false;
|
||
try {
|
||
await apiPut("/api/settings", {
|
||
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
|
||
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
|
||
});
|
||
notificationsSaved.value = true;
|
||
setTimeout(() => (notificationsSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show("Failed to save notification preferences", "error");
|
||
} finally {
|
||
savingNotifications.value = false;
|
||
}
|
||
}
|
||
|
||
async function 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) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
|
||
} finally {
|
||
sendingTest.value = false;
|
||
}
|
||
}
|
||
|
||
function editConnection(c: ForgeConnectionEntry | null) {
|
||
connTestResult.value = null;
|
||
connFormOpen.value = true;
|
||
connForm.value = c
|
||
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
|
||
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
|
||
}
|
||
|
||
async function saveConnection() {
|
||
savingConn.value = true;
|
||
try {
|
||
const { id, ...values } = connForm.value;
|
||
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
|
||
else await apiPost("/api/settings/forge-connections", values);
|
||
connFormOpen.value = false;
|
||
await loadForgeConnections();
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
|
||
} finally {
|
||
savingConn.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeConnection(id: number) {
|
||
try {
|
||
await apiDelete(`/api/settings/forge-connections/${id}`);
|
||
connTestResult.value = null;
|
||
await loadForgeConnections();
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
|
||
}
|
||
}
|
||
|
||
async function testConnection(id: number) {
|
||
testingConnId.value = id;
|
||
connTestResult.value = null;
|
||
try {
|
||
const res = await apiPost<{ version: string; username: string }>(
|
||
`/api/settings/forge-connections/${id}/test`, {},
|
||
);
|
||
connTestResult.value = {
|
||
id, ok: true,
|
||
message: `Connected — ${res.version}, authenticated as ${res.username}`,
|
||
};
|
||
} catch (e) {
|
||
connTestResult.value = {
|
||
id, ok: false,
|
||
message: apiErrorMessage(e, "Connection test failed"),
|
||
};
|
||
} finally {
|
||
testingConnId.value = 0;
|
||
}
|
||
}
|
||
|
||
async function saveForgeWebhook() {
|
||
savingForgeWebhook.value = true;
|
||
try {
|
||
await apiPut("/api/admin/forge-webhook", {
|
||
webhook_secret: forgeWebhookSecret.value,
|
||
});
|
||
await loadForgeWebhook();
|
||
forgeWebhookSaved.value = true;
|
||
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
|
||
} finally {
|
||
savingForgeWebhook.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveBaseUrl() {
|
||
savingBaseUrl.value = true;
|
||
baseUrlSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
|
||
baseUrlSaved.value = true;
|
||
setTimeout(() => (baseUrlSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show("Failed to save application URL", "error");
|
||
} finally {
|
||
savingBaseUrl.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveMarketplaceUrl() {
|
||
savingMarketplaceUrl.value = true;
|
||
marketplaceUrlSaved.value = false;
|
||
try {
|
||
const url = adminMarketplaceUrl.value.trim();
|
||
await apiPut("/api/plugin/marketplace-url", { marketplace_url: url });
|
||
serverMarketplaceUrl.value = url;
|
||
// Reflect the new instance default in the copyable field unless the user
|
||
// set their own per-browser override.
|
||
if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url;
|
||
marketplaceUrlSaved.value = true;
|
||
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
|
||
} finally {
|
||
savingMarketplaceUrl.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveDbMaintenance() {
|
||
savingDbMaint.value = true;
|
||
dbMaintSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/db-maintenance", {
|
||
enabled: dbMaintEnabled.value,
|
||
hour: dbMaintHour.value,
|
||
});
|
||
dbMaintSaved.value = true;
|
||
setTimeout(() => (dbMaintSaved.value = false), 2000);
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
|
||
} finally {
|
||
savingDbMaint.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadDbHealth() {
|
||
loadingHealth.value = true;
|
||
try {
|
||
dbHealth.value = await apiGet<DbHealth>("/api/admin/db-maintenance/health");
|
||
} catch {
|
||
// leave previous value
|
||
} finally {
|
||
loadingHealth.value = false;
|
||
}
|
||
}
|
||
|
||
async function runDbMaintenanceNow() {
|
||
runningDbMaint.value = true;
|
||
try {
|
||
const summary = await apiPost<DbMaintRun>("/api/admin/db-maintenance/run", {});
|
||
dbMaintLastRun.value = summary;
|
||
const failed = summary.tables.filter((t) => !t.ok).length;
|
||
toastStore.show(
|
||
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
||
failed ? "error" : "success",
|
||
);
|
||
await loadDbHealth(); // reflect the dead-tuple drop
|
||
} catch (e) {
|
||
toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
|
||
} finally {
|
||
runningDbMaint.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleRestoreFile(event: Event) {
|
||
const file = (event.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
restoring.value = true;
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
const res = await fetch("/api/admin/restore", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data),
|
||
signal: bulkDeadline(),
|
||
});
|
||
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 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) {
|
||
toastStore.show(apiErrorMessage(e, "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) {
|
||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||
} finally {
|
||
deleting.value = null;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="settings-root">
|
||
<aside class="settings-sidebar" role="navigation" aria-label="Settings navigation">
|
||
<div class="sidebar-group">
|
||
<div class="sidebar-group-label">User</div>
|
||
<button
|
||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab === 'apikeys' ? 'MCP Access' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||
<div class="sidebar-group-label">Admin</div>
|
||
<button
|
||
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<div class="settings-content">
|
||
|
||
<!-- ── General ── -->
|
||
<div v-show="activeTab === 'general'" class="settings-grid">
|
||
<!-- Timezone -->
|
||
<section class="settings-section full-width">
|
||
<h2>Timezone</h2>
|
||
<p class="section-desc">Used to schedule the daily journal prep and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
|
||
<div class="field">
|
||
<label for="user-timezone">Your timezone</label>
|
||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||
<input
|
||
id="user-timezone"
|
||
v-model="userTimezone"
|
||
type="text"
|
||
class="fs-input input"
|
||
placeholder="e.g. America/New_York"
|
||
/>
|
||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||
</div>
|
||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
|
||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Trash retention -->
|
||
<section class="settings-section full-width">
|
||
<h2>Trash retention</h2>
|
||
<p class="section-desc">Deleted items move to <router-link to="/trash">Trash</router-link> and can be restored. They're permanently purged after this many days.</p>
|
||
<div class="field">
|
||
<label for="trash-retention">Retention period (days)</label>
|
||
<input
|
||
id="trash-retention"
|
||
v-model="trashRetentionDays"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
|
||
{{ savingRetention ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="retentionSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Knowledge auto-inject</h2>
|
||
<p class="section-desc">
|
||
When enabled, the Scribe plugin quietly surfaces the titles of your most
|
||
relevant notes on each prompt — never their full text — so Claude can pull
|
||
one in with <code>get_note(id)</code> only when it helps. Titles only, each
|
||
note at most once per session, and nothing is shown unless it clears the
|
||
confidence bar below.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="kbInjectEnabled" />
|
||
Surface relevant note titles each prompt
|
||
</label>
|
||
<p class="field-hint">Off = notes reach context only when Claude searches for them.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-inject-threshold">Confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-inject-threshold"
|
||
v-model="kbInjectThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.05"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-inject-topk">Max notes per prompt</label>
|
||
<input
|
||
id="kb-inject-topk"
|
||
v-model="kbInjectTopK"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
step="1"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
|
||
</div>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="kbWritePathEnabled" />
|
||
Also surface prior art when Claude writes code
|
||
</label>
|
||
<p class="field-hint">
|
||
Checks the file Claude is about to write or edit against your recorded
|
||
snippets — what's already kept at that path, and what resembles the code
|
||
being written — so a helper you already have is offered before it's
|
||
rewritten. When the file being edited is itself a recorded snippet's
|
||
location, the hint instead asks Claude to update or re-verify that
|
||
record as part of the edit — how records stay current without any forge
|
||
connection. Uses the ceiling above with its own threshold below, and never
|
||
blocks the edit. Off = prior art surfaces only on your own prompts.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label for="kb-writepath-threshold">Prior-art confidence threshold (0–1)</label>
|
||
<input
|
||
id="kb-writepath-threshold"
|
||
v-model="kbWritePathThreshold"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Stricter than the prompt threshold above on purpose. Any two pieces of
|
||
code look somewhat alike — shared keywords, indentation, structure — so
|
||
resemblance scores start higher for code than for prose, and a bar tuned
|
||
for prompts flags unrelated code as prior art. Lower this if genuine
|
||
duplicates go unnoticed; raise it if you're being offered snippets that
|
||
have nothing to do with what's being written. Snippets recorded at the
|
||
exact file are always shown regardless — those are prior art by
|
||
location, not by resemblance.
|
||
</p>
|
||
</div>
|
||
<!-- A design system belongs to a PROJECT, and the picker for it lives on
|
||
the project. There was a setting here that designated the system
|
||
this install's own interface was built from; it only ever described
|
||
the app you were already looking at, which is not what the feature
|
||
is for (#274). -->
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-snippet">Near-duplicate report threshold — snippets</label>
|
||
<input
|
||
id="kb-duplicate-threshold-snippet"
|
||
v-model="kbDupThresholdSnippet"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
How alike two snippets must be before the Snippets page suggests merging
|
||
them. Lower = more suggestions, more false pairs. Looser than the 0.90
|
||
used to block a duplicate at creation, because this only proposes a merge
|
||
you review — it never acts on its own.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-note">Near-duplicate report threshold — notes</label>
|
||
<input
|
||
id="kb-duplicate-threshold-note"
|
||
v-model="kbDupThresholdNote"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
The floor for the Knowledge page's note report. Notes are compared
|
||
section by section, so related records — a run of dev-logs, notes on
|
||
one topic — score high without being duplicates. Stricter than the
|
||
snippet floor on purpose; lower it to browse related families rather
|
||
than hunt true duplicates.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="kb-duplicate-threshold-task">Near-duplicate report threshold — tasks</label>
|
||
<input
|
||
id="kb-duplicate-threshold-task"
|
||
v-model="kbDupThresholdTask"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
class="fs-input input"
|
||
style="max-width: 8rem"
|
||
/>
|
||
<p class="field-hint">
|
||
Same as the note floor, for the task report. Step tasks from different
|
||
milestones ("Verify on CI") legitimately resemble each other, so this
|
||
stays strict to keep the report pointed at work opened twice.
|
||
</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
|
||
{{ savingKbInject ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Account ── -->
|
||
<div v-show="activeTab === 'account'" class="settings-grid">
|
||
|
||
<!-- SSO accounts: no local credential management -->
|
||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||
<h2>Account</h2>
|
||
<p class="section-desc">
|
||
Your account is managed by an external identity provider.
|
||
Email and password changes are made through your provider, not here.
|
||
</p>
|
||
</section>
|
||
|
||
<template v-if="authStore.user?.has_password">
|
||
<section class="settings-section">
|
||
<h2>Email Address</h2>
|
||
<p class="section-desc">Used for password resets and notifications.</p>
|
||
<div class="field">
|
||
<label for="new-email">Email</label>
|
||
<input
|
||
id="new-email"
|
||
v-model="newEmail"
|
||
type="email"
|
||
placeholder="you@example.com"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="email-password">Current Password</label>
|
||
<input
|
||
id="email-password"
|
||
v-model="emailPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
class="fs-input input"
|
||
/>
|
||
<p class="field-hint">Required to confirm the change.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button
|
||
class="btn-primary"
|
||
@click="changeEmail"
|
||
:disabled="changingEmail || !emailPassword"
|
||
>
|
||
{{ changingEmail ? "Saving..." : "Save Email" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section">
|
||
<h2>Change Password</h2>
|
||
<div class="field">
|
||
<label for="current-password">Current Password</label>
|
||
<input
|
||
id="current-password"
|
||
v-model="currentPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
class="fs-input 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="fs-input 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="fs-input 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-primary"
|
||
@click="changePassword"
|
||
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
|
||
>
|
||
{{ changingPassword ? "Changing..." : "Change Password" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<section class="settings-section">
|
||
<h2>Active Sessions</h2>
|
||
<p class="section-desc">
|
||
Sign out all other devices and sessions. Use this after a password change
|
||
to ensure stale sessions are revoked.
|
||
</p>
|
||
<div class="actions">
|
||
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
|
||
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Notifications ── -->
|
||
<!-- ── Profile ── -->
|
||
<div v-show="activeTab === 'profile'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>About You</h2>
|
||
<p class="section-desc">This information is used by the assistant to personalise responses in chat and the daily journal.</p>
|
||
<div class="assistant-grid">
|
||
<div class="field">
|
||
<label>Display Name</label>
|
||
<input v-model="profile.display_name" type="text" class="fs-input input" placeholder="e.g. Alex" />
|
||
<p class="field-hint">How the assistant addresses you.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label>Job Title</label>
|
||
<input v-model="profile.job_title" type="text" class="fs-input input" placeholder="e.g. Product Manager" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Industry</label>
|
||
<input v-model="profile.industry" type="text" class="fs-input input" placeholder="e.g. Technology" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Expertise Level</label>
|
||
<select v-model="profile.expertise_level" class="fs-input input">
|
||
<option value="novice">Novice — explain things simply</option>
|
||
<option value="intermediate">Intermediate — balanced explanations</option>
|
||
<option value="expert">Expert — assume deep knowledge</option>
|
||
</select>
|
||
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Response Preferences</h2>
|
||
<div class="assistant-grid">
|
||
<div class="field">
|
||
<label>Response Style</label>
|
||
<select v-model="profile.response_style" class="fs-input input">
|
||
<option value="concise">Concise — short and direct</option>
|
||
<option value="balanced">Balanced — default</option>
|
||
<option value="detailed">Detailed — thorough explanations</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>Tone</label>
|
||
<select v-model="profile.tone" class="fs-input input">
|
||
<option value="casual">Casual — friendly and relaxed</option>
|
||
<option value="professional">Professional — formal and precise</option>
|
||
<option value="technical">Technical — jargon-friendly</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Interests</h2>
|
||
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
|
||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Work Schedule</h2>
|
||
<p class="section-desc">Helps the journal understand when you're working and what's relevant each morning.</p>
|
||
<div class="field">
|
||
<label>Work Days</label>
|
||
<div class="day-picker">
|
||
<button
|
||
v-for="day in WORK_DAYS"
|
||
:key="day"
|
||
class="day-btn"
|
||
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
|
||
@click="toggleProfileWorkDay(day)"
|
||
type="button"
|
||
>{{ day }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="assistant-grid" style="margin-top:0.75rem">
|
||
<div class="field">
|
||
<label>Start Time</label>
|
||
<input v-model="profile.work_schedule.start" type="time" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label>End Time</label>
|
||
<input v-model="profile.work_schedule.end" type="time" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||
|
||
<section class="settings-section">
|
||
<h2>Email Notifications</h2>
|
||
<p class="section-desc">
|
||
Email notifications when SMTP is configured by an admin.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="notifyTaskReminders" />
|
||
Task due date reminders
|
||
</label>
|
||
<p class="field-hint">Daily email for tasks due or overdue.</p>
|
||
</div>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="notifySecurityAlerts" />
|
||
Security alerts
|
||
</label>
|
||
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
|
||
{{ savingNotifications ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Integrations ── -->
|
||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>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="fs-input input"
|
||
placeholder="Enter a search query..."
|
||
@keydown="onSearchKeydown"
|
||
/>
|
||
<button class="btn-primary" @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>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Git Forges</h2>
|
||
<p class="section-desc">
|
||
Read-only connections to your git forges, one per host. Projects you
|
||
own use them server-side — resolved by repo host — to fetch and
|
||
drift-check recorded snippets and measure pattern coverage. A
|
||
read-scope token is enough. Gitea: an access token with read scope
|
||
on repositories. GitHub: a fine-grained PAT with Contents:
|
||
Read-only, base URL <code>https://github.com</code> (or your GitHub
|
||
Enterprise URL).
|
||
</p>
|
||
<table v-if="forgeConnections.length" class="api-keys-table">
|
||
<thead>
|
||
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in forgeConnections" :key="c.id">
|
||
<td>{{ c.host }}</td>
|
||
<td>{{ c.kind }}</td>
|
||
<td><code>{{ c.base_url }}</code></td>
|
||
<td>
|
||
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
|
||
{{ testingConnId === c.id ? "Testing…" : "Test" }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
|
||
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="section-desc not-configured">No forge connections yet.</p>
|
||
<p
|
||
v-if="connTestResult"
|
||
:class="connTestResult.ok ? 'text-success' : 'text-error'"
|
||
>
|
||
{{ connTestResult.message }}
|
||
</p>
|
||
<div v-if="connFormOpen" class="smtp-grid">
|
||
<div class="field">
|
||
<label for="conn-kind">Forge</label>
|
||
<select id="conn-kind" v-model="connForm.kind" class="fs-input input">
|
||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="conn-base-url">Base URL</label>
|
||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="conn-token">API Token (read scope)</label>
|
||
<input id="conn-token" v-model="connForm.token" type="password" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<template v-if="connFormOpen">
|
||
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
|
||
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
|
||
</button>
|
||
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
|
||
</template>
|
||
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
|
||
</div>
|
||
</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 & Restore</h2>
|
||
<p class="section-desc">Full application backup includes all users and their data.</p>
|
||
<div class="data-actions">
|
||
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
||
{{ exporting ? "Exporting..." : "Full Backup" }}
|
||
</button>
|
||
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
||
</button>
|
||
<input
|
||
ref="restoreFileInput"
|
||
type="file"
|
||
accept=".json"
|
||
class="hidden-file-input"
|
||
@change="handleRestoreFile"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
</div>
|
||
|
||
|
||
<!-- ── MCP Access ── -->
|
||
<div v-show="activeTab === 'apikeys'" class="settings-grid">
|
||
<!-- Endpoint URL -->
|
||
<section class="settings-section full-width">
|
||
<h2>MCP Access</h2>
|
||
<p class="settings-description">
|
||
Connect Claude (Code or Desktop) to this Scribe instance. Claude reads and writes your notes,
|
||
tasks, projects, events, and people via the built-in MCP endpoint below.
|
||
</p>
|
||
|
||
<div class="mcp-url-block">
|
||
<label class="mcp-url-label">MCP endpoint</label>
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ mcpUrl }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copyMcpUrl">
|
||
{{ mcpUrlCopied ? 'Copied' : 'Copy URL' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tokens -->
|
||
<section class="settings-section full-width">
|
||
<h2>Personal access tokens</h2>
|
||
<p class="settings-description">
|
||
Tokens are sent as <code>Authorization: Bearer …</code> on every MCP request and resolve to
|
||
your user account. Write-scoped tokens can create and update content; read-only tokens can
|
||
only query. A token is shown <strong>once</strong> at creation — keep it safe.
|
||
</p>
|
||
|
||
<!-- Create form -->
|
||
<div class="api-key-create-form">
|
||
<input v-model="newKeyName" placeholder="Token name (e.g. claude-laptop)" class="settings-input" />
|
||
<div class="api-key-scope-select">
|
||
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
|
||
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
|
||
</div>
|
||
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
|
||
{{ creatingApiKey ? 'Generating…' : 'Generate token' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- One-time token reveal -->
|
||
<div v-if="newKeyValue" class="api-key-reveal">
|
||
<p><strong>Copy this token now — it will not be shown again.</strong></p>
|
||
<div class="api-key-value-row">
|
||
<code class="api-key-value">{{ newKeyValue }}</code>
|
||
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
|
||
</div>
|
||
<p class="mcp-hint" style="margin-top:0.5rem;">
|
||
The snippets in <strong>Connect Claude</strong> below are pre-filled with this token until you click Done.
|
||
</p>
|
||
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
|
||
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tokens table -->
|
||
<table v-if="apiKeys.length > 0" class="api-keys-table">
|
||
<thead>
|
||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="key in apiKeys" :key="key.id">
|
||
<td>{{ key.name }}</td>
|
||
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
||
<td><code>{{ key.key_prefix }}…</code></td>
|
||
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
||
<td>
|
||
<span v-if="revokeConfirmId !== key.id">
|
||
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
|
||
</span>
|
||
<span v-else>
|
||
Sure?
|
||
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
|
||
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No tokens yet.</p>
|
||
</section>
|
||
|
||
<!-- Connect Claude -->
|
||
<section class="settings-section full-width">
|
||
<h2>Connect Claude</h2>
|
||
<p class="settings-description">
|
||
Run the appropriate snippet for your client. Generate a token above first to pre-fill the
|
||
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
||
</p>
|
||
|
||
<div class="mcp-client-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-code'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
|
||
@click="mcpClientTab = 'claude-code'"
|
||
>Claude Code</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-desktop'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
|
||
@click="mcpClientTab = 'claude-desktop'"
|
||
>Claude Desktop</button>
|
||
</div>
|
||
|
||
<!-- Claude Code tab -->
|
||
<div v-if="mcpClientTab === 'claude-code'">
|
||
<p class="settings-description">
|
||
<strong>Recommended — install the Scribe plugin.</strong> One install wires up the MCP
|
||
connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
|
||
</p>
|
||
<ol>
|
||
<li>
|
||
Add the marketplace and install the plugin:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ pluginInstallCommands }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(pluginInstallCommands, 'plugin-install')">
|
||
{{ copiedSnippetKey === 'plugin-install' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
When prompted, enter your <strong>Scribe base URL</strong> (<code>{{ origin }}</code>),
|
||
an <strong>API key</strong> (generate one above), and optionally a
|
||
<strong>project id</strong> to scope the session-start context.
|
||
</li>
|
||
<li>
|
||
Restart Claude Code — <code>/mcp</code> shows <code>scribe</code> connected and your
|
||
standing rules load at session start.
|
||
</li>
|
||
</ol>
|
||
|
||
<details class="mcp-advanced">
|
||
<summary>Customize</summary>
|
||
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Plugin marketplace (git URL)</span>
|
||
<input
|
||
v-model="pluginMarketplaceUrl"
|
||
type="text"
|
||
:placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Scribe.git'"
|
||
class="settings-input"
|
||
spellcheck="false"
|
||
/>
|
||
<span class="mcp-hint">
|
||
Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
|
||
</span>
|
||
</label>
|
||
|
||
<div class="mcp-client-config">
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Server name</span>
|
||
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||
<span class="mcp-hint">
|
||
Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
|
||
</span>
|
||
</label>
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Scope</span>
|
||
<select v-model="mcpScope" class="settings-input">
|
||
<option value="user">user — available across all projects</option>
|
||
<option value="project">project — write to current repo's .mcp.json</option>
|
||
<option value="local">local — this machine + repo only</option>
|
||
</select>
|
||
<span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
|
||
</label>
|
||
</div>
|
||
|
||
<p class="mcp-hint" style="margin-top: 0.75rem;">
|
||
<strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
|
||
push or the Scribe skills this way.
|
||
</p>
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
||
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
<p class="mcp-hint">
|
||
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
||
</p>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Claude Desktop tab -->
|
||
<div v-else-if="mcpClientTab === 'claude-desktop'">
|
||
<label class="mcp-config-field">
|
||
<span class="mcp-config-label">Server name</span>
|
||
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
|
||
</label>
|
||
<ol>
|
||
<li>
|
||
Add this block to your Claude Desktop MCP config file
|
||
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
||
<code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
|
||
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ── Admin ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>What's running</h2>
|
||
<p class="section-desc">
|
||
The build serving this page. Paste the commit into a <code>:sha</code> image
|
||
lookup to check the registry and the app agree about what was published.
|
||
</p>
|
||
|
||
<div v-if="versionLoading" class="state-msg">Reading the ledger…</div>
|
||
<div v-else-if="versionError" class="error-msg">
|
||
{{ versionError }}
|
||
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
|
||
</div>
|
||
<dl v-else-if="versionInfo" class="version-grid">
|
||
<dt>Version</dt>
|
||
<dd class="version-value">{{ versionInfo.version }}</dd>
|
||
|
||
<dt>Channel</dt>
|
||
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
|
||
{{ versionInfo.channel ?? "unknown" }}
|
||
</dd>
|
||
|
||
<dt>Commit</dt>
|
||
<dd v-if="versionInfo.commit" class="version-value version-commit">
|
||
<span class="version-sha">{{ versionInfo.commit }}</span>
|
||
<button class="btn-ghost btn-compact" @click="copyCommit">
|
||
{{ commitCopied ? "Copied" : "Copy" }}
|
||
</button>
|
||
</dd>
|
||
<dd v-else class="version-unknown">unknown</dd>
|
||
|
||
<dt>Build</dt>
|
||
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
|
||
no key means this build is not part of any update order, which is
|
||
what a local or hand-built image looks like. `??` not `||` — 0 is
|
||
a legitimate key. -->
|
||
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
|
||
{{ versionInfo.build ?? "unknown" }}
|
||
</dd>
|
||
</dl>
|
||
<div v-else class="empty-msg">Nothing asked yet.</div>
|
||
</section>
|
||
|
||
<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="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
|
||
{{ savingBaseUrl ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Plugin marketplace</h2>
|
||
<p class="section-desc">
|
||
Git URL of the repo that ships this Scribe plugin (usually this app's own repo).
|
||
Shown to every user in <strong>MCP Access</strong> so the install command is
|
||
copyable. Example: https://git.example.com/you/Scribe.git
|
||
</p>
|
||
<div class="field url-field">
|
||
<label for="marketplace-url">Marketplace git URL</label>
|
||
<input
|
||
id="marketplace-url"
|
||
v-model="adminMarketplaceUrl"
|
||
type="url"
|
||
placeholder="https://git.example.com/you/Scribe.git"
|
||
class="fs-input input"
|
||
/>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
|
||
</button>
|
||
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Database maintenance</h2>
|
||
<p class="section-desc">
|
||
A daily <code>VACUUM (ANALYZE)</code> over the high-churn tables (logs, notifications,
|
||
tokens, notes, version history) — on top of Postgres autovacuum — to reclaim space left
|
||
by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC),
|
||
just after trash purge.
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="dbMaintEnabled" />
|
||
Run scheduled maintenance daily
|
||
</label>
|
||
</div>
|
||
<div class="field url-field">
|
||
<label for="db-maint-hour">Run hour (UTC)</label>
|
||
<select id="db-maint-hour" v-model.number="dbMaintHour" class="fs-input input">
|
||
<option v-for="h in 24" :key="h - 1" :value="h - 1">
|
||
{{ String(h - 1).padStart(2, '0') }}:00
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||
{{ savingDbMaint ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||
{{ runningDbMaint ? "Running..." : "Run now" }}
|
||
</button>
|
||
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
<div v-if="dbMaintLastRun" class="db-maint-last">
|
||
<span class="db-maint-last-label">
|
||
Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
|
||
· {{ dbMaintLastRun.elapsed_ms }}ms
|
||
</span>
|
||
<ul class="db-maint-table-list">
|
||
<li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
|
||
<code>{{ t.table }}</code>
|
||
<span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="db-health">
|
||
<h3 class="subsection-label">
|
||
Table health
|
||
<span v-if="dbHealth" class="db-health-total">· database {{ formatBytes(dbHealth.db_bytes) }}</span>
|
||
</h3>
|
||
<p class="field-hint">
|
||
Dead-tuple ratio is bloat — rows left by updates/deletes not yet reclaimed.
|
||
Above {{ DEAD_PCT_WARN }}% on a large table means autovacuum is falling behind;
|
||
consider adding it to the maintenance set.
|
||
</p>
|
||
<p v-if="loadingHealth && !dbHealth" class="field-hint">Loading…</p>
|
||
<div v-else-if="dbHealth" class="db-health-scroll">
|
||
<table class="db-health-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Table</th><th class="num">Size</th><th class="num">Live</th>
|
||
<th class="num">Dead</th><th class="num">Dead %</th>
|
||
<th>Last vacuum</th><th>Last analyze</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="t in dbHealth.tables" :key="t.table" :class="{ 'dh-warn': t.dead_pct >= DEAD_PCT_WARN }">
|
||
<td><code>{{ t.table }}</code></td>
|
||
<td class="num">{{ formatBytes(t.total_bytes) }}</td>
|
||
<td class="num">{{ t.live.toLocaleString() }}</td>
|
||
<td class="num">{{ t.dead.toLocaleString() }}</td>
|
||
<td class="num">{{ t.dead_pct }}%</td>
|
||
<td>{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}</td>
|
||
<td>{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</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="fs-input 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="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-username">Username</label>
|
||
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-password">Password</label>
|
||
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="fs-input 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="fs-input input" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="smtp-from-name">From Name</label>
|
||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="fs-input 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-primary" @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="fs-input input"
|
||
/>
|
||
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
||
{{ sendingTest ? "Sending..." : "Send Test" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Forge Webhook</h2>
|
||
<p class="section-desc">
|
||
Forge connections are per-user (Settings → Integrations → Git
|
||
Forges). What stays instance-wide is the push webhook: create one on
|
||
the forge pointing at <code>/api/webhooks/forge</code> with this
|
||
secret, and snippets whose recorded files change get flagged for
|
||
re-verification.
|
||
</p>
|
||
<div class="smtp-grid">
|
||
<div class="field">
|
||
<label for="forge-webhook-secret">Webhook Secret</label>
|
||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="fs-input input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
|
||
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
|
||
</button>
|
||
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Users ── -->
|
||
<!-- ── Shared areas ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>Shared areas</h2>
|
||
<p class="field-hint">
|
||
The vocabulary every project's Systems can be filed under, so the same word means the
|
||
same thing everywhere. A project keeps its own name for an area — mapping records which
|
||
shared area it is, it never renames anything. Editing a name here re-derives its match
|
||
key, so existing mappings are kept but future name matching follows the new spelling.
|
||
</p>
|
||
|
||
<ul class="area-admin-list">
|
||
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
|
||
<template v-if="editingAreaId === entry.id">
|
||
<form class="area-admin-form" @submit.prevent="saveArea">
|
||
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
|
||
<textarea
|
||
v-model="editAreaDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
aria-label="Area description"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
|
||
{{ savingArea ? "Saving…" : "Save" }}
|
||
</button>
|
||
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
|
||
</div>
|
||
</form>
|
||
</template>
|
||
<template v-else>
|
||
<div class="area-admin-body">
|
||
<div class="area-admin-name-row">
|
||
<span class="area-admin-name">{{ entry.name }}</span>
|
||
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
|
||
</div>
|
||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||
</div>
|
||
<button
|
||
class="btn-ghost btn-compact"
|
||
@click="startEditArea(entry.id, entry.name, entry.description)"
|
||
>Edit</button>
|
||
</template>
|
||
</li>
|
||
</ul>
|
||
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
|
||
No areas yet.
|
||
</p>
|
||
|
||
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
|
||
<input
|
||
v-model="newAreaName"
|
||
class="fs-input"
|
||
placeholder="New area name (e.g. Search & Indexing)"
|
||
aria-label="New area name"
|
||
/>
|
||
<textarea
|
||
v-model="newAreaDescription"
|
||
class="fs-input"
|
||
rows="2"
|
||
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
|
||
aria-label="New area description"
|
||
></textarea>
|
||
<div class="area-admin-actions">
|
||
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
|
||
{{ creatingArea ? "Adding…" : "Add area" }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
|
||
<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-primary 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="fs-input input invite-input"
|
||
required
|
||
:disabled="sendingInvite"
|
||
/>
|
||
<button type="submit" class="btn-primary" :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">{{ fmtDate(inv.created_at) }}</td>
|
||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||
<td class="cell-actions">
|
||
<button class="btn-ghost btn-compact" @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">{{ fmtDate(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-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||
</button>
|
||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||
</template>
|
||
<template v-else>
|
||
<button class="btn-ghost btn-compact" @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">{{ fmtLogStamp(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-ghost btn-compact" @click="toggleGroupExpand(g)">
|
||
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
|
||
</button>
|
||
<button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Members panel -->
|
||
<div v-if="expandedGroupId === g.id" class="group-members-panel">
|
||
<div class="members-search">
|
||
<div class="member-search-wrap">
|
||
<input
|
||
v-model="groupMemberSearch"
|
||
class="input-field"
|
||
placeholder="Search user to add…"
|
||
@input="debounceGroupMemberSearch"
|
||
autocomplete="off"
|
||
/>
|
||
<ul v-if="groupMemberResults.length" class="member-results">
|
||
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
|
||
<span class="member-result-name">{{ u.username }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<select v-model="groupMemberRole" class="role-select">
|
||
<option value="member">Member</option>
|
||
<option value="owner">Owner</option>
|
||
</select>
|
||
</div>
|
||
<ul class="members-list">
|
||
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
|
||
<span class="member-name">{{ m.username }}</span>
|
||
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
|
||
<button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||
</li>
|
||
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</div><!-- end .settings-content -->
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Settings root layout */
|
||
.settings-root {
|
||
display: flex;
|
||
gap: 0;
|
||
max-width: 1100px;
|
||
margin: 2rem auto;
|
||
padding: 0 1.5rem;
|
||
align-items: flex-start;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.settings-sidebar {
|
||
width: 175px;
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 1.5rem;
|
||
padding-right: 1rem;
|
||
}
|
||
.sidebar-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.sidebar-group-label {
|
||
font-size: 0.65rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--fs-text-tertiary);
|
||
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(--fs-text-secondary);
|
||
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
|
||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.sidebar-item:hover {
|
||
color: var(--fs-text-primary);
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
.sidebar-item.active {
|
||
color: var(--fs-accent-fg);
|
||
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||
border-left-color: var(--fs-accent);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Content panel */
|
||
.settings-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Two-column grid — small sections pair up, full-width sections span both */
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1.25rem;
|
||
align-items: start;
|
||
}
|
||
.settings-section {
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-lg);
|
||
padding: 1.25rem;
|
||
}
|
||
.settings-section.full-width {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.settings-section h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
/* What's running — a definition list of instance facts. Spacing/geometry only;
|
||
colour and type come from the tokens. */
|
||
.version-grid {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
gap: 0.4rem 1rem;
|
||
margin: 0;
|
||
align-items: baseline;
|
||
}
|
||
.version-grid dt {
|
||
font-size: 0.8rem;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.version-grid dd {
|
||
margin: 0;
|
||
font-size: 0.875rem;
|
||
font-family: var(--fs-font-mono);
|
||
color: var(--fs-text-primary);
|
||
}
|
||
/* An absent field reads as absent — never as a blank, and never styled to look
|
||
like a value it does not have (#3127 checklist 12). */
|
||
.version-grid dd.version-unknown {
|
||
font-family: inherit;
|
||
font-style: italic;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
.version-commit {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.version-sha {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.version-retry {
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.section-desc {
|
||
margin: 0 0 1rem;
|
||
font-size: 0.875rem;
|
||
color: var(--fs-text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Assistant — 2-col internal grid */
|
||
.assistant-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0 1rem;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.assistant-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.field {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.field label {
|
||
display: block;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
margin-bottom: 0.35rem;
|
||
color: var(--fs-text-primary);
|
||
}
|
||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||
.input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.65rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* Save: Moss action-primary per Hybrid */
|
||
.db-maint-table-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
.db-maint-table-list li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
font-size: 0.82rem;
|
||
padding: 0.2rem 0;
|
||
}
|
||
.db-maint-table-list .dm-status { color: var(--fs-text-tertiary); }
|
||
.db-maint-table-list li.dm-failed .dm-status { color: var(--fs-error); }
|
||
|
||
/* DB table-health readout */
|
||
.db-health { margin-top: 1.5rem; }
|
||
.db-health-total { color: var(--fs-text-tertiary); font-weight: 400; }
|
||
.db-health-scroll { overflow-x: auto; margin-top: 0.5rem; }
|
||
.db-health-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.82rem;
|
||
}
|
||
.db-health-table th, .db-health-table td {
|
||
text-align: left;
|
||
padding: 0.35rem 0.6rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
white-space: nowrap;
|
||
}
|
||
.db-health-table th {
|
||
font-size: 0.72rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--fs-text-tertiary);
|
||
font-weight: 500;
|
||
}
|
||
.db-health-table td.num, .db-health-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.db-health-table tr.dh-warn td { color: var(--fs-warning); }
|
||
.db-health-table tr.dh-warn td:first-child code { color: var(--fs-warning); }
|
||
.btn-warn:hover:not(:disabled) {
|
||
background: var(--fs-warning);
|
||
color: var(--fs-text-on-action);
|
||
}
|
||
|
||
.saved-msg {
|
||
color: var(--fs-success);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
}
|
||
.input-error { border-color: var(--fs-error); }
|
||
.input-error:focus { border-color: var(--fs-error); }
|
||
.error-hint {
|
||
margin: 0.3rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: var(--fs-error);
|
||
}
|
||
|
||
/* 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(--fs-text-primary);
|
||
cursor: pointer;
|
||
}
|
||
.checkbox-field input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
accent-color: var(--fs-accent);
|
||
}
|
||
|
||
/* Search test */
|
||
.url-chip {
|
||
font-size: 0.8rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 4px;
|
||
}
|
||
.not-configured {
|
||
opacity: 0.75;
|
||
}
|
||
.search-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.search-row .input { flex: 1; }
|
||
.search-error {
|
||
font-size: 0.875rem;
|
||
color: var(--fs-error);
|
||
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(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
}
|
||
.result-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.result-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: var(--fs-accent);
|
||
text-decoration: none;
|
||
word-break: break-word;
|
||
}
|
||
.result-title:hover { text-decoration: underline; }
|
||
.result-host {
|
||
font-size: 0.75rem;
|
||
color: var(--fs-text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
.result-snippet {
|
||
margin: 0;
|
||
font-size: 0.82rem;
|
||
color: var(--fs-text-secondary);
|
||
line-height: 1.45;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Application URL — constrain input width */
|
||
.url-field { max-width: 480px; }
|
||
|
||
/* SMTP grid */
|
||
.smtp-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 0 1rem;
|
||
}
|
||
@media (max-width: 700px) {
|
||
.smtp-grid { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.smtp-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* Test email */
|
||
.subsection-label {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.test-email-section {
|
||
border-top: 1px solid var(--fs-border-color);
|
||
padding-top: 1rem;
|
||
}
|
||
.test-email-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
max-width: 480px;
|
||
}
|
||
|
||
@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(--fs-border-color);
|
||
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(--fs-radius-sm) var(--fs-radius-sm) 0 0;
|
||
font-size: 0.82rem;
|
||
padding: 0.35rem 0.7rem;
|
||
}
|
||
.sidebar-item.active {
|
||
border-bottom-color: var(--fs-accent);
|
||
background: var(--fs-accent-soft);
|
||
}
|
||
.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;
|
||
}
|
||
}
|
||
|
||
/* 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(--fs-success); }
|
||
.text-muted { color: var(--fs-text-tertiary); }
|
||
.invite-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.invite-input { flex: 1; }
|
||
.invite-list { margin-top: 1rem; }
|
||
.subsection-label {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.users-table { width: 100%; border-collapse: collapse; }
|
||
.users-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.users-table td {
|
||
padding: 0.65rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
font-size: 0.9rem;
|
||
}
|
||
.users-table tbody tr:last-child td { border-bottom: none; }
|
||
.cell-username { font-weight: 500; }
|
||
.cell-email { color: var(--fs-text-secondary); }
|
||
.cell-date { color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||
.cell-actions { white-space: nowrap; }
|
||
.role-badge {
|
||
display: inline-block;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: var(--fs-radius-sm);
|
||
}
|
||
.role-admin {
|
||
color: var(--fs-accent-fg);
|
||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||
}
|
||
.role-user {
|
||
color: var(--fs-text-tertiary);
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
.you-label { font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
|
||
|
||
/* Logs panel */
|
||
.stats-section { padding: 1rem 1.25rem; }
|
||
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||
.stat-card {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
}
|
||
.stat-count { font-size: 1.5rem; font-weight: 500; color: var(--fs-text-primary); }
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
}
|
||
.stat-audit { color: var(--fs-accent); }
|
||
.stat-usage { color: var(--fs-success); }
|
||
.stat-error { color: var(--fs-error); }
|
||
|
||
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||
.filter-select { min-width: 140px; }
|
||
.filter-input { flex: 1; min-width: 150px; }
|
||
.filter-date { width: 140px; }
|
||
|
||
.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||
.logs-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--fs-text-tertiary);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.logs-table td {
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
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(--fs-surface-raised); }
|
||
.row-expanded { background: var(--fs-surface-raised); }
|
||
.cell-time { white-space: nowrap; color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||
.cell-user { color: var(--fs-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(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
|
||
.text-error { color: var(--fs-error); }
|
||
/* Bare by design: a `<tr>` has nothing to style
|
||
that its cells don't carry (#2444). */
|
||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
|
||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
|
||
.detail-json {
|
||
margin: 0; padding: 0.75rem;
|
||
background: var(--fs-surface-page); border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm); font-size: 0.8rem;
|
||
overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
|
||
}
|
||
.category-badge {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 500;
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm);
|
||
}
|
||
.cat-audit { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
||
.cat-usage { color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 15%, transparent); }
|
||
.cat-error { color: var(--fs-error-fg); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
|
||
.method-tag {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 500; font-family: monospace;
|
||
padding: 0.05rem 0.25rem; border-radius: 3px;
|
||
background: var(--fs-surface-raised); color: var(--fs-text-tertiary);
|
||
margin-right: 0.25rem;
|
||
}
|
||
|
||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||
/* Moss action-primary per Hybrid */
|
||
|
||
.input-field {
|
||
width: 100%;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.input-field:focus { border-color: var(--fs-accent); }
|
||
|
||
.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(--fs-text-tertiary);
|
||
font-size: 0.88rem;
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
.groups-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.group-card {
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.group-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
background: var(--fs-surface-raised);
|
||
}
|
||
|
||
.group-card-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.group-name {
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
color: var(--fs-text-primary);
|
||
}
|
||
.group-meta {
|
||
font-size: 0.78rem;
|
||
color: var(--fs-text-tertiary);
|
||
white-space: nowrap;
|
||
}
|
||
.group-desc {
|
||
font-size: 0.82rem;
|
||
color: var(--fs-text-tertiary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.group-card-actions {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.member-search-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
.member-results {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--fs-surface-hover);
|
||
border: 1px solid var(--fs-border-color);
|
||
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(--fs-surface-hover); }
|
||
.member-result-name { font-weight: 500; font-size: 0.85rem; }
|
||
|
||
.role-select {
|
||
padding: 0.4rem 0.5rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
background: var(--fs-surface-hover);
|
||
color: var(--fs-text-primary);
|
||
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(--fs-surface-hover);
|
||
}
|
||
|
||
.member-name { flex: 1; font-size: 0.88rem; font-weight: 500; color: var(--fs-text-primary); }
|
||
|
||
.member-role-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
|
||
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
|
||
|
||
.members-empty {
|
||
color: var(--fs-text-tertiary);
|
||
font-size: 0.82rem;
|
||
padding: 0.25rem 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(--fs-accent) 8%, var(--fs-surface-hover));
|
||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||
border-radius: var(--fs-radius-lg);
|
||
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(--fs-surface-hover);
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: var(--fs-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(--fs-border-color);
|
||
font-size: 0.9rem;
|
||
}
|
||
.api-keys-table th { font-weight: 500; opacity: 0.7; }
|
||
.scope-badge {
|
||
display: inline-block;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
}
|
||
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
|
||
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||
.mcp-code {
|
||
margin-top: 0.4rem;
|
||
padding: 0.55rem 0.75rem;
|
||
background: color-mix(in srgb, var(--fs-text-primary) 6%, transparent);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
font-size: 0.82rem;
|
||
font-family: monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
overflow-x: auto;
|
||
flex: 1;
|
||
}
|
||
.mcp-client-tabs {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
margin-bottom: 1rem;
|
||
border-bottom: 1px solid var(--fs-border-color);
|
||
}
|
||
.mcp-client-tab {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0.5rem 0.9rem;
|
||
font-size: 0.85rem;
|
||
color: var(--fs-text-tertiary);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.mcp-client-tab:hover { color: var(--fs-text-primary); }
|
||
.mcp-client-tab.active {
|
||
color: var(--fs-accent);
|
||
border-bottom-color: var(--fs-accent);
|
||
font-weight: 500;
|
||
}
|
||
.mcp-url-block { margin-top: 0.25rem; }
|
||
.mcp-url-label {
|
||
display: block;
|
||
font-size: 0.85rem;
|
||
opacity: 0.7;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.mcp-client-config {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
margin: 1rem 0 1.5rem;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.mcp-client-config { grid-template-columns: 1fr; }
|
||
}
|
||
.mcp-config-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
.mcp-config-label {
|
||
font-size: 0.85rem;
|
||
opacity: 0.7;
|
||
}
|
||
.mcp-code-row {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0.4rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
.mcp-code-row .mcp-code { margin-top: 0; }
|
||
.mcp-code-row .btn-sm { white-space: nowrap; }
|
||
.mcp-advanced {
|
||
margin-top: 1.25rem;
|
||
border-top: 1px solid var(--fs-border-color);
|
||
padding-top: 0.75rem;
|
||
}
|
||
.mcp-advanced summary {
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
opacity: 0.75;
|
||
user-select: none;
|
||
}
|
||
.mcp-advanced summary:hover { opacity: 1; }
|
||
.mcp-advanced[open] summary { margin-bottom: 0.5rem; }
|
||
.mcp-hint {
|
||
margin-top: 0.5rem;
|
||
font-size: 0.8rem;
|
||
opacity: 0.7;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Profile tab ─────────────────────────────────────────────────────────── */
|
||
.day-picker {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.35rem;
|
||
}
|
||
.day-btn {
|
||
padding: 0.3rem 0.65rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: 6px;
|
||
background: var(--fs-surface-raised);
|
||
color: var(--fs-text-tertiary);
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.day-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||
.day-btn.active {
|
||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||
border-color: var(--fs-accent);
|
||
color: var(--fs-accent-fg);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ── Shared areas (milestone 307) ──────────────────────────────────
|
||
The slug is shown deliberately: it is what decides whether two names are
|
||
the same area, and an admin renaming an entry needs to see it move. */
|
||
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||
.area-admin-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--fs-space-3);
|
||
padding: var(--fs-space-3);
|
||
background: var(--fs-surface-raised);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-md);
|
||
}
|
||
.area-admin-body { flex: 1; min-width: 0; }
|
||
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||
.area-admin-name { color: var(--fs-text-primary); }
|
||
.area-admin-slug {
|
||
font-family: var(--fs-font-mono);
|
||
font-size: 0.72rem;
|
||
color: var(--fs-text-tertiary);
|
||
background: var(--fs-surface-code-inline);
|
||
border-radius: var(--fs-radius-sm);
|
||
padding: 0.05rem 0.35rem;
|
||
}
|
||
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||
.area-admin-create { margin-top: var(--fs-space-4); }
|
||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||
</style>
|