diff --git a/apps/web/components/AppShell.vue b/apps/web/components/AppShell.vue index 11cbd02..fbd04de 100644 --- a/apps/web/components/AppShell.vue +++ b/apps/web/components/AppShell.vue @@ -1,6 +1,6 @@ @@ -22,7 +18,7 @@ async function leave() { PRIVATE ALPHA / {{ section }} ⌂ - {{ initials }} + {{ initials }} D&G @@ -31,5 +27,5 @@ async function leave() { diff --git a/apps/web/composables/useDngAuth.test.ts b/apps/web/composables/useDngAuth.test.ts new file mode 100644 index 0000000..b63f4bd --- /dev/null +++ b/apps/web/composables/useDngAuth.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDngAuth } from './useDngAuth' + +const states = new Map() + +function activeSession() { + return { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 600_000, + user: { + id: 'user-123', + email: 'old-name@example.com', + user_metadata: { display_name: 'Old Name' }, + }, + } +} + +describe('useDngAuth profile updates', () => { + beforeEach(() => { + states.clear() + vi.restoreAllMocks() + vi.stubGlobal('useRuntimeConfig', () => ({ + public: { + supabaseUrl: 'https://supabase.example', + supabaseAnonKey: 'anon-key', + }, + })) + vi.stubGlobal('useState', (key: string, factory: () => unknown) => { + if (!states.has(key)) states.set(key, { value: factory() }) + return states.get(key) + }) + }) + + it('updates both the public profile and authenticated user metadata', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ + id: 'user-123', + email: 'old-name@example.com', + user_metadata: { display_name: 'New Name' }, + }) + vi.stubGlobal('$fetch', fetchMock) + + const auth = useDngAuth() + auth.session.value = activeSession() + + await auth.updateProfile(' New Name ') + + expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({ + method: 'PATCH', + body: { display_name: 'New Name' }, + })) + expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://supabase.example/auth/v1/user', expect.objectContaining({ + method: 'PUT', + body: { data: { display_name: 'New Name' } }, + })) + expect(auth.session.value?.user.user_metadata?.display_name).toBe('New Name') + }) + + it('rolls the public profile back when the auth metadata update fails', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Auth update failed')) + .mockResolvedValueOnce(undefined) + vi.stubGlobal('$fetch', fetchMock) + + const auth = useDngAuth() + auth.session.value = activeSession() + + await expect(auth.updateProfile('New Name')).rejects.toThrow('Auth update failed') + expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({ + body: { display_name: 'Old Name' }, + })) + expect(auth.session.value?.user.user_metadata?.display_name).toBe('Old Name') + }) + + it('rejects invalid display names before making a request', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('$fetch', fetchMock) + + const auth = useDngAuth() + auth.session.value = activeSession() + + await expect(auth.updateProfile(' ')).rejects.toThrow('between 2 and 80 characters') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/composables/useDngAuth.ts b/apps/web/composables/useDngAuth.ts index 227dde5..66de38d 100644 --- a/apps/web/composables/useDngAuth.ts +++ b/apps/web/composables/useDngAuth.ts @@ -211,6 +211,44 @@ export function useDngAuth() { return user } + async function updateProfile(displayName: string) { + const nextDisplayName = displayName.trim() + if (nextDisplayName.length < 2 || nextDisplayName.length > 80) { + throw new Error('Display name must contain between 2 and 80 characters.') + } + + const token = await accessToken() + if (!session.value) throw new Error('Enter the alpha to continue.') + const activeSession = session.value + + await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, { + method: 'PATCH', + headers: { ...authHeaders(token), Prefer: 'return=minimal' }, + body: { display_name: nextDisplayName }, + }) + + try { + const user = normalizeUser(await $fetch(`${config.public.supabaseUrl}/auth/v1/user`, { + method: 'PUT', + headers: authHeaders(token), + body: { data: { display_name: nextDisplayName } }, + })) + persist({ ...activeSession, user }) + return user + } catch (cause) { + const previousDisplayName = activeSession.user.user_metadata?.display_name + const rollbackDisplayName = typeof previousDisplayName === 'string' && previousDisplayName.trim() + ? previousDisplayName.trim() + : activeSession.user.email?.split('@')[0] || 'Adventurer' + await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, { + method: 'PATCH', + headers: { ...authHeaders(token), Prefer: 'return=minimal' }, + body: { display_name: rollbackDisplayName }, + }).catch(() => undefined) + throw cause + } + } + async function accessToken() { await restore() if (!session.value) throw new Error('Enter the alpha to continue.') @@ -238,5 +276,5 @@ export function useDngAuth() { hydrated.value = true } - return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate } + return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, updateProfile, accessToken, signOut, invalidate } } diff --git a/apps/web/pages/profile.vue b/apps/web/pages/profile.vue new file mode 100644 index 0000000..a553fe5 --- /dev/null +++ b/apps/web/pages/profile.vue @@ -0,0 +1,115 @@ + + + + + + + PLAYER IDENTITY + YOUR PROFILE. + Manage how your party sees you and control access to your account. + + + + {{ savedDisplayName.slice(0, 2).toUpperCase() || 'D&G' }} + + ACCOUNT DETAILS + + DISPLAY NAME + EMAIL + Your email is fixed to protect campaign ownership. Use password recovery if you need new credentials. + {{ error }} + {{ success }} + + {{ saving ? 'SAVING…' : 'SAVE PROFILE →' }} + RESET PASSWORD + + + RECEIVING ACCOUNT DETAILS… + + + + + SESSION CONTROLLEAVE THE TABLE.Sign out on this device. Your worlds and campaign progress stay saved. + {{ signingOut ? 'SIGNING OUT…' : 'SIGN OUT' }} + + + + + +
PLAYER IDENTITY
Manage how your party sees you and control access to your account.
Your email is fixed to protect campaign ownership. Use password recovery if you need new credentials.
{{ error }}
{{ success }}
RECEIVING ACCOUNT DETAILS…
Sign out on this device. Your worlds and campaign progress stay saved.