Compare commits
1 Commits
fb4b06711d
...
agent/code
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d65bcde9 |
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ section?: string }>()
|
defineProps<{ section?: string }>()
|
||||||
const { session, restore, signOut } = useDngAuth()
|
const { session, restore } = useDngAuth()
|
||||||
onMounted(() => void restore())
|
onMounted(() => void restore())
|
||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
@@ -9,10 +9,6 @@ const initials = computed(() => {
|
|||||||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||||||
})
|
})
|
||||||
|
|
||||||
async function leave() {
|
|
||||||
await signOut()
|
|
||||||
await navigateTo('/')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,7 +18,7 @@ async function leave() {
|
|||||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||||
<button v-if="session" class="avatar" :title="`Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Manage profile for ${session.user.email ?? ''}`" aria-label="Manage profile">{{ initials }}</NuxtLink>
|
||||||
<div v-else class="avatar">D&G</div>
|
<div v-else class="avatar">D&G</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -31,5 +27,5 @@ async function leave() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono);text-decoration:none}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
88
apps/web/composables/useDngAuth.test.ts
Normal file
88
apps/web/composables/useDngAuth.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useDngAuth } from './useDngAuth'
|
||||||
|
|
||||||
|
const states = new Map<string, { value: unknown }>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -211,6 +211,44 @@ export function useDngAuth() {
|
|||||||
return user
|
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<DngAuthUser>(`${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() {
|
async function accessToken() {
|
||||||
await restore()
|
await restore()
|
||||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||||
@@ -238,5 +276,5 @@ export function useDngAuth() {
|
|||||||
hydrated.value = true
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
115
apps/web/pages/profile.vue
Normal file
115
apps/web/pages/profile.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const auth = useDngAuth()
|
||||||
|
const displayName = ref('')
|
||||||
|
const savedDisplayName = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const signingOut = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const email = computed(() => auth.session.value?.user.email ?? '')
|
||||||
|
const hasChanges = computed(() => displayName.value.trim() !== savedDisplayName.value)
|
||||||
|
|
||||||
|
function messageFrom(cause: unknown, fallback: string) {
|
||||||
|
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
|
||||||
|
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const session = await auth.restore()
|
||||||
|
if (!session) {
|
||||||
|
await navigateTo('/auth/sign-in', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = session.user.user_metadata?.display_name
|
||||||
|
savedDisplayName.value = typeof value === 'string' && value.trim() ? value.trim() : session.user.email?.split('@')[0] ?? 'Adventurer'
|
||||||
|
displayName.value = savedDisplayName.value
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = messageFrom(cause, 'Could not load your profile.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
const nextDisplayName = displayName.value.trim()
|
||||||
|
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
||||||
|
error.value = 'Display name must contain between 2 and 80 characters.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await auth.updateProfile(nextDisplayName)
|
||||||
|
displayName.value = nextDisplayName
|
||||||
|
savedDisplayName.value = nextDisplayName
|
||||||
|
success.value = 'PROFILE UPDATED.'
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = messageFrom(cause, 'Could not update your profile.')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leave() {
|
||||||
|
signingOut.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await auth.signOut()
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = messageFrom(cause, 'Could not sign out.')
|
||||||
|
signingOut.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell section="ACCOUNT">
|
||||||
|
<div class="profile-page noise">
|
||||||
|
<section class="profile-intro">
|
||||||
|
<p class="kicker">PLAYER IDENTITY</p>
|
||||||
|
<h1>YOUR PROFILE<span>.</span></h1>
|
||||||
|
<p>Manage how your party sees you and control access to your account.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card">
|
||||||
|
<div class="identity-mark" aria-hidden="true">{{ savedDisplayName.slice(0, 2).toUpperCase() || 'D&G' }}</div>
|
||||||
|
<div class="profile-content">
|
||||||
|
<small>ACCOUNT DETAILS</small>
|
||||||
|
<form v-if="!loading" class="profile-form" @submit.prevent="saveProfile">
|
||||||
|
<label>DISPLAY NAME<input v-model="displayName" name="name" autocomplete="name" minlength="2" maxlength="80" required placeholder="How your party sees you"></label>
|
||||||
|
<label>EMAIL<input :value="email" name="email" type="email" autocomplete="email" readonly></label>
|
||||||
|
<p class="field-note">Your email is fixed to protect campaign ownership. Use password recovery if you need new credentials.</p>
|
||||||
|
<p v-if="error" class="form-message error" role="alert">{{ error }}</p>
|
||||||
|
<p v-if="success" class="form-message success" role="status">{{ success }}</p>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<button class="save-button" :disabled="saving || signingOut || !hasChanges">{{ saving ? 'SAVING…' : 'SAVE PROFILE →' }}</button>
|
||||||
|
<NuxtLink to="/auth/forgot-password">RESET PASSWORD</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-else class="loading-state">RECEIVING ACCOUNT DETAILS…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="session-card">
|
||||||
|
<div><small>SESSION CONTROL</small><h2>LEAVE THE TABLE.</h2><p>Sign out on this device. Your worlds and campaign progress stay saved.</p></div>
|
||||||
|
<button :disabled="signingOut || loading" @click="leave">{{ signingOut ? 'SIGNING OUT…' : 'SIGN OUT' }}</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-page{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}
|
||||||
|
.profile-intro{max-width:760px}.kicker{margin:0;font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.profile-intro h1{margin:14px 0 12px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.profile-intro h1 span{color:var(--acid)}.profile-intro>p:last-child{color:var(--muted);line-height:1.7}
|
||||||
|
.profile-card{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);max-width:1050px;margin-top:52px;border:1px solid var(--line);background:#0e0e0d}.identity-mark{min-height:460px;display:grid;place-items:center;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 50%,#303018 0 3%,#15170c 17%,#090909 64%);color:var(--acid);font:600 clamp(48px,7vw,92px) var(--display);letter-spacing:-.08em}.profile-content{padding:clamp(30px,5vw,58px)}.profile-content>small,.session-card small{font:600 8px var(--mono);letter-spacing:.17em;color:var(--acid)}
|
||||||
|
.profile-form{display:grid;gap:18px;margin-top:30px}.profile-form label{display:grid;gap:9px;font:600 8px var(--mono);letter-spacing:.13em;color:#b5b5ae}.profile-form input{width:100%;min-height:50px;padding:0 15px;border:1px solid #373732;background:#090909;color:var(--ink);font:12px var(--body);outline:none}.profile-form input:focus{border-color:var(--acid)}.profile-form input[readonly]{color:var(--muted);cursor:not-allowed}.field-note{margin:0;color:var(--muted);font-size:11px;line-height:1.65}.form-message{margin:0;padding:12px 14px;font:500 9px/1.55 var(--mono)}.form-message.error{border:1px solid #6b372e;background:#221310;color:#ffab98}.form-message.success{border:1px solid #526425;background:#151a0d;color:var(--acid)}.profile-actions{display:flex;align-items:center;gap:20px;margin-top:8px}.profile-actions button{min-height:50px;padding:0 22px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions a{color:var(--muted);font:600 8px var(--mono);letter-spacing:.1em}.loading-state{margin-top:30px;color:var(--muted);font:500 9px var(--mono);letter-spacing:.12em}
|
||||||
|
.session-card{display:flex;align-items:center;justify-content:space-between;gap:30px;max-width:1050px;margin-top:18px;padding:30px clamp(28px,4vw,46px);border:1px solid var(--line);background:#0c0c0b}.session-card h2{margin:12px 0 8px;font:600 clamp(20px,3vw,30px) var(--display);letter-spacing:-.04em}.session-card p{margin:0;color:var(--muted);font-size:12px;line-height:1.6}.session-card button{min-width:150px;min-height:46px;border:1px solid #6b372e;background:transparent;color:#ffab98;font:600 9px var(--mono);letter-spacing:.12em}.session-card button:disabled{opacity:.45;cursor:wait}
|
||||||
|
@media(max-width:760px){.profile-card{grid-template-columns:1fr}.identity-mark{min-height:210px;border-right:0;border-bottom:1px solid var(--line)}.session-card{align-items:flex-start;flex-direction:column}.session-card button{width:100%}}
|
||||||
|
@media(max-width:520px){.profile-intro h1{font-size:clamp(36px,13vw,54px)}.profile-content{padding:28px 22px}.profile-actions{align-items:stretch;flex-direction:column}.profile-actions button{width:100%}.profile-actions a{text-align:center}.session-card{padding:26px 22px}}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user