feat(SPA): users page and invite modal
This commit is contained in:
1 parent
acdef18b37
commit
6afffc1984
20 files changed
+1880
-55
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user