feat: registration with invite codes

This commit is contained in:
2026-08-31 21:35:37 +02:00
parent 68991a3722
commit 2c45478478
9 changed files with 645 additions and 11 deletions
+96
View File
@@ -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')
})
})
})
+9
View File
@@ -1,6 +1,7 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { ChangePasswordPayload } from '@/types/auth'
import type { CreateUserPayload, User } from '@/types/user'
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
const response = await apiClient.post('/user/password', payload)
@@ -8,3 +9,11 @@ export async function changePasswordApi(payload: ChangePasswordPayload): Promise
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()
}
+35 -11
View File
@@ -12,17 +12,17 @@ const isCreating = ref(false)
const error = ref('')
const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null)
const copiedId = ref<string | null>(null)
const sharedId = ref<string | null>(null)
let hasLoaded = false
let copiedTimeout: ReturnType<typeof setTimeout> | undefined
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed(
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
)
onBeforeUnmount(() => {
clearTimeout(copiedTimeout)
clearTimeout(sharedTimeout)
})
function isOffline(): boolean {
@@ -66,6 +66,9 @@ async function handleCreate() {
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 {
@@ -102,13 +105,34 @@ async function confirmDelete(id: string) {
}
}
async function copyCode(invite: Invite) {
// 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.clipboard.writeText(invite.code)
copiedId.value = invite.id
clearTimeout(copiedTimeout)
copiedTimeout = setTimeout(() => {
copiedId.value = null
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.
@@ -203,8 +227,8 @@ function inviteDetail(invite: Invite): string {
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
<button type="button" class="ticket-btn" @click="copyCode(invite)">
{{ copiedId === invite.id ? 'Copied!' : 'Copy' }}
<button type="button" class="ticket-btn" @click="shareInvite(invite)">
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
</button>
<button
type="button"
@@ -8,6 +8,12 @@ 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', () => {
@@ -78,6 +84,60 @@ describe('InvitesPanel', () => {
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])
+55
View File
@@ -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')
})
})
+13
View File
@@ -28,12 +28,25 @@ const router = createRouter({
name: 'account',
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'),
},
],
})
router.beforeEach((to) => {
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) {
return { name: 'login', query: { redirect: to.fullPath } }
}
+13
View File
@@ -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
}
+218
View File
@@ -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>
+146
View File
@@ -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))
}