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).
This commit is contained in:
2026-06-18 14:16:53 +02:00
parent 5c1c2a8591
commit c9670ca309
23 changed files with 1469 additions and 24 deletions

View File

@@ -23,6 +23,7 @@ import { onMounted, onUnmounted } from 'vue';
import { useSessionsStore } from '../../stores/sessions';
import { useWorktreesStore } from '../../stores/worktrees';
import { useGroupsStore } from '../../stores/groups';
import { useSettingsStore } from '../../stores/settings';
import AppSidebar from './AppSidebar.vue';
import MobileTabBar from './MobileTabBar.vue';
@@ -31,11 +32,14 @@ defineProps<{ fullbleed?: boolean }>();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
const groups = useGroupsStore();
const settings = useSettingsStore();
onMounted(() => {
void worktrees.fetchAll();
void sessions.fetchSessions();
void groups.fetchGroups();
void settings.fetch(); // alimente l'icône de nav Gitea et la vue Réglages
worktrees.startRealtime();
sessions.startRealtime();
groups.startRealtime();

View File

@@ -15,7 +15,7 @@
</button>
<nav class="flex flex-1 flex-col gap-1 px-2">
<NavItem
v-for="item in items"
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
@@ -25,6 +25,19 @@
orientation="row"
/>
</nav>
<nav class="flex flex-col gap-1 px-2 pb-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<div class="border-t border-zinc-800/80 p-3">
<AppShellFooter />
</div>
@@ -40,6 +53,6 @@ import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
const { t } = useI18n();
const { items, isActive } = useNav();
const { primary, secondary, isActive } = useNav();
const palette = useCommandPalette();
</script>

View File

@@ -4,7 +4,7 @@
style="padding-bottom: env(safe-area-inset-bottom)"
>
<NavItem
v-for="item in items"
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
@@ -17,24 +17,27 @@
<button
type="button"
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
:class="moreActive ? 'text-emerald-400' : ''"
@click="showMore = true"
>
<Settings :size="20" :stroke-width="1.75" />
<span class="text-[10px]">{{ t('nav.settings') }}</span>
<Menu :size="20" :stroke-width="1.75" />
<span class="text-[10px]">{{ t('nav.more') }}</span>
</button>
<MoreSheet v-if="showMore" @close="showMore = false" />
</nav>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Settings } from '@lucide/vue';
import { Menu } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import NavItem from './NavItem.vue';
import MoreSheet from './MoreSheet.vue';
const { t } = useI18n();
const { items, isActive } = useNav();
const { primary, secondary, isActive } = useNav();
const showMore = ref(false);
// « More » est actif quand la route courante appartient aux items secondaires (Réglages/Aide).
const moreActive = computed(() => secondary.value.some((i) => isActive(i.match)));
</script>

View File

@@ -7,7 +7,22 @@
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
>
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
<AppShellFooter />
<nav class="mb-3 flex flex-col gap-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:active="isActive(item.match)"
orientation="row"
@click="emit('close')"
/>
</nav>
<div class="border-t border-zinc-800/80 pt-3">
<AppShellFooter />
</div>
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
</div>
</div>
@@ -16,9 +31,12 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useNav } from '../../composables/useNav';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n();
const { secondary, isActive } = useNav();
const emit = defineEmits<{ close: [] }>();
</script>

View File

@@ -1,7 +1,12 @@
<template>
<RouterLink
:to="to"
:aria-current="active ? 'page' : undefined"
<!-- RouterLink pour une route interne ; <a target=_blank> pour un lien externe (ex. Gitea). -->
<component
:is="href ? 'a' : RouterLink"
:to="href ? undefined : to"
:href="href"
:target="href ? '_blank' : undefined"
:rel="href ? 'noopener noreferrer' : undefined"
:aria-current="!href && active ? 'page' : undefined"
:class="orientation === 'col' ? colClass : rowClass"
>
<span class="relative">
@@ -20,16 +25,19 @@
>
{{ badge }}
</span>
</RouterLink>
</component>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
const props = withDefaults(
defineProps<{
to: RouteLocationRaw;
// `| undefined` requis par exactOptionalPropertyTypes : les items de nav passent `to`/`href`
// potentiellement undefined (l'un ou l'autre selon route interne vs lien externe).
to?: RouteLocationRaw | undefined;
href?: string | undefined;
icon: Component;
label: string;
badge?: number;