/** * 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) { 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('/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().