feat(SPA): users page and invite modal

This commit is contained in:
fsylvain committed 2026-09-29 08:35:14 +02:00
1 parent acdef18b37
commit 6afffc1984
20 files changed
+1880 -55

No files matched your search

+21 -6
View File
@@ -5,11 +5,13 @@ import { useSessionStore } from '@/stores/session'
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
constructor(status: number, retryAfter?: number) {
constructor(status: number, retryAfter?: number, body?: string) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
}
}
@@ -20,6 +22,7 @@ export class NetworkError extends Error {
}
export interface MeResult {
id?: number
verified: boolean
username?: string
}
@@ -51,6 +54,17 @@ function parseRetryAfterSeconds(response: Response): number | undefined {
return Number.parseInt(header, 10)
}
async function readErrorBody(response: Response): Promise<string | undefined> {
if (typeof response.text !== 'function') {
return undefined
}
try {
return (await response.text()).trim()
} catch {
return undefined
}
}
if (typeof window !== 'undefined') {
window.addEventListener('offline', () => {
isOffline.value = true
@@ -102,7 +116,7 @@ export function useApi() {
throw new ApiError(429, parseRetryAfterSeconds(response))
}
throw new ApiError(response.status)
throw new ApiError(response.status, undefined, await readErrorBody(response))
}
if (response.status === 204) {
@@ -113,10 +127,11 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified), username: body.username }
const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
'/v1/auth/me',
{ onUnauthorized: 'passthrough' },
)
return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
}
return { getMe, request }
+51
View File
@@ -0,0 +1,51 @@
import { useApi } from '@/composables/useApi'
export type UserListItem =
| {
kind: 'user'
id: number
username: string
display_name: string
status: 'accepted'
created_at: string
}
| {
kind: 'invite'
id: number
email: string
status: 'pending' | 'expired'
created_at: string
}
export const USER_SKELETON_ROWS = 5
export function userRowKey(kind: UserListItem['kind'], id: number) {
return `${kind}-${id}`
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
status: 'pending'
created_at: string
expires_at: string
}
export function useUsers() {
const { request } = useApi()
function listUsers(): Promise<UserListItem[]> {
return request<UserListItem[]>('/v1/user/')
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
}
return { listUsers, inviteUser }
}