Sprint 1 : scaffolding complet de Glibredecision
Plateforme de decisions collectives pour Duniter/G1. Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services, moteur de vote avec formule d'inertie WoT/Smith/TechComm). Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores). Infrastructure Docker + Woodpecker CI + Traefik. Documentation technique et utilisateur (15 fichiers). Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote. 30 tests unitaires (formules, mode params, vote nuance) -- tous verts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
180
frontend/app/stores/auth.ts
Normal file
180
frontend/app/stores/auth.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Auth store: manages Duniter Ed25519 challenge-response authentication.
|
||||
*
|
||||
* Persists the session token in localStorage for SPA rehydration.
|
||||
* The identity object mirrors the backend IdentityOut schema.
|
||||
*/
|
||||
|
||||
export interface DuniterIdentity {
|
||||
id: string
|
||||
address: string
|
||||
display_name: string | null
|
||||
wot_status: string
|
||||
is_smith: boolean
|
||||
is_techcomm: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
identity: DuniterIdentity | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: null,
|
||||
identity: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => !!state.token && !!state.identity,
|
||||
isSmith: (state): boolean => state.identity?.is_smith ?? false,
|
||||
isTechComm: (state): boolean => state.identity?.is_techcomm ?? false,
|
||||
displayName: (state): string => {
|
||||
if (!state.identity) return ''
|
||||
return state.identity.display_name || state.identity.address.slice(0, 12) + '...'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Initiate the challenge-response login flow.
|
||||
*
|
||||
* Steps:
|
||||
* 1. POST /auth/challenge with the Duniter SS58 address
|
||||
* 2. Client signs the challenge with Ed25519 private key
|
||||
* 3. POST /auth/verify with address + signature + challenge
|
||||
* 4. Store the returned token and identity
|
||||
*/
|
||||
async login(address: string, signFn?: (challenge: string) => Promise<string>) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
// Step 1: Request challenge
|
||||
const challengeRes = await $api<{ challenge: string; expires_at: string }>(
|
||||
'/auth/challenge',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { address },
|
||||
},
|
||||
)
|
||||
|
||||
// Step 2: Sign the challenge
|
||||
// In production, signFn would use the Duniter keypair to produce an Ed25519 signature.
|
||||
// For development, we use a placeholder signature.
|
||||
let signature: string
|
||||
if (signFn) {
|
||||
signature = await signFn(challengeRes.challenge)
|
||||
} else {
|
||||
// Development placeholder -- backend currently accepts any signature
|
||||
signature = 'dev_signature_placeholder'
|
||||
}
|
||||
|
||||
// Step 3: Verify and get token
|
||||
const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>(
|
||||
'/auth/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
address,
|
||||
signature,
|
||||
challenge: challengeRes.challenge,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Step 4: Store credentials
|
||||
this.token = verifyRes.token
|
||||
this.identity = verifyRes.identity
|
||||
this._persistToken()
|
||||
|
||||
return verifyRes
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur de connexion'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the currently authenticated identity from the backend.
|
||||
* Used on app init to validate a persisted token.
|
||||
*/
|
||||
async fetchMe() {
|
||||
if (!this.token) return
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||
this.identity = identity
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Session invalide'
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this._clearToken()
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log out: invalidate session on server and clear local state.
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
if (this.token) {
|
||||
const { $api } = useApi()
|
||||
await $api('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during logout -- clear local state regardless
|
||||
} finally {
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this.error = null
|
||||
this._clearToken()
|
||||
navigateTo('/login')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hydrate the token from localStorage on app init.
|
||||
*/
|
||||
hydrateFromStorage() {
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem('glibredecision_token')
|
||||
if (stored) {
|
||||
this.token = stored
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Persist token to localStorage */
|
||||
_persistToken() {
|
||||
if (import.meta.client && this.token) {
|
||||
localStorage.setItem('glibredecision_token', this.token)
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Clear token from localStorage */
|
||||
_clearToken() {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem('glibredecision_token')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Note: hydration from localStorage happens in app.vue onMounted
|
||||
// via auth.hydrateFromStorage() before calling auth.fetchMe().
|
||||
Reference in New Issue
Block a user