Compare commits
1 Commits
fb4b06711d
...
agent/code
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d65bcde9 |
@@ -8,10 +8,7 @@ const initials = computed(() => {
|
|||||||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||||||
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'
|
||||||
})
|
})
|
||||||
const avatarUrl = computed(() => {
|
|
||||||
const value = session.value?.user.user_metadata?.avatar_url
|
|
||||||
return typeof value === 'string' && value ? value : ''
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,9 +18,7 @@ const avatarUrl = computed(() => {
|
|||||||
<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>
|
||||||
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Open profile for ${session.user.email ?? ''}`" aria-label="Open profile">
|
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Manage profile for ${session.user.email ?? ''}`" aria-label="Manage profile">{{ initials }}</NuxtLink>
|
||||||
<span>{{ initials }}</span><img v-if="avatarUrl" :src="avatarUrl" alt="">
|
|
||||||
</NuxtLink>
|
|
||||||
<div v-else class="avatar">D&G</div>
|
<div v-else class="avatar">D&G</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -32,5 +27,5 @@ const avatarUrl = computed(() => {
|
|||||||
</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{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}@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,62 +211,42 @@ export function useDngAuth() {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUserMetadata(data: Record<string, unknown>) {
|
async function updateProfile(displayName: string) {
|
||||||
const token = await accessToken()
|
const nextDisplayName = displayName.trim()
|
||||||
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
||||||
method: 'PUT',
|
throw new Error('Display name must contain between 2 and 80 characters.')
|
||||||
headers: authHeaders(token),
|
|
||||||
body: { data },
|
|
||||||
}))
|
|
||||||
if (session.value) persist({ ...session.value, user })
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
async function avatarStorageRequest(path: string, options: RequestInit) {
|
|
||||||
const token = await accessToken()
|
|
||||||
const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '')
|
|
||||||
const response = await fetch(`${baseUrl}/storage/v1/${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
apikey: config.public.supabaseAnonKey,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
|
|
||||||
throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAvatar(file: File) {
|
const token = await accessToken()
|
||||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||||
throw new Error('Choose a JPG, PNG, or WebP image.')
|
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
|
||||||
}
|
}
|
||||||
if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.')
|
|
||||||
await restore()
|
|
||||||
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
|
||||||
const path = `${session.value.user.id}/avatar`
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('cacheControl', '3600')
|
|
||||||
body.append('', file)
|
|
||||||
await avatarStorageRequest(`object/profile-avatars/${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'x-upsert': 'true' },
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeAvatar() {
|
|
||||||
await restore()
|
|
||||||
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
|
||||||
const path = `${session.value.user.id}/avatar`
|
|
||||||
await avatarStorageRequest('object/profile-avatars', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ prefixes: [path] }),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessToken() {
|
async function accessToken() {
|
||||||
@@ -296,20 +276,5 @@ export function useDngAuth() {
|
|||||||
hydrated.value = true
|
hydrated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, updateProfile, accessToken, signOut, invalidate }
|
||||||
session,
|
|
||||||
hydrated,
|
|
||||||
restore,
|
|
||||||
refresh,
|
|
||||||
signUp,
|
|
||||||
signIn,
|
|
||||||
requestPasswordReset,
|
|
||||||
updatePassword,
|
|
||||||
updateUserMetadata,
|
|
||||||
uploadAvatar,
|
|
||||||
removeAvatar,
|
|
||||||
accessToken,
|
|
||||||
signOut,
|
|
||||||
invalidate,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated'
|
|||||||
interface CampaignPayload {
|
interface CampaignPayload {
|
||||||
campaign: Record<string, any>
|
campaign: Record<string, any>
|
||||||
world: Record<string, any>
|
world: Record<string, any>
|
||||||
members: Array<Record<string, any> & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }>
|
members: Array<Record<string, any> & { profile?: { display_name?: string } | null }>
|
||||||
characters: Array<Record<string, any>>
|
characters: Array<Record<string, any>>
|
||||||
round: Record<string, any> | null
|
round: Record<string, any> | null
|
||||||
rounds: Array<Record<string, any>>
|
rounds: Array<Record<string, any>>
|
||||||
@@ -311,7 +311,7 @@ onBeforeUnmount(() => {
|
|||||||
<aside class="control-panel">
|
<aside class="control-panel">
|
||||||
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
||||||
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
||||||
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><NuxtLink class="member-profile" :to="`/profile/${member.user_id}?campaign=${campaignId}`"><span class="member-avatar"><b>{{ initials(member.profile?.display_name || 'Adventurer') }}</b><img v-if="member.profile?.avatar_url" :src="member.profile.avatar_url" alt=""></span><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em></span></NuxtLink><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></article></section>
|
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></span></article></section>
|
||||||
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
||||||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||||
@@ -334,12 +334,4 @@ onBeforeUnmount(() => {
|
|||||||
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
||||||
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
||||||
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
||||||
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
|
|
||||||
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
|
|
||||||
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
|
|
||||||
.member-avatar{position:relative;overflow:hidden;display:grid!important;place-items:center;width:30px;height:30px;border:1px solid var(--line);background:#171714;color:var(--acid)}
|
|
||||||
.member-avatar>b{font:600 7px var(--mono)!important}
|
|
||||||
.member-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
|
||||||
.members .member-profile b{overflow-wrap:anywhere}
|
|
||||||
.members .takeover-toggle{grid-column:2;margin-left:39px}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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>
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
interface ProfileView {
|
|
||||||
id: string
|
|
||||||
display_name: string
|
|
||||||
description: string
|
|
||||||
avatar_url: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SharedCampaign {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
status: string
|
|
||||||
role: string
|
|
||||||
joined_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { api } = useDngApi()
|
|
||||||
const auth = useDngAuth()
|
|
||||||
const profile = ref<ProfileView | null>(null)
|
|
||||||
const campaigns = ref<SharedCampaign[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref('')
|
|
||||||
const profileId = computed(() => String(route.params.id ?? ''))
|
|
||||||
const returnCampaign = computed(() => typeof route.query.campaign === 'string' ? route.query.campaign : '')
|
|
||||||
const initials = computed(() => (profile.value?.display_name || 'Adventurer')
|
|
||||||
.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
|
|
||||||
|
|
||||||
function messageFrom(cause: unknown) {
|
|
||||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
|
||||||
return value.data?.statusMessage ?? value.message ?? 'This player profile is not available.'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await auth.restore()
|
|
||||||
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${profileId.value}`)
|
|
||||||
profile.value = result.profile
|
|
||||||
campaigns.value = result.sharedCampaigns
|
|
||||||
} catch (cause) {
|
|
||||||
error.value = messageFrom(cause)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppShell section="PLAYER RECORD">
|
|
||||||
<div v-if="loading" class="public-state noise"><i /><p>LOCATING PLAYER RECORD…</p></div>
|
|
||||||
<div v-else-if="!profile" class="public-state noise"><small>PRIVATE RECORD</small><h1>PLAYER NOT FOUND.</h1><p>{{ error }}</p><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">GO BACK</NuxtLink></div>
|
|
||||||
<div v-else class="public-profile noise">
|
|
||||||
<nav><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">← {{ returnCampaign ? 'BACK TO CAMPAIGN' : 'BACK TO DASHBOARD' }}</NuxtLink><NuxtLink v-if="profile.id === auth.session.value?.user.id" to="/profile">MANAGE PROFILE →</NuxtLink></nav>
|
|
||||||
<main>
|
|
||||||
<div class="public-avatar"><span>{{ initials }}</span><img v-if="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.display_name}'s profile picture`"><i>VERIFIED<br>PARTY MEMBER</i></div>
|
|
||||||
<section class="public-copy">
|
|
||||||
<p class="eyebrow">PLAYER PROFILE / DUNGEONS & GROUND</p>
|
|
||||||
<h1>{{ profile.display_name }}<span>.</span></h1>
|
|
||||||
<blockquote>{{ profile.description || 'This adventurer has not written a profile description yet.' }}</blockquote>
|
|
||||||
<dl>
|
|
||||||
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
|
|
||||||
<div><dt>SHARED UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div v-if="campaigns.length" class="shared-worlds"><small>YOUR SHARED UNIVERSES</small><NuxtLink v-for="campaign in campaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`"><span><b>{{ campaign.title }}</b><em>{{ campaign.role }} · joined {{ new Date(campaign.joined_at).toLocaleDateString() }}</em></span><i>↗</i></NuxtLink></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.public-profile{min-height:calc(100vh - 76px);padding:32px clamp(20px,6vw,88px) 80px}.public-profile nav{display:flex;justify-content:space-between;gap:20px;padding:0 0 28px;border-bottom:1px solid var(--line)}.public-profile nav a,.public-state a{color:var(--muted);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.public-profile main{display:grid;grid-template-columns:minmax(300px,42%) minmax(0,1fr);min-height:650px;border:1px solid var(--line);border-top:0;background:#0e0e0d}.public-avatar{position:relative;overflow:hidden;display:grid;place-items:center;min-height:650px;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 42%,#4e5724,#17170f 33%,#080808 70%);color:var(--acid);font:600 clamp(50px,8vw,110px) var(--display)}.public-avatar::after{content:"";position:absolute;inset:28px;border:1px solid rgba(217,247,95,.2)}.public-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.public-avatar i{position:absolute;z-index:2;left:50px;bottom:45px;color:var(--ink);font:500 8px/1.6 var(--mono);letter-spacing:.16em;font-style:normal}.public-copy{padding:clamp(35px,6vw,80px);align-self:center}.eyebrow{color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.public-copy h1,.public-state h1{margin:18px 0 28px;font:600 clamp(42px,6vw,78px)/1.04 var(--display);letter-spacing:-.07em;overflow-wrap:anywhere}.public-copy h1 span{color:var(--acid)}blockquote{margin:0;padding-left:22px;border-left:2px solid var(--acid);color:#c9c9c2;font:400 clamp(14px,2vw,19px)/1.8 var(--body)}dl{display:grid;grid-template-columns:1fr 1fr;margin:42px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}dl div{padding:18px 0}dl div+div{padding-left:24px;border-left:1px solid var(--line)}dt{color:var(--muted);font:500 7px var(--mono);letter-spacing:.13em}dd{margin:7px 0 0;font-size:11px}.shared-worlds>small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.14em}.shared-worlds>a{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 0;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none}.shared-worlds span{display:flex;flex-direction:column;gap:6px}.shared-worlds b{font:600 11px var(--display)}.shared-worlds em{color:var(--muted);font:500 7px var(--mono);text-transform:uppercase;font-style:normal}.shared-worlds>a>i{color:var(--acid);font-style:normal}.public-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.public-state>i{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}.public-state small{color:var(--acid);font:500 8px var(--mono);letter-spacing:.15em}.public-state p{color:var(--muted)}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:820px){.public-profile main{grid-template-columns:1fr}.public-avatar{min-height:380px;border-right:0;border-bottom:1px solid var(--line)}.public-avatar i{left:28px;bottom:26px}.public-copy{padding:34px 26px}}@media(max-width:480px){dl{grid-template-columns:1fr}dl div+div{padding-left:0;border-left:0;border-top:1px solid var(--line)}}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
|||||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -31,12 +30,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
||||||
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
||||||
const profiles = userIds.length
|
const profiles = userIds.length
|
||||||
? await stageTwoDatabase<ProfileRecord[]>(
|
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
|
||||||
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=in.(${userIds.join(',')})`,
|
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const supabaseUrl = useRuntimeConfig().supabaseUrl
|
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
||||||
const profileById = new Map(profiles.map(profile => [profile.id, visibleProfile(profile, supabaseUrl)]))
|
|
||||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||||
const visibleRounds = rounds.slice().reverse()
|
const visibleRounds = rounds.slice().reverse()
|
||||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
|
||||||
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
|
||||||
|
|
||||||
const ProfileUpdateSchema = z.object({
|
|
||||||
displayName: z.string().trim().min(1).max(80).optional(),
|
|
||||||
description: z.string().trim().max(500).optional(),
|
|
||||||
avatarPath: z.string().nullable().optional(),
|
|
||||||
}).strict().refine(value => Object.keys(value).length > 0, 'At least one profile field is required.')
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const user = await requireStageTwoUser(event)
|
|
||||||
const update = ProfileUpdateSchema.parse(await readBody(event))
|
|
||||||
const expectedAvatarPath = `${user.id}/avatar`
|
|
||||||
if (update.avatarPath !== undefined && update.avatarPath !== null && update.avatarPath !== expectedAvatarPath) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Invalid profile picture path.' })
|
|
||||||
}
|
|
||||||
if (update.displayName !== undefined) requireStageTwoSafeText(update.displayName)
|
|
||||||
if (update.description !== undefined) requireStageTwoSafeText(update.description)
|
|
||||||
|
|
||||||
const changes: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
|
||||||
if (update.displayName !== undefined) changes.display_name = update.displayName
|
|
||||||
if (update.description !== undefined) changes.description = update.description
|
|
||||||
if (update.avatarPath !== undefined) changes.avatar_path = update.avatarPath
|
|
||||||
|
|
||||||
const profiles = await stageTwoDatabase<ProfileRecord[]>(`profiles?id=eq.${user.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(changes),
|
|
||||||
prefer: 'return=representation',
|
|
||||||
})
|
|
||||||
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
|
||||||
return { profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl) }
|
|
||||||
} catch (error) {
|
|
||||||
stageTwoApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
|
||||||
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
|
||||||
|
|
||||||
interface MembershipRow {
|
|
||||||
campaign_id: string
|
|
||||||
role: string
|
|
||||||
joined_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
try {
|
|
||||||
const user = await requireStageTwoUser(event)
|
|
||||||
const profileId = stageTwoUuid(getRouterParam(event, 'id'), 'profile id')
|
|
||||||
const viewerMemberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
|
|
||||||
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
|
|
||||||
)
|
|
||||||
const viewerCampaignIds = [...new Set(viewerMemberships.map(member => member.campaign_id))]
|
|
||||||
|
|
||||||
let sharedMemberships: MembershipRow[]
|
|
||||||
if (profileId === user.id) {
|
|
||||||
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
|
||||||
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&order=joined_at.desc`,
|
|
||||||
)
|
|
||||||
} else if (viewerCampaignIds.length) {
|
|
||||||
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
|
||||||
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&campaign_id=in.(${viewerCampaignIds.join(',')})&order=joined_at.desc`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
sharedMemberships = []
|
|
||||||
}
|
|
||||||
if (profileId !== user.id && !sharedMemberships.length) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Profile not found in your campaigns.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const profiles = await stageTwoDatabase<ProfileRecord[]>(
|
|
||||||
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=eq.${profileId}&limit=1`,
|
|
||||||
)
|
|
||||||
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
|
||||||
|
|
||||||
const campaignIds = sharedMemberships.map(member => member.campaign_id)
|
|
||||||
const campaigns = campaignIds.length
|
|
||||||
? await stageTwoDatabase<Array<{ id: string; title: string; status: string }>>(
|
|
||||||
`campaigns?select=id,title,status&id=in.(${campaignIds.join(',')})`,
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
const campaignById = new Map(campaigns.map(campaign => [campaign.id, campaign]))
|
|
||||||
const sharedCampaigns = sharedMemberships.flatMap((membership) => {
|
|
||||||
const campaign = campaignById.get(membership.campaign_id)
|
|
||||||
return campaign ? [{
|
|
||||||
id: campaign.id,
|
|
||||||
title: campaign.title,
|
|
||||||
status: campaign.status,
|
|
||||||
role: membership.role,
|
|
||||||
joined_at: membership.joined_at,
|
|
||||||
}] : []
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl),
|
|
||||||
sharedCampaigns,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
stageTwoApiError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { profileAvatarUrl, visibleProfile } from './profile'
|
|
||||||
|
|
||||||
const profile = {
|
|
||||||
id: '00000000-0000-0000-0000-000000000001',
|
|
||||||
display_name: 'Aster Vale',
|
|
||||||
description: 'Keeper of impossible maps.',
|
|
||||||
avatar_path: '00000000-0000-0000-0000-000000000001/avatar',
|
|
||||||
created_at: '2026-08-20T10:00:00.000Z',
|
|
||||||
updated_at: '2026-08-20T11:00:00.000Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('profile presentation', () => {
|
|
||||||
it('builds a cache-busted public avatar URL', () => {
|
|
||||||
expect(profileAvatarUrl(profile, 'https://example.supabase.co/')).toBe(
|
|
||||||
'https://example.supabase.co/storage/v1/object/public/profile-avatars/00000000-0000-0000-0000-000000000001/avatar?v=2026-08-20T11%3A00%3A00.000Z',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not expose the storage path in a visible profile', () => {
|
|
||||||
const visible = visibleProfile(profile, 'https://example.supabase.co')
|
|
||||||
expect(visible).not.toHaveProperty('avatar_path')
|
|
||||||
expect(visible.avatar_url).toContain('/profile-avatars/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns no URL when the profile has no avatar', () => {
|
|
||||||
expect(profileAvatarUrl({ ...profile, avatar_path: null }, 'https://example.supabase.co')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export interface ProfileRecord {
|
|
||||||
id: string
|
|
||||||
display_name: string
|
|
||||||
description: string
|
|
||||||
avatar_path: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function profileAvatarUrl(profile: Pick<ProfileRecord, 'avatar_path' | 'updated_at'>, supabaseUrl: string): string | null {
|
|
||||||
if (!profile.avatar_path) return null
|
|
||||||
const encodedPath = profile.avatar_path.split('/').map(encodeURIComponent).join('/')
|
|
||||||
const version = encodeURIComponent(profile.updated_at)
|
|
||||||
return `${supabaseUrl.replace(/\/$/, '')}/storage/v1/object/public/profile-avatars/${encodedPath}?v=${version}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function visibleProfile(profile: ProfileRecord, supabaseUrl: string) {
|
|
||||||
return {
|
|
||||||
id: profile.id,
|
|
||||||
display_name: profile.display_name,
|
|
||||||
description: profile.description,
|
|
||||||
avatar_url: profileAvatarUrl(profile, supabaseUrl),
|
|
||||||
created_at: profile.created_at,
|
|
||||||
updated_at: profile.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2258,72 +2258,6 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 0009_profile_management.sql
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
alter table public.profiles
|
|
||||||
add column description text not null default '' check (char_length(description) <= 500),
|
|
||||||
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
|
||||||
add column updated_at timestamptz not null default now();
|
|
||||||
|
|
||||||
-- Avatars are public profile media, but only the owning authenticated user may
|
|
||||||
-- create, replace, or remove the one deterministic object in their folder.
|
|
||||||
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
|
||||||
values (
|
|
||||||
'profile-avatars',
|
|
||||||
'profile-avatars',
|
|
||||||
true,
|
|
||||||
2097152,
|
|
||||||
array['image/jpeg', 'image/png', 'image/webp']
|
|
||||||
)
|
|
||||||
on conflict (id) do update
|
|
||||||
set public = excluded.public,
|
|
||||||
file_size_limit = excluded.file_size_limit,
|
|
||||||
allowed_mime_types = excluded.allowed_mime_types;
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_insert
|
|
||||||
on storage.objects for insert to authenticated
|
|
||||||
with check (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_select_own
|
|
||||||
on storage.objects for select to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_update
|
|
||||||
on storage.objects for update to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
)
|
|
||||||
with check (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_delete
|
|
||||||
on storage.objects for delete to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create or replace function public.dng_schema_version()
|
|
||||||
returns integer language sql stable security definer set search_path = '' as $$
|
|
||||||
select 9;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
|
||||||
grant execute on function public.dng_schema_version() to service_role;
|
|
||||||
|
|
||||||
notify pgrst, 'reload schema';
|
|
||||||
|
|
||||||
|
|
||||||
do $$
|
do $$
|
||||||
begin
|
begin
|
||||||
@@ -2348,7 +2282,7 @@ begin
|
|||||||
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
||||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||||
end if;
|
end if;
|
||||||
if public.dng_schema_version() <> 9 then
|
if public.dng_schema_version() <> 8 then
|
||||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
alter table public.profiles
|
|
||||||
add column description text not null default '' check (char_length(description) <= 500),
|
|
||||||
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
|
||||||
add column updated_at timestamptz not null default now();
|
|
||||||
|
|
||||||
-- Avatars are public profile media, but only the owning authenticated user may
|
|
||||||
-- create, replace, or remove the one deterministic object in their folder.
|
|
||||||
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
|
||||||
values (
|
|
||||||
'profile-avatars',
|
|
||||||
'profile-avatars',
|
|
||||||
true,
|
|
||||||
2097152,
|
|
||||||
array['image/jpeg', 'image/png', 'image/webp']
|
|
||||||
)
|
|
||||||
on conflict (id) do update
|
|
||||||
set public = excluded.public,
|
|
||||||
file_size_limit = excluded.file_size_limit,
|
|
||||||
allowed_mime_types = excluded.allowed_mime_types;
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_insert
|
|
||||||
on storage.objects for insert to authenticated
|
|
||||||
with check (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_select_own
|
|
||||||
on storage.objects for select to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_update
|
|
||||||
on storage.objects for update to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
)
|
|
||||||
with check (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy dng_profile_avatar_delete
|
|
||||||
on storage.objects for delete to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'profile-avatars'
|
|
||||||
and name = (select auth.uid())::text || '/avatar'
|
|
||||||
);
|
|
||||||
|
|
||||||
create or replace function public.dng_schema_version()
|
|
||||||
returns integer language sql stable security definer set search_path = '' as $$
|
|
||||||
select 9;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
|
||||||
grant execute on function public.dng_schema_version() to service_role;
|
|
||||||
|
|
||||||
notify pgrst, 'reload schema';
|
|
||||||
Reference in New Issue
Block a user