feat: onglets Réglages & Aide + icône Gitea
Réglages : préférences (langue, notifications push + test), gestion complète des tokens d'accès (liste/création/révocation, garde anti lock-out sur le dernier token), URL Gitea configurable, infos serveur en lecture seule (port/bind/origines/VAPID + flags CLI). Aide : documentation bilingue EN/FR de toutes les fonctionnalités, avec recherche. Icône Gitea (lien externe) dans la nav (sidebar + MoreSheet mobile). Backend : routes /api/v1/auth/tokens (GET/POST/DELETE) + tokenId dans /me ; routes/settings.ts (GET/PATCH, allow-list stricte gitea_url, aucun secret exposé, URL validée http/https anti-XSS) ; AuthService.listTokens/revokeToken (transaction). Front : NavItem gère les liens externes, nav primaire/secondaire, store settings, vues SettingsView/HelpView. 236 tests verts (+15 nouveaux : auth-tokens, settings-routes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
257
packages/web/src/views/SettingsView.vue
Normal file
257
packages/web/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<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-zinc-100">
|
||||
<SlidersHorizontal :size="16" /> {{ t('settings.preferences') }}
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500">{{ t('settings.preferencesHint') }}</p>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-zinc-300">{{ t('settings.language') }}</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-zinc-800/80 pt-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-zinc-300">{{ t('settings.notifications') }}</p>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ 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-zinc-100">
|
||||
<Shield :size="16" /> {{ t('settings.security') }}
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500">{{ 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-zinc-400">{{ 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-emerald-800/60 bg-emerald-950/20">
|
||||
<p class="text-xs text-emerald-300">{{ t('settings.copyTokenHint') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="min-w-0 flex-1 truncate rounded bg-zinc-950 px-2 py-1 font-mono text-xs text-zinc-100">{{ 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-zinc-500" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-2 truncate text-sm text-zinc-200">
|
||||
{{ tok.label }}
|
||||
<span v-if="tok.current" class="badge bg-emerald-500/15 text-emerald-300">{{ t('settings.current') }}</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-zinc-500">
|
||||
{{ 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>
|
||||
|
||||
<!-- Intégrations : Gitea -->
|
||||
<section class="card flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Puzzle :size="16" /> {{ t('settings.integrations') }}
|
||||
</h2>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="saveGitea">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-zinc-400">{{ t('settings.giteaUrlLabel') }}</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input v-model="giteaInput" type="url" class="input min-w-48 flex-1" :placeholder="t('settings.giteaUrlPlaceholder')" />
|
||||
<BaseButton type="submit" variant="primary" :loading="settings.saving" :disabled="!giteaDirty">
|
||||
{{ t('settings.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</label>
|
||||
<p class="text-xs text-zinc-500">{{ t('settings.giteaUrlHint') }}</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Serveur (lecture seule) -->
|
||||
<section class="card flex flex-col gap-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Server :size="16" /> {{ t('settings.server') }}
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500">{{ t('settings.serverHint') }}</p>
|
||||
<dl v-if="settings.server" class="flex flex-col divide-y divide-zinc-800/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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Bell, BellOff, Check, Copy, KeyRound, Plus, Puzzle, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
|
||||
import type { CreateTokenResponse, TokenInfo, TokensListResponse } from '@arboretum/shared';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
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 SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||
import ServerRow from '../components/settings/ServerRow.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
const push = usePushStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
// ---- Préférences ----
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gitea ----
|
||||
const giteaInput = ref('');
|
||||
const giteaDirty = computed(() => giteaInput.value.trim() !== (settings.giteaUrl ?? ''));
|
||||
async function saveGitea(): Promise<void> {
|
||||
try {
|
||||
await settings.save({ giteaUrl: giteaInput.value.trim() || null });
|
||||
giteaInput.value = settings.giteaUrl ?? '';
|
||||
toasts.success(t('settings.saved'));
|
||||
} catch (e) {
|
||||
toasts.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
||||
giteaInput.value = settings.giteaUrl ?? '';
|
||||
void push.refresh();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user