Adopte le langage visuel du design system Emerald dans l'IDE web : neutres zinc + accent emerald, polices auto-hébergées Inter + JetBrains Mono, radii/pills mono, points de statut, motif terminal, halo/grain. Refonte purement visuelle et additive (layout, routing, stores, protocole inchangés). - style.css : défauts sombres dans @theme + override clair sous html[data-theme=light] ; accent scindé (bright #34d399 / solid #059669 / hover) ; tokens border-soft/info/warn/danger/coffee ; idiomes globaux (focus/selection accent, scrollbars fines, jl-pulse/jl-blink, classes .chip/.status/.eyebrow/.label-mono/.caret/.halo/.grain, .btn*/.badge pill). - lib/theme.ts : singleton themeMode/resolvedTheme (dark/light/system, persistedRef arb.theme) + application dataset.theme/metas ; script anti-FOUC inline dans index.html ; toggle ActivityBar + Réglages. - Thèmes Monaco + xterm dark/light réactifs (palette ANSI + coloration syntaxique) ; fonts.ready + remeasureFonts pour l'alignement des glyphes. - status-tokens : tons sémantiques tokenisés ; balayage des ~340 couleurs Tailwind brutes vers les tokens sur tout components/* et views/* (exceptions assumées commentées). - GroupSummary.color exploité : icône de groupe teintée + ColorSwatchPicker (lib/group-colors.ts) dans les modals de groupe. - Polices via @fontsource-variable/{inter,jetbrains-mono} (offline).
505 lines
21 KiB
Vue
505 lines
21 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-4">
|
|
<PageHeader :title="t('settings.title')" />
|
|
|
|
<!-- Préférences (client) -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<SlidersHorizontal :size="16" /> {{ t('settings.preferences') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.preferencesHint') }}</p>
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
<span class="text-sm text-fg-muted">{{ t('settings.language') }}</span>
|
|
<LanguageSwitcher />
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-3 border-t border-border/80 pt-3">
|
|
<span class="text-sm text-fg-muted">{{ t('theme.label') }}</span>
|
|
<SegmentedControl :model-value="themeMode" :options="themeOptions" @update:model-value="onTheme" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 border-t border-border/80 pt-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-sm text-fg-muted">{{ t('settings.notifications') }}</p>
|
|
<p class="text-xs text-fg-subtle">
|
|
{{ push.supported ? (push.enabled ? t('settings.notificationsEnabled') : t('settings.notificationsDisabled')) : t('settings.pushUnsupported') }}
|
|
</p>
|
|
</div>
|
|
<BaseButton
|
|
v-if="push.supported"
|
|
:variant="push.enabled ? 'secondary' : 'primary'"
|
|
size="sm"
|
|
:icon="push.enabled ? BellOff : Bell"
|
|
:loading="push.busy"
|
|
@click="push.toggle()"
|
|
>
|
|
{{ push.enabled ? t('settings.disableNotifications') : t('settings.enableNotifications') }}
|
|
</BaseButton>
|
|
</div>
|
|
<div v-if="push.enabled">
|
|
<BaseButton variant="ghost" size="sm" :icon="Send" :loading="testing" @click="sendTest">
|
|
{{ t('settings.testNotification') }}
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Accès & sécurité : tokens -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Shield :size="16" /> {{ t('settings.security') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.securityHint') }}</p>
|
|
|
|
<!-- Création -->
|
|
<form class="flex flex-wrap items-end gap-2" @submit.prevent="createToken">
|
|
<label class="flex min-w-40 flex-1 flex-col gap-1">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.newTokenLabel') }}</span>
|
|
<input v-model="newLabel" class="input" :placeholder="t('settings.newTokenPlaceholder')" maxlength="64" />
|
|
</label>
|
|
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="!newLabel.trim()">
|
|
{{ t('settings.createToken') }}
|
|
</BaseButton>
|
|
</form>
|
|
|
|
<!-- Valeur en clair (une seule fois) -->
|
|
<div v-if="createdToken" class="card-inset flex flex-col gap-2 border-accent/60 bg-accent/15">
|
|
<p class="text-xs text-accent">{{ t('settings.copyTokenHint') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<code class="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-xs text-fg">{{ createdToken.token }}</code>
|
|
<BaseButton size="sm" :icon="copiedNew ? Check : Copy" @click="copy(createdToken.token, 'new')">
|
|
{{ copiedNew ? t('settings.copied') : t('settings.copy') }}
|
|
</BaseButton>
|
|
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('common.close')" @click="createdToken = null" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Liste -->
|
|
<ul class="flex flex-col gap-2">
|
|
<li
|
|
v-for="tok in tokens"
|
|
:key="tok.id"
|
|
class="card-inset flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
>
|
|
<KeyRound :size="15" class="shrink-0 text-fg-subtle" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="flex items-center gap-2 truncate text-sm text-fg">
|
|
{{ tok.label }}
|
|
<span v-if="tok.current" class="badge bg-accent/15 text-accent">{{ t('settings.current') }}</span>
|
|
</p>
|
|
<p class="text-[11px] text-fg-subtle">
|
|
{{ t('settings.created', { date: fmt(tok.createdAt) }) }} ·
|
|
{{ tok.lastUsedAt ? t('settings.lastUsed', { date: fmt(tok.lastUsedAt) }) : t('settings.neverUsed') }}
|
|
</p>
|
|
</div>
|
|
<BaseButton
|
|
v-if="!tok.current"
|
|
size="sm"
|
|
:variant="confirmId === tok.id ? 'danger' : 'ghost'"
|
|
:icon="Trash2"
|
|
@click="revoke(tok)"
|
|
>
|
|
{{ confirmId === tok.id ? t('settings.confirmRevoke') : t('settings.revoke') }}
|
|
</BaseButton>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- Découverte des dépôts -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<ScanSearch :size="16" /> {{ t('settings.discovery') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.discoveryHint') }}</p>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.scanRoots') }}</span>
|
|
<p v-if="rootsDraft.length === 0" class="text-xs text-fg-subtle">{{ t('settings.scanRootsEmpty') }}</p>
|
|
<ul v-else class="flex flex-col gap-1">
|
|
<li v-for="(root, i) in rootsDraft" :key="root" class="card-inset flex items-center gap-2">
|
|
<span class="min-w-0 flex-1 truncate font-mono text-xs text-fg" :title="root">{{ root }}</span>
|
|
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('settings.removeRoot')" @click="rootsDraft.splice(i, 1)" />
|
|
</li>
|
|
</ul>
|
|
<div>
|
|
<BaseButton size="sm" :icon="FolderOpen" @click="showRootPicker = !showRootPicker">{{ t('settings.addRoot') }}</BaseButton>
|
|
</div>
|
|
<DirectoryPicker v-if="showRootPicker" mode="dir" @select="onAddRoot" @close="showRootPicker = false" />
|
|
</div>
|
|
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.scanInterval') }}</span>
|
|
<input v-model.number="intervalDraft" type="number" min="0" max="1440" class="input w-32" />
|
|
<span class="text-xs text-fg-subtle">{{ t('settings.scanIntervalHint') }}</span>
|
|
</label>
|
|
|
|
<div>
|
|
<BaseButton variant="primary" :loading="settings.saving" :disabled="!discoveryDirty" @click="saveDiscovery">
|
|
{{ t('settings.save') }}
|
|
</BaseButton>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Claude CLI -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.claudeCliHint') }}</p>
|
|
|
|
<!-- Diagnostic de détection -->
|
|
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
|
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-accent' : 'text-danger'" />
|
|
<span :class="claudeBin.ok ? 'text-fg-muted' : 'text-danger'">
|
|
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
|
|
</span>
|
|
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-fg" :title="claudeBin.path">{{ claudeBin.path }}</code>
|
|
<span v-if="claudeBin.source" class="badge bg-surface-3/40 text-fg-muted">
|
|
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
|
|
</span>
|
|
</div>
|
|
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.claudeBinPathLabel') }}</span>
|
|
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
|
|
<span class="text-xs text-fg-subtle">{{ t('settings.claudeBinPathHint') }}</span>
|
|
</label>
|
|
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.claudeHomeLabel') }}</span>
|
|
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
|
|
<span class="text-xs text-fg-subtle">{{ t('settings.claudeHomeHint') }}</span>
|
|
</label>
|
|
|
|
<div>
|
|
<BaseButton variant="primary" :loading="settings.saving" :disabled="!claudeDirty" @click="saveClaude">
|
|
{{ t('settings.save') }}
|
|
</BaseButton>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Sessions (rétention / archivage auto) -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Archive :size="16" /> {{ t('settings.sessions') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.sessionsHint') }}</p>
|
|
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs text-fg-muted">{{ t('settings.retentionDays') }}</span>
|
|
<input v-model.number="retentionDraft" type="number" min="0" max="3650" class="input w-32" />
|
|
<span class="text-xs text-fg-subtle">{{ t('settings.retentionDaysHint') }}</span>
|
|
</label>
|
|
|
|
<div>
|
|
<BaseButton variant="primary" :loading="settings.saving" :disabled="!retentionDirty" @click="saveRetention">
|
|
{{ t('settings.save') }}
|
|
</BaseButton>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Connexions aux services git (P12) -->
|
|
<GitConnectionsSection />
|
|
|
|
<!-- Sécurité & conformité -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Shield :size="16" /> {{ t('settings.compliance') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.complianceHint') }}</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
|
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
|
|
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
|
|
</div>
|
|
<ul v-if="audit.length" class="flex flex-col divide-y divide-border/80 text-xs">
|
|
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
|
|
<span class="font-mono text-fg-muted">{{ e.action }}</span>
|
|
<span class="truncate text-fg-subtle">{{ e.actor }}</span>
|
|
<span class="shrink-0 text-fg-subtle">{{ new Date(e.ts).toLocaleString() }}</span>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- Serveur (lecture seule) -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Server :size="16" /> {{ t('settings.server') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.serverHint') }}</p>
|
|
<dl v-if="settings.server" class="flex flex-col divide-y divide-border/80">
|
|
<ServerRow :label="t('settings.version')" :value="settings.server.version" />
|
|
<ServerRow :label="t('settings.port')" :value="String(settings.server.port)" flag="--port" />
|
|
<ServerRow :label="t('settings.bind')" :value="settings.server.bind" flag="--bind" />
|
|
<ServerRow
|
|
:label="t('settings.allowedOrigins')"
|
|
:value="settings.server.allowedOrigins.length ? settings.server.allowedOrigins.join(', ') : t('settings.none')"
|
|
flag="--allow-origin"
|
|
/>
|
|
<ServerRow :label="t('settings.dataDir')" :value="settings.server.dataDir" />
|
|
<ServerRow :label="t('settings.vapidContact')" :value="settings.server.vapidContact" flag="--vapid-contact" />
|
|
<ServerRow :label="t('settings.vapidPublicKey')" :value="settings.server.vapidPublicKey ?? t('settings.none')" mono />
|
|
</dl>
|
|
<SkeletonRow v-else />
|
|
</section>
|
|
|
|
<!-- Support -->
|
|
<section class="card flex flex-col gap-3">
|
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
|
<Coffee :size="16" /> {{ t('settings.support') }}
|
|
</h2>
|
|
<p class="text-xs text-fg-subtle">{{ t('settings.supportHint') }}</p>
|
|
<div>
|
|
<a :href="BUYMEACOFFEE_URL" target="_blank" rel="noopener noreferrer" class="btn-coffee">
|
|
<Coffee :size="16" /> {{ t('settings.supportCta') }}
|
|
</a>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Monitor, Moon, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Sun, Terminal, Trash2, X } from '@lucide/vue';
|
|
import type {
|
|
AuditLogEntry,
|
|
AuditLogsResponse,
|
|
CreateTokenResponse,
|
|
DataExportResponse,
|
|
DeleteMyDataResponse,
|
|
TokenInfo,
|
|
TokensListResponse,
|
|
} from '@arboretum/shared';
|
|
import { api, ApiError } from '../lib/api';
|
|
import { BUYMEACOFFEE_URL } from '../lib/constants';
|
|
import { useSettingsStore } from '../stores/settings';
|
|
import { usePushStore } from '../stores/push';
|
|
import { useToastsStore } from '../stores/toasts';
|
|
import PageHeader from '../components/layout/PageHeader.vue';
|
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
|
import BaseButton from '../components/ui/BaseButton.vue';
|
|
import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
|
import { themeMode, setThemeMode, type ThemeMode } from '../lib/theme';
|
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
|
import ServerRow from '../components/settings/ServerRow.vue';
|
|
import GitConnectionsSection from '../components/settings/GitConnectionsSection.vue';
|
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
|
|
|
const { t, locale } = useI18n();
|
|
const settings = useSettingsStore();
|
|
const push = usePushStore();
|
|
const toasts = useToastsStore();
|
|
|
|
// ---- Préférences ----
|
|
const themeOptions = computed(() => [
|
|
{ value: 'dark', label: t('theme.dark'), icon: Moon },
|
|
{ value: 'light', label: t('theme.light'), icon: Sun },
|
|
{ value: 'system', label: t('theme.system'), icon: Monitor },
|
|
]);
|
|
function onTheme(v: string): void {
|
|
setThemeMode(v as ThemeMode);
|
|
}
|
|
|
|
const testing = ref(false);
|
|
async function sendTest(): Promise<void> {
|
|
testing.value = true;
|
|
try {
|
|
await api.post('/api/v1/push/test');
|
|
toasts.success(t('settings.testSent'));
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
} finally {
|
|
testing.value = false;
|
|
}
|
|
}
|
|
|
|
// ---- Tokens ----
|
|
const tokens = ref<TokenInfo[]>([]);
|
|
const newLabel = ref('');
|
|
const creating = ref(false);
|
|
const createdToken = ref<CreateTokenResponse | null>(null);
|
|
const confirmId = ref<string | null>(null);
|
|
const copiedNew = ref(false);
|
|
|
|
const fmt = (iso: string): string => new Date(iso).toLocaleString(locale.value);
|
|
|
|
async function loadTokens(): Promise<void> {
|
|
tokens.value = (await api.get<TokensListResponse>('/api/v1/auth/tokens')).tokens;
|
|
}
|
|
|
|
async function createToken(): Promise<void> {
|
|
const label = newLabel.value.trim();
|
|
if (!label) return;
|
|
creating.value = true;
|
|
try {
|
|
createdToken.value = await api.post<CreateTokenResponse>('/api/v1/auth/tokens', { label });
|
|
newLabel.value = '';
|
|
toasts.success(t('settings.tokenCreated'));
|
|
await loadTokens();
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
}
|
|
|
|
async function revoke(tok: TokenInfo): Promise<void> {
|
|
if (confirmId.value !== tok.id) {
|
|
confirmId.value = tok.id;
|
|
return;
|
|
}
|
|
confirmId.value = null;
|
|
try {
|
|
await api.delete(`/api/v1/auth/tokens/${tok.id}`);
|
|
toasts.success(t('settings.tokenRevoked'));
|
|
await loadTokens();
|
|
} catch (e) {
|
|
toasts.error(e instanceof ApiError && e.code === 'LAST_TOKEN' ? t('settings.lastTokenError') : e);
|
|
}
|
|
}
|
|
|
|
async function copy(text: string, target?: 'new'): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
if (target === 'new') {
|
|
copiedNew.value = true;
|
|
setTimeout(() => (copiedNew.value = false), 1500);
|
|
}
|
|
} catch {
|
|
/* presse-papiers indisponible (http non sécurisé) : silencieux */
|
|
}
|
|
}
|
|
|
|
// ---- Découverte des dépôts ----
|
|
const rootsDraft = ref<string[]>([]);
|
|
const intervalDraft = ref(0);
|
|
const showRootPicker = ref(false);
|
|
const discoveryDirty = computed(
|
|
() =>
|
|
intervalDraft.value !== settings.scanIntervalMin ||
|
|
rootsDraft.value.length !== settings.scanRoots.length ||
|
|
rootsDraft.value.some((r, i) => r !== settings.scanRoots[i]),
|
|
);
|
|
function syncDiscoveryDraft(): void {
|
|
rootsDraft.value = [...settings.scanRoots];
|
|
intervalDraft.value = settings.scanIntervalMin;
|
|
}
|
|
function onAddRoot(path: string): void {
|
|
if (!rootsDraft.value.includes(path)) rootsDraft.value.push(path);
|
|
showRootPicker.value = false;
|
|
}
|
|
async function saveDiscovery(): Promise<void> {
|
|
try {
|
|
await settings.save({ scanRoots: rootsDraft.value, scanIntervalMin: intervalDraft.value });
|
|
syncDiscoveryDraft();
|
|
toasts.success(t('settings.saved'));
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
}
|
|
}
|
|
|
|
// ---- Claude CLI ----
|
|
const claudeBinDraft = ref('');
|
|
const claudeHomeDraft = ref('');
|
|
const claudeBin = computed(() => settings.server?.claudeBin ?? { path: null, source: null, ok: false });
|
|
const claudeDirty = computed(
|
|
() => claudeBinDraft.value !== (settings.claudeBinPath ?? '') || claudeHomeDraft.value !== (settings.claudeHome ?? ''),
|
|
);
|
|
function syncClaudeDraft(): void {
|
|
claudeBinDraft.value = settings.claudeBinPath ?? '';
|
|
claudeHomeDraft.value = settings.claudeHome ?? '';
|
|
}
|
|
async function saveClaude(): Promise<void> {
|
|
try {
|
|
await settings.save({ claudeBinPath: claudeBinDraft.value, claudeHome: claudeHomeDraft.value });
|
|
syncClaudeDraft();
|
|
toasts.success(t('settings.saved'));
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
}
|
|
}
|
|
|
|
// ---- Sessions (rétention / archivage auto) ----
|
|
const retentionDraft = ref(0);
|
|
const retentionDirty = computed(() => retentionDraft.value !== settings.retentionDays);
|
|
function syncRetentionDraft(): void {
|
|
retentionDraft.value = settings.retentionDays;
|
|
}
|
|
async function saveRetention(): Promise<void> {
|
|
try {
|
|
await settings.save({ retentionDays: retentionDraft.value });
|
|
syncRetentionDraft();
|
|
toasts.success(t('settings.saved'));
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
}
|
|
}
|
|
|
|
// ---- Sécurité & conformité (audit / RGPD) ----
|
|
const audit = ref<AuditLogEntry[]>([]);
|
|
const auditing = ref(false);
|
|
const exporting = ref(false);
|
|
|
|
async function loadAudit(): Promise<void> {
|
|
auditing.value = true;
|
|
try {
|
|
audit.value = (await api.get<AuditLogsResponse>('/api/v1/audit-logs?limit=20')).entries;
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
} finally {
|
|
auditing.value = false;
|
|
}
|
|
}
|
|
|
|
async function exportData(): Promise<void> {
|
|
exporting.value = true;
|
|
try {
|
|
const data = await api.get<DataExportResponse>('/api/v1/data/export');
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'arboretum-data-export.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
} finally {
|
|
exporting.value = false;
|
|
}
|
|
}
|
|
|
|
async function deleteMyData(): Promise<void> {
|
|
if (!window.confirm(t('settings.deleteConfirm'))) return;
|
|
try {
|
|
const pending = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', {});
|
|
if (pending.status !== 'pending' || !pending.confirm) return;
|
|
const done = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', { confirm: pending.confirm });
|
|
toasts.success(t('settings.deleteDone'));
|
|
if (done.summary.tokenRevoked) window.location.reload(); // token courant révoqué → re-login
|
|
} catch (e) {
|
|
toasts.error(e);
|
|
}
|
|
}
|
|
|
|
// P11 : un settings_update WS (autre onglet/client) re-synchronise un draft SCALAIRE uniquement
|
|
// s'il n'a pas été modifié localement (le draft valait encore l'ancienne valeur du store) → on
|
|
// n'écrase jamais une saisie en cours.
|
|
watch(() => settings.scanIntervalMin, (n, o) => { if (intervalDraft.value === o) intervalDraft.value = n; });
|
|
watch(() => settings.retentionDays, (n, o) => { if (retentionDraft.value === o) retentionDraft.value = n; });
|
|
watch(() => settings.claudeBinPath, (n, o) => { if (claudeBinDraft.value === (o ?? '')) claudeBinDraft.value = n ?? ''; });
|
|
watch(() => settings.claudeHome, (n, o) => { if (claudeHomeDraft.value === (o ?? '')) claudeHomeDraft.value = n ?? ''; });
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
|
syncDiscoveryDraft();
|
|
syncClaudeDraft();
|
|
syncRetentionDraft();
|
|
void push.refresh();
|
|
});
|
|
// Le temps réel des réglages est géré globalement par useRealtimeBootstrap (App.vue).
|
|
</script>
|