From 22174ebf79ecdca15ff0f627dea3928e6d43873a Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 13:52:33 +1100 Subject: [PATCH] Auth api updates for v2 --- src/api/auth.ts | 60 ++++++--- src/api/client.ts | 7 + src/api/types.ts | 214 ++++++++++++++++++++++++++++-- src/composables/useAuth.ts | 4 +- src/domain/types.ts | 2 + tests/auth.api.test.ts | 82 ++++++++---- tests/useAuth.multitenant.test.ts | 16 +-- 7 files changed, 317 insertions(+), 68 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index f1e9db6..b6d2c14 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,43 +1,58 @@ import { api } from '@/api/client' -import type { Person } from '@/domain/types' +import { setAuthTokenProvider } from '@/api/client' +import type { User } from '@/domain/types' -let cachedUser: Person | null = null +let cachedUser: User | null = null +let authToken: string | null = null +setAuthTokenProvider(() => authToken) -export async function currentUser(): Promise { +export async function currentUser(): Promise { if (cachedUser) return cachedUser try { const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } }) if (!res.response.ok) return null - cachedUser = res.data ?? null + // refresh returns legacy Person; adapt minimally to User shape + const p = res.data as unknown as { id: number; name: string } | null + cachedUser = p ? ({ id: p.id, email: '', displayName: p.name } as User) : null } catch (_) { cachedUser = null } return cachedUser } -export async function login(username: string): Promise { - const res = await api.POST('/api/v1/auth/login', { body: { username } }) - if (!res.response.ok) { - const err = res.error - throw ( - (err instanceof Error && err) || - (typeof err === 'string' ? new Error(err) : new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`)) - ) - } - cachedUser = res.data ?? null - if (!cachedUser) throw new Error('Login failed: empty response') +export async function login(username: string): Promise { + // Legacy compatibility shim: avoid network call; will be removed with new LoginPage + cachedUser = { id: -1, email: '', displayName: username } as User return cachedUser } -// New multitenant-ready API surface (backward compatible) -export async function loginWithPassword(email: string, _password: string): Promise { - // Backend currently expects { username }; map email to username until OpenAPI updates - return login(email) +// New multitenant-ready API surface +export async function loginWithPassword(email: string, password: string): Promise { + const res = await api.POST('/api/v1/auth/login', { body: { email, password } }) + if (!res.response.ok) { + const err = res.error + throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`)) + } + const token = res.data?.accessToken ?? null + const user = res.data?.user ?? null + if (!token || !user) throw new Error('Invalid token response') + authToken = token + cachedUser = user as User + return cachedUser } -export async function createAccount(_email: string, _displayName: string, _password: string): Promise { - // Placeholder until backend endpoints and OpenAPI are finalized - throw new Error('createAccount not implemented yet') +export async function createAccount(email: string, displayName: string, password: string): Promise { + const res = await api.POST('/api/v1/auth/register', { body: { email, password, displayName } }) + if (!res.response.ok) { + const err = res.error + throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`)) + } + const token = res.data?.accessToken ?? null + const user = res.data?.user ?? null + if (!token || !user) throw new Error('Invalid token response') + authToken = token + cachedUser = user as User + return cachedUser } export async function handleGoogleLogin(_token: string): Promise { @@ -48,4 +63,5 @@ export async function handleGoogleLogin(_token: string): Promise { export async function logout(): Promise { // Clear local cache; server session is cookie-based and will be refreshed on next call cachedUser = null + authToken = null } diff --git a/src/api/client.ts b/src/api/client.ts index b214122..b4f6c6d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -10,17 +10,24 @@ const defaultBase = '' const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase) let householdSlugProvider: (() => string | null) | null = null +let authTokenProvider: (() => string | null) | null = null export function setHouseholdSlugProvider(provider: (() => string | null) | null) { householdSlugProvider = provider } +export function setAuthTokenProvider(provider: (() => string | null) | null) { + authTokenProvider = provider +} + export const api = createClient({ baseUrl, fetch: (input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers || {}) const slug = householdSlugProvider ? householdSlugProvider() : null if (slug) headers.set('X-Household-Slug', slug) + const token = authTokenProvider ? authTokenProvider() : null + if (token) headers.set('Authorization', `Bearer ${token}`) return globalThis.fetch(input, { credentials: 'include', ...init, diff --git a/src/api/types.ts b/src/api/types.ts index 827c9cf..35463fc 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -291,8 +291,8 @@ export interface paths { }; get?: never; put?: never; - /** Login and set user_id cookie */ - post: operations["login"]; + /** Login */ + post: operations["loginV2"]; delete?: never; options?: never; head?: never; @@ -316,6 +316,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register */ + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/me/households": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List My Households */ + get: operations["list_my_households_api_v1_users_me_households_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/households": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Household */ + post: operations["create_household_api_v1_households_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/healthz": { parameters: { query?: never; @@ -337,6 +388,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** CreateHouseholdBody */ + CreateHouseholdBody: { + /** Name */ + name: string; + }; /** CurrentShoppingList */ CurrentShoppingList: { /** Outstandingitems */ @@ -375,6 +431,15 @@ export interface components { */ status: string; }; + /** HouseholdResponse */ + HouseholdResponse: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Slug */ + slug: string; + }; /** Ingredient */ Ingredient: { /** @@ -447,11 +512,6 @@ export interface components { /** Recipeid */ recipeId?: number | null; }; - /** LoginBody */ - LoginBody: { - /** Username */ - username: string; - }; /** Meal */ "Meal-Input": { /** @@ -776,6 +836,15 @@ export interface components { hiddenById?: number | null; hiddenBy?: components["schemas"]["Person"] | null; }; + /** RegisterBody */ + RegisterBody: { + /** Email */ + email: string; + /** Password */ + password: string; + /** Displayname */ + displayName: string; + }; /** RequestedMealItem */ RequestedMealItem: { /** @@ -825,6 +894,31 @@ export interface components { * @enum {string} */ StoreEnum: "woolworths" | "coles" | ""; + /** TokenResponse */ + TokenResponse: { + /** Accesstoken */ + accessToken: string; + /** + * Tokentype + * @default bearer + */ + tokenType: string; + user: components["schemas"]["User"]; + }; + /** User */ + User: { + /** + * Id + * @default -1 + */ + id: number; + /** Email */ + email: string; + /** Displayname */ + displayName: string; + /** Profilephotourl */ + profilePhotoUrl?: string | null; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -834,6 +928,18 @@ export interface components { /** Error Type */ type: string; }; + /** LoginBody */ + api__auth__LoginBody: { + /** Username */ + username: string; + }; + /** LoginBody */ + api__auth_v2__LoginBody: { + /** Email */ + email: string; + /** Password */ + password: string; + }; /** * StoreNameOut * @enum {string} @@ -1655,7 +1761,7 @@ export interface operations { }; }; }; - login: { + loginV2: { parameters: { query?: never; header?: never; @@ -1664,7 +1770,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["LoginBody"]; + "application/json": components["schemas"]["api__auth_v2__LoginBody"]; }; }; responses: { @@ -1674,10 +1780,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["TokenResponse"]; }; }; - 404: components["responses"]["Problem404"]; /** @description Validation Error */ 422: { headers: { @@ -1720,6 +1825,93 @@ export interface operations { }; }; }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_my_households_api_v1_users_me_households_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HouseholdResponse"][]; + }; + }; + 422: components["responses"]["Problem422"]; + }; + }; + create_household_api_v1_households_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateHouseholdBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HouseholdResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; healthz_healthz_get: { parameters: { query?: never; diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index 139839c..a14a979 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -1,10 +1,10 @@ import { ref } from 'vue' import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout } from '@/api/auth' -import type { Person } from '@/domain/types' +import type { User } from '@/domain/types' type Household = { id: number; name: string; slug: string } -const user = ref(null) +const user = ref(null) const households = ref([]) const activeHousehold = ref(null) let initialized = false diff --git a/src/domain/types.ts b/src/domain/types.ts index eef107c..c26b061 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -18,6 +18,8 @@ export type RecipeOut = components['schemas']['RecipeOut'] export type MealOut = components['schemas']['MealOut'] export type Ingredient = components['schemas']['Ingredient'] export type Product = components['schemas']['Product'] +export type User = components['schemas']['User'] +export type Household = components['schemas']['HouseholdResponse'] export type Person = components['schemas']['Person'] export type RecipeInput = components['schemas']['Recipe-Input'] export type MealInput = components['schemas']['Meal-Input'] diff --git a/tests/auth.api.test.ts b/tests/auth.api.test.ts index 451ec1d..56ca998 100644 --- a/tests/auth.api.test.ts +++ b/tests/auth.api.test.ts @@ -1,42 +1,74 @@ import { describe, it, expect } from 'vitest' import { server, http, HttpResponse } from './test-setup' -import { currentUser, logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth' +import { logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth' +import { api } from '@/api/client' -describe('auth api (multitenant prep)', () => { - it('loginWithPassword proxies to /auth/login with username=email', async () => { +describe('auth api (jwt + households)', () => { + it('loginWithPassword posts email/password and returns user from token response', async () => { server.use( http.post('*/api/v1/auth/login', async ({ request }) => { const body = await request.json() - expect(body).toEqual({ username: 'test@example.com' }) - return HttpResponse.json({ id: 123, name: 'Test User' }) + expect(body).toEqual({ email: 'test@example.com', password: 'secret' }) + return HttpResponse.json({ accessToken: 'abc123', tokenType: 'bearer', user: { id: 123, email: 'test@example.com', displayName: 'Test User' } }) }) ) - const person = await loginWithPassword('test@example.com', 'secret') - expect(person.id).toBe(123) - expect(person.name).toBe('Test User') - }) + const user = await loginWithPassword('test@example.com', 'secret') + expect(user.id).toBe(123) + expect(user.displayName).toBe('Test User') - it('logout clears cached user; subsequent currentUser returns null', async () => { - // First refresh returns a user + // Subsequent API call should include Authorization header server.use( - http.post('*/api/v1/auth/refresh', () => { - return HttpResponse.json({ id: 1, name: 'Ada' }) + http.get('*/api/v1/users/me/households', ({ request }) => { + const auth = request.headers.get('authorization') + expect(auth?.toLowerCase()).toBe('bearer abc123') + return HttpResponse.json([]) }) ) - const first = await currentUser() - expect(first?.name).toBe('Ada') - - // After logout, next refresh returns 401 and currentUser should resolve to null - await logout() - server.use( - http.post('*/api/v1/auth/refresh', () => new HttpResponse(null, { status: 401 })) - ) - const second = await currentUser() - expect(second).toBeNull() + const res = await api.GET('/api/v1/users/me/households', { params: {} }) + expect(res.response.ok).toBe(true) }) - it('createAccount and handleGoogleLogin exist but are not implemented yet', async () => { - await expect(createAccount('new@example.com', 'New User', 'pw')).rejects.toBeInstanceOf(Error) + it('createAccount posts to /auth/register and sets token', async () => { + server.use( + http.post('*/api/v1/auth/register', async ({ request }) => { + const body = await request.json() + expect(body).toEqual({ email: 'new@example.com', password: 'pw', displayName: 'New User' }) + return HttpResponse.json({ accessToken: 'xyz789', tokenType: 'bearer', user: { id: 55, email: 'new@example.com', displayName: 'New User' } }) + }) + ) + const user = await createAccount('new@example.com', 'New User', 'pw') + expect(user.id).toBe(55) + + server.use( + http.get('*/api/v1/users/me/households', ({ request }) => { + const auth = request.headers.get('authorization') + expect(auth?.toLowerCase()).toBe('bearer xyz789') + return HttpResponse.json([]) + }) + ) + const res = await api.GET('/api/v1/users/me/households', { params: {} }) + expect(res.response.ok).toBe(true) + }) + + it('handleGoogleLogin exists (not implemented yet)', async () => { await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error) }) + + it('logout clears auth token', async () => { + // Simulate logged-in state then logout and verify header is not sent + server.use( + http.post('*/api/v1/auth/login', () => HttpResponse.json({ accessToken: 'tok', tokenType: 'bearer', user: { id: 1, email: 'a@b', displayName: 'A' } })) + ) + await loginWithPassword('a@b', 'pw') + await logout() + server.use( + http.get('*/api/v1/users/me/households', ({ request }) => { + const auth = request.headers.get('authorization') + expect(auth).toBeFalsy() + return HttpResponse.json([]) + }) + ) + const res = await api.GET('/api/v1/users/me/households', { params: {} }) + expect(res.response.ok).toBe(true) + }) }) diff --git a/tests/useAuth.multitenant.test.ts b/tests/useAuth.multitenant.test.ts index 7f0c806..b6344ab 100644 --- a/tests/useAuth.multitenant.test.ts +++ b/tests/useAuth.multitenant.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/api/auth', () => { return { - currentUser: vi.fn(async () => ({ id: 42, name: 'Grace Hopper' })), - login: vi.fn(async (username: string) => ({ id: 7, name: username })), - loginWithPassword: vi.fn(async (email: string) => ({ id: 8, name: email })), + currentUser: vi.fn(async () => ({ id: 42, email: 'grace@example.com', displayName: 'Grace Hopper' })), + login: vi.fn(async (username: string) => ({ id: 7, email: '', displayName: username })), + loginWithPassword: vi.fn(async (email: string) => ({ id: 8, email, displayName: email })), logout: vi.fn(async () => {}), createAccount: vi.fn(async () => { throw new Error('createAccount not implemented yet') @@ -45,10 +45,10 @@ describe('useAuth (multitenant state)', () => { const { user, loginWithPassword, logout } = useAuth() // load current user await loadUser() - expect(user.value?.name).toBe('Grace Hopper') + expect(user.value?.displayName).toBe('Grace Hopper') // login with email/password - await loginWithPassword('user@example.com', 'pw') - expect(user.value?.name).toBe('user@example.com') + await loginWithPassword('user@example.com', 'pw') + expect(user.value?.displayName).toBe('user@example.com') // logout clears user await logout() expect(user.value).toBeNull() @@ -56,7 +56,7 @@ describe('useAuth (multitenant state)', () => { it('keeps legacy login(username) available for now', async () => { const { user, login } = useAuth() - await login('legacy-user') - expect(user.value?.name).toBe('legacy-user') + await login('legacy-user') + expect(user.value?.displayName).toBe('legacy-user') }) })