feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+45 -15
View File
@@ -5,11 +5,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(session.session).toEqual({ verified: false })
})
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
expect(isOffline.value).toBe(false)
})
})
describe('getMe maps the account fields', () => {
it('maps email and display_name onto email and displayName', async () => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: 'Marta Alves',
}),
)
await expect(useApi().getMe()).resolves.toEqual({
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: 'Marta Alves',
})
})
it.each([
['absent', undefined],
['null', null],
])('reads a %s display_name as an empty string', async (_label, value) => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: value,
}),
)
const me = await useApi().getMe()
expect(me.displayName).toBe('')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import { accountInitials, accountName } from '@/lib/accountName'
const PROFILE_PATH = '/profile'
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
const route = useRoute()
const router = useRouter()
const colorMode = useColorMode()
const name = computed(() => accountName(account().username, account().displayName))
const initials = computed(() => accountInitials(account().username, account().displayName))
const onProfile = computed(() => route.path === PROFILE_PATH)
function goToProfile() {
if (!onProfile.value) {
router.push(PROFILE_PATH)
}
}
return { colorMode, name, initials, onProfile, goToProfile }
}
+16 -5
View File
@@ -25,6 +25,8 @@ export interface MeResult {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -127,11 +129,20 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
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 }
const body = await request<{
id?: number
is_verified: boolean
username?: string
email?: string
display_name?: string | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
verified: Boolean(body.is_verified),
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
}
}
return { getMe, request }