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>
181 lines
4.8 KiB
TypeScript
181 lines
4.8 KiB
TypeScript
/**
|
|
* 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().
|