Invite system and user registration #12
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { API_BASE_URL } from '@/api/http'
|
||||||
|
|
||||||
|
describe('invites API', () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getInvitesApi sends GET to /user/invites and returns the invites', async () => {
|
||||||
|
const mockInvites = [{ id: 'invite-1', code: 'ABC123' }]
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockInvites,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
const result = await getInvitesApi()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/user/invites`,
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockInvites)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getInvitesApi throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Failed' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(getInvitesApi()).rejects.toThrow('Failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
|
||||||
|
const mockInvite = { id: 'invite-1', code: 'ABC123' }
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => mockInvite,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
const result = await createInviteApi()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/user/invites`,
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockInvite)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createInviteApi throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Failed to create invite' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(createInviteApi()).rejects.toThrow('Failed to create invite')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteInviteApi sends DELETE to /user/invites/:id', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
await deleteInviteApi('invite-1')
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/user/invites/invite-1`,
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteInviteApi throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Failed to delete invite' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(deleteInviteApi('invite-1')).rejects.toThrow('Failed to delete invite')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { changePasswordApi, createUserApi } from '../users'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { API_BASE_URL } from '@/api/http'
|
||||||
|
|
||||||
|
describe('users API', () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('changePasswordApi', () => {
|
||||||
|
it('sends POST to /user/password with the old and new password', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
|
||||||
|
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
await changePasswordApi({ old_password: 'old', new_password: 'new12345' })
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/user/password`,
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ old_password: 'old', new_password: 'new12345' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Failed' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
changePasswordApi({ old_password: 'old', new_password: 'new12345' }),
|
||||||
|
).rejects.toThrow('Failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createUserApi', () => {
|
||||||
|
it('sends POST to /users with the registration payload and returns the created user', async () => {
|
||||||
|
const mockUser = { id: 'user-1', email: 'new@example.com', name: 'New User' }
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => mockUser,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
name: 'New User',
|
||||||
|
invite_code: 'invite-abc',
|
||||||
|
}
|
||||||
|
const result = await createUserApi(payload)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/users`,
|
||||||
|
expect.objectContaining({ method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Invalid invite code' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createUserApi({
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
name: 'New User',
|
||||||
|
invite_code: 'bad-code',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Invalid invite code')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { extractErrorMessage } from '@/api/http'
|
||||||
|
import type { Invite } from '@/types/invite'
|
||||||
|
|
||||||
|
export async function getInvitesApi(): Promise<Invite[]> {
|
||||||
|
const response = await apiClient.get('/user/invites')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to load invites'))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInviteApi(): Promise<Invite> {
|
||||||
|
const response = await apiClient.post('/user/invites')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to create invite'))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInviteApi(id: string): Promise<void> {
|
||||||
|
const response = await apiClient.delete(`/user/invites/${id}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to delete invite'))
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -1,10 +1,19 @@
|
|||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { extractErrorMessage } from '@/api/http'
|
import { extractErrorMessage } from '@/api/http'
|
||||||
import type { ChangePasswordPayload } from '@/types/auth'
|
import type { ChangePasswordPayload } from '@/types/auth'
|
||||||
|
import type { CreateUserPayload, User } from '@/types/user'
|
||||||
|
|
||||||
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
|
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
|
||||||
const response = await apiClient.post('/users/password', payload)
|
const response = await apiClient.post('/user/password', payload)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await extractErrorMessage(response, 'Failed to change password'))
|
throw new Error(await extractErrorMessage(response, 'Failed to change password'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUserApi(payload: CreateUserPayload): Promise<User> {
|
||||||
|
const response = await apiClient.post('/users', payload)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to create account'))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const listsStore = useListsStore()
|
|||||||
listsStore.pendingCount
|
listsStore.pendingCount
|
||||||
}}</span>
|
}}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/about" class="nav-item" active-class="is-active">
|
<RouterLink to="/account" class="nav-item" active-class="is-active">
|
||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">⚙</span>
|
||||||
<span class="nav-label">Account</span>
|
<span class="nav-label">Account</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||||
|
import type { Invite } from '@/types/invite'
|
||||||
|
|
||||||
|
type InviteStatus = 'active' | 'used' | 'expired'
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
const invites = ref<Invite[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const pendingDeleteId = ref<string | null>(null)
|
||||||
|
const deletingId = ref<string | null>(null)
|
||||||
|
const sharedId = ref<string | null>(null)
|
||||||
|
|
||||||
|
let hasLoaded = false
|
||||||
|
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const activeCount = computed(
|
||||||
|
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(sharedTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
function isOffline(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && !navigator.onLine
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleExpanded() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
if (expanded.value && !hasLoaded) {
|
||||||
|
await loadInvites()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvites() {
|
||||||
|
error.value = ''
|
||||||
|
if (isOffline()) {
|
||||||
|
error.value = 'You must be online to manage invites.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
invites.value = await getInvitesApi()
|
||||||
|
hasLoaded = true
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load invites'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
error.value = ''
|
||||||
|
if (isOffline()) {
|
||||||
|
error.value = 'You must be online to create an invite.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreating.value = true
|
||||||
|
try {
|
||||||
|
const invite = await createInviteApi()
|
||||||
|
invites.value = [invite, ...invites.value]
|
||||||
|
hasLoaded = true
|
||||||
|
// Sharing is the whole point of an invite, so offer it immediately
|
||||||
|
// instead of making the user hunt for the Share button afterwards.
|
||||||
|
await shareInvite(invite)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to create invite'
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id: string) {
|
||||||
|
error.value = ''
|
||||||
|
pendingDeleteId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
pendingDeleteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(id: string) {
|
||||||
|
error.value = ''
|
||||||
|
if (isOffline()) {
|
||||||
|
error.value = 'You must be online to delete an invite.'
|
||||||
|
pendingDeleteId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingId.value = id
|
||||||
|
try {
|
||||||
|
await deleteInviteApi(id)
|
||||||
|
invites.value = invites.value.filter((invite) => invite.id !== id)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
pendingDeleteId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link that lands someone on the (otherwise unlinked) register page —
|
||||||
|
// see router.beforeEach, which redirects any URL carrying ?invite=... there.
|
||||||
|
function getInviteUrl(code: string): string {
|
||||||
|
const base = `${window.location.origin}${import.meta.env.BASE_URL}`
|
||||||
|
return `${base}?invite=${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareInvite(invite: Invite) {
|
||||||
|
const url = getInviteUrl(invite.code)
|
||||||
|
|
||||||
|
if (typeof navigator.share === 'function') {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: 'Join dttmr', text: 'Use this link to create your account', url })
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Web Share unsupported in this context; fall through to clipboard copy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
sharedId.value = invite.id
|
||||||
|
clearTimeout(sharedTimeout)
|
||||||
|
sharedTimeout = setTimeout(() => {
|
||||||
|
sharedId.value = null
|
||||||
|
}, 1500)
|
||||||
|
} catch {
|
||||||
|
// Clipboard access denied or unavailable; nothing sensible to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteStatus(invite: Invite): InviteStatus {
|
||||||
|
if (invite.consumed_at) return 'used'
|
||||||
|
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) return 'expired'
|
||||||
|
return 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(invite: Invite): string {
|
||||||
|
const status = inviteStatus(invite)
|
||||||
|
return status === 'used' ? 'Used' : status === 'expired' ? 'Expired' : 'Active'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard "time ago"/"time until" formatter: walk unit divisions until the
|
||||||
|
// duration fits in one, so both past and future dates read naturally.
|
||||||
|
function formatRelative(dateStr: string): string {
|
||||||
|
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
|
[60, 'seconds'],
|
||||||
|
[60, 'minutes'],
|
||||||
|
[24, 'hours'],
|
||||||
|
[7, 'days'],
|
||||||
|
[4.34524, 'weeks'],
|
||||||
|
[12, 'months'],
|
||||||
|
[Number.POSITIVE_INFINITY, 'years'],
|
||||||
|
]
|
||||||
|
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||||
|
|
||||||
|
let duration = (new Date(dateStr).getTime() - Date.now()) / 1000
|
||||||
|
for (const [amount, unit] of divisions) {
|
||||||
|
if (Math.abs(duration) < amount) {
|
||||||
|
return rtf.format(Math.round(duration), unit)
|
||||||
|
}
|
||||||
|
duration /= amount
|
||||||
|
}
|
||||||
|
return rtf.format(Math.round(duration), 'years')
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteDetail(invite: Invite): string {
|
||||||
|
const status = inviteStatus(invite)
|
||||||
|
if (status === 'used') {
|
||||||
|
return invite.consumed_at ? `Used ${formatRelative(invite.consumed_at)}` : 'Used'
|
||||||
|
}
|
||||||
|
if (!invite.expires_at) {
|
||||||
|
return 'No expiry'
|
||||||
|
}
|
||||||
|
return `${status === 'expired' ? 'Expired' : 'Expires'} ${formatRelative(invite.expires_at)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card info-card invites-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="invites-toggle"
|
||||||
|
:aria-expanded="expanded"
|
||||||
|
aria-controls="invites-panel"
|
||||||
|
@click="toggleExpanded"
|
||||||
|
>
|
||||||
|
<span class="invites-toggle-label">
|
||||||
|
<h4>Invites</h4>
|
||||||
|
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span>
|
||||||
|
</span>
|
||||||
|
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true">⌄</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="expanded" id="invites-panel" class="invites-body">
|
||||||
|
<p class="invites-hint">
|
||||||
|
Invite codes let someone new create an account. Creating, listing, and deleting invites
|
||||||
|
requires an internet connection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="isLoading" class="invites-loading">Loading invites…</p>
|
||||||
|
|
||||||
|
<ul v-else-if="invites.length > 0" class="invite-list">
|
||||||
|
<li
|
||||||
|
v-for="invite in invites"
|
||||||
|
:key="invite.id"
|
||||||
|
class="invite-ticket"
|
||||||
|
:class="`is-${inviteStatus(invite)}`"
|
||||||
|
>
|
||||||
|
<div class="invite-ticket-main">
|
||||||
|
<code class="invite-code">{{ invite.code }}</code>
|
||||||
|
<span class="invite-status-pill" :class="`pill-${inviteStatus(invite)}`">{{
|
||||||
|
statusLabel(invite)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="invite-ticket-meta">
|
||||||
|
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
|
||||||
|
|
||||||
|
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
|
||||||
|
<button type="button" class="ticket-btn" @click="shareInvite(invite)">
|
||||||
|
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ticket-btn ticket-btn-danger"
|
||||||
|
:disabled="inviteStatus(invite) === 'used'"
|
||||||
|
:title="inviteStatus(invite) === 'used' ? 'Used invites cannot be deleted' : ''"
|
||||||
|
@click="requestDelete(invite.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="invite-actions">
|
||||||
|
<span class="confirm-label">Delete this invite?</span>
|
||||||
|
<button type="button" class="ticket-btn" @click="cancelDelete">No</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ticket-btn ticket-btn-danger"
|
||||||
|
:disabled="deletingId === invite.id"
|
||||||
|
@click="confirmDelete(invite.id)"
|
||||||
|
>
|
||||||
|
{{ deletingId === invite.id ? 'Deleting…' : 'Yes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p>
|
||||||
|
|
||||||
|
<p v-if="error" class="banner banner-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary generate-btn"
|
||||||
|
:disabled="isCreating"
|
||||||
|
@click="handleCreate"
|
||||||
|
>
|
||||||
|
{{ isCreating ? 'Generating…' : '+ Generate invite' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invites-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-toggle-label h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-count-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--c-accent-bg);
|
||||||
|
color: var(--c-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
transition: transform 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-loading,
|
||||||
|
.invites-empty {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket {
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-left: 3px solid var(--c-text-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--c-bg-mute);
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket.is-active {
|
||||||
|
border-left-color: var(--c-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket.is-expired {
|
||||||
|
border-left-color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket.is-used {
|
||||||
|
border-left-color: var(--c-text-soft);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--c-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-status-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-active {
|
||||||
|
background-color: rgba(52, 211, 153, 0.14);
|
||||||
|
color: var(--c-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-expired {
|
||||||
|
background-color: var(--c-danger-bg);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-used {
|
||||||
|
background-color: var(--c-bg-elevated);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-ticket-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-top: 1px dashed var(--c-border);
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-detail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-border-hover);
|
||||||
|
color: var(--c-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-btn-danger {
|
||||||
|
color: var(--c-danger);
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-btn-danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--c-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn {
|
||||||
|
width: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import InvitesPanel from '../InvitesPanel.vue'
|
||||||
|
import * as invitesApi from '@/api/invites'
|
||||||
|
import type { Invite } from '@/types/invite'
|
||||||
|
|
||||||
|
describe('InvitesPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue(undefined) },
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
// jsdom has no Web Share API; tests that want it define it explicitly.
|
||||||
|
Reflect.deleteProperty(navigator, 'share')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders collapsed by default without loading invites', () => {
|
||||||
|
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Invites')
|
||||||
|
expect(wrapper.find('#invites-panel').exists()).toBe(false)
|
||||||
|
expect(getSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads and displays invites on expand', async () => {
|
||||||
|
const mockInvites: Invite[] = [
|
||||||
|
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
|
||||||
|
{
|
||||||
|
id: 'invite-2',
|
||||||
|
code: 'USEDCODE',
|
||||||
|
expires_at: '2099-01-01T00:00:00.000Z',
|
||||||
|
consumed_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(mockInvites)
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('ABC123')
|
||||||
|
expect(wrapper.text()).toContain('USEDCODE')
|
||||||
|
expect(wrapper.text()).toContain('Active')
|
||||||
|
expect(wrapper.text()).toContain('Used')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an offline message instead of fetching when expanded offline', async () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||||
|
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
|
||||||
|
expect(getSpy).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('.banner-error').text()).toContain('must be online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when there are no invites', async () => {
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No invites yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates a new invite and prepends it to the list', async () => {
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
|
||||||
|
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
|
||||||
|
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.generate-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('NEWCODE1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares the newly created invite automatically', async () => {
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
|
||||||
|
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
|
||||||
|
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.generate-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('?invite=NEWCODE1'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => {
|
||||||
|
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.ticket-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('?invite=ABC123'),
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('Copied!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the Web Share API instead of the clipboard when available', async () => {
|
||||||
|
const shareMock = vi.fn<(data: ShareData) => Promise<void>>().mockResolvedValue(undefined)
|
||||||
|
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
|
||||||
|
|
||||||
|
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.ticket-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(shareMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ url: expect.stringContaining('?invite=ABC123') }),
|
||||||
|
)
|
||||||
|
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes an invite after confirming', async () => {
|
||||||
|
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite])
|
||||||
|
const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.ticket-btn-danger').trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Delete this invite?')
|
||||||
|
|
||||||
|
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
|
||||||
|
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(deleteSpy).toHaveBeenCalledWith('invite-1')
|
||||||
|
expect(wrapper.text()).toContain('No invites yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables delete for already-used invites', async () => {
|
||||||
|
const usedInvite: Invite = {
|
||||||
|
id: 'invite-1',
|
||||||
|
code: 'USEDCODE',
|
||||||
|
consumed_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([usedInvite])
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const deleteBtn = wrapper.find('.ticket-btn-danger')
|
||||||
|
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error banner when loading invites fails', async () => {
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.banner-error').text()).toContain('Network error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function flushPromises() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import router from '../index'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
describe('router', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated access to protected routes to login', async () => {
|
||||||
|
await router.push('/')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows authenticated access to protected routes', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
|
||||||
|
|
||||||
|
await router.push('/')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('lists')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects any route carrying ?invite= to the (unlisted) register route', async () => {
|
||||||
|
await router.push('/?invite=abc123')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('register')
|
||||||
|
expect(router.currentRoute.value.query.invite).toBe('abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects an authenticated route with an invite code to register too', async () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
|
||||||
|
|
||||||
|
await router.push('/account?invite=abc123')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('register')
|
||||||
|
expect(router.currentRoute.value.query.invite).toBe('abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not redirect when already navigating to register with an invite code', async () => {
|
||||||
|
await router.push('/register?invite=xyz')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('register')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not redirect to register when there is no invite code', async () => {
|
||||||
|
await router.push('/login')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
})
|
||||||
|
})
|
||||||
+16
-3
@@ -24,9 +24,16 @@ const router = createRouter({
|
|||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/account',
|
||||||
name: 'about',
|
name: 'account',
|
||||||
component: () => import('../views/AboutView.vue'),
|
component: () => import('../views/AccountView.vue'),
|
||||||
|
},
|
||||||
|
// Intentionally not linked from anywhere in the UI — reached only via an
|
||||||
|
// invite link (see router.beforeEach below) or by typing the URL directly.
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
component: () => import('../views/RegisterView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -34,6 +41,12 @@ const router = createRouter({
|
|||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// An invite link may point anywhere (e.g. the app root) so it still works
|
||||||
|
// if the recipient doesn't have the app installed/bookmarked at /register.
|
||||||
|
if (to.name !== 'register' && typeof to.query.invite === 'string' && to.query.invite) {
|
||||||
|
return { name: 'register', query: { invite: to.query.invite } }
|
||||||
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Invite {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
expires_at?: string
|
||||||
|
consumed_at?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPayload {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
invite_code: string
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useListsStore } from '@/stores/lists'
|
import { useListsStore } from '@/stores/lists'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
||||||
|
import InvitesPanel from '@/components/InvitesPanel.vue'
|
||||||
|
|
||||||
const listsStore = useListsStore()
|
const listsStore = useListsStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -22,7 +23,7 @@ onMounted(() => {
|
|||||||
<section class="card info-card">
|
<section class="card info-card">
|
||||||
<h4>Users</h4>
|
<h4>Users</h4>
|
||||||
<p class="row">
|
<p class="row">
|
||||||
<span>Signed in as</span>
|
<span>Signed in as </span>
|
||||||
<strong>{{ authStore.email ?? 'Unknown' }}</strong>
|
<strong>{{ authStore.email ?? 'Unknown' }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -34,6 +35,8 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<InvitesPanel />
|
||||||
|
|
||||||
<h1>Pending changes</h1>
|
<h1>Pending changes</h1>
|
||||||
|
|
||||||
<section class="card info-card">
|
<section class="card info-card">
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { createUserApi } from '@/api/users'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useListsStore } from '@/stores/lists'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const listsStore = useListsStore()
|
||||||
|
|
||||||
|
const inviteCode = computed(() => {
|
||||||
|
const invite = route.query.invite
|
||||||
|
return typeof invite === 'string' ? invite : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = 'Password must be at least 8 characters.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = 'Passwords do not match.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await createUserApi({
|
||||||
|
name: name.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
invite_code: inviteCode.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
await authStore.login({ email: email.value, password: password.value })
|
||||||
|
listsStore.sync().catch(() => {})
|
||||||
|
router.push('/')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to create account'
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="register-container">
|
||||||
|
<div class="register-card card">
|
||||||
|
<div class="brand-mark">
|
||||||
|
<span class="brand-dot"></span>
|
||||||
|
</div>
|
||||||
|
<h2>Create your account</h2>
|
||||||
|
|
||||||
|
<div v-if="authStore.isAuthenticated" class="already-logged-in">
|
||||||
|
<p>You're already logged in as {{ authStore.email }}.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="router.push('/')">
|
||||||
|
Go to Home
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger" @click="authStore.logout()">Log Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!inviteCode" class="invalid-invite">
|
||||||
|
<p>This page requires a valid invite link. Ask whoever invited you for a new one.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="handleSubmit">
|
||||||
|
<p class="subtitle">You've been invited to join dttmr. Set up your account below.</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-banner banner banner-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
autocomplete="name"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autocomplete="email"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirm-password">Confirm password</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||||
|
<span v-if="isSubmitting">Creating account...</span>
|
||||||
|
<span v-else>Create account</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-dot {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft));
|
||||||
|
box-shadow: 0 0 24px var(--c-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-logged-in,
|
||||||
|
.invalid-invite {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-logged-in p,
|
||||||
|
.invalid-invite p {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .field {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.register-card {
|
||||||
|
padding: 2.5rem 2.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import RegisterView from '../RegisterView.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useListsStore } from '@/stores/lists'
|
||||||
|
import * as usersApi from '@/api/users'
|
||||||
|
|
||||||
|
const mockPush = vi.fn<(to: string) => void>()
|
||||||
|
let routeQuery: Record<string, string> = { invite: 'invite-code-123' }
|
||||||
|
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-router')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
useRoute: () => ({
|
||||||
|
query: routeQuery,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RegisterView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
mockPush.mockClear()
|
||||||
|
routeQuery = { invite: 'invite-code-123' }
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an invalid-invite message when there is no invite code in the URL', () => {
|
||||||
|
routeQuery = {}
|
||||||
|
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
expect(wrapper.find('.invalid-invite').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the registration form when an invite code is present', () => {
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
expect(wrapper.find('h2').text()).toBe('Create your account')
|
||||||
|
expect(wrapper.find('input#name').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('input#email').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('input#password').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('input#confirm-password').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows logged in status and hides the form if already authenticated', () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setTokens({ access_token: 'active-token', refresh_token: 'active-refresh' })
|
||||||
|
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
expect(wrapper.find('.already-logged-in').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects passwords that are too short', async () => {
|
||||||
|
const createSpy = vi.spyOn(usersApi, 'createUserApi')
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
await wrapper.find('#name').setValue('Jane Doe')
|
||||||
|
await wrapper.find('#email').setValue('jane@example.com')
|
||||||
|
await wrapper.find('#password').setValue('short')
|
||||||
|
await wrapper.find('#confirm-password').setValue('short')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(wrapper.find('.error-banner').text()).toContain('at least 8 characters')
|
||||||
|
expect(createSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects mismatched passwords', async () => {
|
||||||
|
const createSpy = vi.spyOn(usersApi, 'createUserApi')
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
await wrapper.find('#name').setValue('Jane Doe')
|
||||||
|
await wrapper.find('#email').setValue('jane@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('#confirm-password').setValue('password456')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(wrapper.find('.error-banner').text()).toContain('do not match')
|
||||||
|
expect(createSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates the account, logs in, syncs, and redirects home on success', async () => {
|
||||||
|
const createSpy = vi.spyOn(usersApi, 'createUserApi').mockResolvedValueOnce({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
name: 'Jane Doe',
|
||||||
|
})
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const loginSpy = vi.spyOn(authStore, 'login').mockResolvedValueOnce({
|
||||||
|
access_token: 'access-123',
|
||||||
|
refresh_token: 'refresh-456',
|
||||||
|
})
|
||||||
|
const listsStore = useListsStore()
|
||||||
|
const syncSpy = vi.spyOn(listsStore, 'sync').mockResolvedValueOnce()
|
||||||
|
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
await wrapper.find('#name').setValue('Jane Doe')
|
||||||
|
await wrapper.find('#email').setValue('jane@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('#confirm-password').setValue('password123')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(createSpy).toHaveBeenCalledWith({
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
invite_code: 'invite-code-123',
|
||||||
|
})
|
||||||
|
expect(loginSpy).toHaveBeenCalledWith({
|
||||||
|
email: 'jane@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
expect(syncSpy).toHaveBeenCalled()
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error banner when account creation fails', async () => {
|
||||||
|
vi.spyOn(usersApi, 'createUserApi').mockRejectedValueOnce(new Error('Invalid invite code'))
|
||||||
|
|
||||||
|
const wrapper = mount(RegisterView)
|
||||||
|
|
||||||
|
await wrapper.find('#name').setValue('Jane Doe')
|
||||||
|
await wrapper.find('#email').setValue('jane@example.com')
|
||||||
|
await wrapper.find('#password').setValue('password123')
|
||||||
|
await wrapper.find('#confirm-password').setValue('password123')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.error-banner').text()).toContain('Invalid invite code')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function flushPromises() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user