From 6b416eeb026e72977c2a77555e2b628640fbde2d Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Mon, 31 Aug 2026 19:36:00 +0200 Subject: [PATCH 1/4] fix: added missing space --- src/views/AboutView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index 103fe5c..404193e 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -22,7 +22,7 @@ onMounted(() => {

Users

- Signed in as + Signed in as {{ authStore.email ?? 'Unknown' }}

+ +

Pending changes

From 2c45478478de83ad8057123d694d77e2862bbdff Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Mon, 31 Aug 2026 21:35:37 +0200 Subject: [PATCH 4/4] feat: registration with invite codes --- src/api/__tests__/users.spec.ts | 96 ++++++++ src/api/users.ts | 9 + src/components/InvitesPanel.vue | 46 +++- src/components/__tests__/InvitesPanel.spec.ts | 60 +++++ src/router/__tests__/index.spec.ts | 55 +++++ src/router/index.ts | 13 ++ src/types/user.ts | 13 ++ src/views/RegisterView.vue | 218 ++++++++++++++++++ src/views/__tests__/RegisterView.spec.ts | 146 ++++++++++++ 9 files changed, 645 insertions(+), 11 deletions(-) create mode 100644 src/api/__tests__/users.spec.ts create mode 100644 src/router/__tests__/index.spec.ts create mode 100644 src/types/user.ts create mode 100644 src/views/RegisterView.vue create mode 100644 src/views/__tests__/RegisterView.spec.ts diff --git a/src/api/__tests__/users.spec.ts b/src/api/__tests__/users.spec.ts new file mode 100644 index 0000000..67d8fc7 --- /dev/null +++ b/src/api/__tests__/users.spec.ts @@ -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().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().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().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().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') + }) + }) +}) diff --git a/src/api/users.ts b/src/api/users.ts index 25ea654..1bbee6f 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -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 { 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 { + const response = await apiClient.post('/users', payload) + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to create account')) + } + return response.json() +} diff --git a/src/components/InvitesPanel.vue b/src/components/InvitesPanel.vue index 4c1772e..2121f7c 100644 --- a/src/components/InvitesPanel.vue +++ b/src/components/InvitesPanel.vue @@ -12,17 +12,17 @@ const isCreating = ref(false) const error = ref('') const pendingDeleteId = ref(null) const deletingId = ref(null) -const copiedId = ref(null) +const sharedId = ref(null) let hasLoaded = false -let copiedTimeout: ReturnType | undefined +let sharedTimeout: ReturnType | 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.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(invite.code) - copiedId.value = invite.id - clearTimeout(copiedTimeout) - copiedTimeout = setTimeout(() => { - copiedId.value = null + 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 { {{ inviteDetail(invite) }}
-