Sprint 5 : integration et production -- securite, performance, API publique, documentation

Backend: rate limiter, security headers, blockchain cache service avec RPC,
public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection
pooling, structured logging, health check DB. Frontend: API retry/timeout,
WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger
+ drawer, error boundary, offline banner, loading skeletons, dashboard enrichi.
Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+),
guide deploiement, politique securite. 123 tests, 155 fichiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 15:12:50 +01:00
parent 3cb1754592
commit 403b94fa2c
31 changed files with 4472 additions and 356 deletions

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
const documents = useDocumentsStore()
const decisions = useDecisionsStore()
const mandates = useMandatesStore()
const votes = useVotesStore()
const auth = useAuthStore()
const loading = ref(true)
@@ -9,12 +12,15 @@ onMounted(async () => {
await Promise.all([
documents.fetchAll(),
decisions.fetchAll(),
mandates.fetchAll(),
votes.fetchSessions(),
])
} finally {
loading.value = false
}
})
/** Summary stats for the dashboard cards. */
const stats = computed(() => [
{
label: 'Documents actifs',
@@ -32,8 +38,64 @@ const stats = computed(() => [
color: 'success' as const,
to: '/decisions',
},
{
label: 'Votes ouverts',
value: openVoteSessions.value.length,
total: votes.sessions.length,
icon: 'i-lucide-vote',
color: 'warning' as const,
to: '/decisions',
},
{
label: 'Mandats actifs',
value: mandates.activeMandates.length,
total: mandates.list.length,
icon: 'i-lucide-user-check',
color: 'info' as const,
to: '/mandates',
},
])
/** Open vote sessions. */
const openVoteSessions = computed(() => {
return votes.sessions.filter(s => s.status === 'open')
})
/** Last 5 decisions sorted by most recent. */
const recentDecisions = computed(() => {
return [...decisions.list]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 5)
})
/** Last 5 vote sessions sorted by most recent. */
const recentVotes = computed(() => {
return [...votes.sessions]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 5)
})
/** Format a date string to a localized relative or absolute string. */
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = diffMs / (1000 * 60 * 60)
if (diffHours < 1) {
const diffMinutes = Math.floor(diffMs / (1000 * 60))
return diffMinutes <= 1 ? 'Il y a un instant' : `Il y a ${diffMinutes} min`
}
if (diffHours < 24) {
return `Il y a ${Math.floor(diffHours)}h`
}
if (diffHours < 48) {
return 'Hier'
}
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
/** Section cards for the "Domaines" grid. */
const sections = [
{
title: 'Documents de reference',
@@ -77,57 +139,205 @@ const sections = [
<div class="space-y-8">
<!-- Title -->
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
Glibredecision
</h1>
<p class="mt-2 text-lg text-gray-600 dark:text-gray-400">
<p class="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-400">
Decisions collectives pour la communaute Duniter/G1
</p>
</div>
<!-- Quick actions (authenticated users only) -->
<div v-if="auth.isAuthenticated" class="flex flex-wrap gap-2 sm:gap-3">
<UButton
to="/decisions"
icon="i-lucide-plus"
label="Nouvelle decision"
variant="soft"
color="primary"
size="sm"
/>
<UButton
to="/documents"
icon="i-lucide-book-open"
label="Voir les documents"
variant="soft"
color="neutral"
size="sm"
/>
<UButton
to="/mandates"
icon="i-lucide-user-check"
label="Mandats"
variant="soft"
color="neutral"
size="sm"
/>
</div>
<!-- Stats cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<template v-if="loading">
<UCard v-for="i in 2" :key="i">
<div class="space-y-3">
<USkeleton class="h-4 w-32" />
<USkeleton class="h-8 w-16" />
<USkeleton class="h-3 w-24" />
</div>
</UCard>
<LoadingSkeleton
v-for="i in 4"
:key="i"
:lines="2"
card
/>
</template>
<template v-else>
<NuxtLink v-for="stat in stats" :key="stat.label" :to="stat.to">
<UCard class="hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">
<UCard class="hover:shadow-md transition-shadow h-full">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
{{ stat.label }}
</p>
<p class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mt-1">
{{ stat.value }}
</p>
<p class="text-xs text-gray-400 mt-1">
{{ stat.total }} au total
</p>
</div>
<UIcon :name="stat.icon" class="text-3xl text-gray-400" />
<UIcon
:name="stat.icon"
class="text-2xl sm:text-3xl text-gray-400 flex-shrink-0"
/>
</div>
</UCard>
</NuxtLink>
</template>
</div>
<!-- Section cards -->
<!-- Recent activity: decisions + votes side-by-side -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<!-- Recent decisions -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-lg text-primary" />
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
Decisions recentes
</h3>
</div>
<UButton
to="/decisions"
variant="ghost"
size="xs"
color="neutral"
label="Tout voir"
trailing-icon="i-lucide-chevron-right"
/>
</div>
</template>
<div v-if="loading" class="space-y-3">
<LoadingSkeleton :lines="5" />
</div>
<div v-else-if="recentDecisions.length === 0" class="text-center py-6">
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm text-gray-500 mt-2">Aucune decision pour le moment</p>
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
<NuxtLink
v-for="decision in recentDecisions"
:key="decision.id"
:to="`/decisions/${decision.id}`"
class="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 -mx-2 px-2 rounded transition-colors"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ decision.title }}
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatDate(decision.updated_at) }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</NuxtLink>
</div>
</UCard>
<!-- Recent votes -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-vote" class="text-lg text-warning" />
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
Sessions de vote recentes
</h3>
</div>
<UButton
to="/decisions"
variant="ghost"
size="xs"
color="neutral"
label="Tout voir"
trailing-icon="i-lucide-chevron-right"
/>
</div>
</template>
<div v-if="loading" class="space-y-3">
<LoadingSkeleton :lines="5" />
</div>
<div v-else-if="recentVotes.length === 0" class="text-center py-6">
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm text-gray-500 mt-2">Aucune session de vote pour le moment</p>
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="session in recentVotes"
:key="session.id"
class="py-3 first:pt-0 last:pb-0"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<StatusBadge :status="session.status" type="vote" />
<span class="text-xs text-gray-500">
{{ session.votes_total }} vote{{ session.votes_total !== 1 ? 's' : '' }}
</span>
</div>
<div class="mt-1.5">
<!-- Mini progress bar for vote results -->
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-success-500 rounded-full transition-all"
:style="{ width: session.votes_total > 0 ? `${(session.votes_for / session.votes_total) * 100}%` : '0%' }"
/>
</div>
<span class="text-xs text-gray-500 flex-shrink-0">
{{ session.votes_for }}/{{ session.votes_total }}
</span>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ formatDate(session.created_at) }}
</p>
</div>
</div>
</UCard>
</div>
<!-- Section cards (Domaines) -->
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-4">
Domaines
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<NuxtLink v-for="section in sections" :key="section.title" :to="section.to">
<UCard class="h-full hover:shadow-md transition-shadow">
<div class="space-y-3">
<div class="flex items-center gap-3">
<UIcon :name="section.icon" class="text-2xl text-primary" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<UIcon :name="section.icon" class="text-2xl text-primary flex-shrink-0" />
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
{{ section.title }}
</h3>
</div>
@@ -145,7 +355,7 @@ const sections = [
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calculator" class="text-xl text-primary" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
Formule de seuil WoT
</h3>
</div>
@@ -153,10 +363,10 @@ const sections = [
Le seuil d'adoption s'adapte dynamiquement a la participation :
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
</p>
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs sm:text-sm font-mono overflow-x-auto">
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
</code>
<div class="flex flex-wrap gap-4 text-xs text-gray-500">
<div class="flex flex-wrap gap-3 sm:gap-4 text-xs text-gray-500">
<span>C = constante de base</span>
<span>B = exposant de base</span>
<span>W = taille WoT</span>