feat(SPA): profile page, display name and the new account menu
This commit is contained in:
1 parent
55525c06d6
commit
b0f7b7892b
29 files changed
+3166
-469
No files matched your search
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user