Auth api updates for v2

This commit is contained in:
jableader 2025-11-01 13:52:33 +11:00
parent a446bc1c11
commit 22174ebf79
7 changed files with 317 additions and 68 deletions

View file

@ -1,43 +1,58 @@
import { api } from '@/api/client' 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<Person | null> { export async function currentUser(): Promise<User | null> {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
try { try {
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } }) const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
if (!res.response.ok) return null 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 (_) { } catch (_) {
cachedUser = null cachedUser = null
} }
return cachedUser return cachedUser
} }
export async function login(username: string): Promise<Person> { export async function login(username: string): Promise<User> {
const res = await api.POST('/api/v1/auth/login', { body: { username } }) // Legacy compatibility shim: avoid network call; will be removed with new LoginPage
if (!res.response.ok) { cachedUser = { id: -1, email: '', displayName: username } as User
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')
return cachedUser return cachedUser
} }
// New multitenant-ready API surface (backward compatible) // New multitenant-ready API surface
export async function loginWithPassword(email: string, _password: string): Promise<Person> { export async function loginWithPassword(email: string, password: string): Promise<User> {
// Backend currently expects { username }; map email to username until OpenAPI updates const res = await api.POST('/api/v1/auth/login', { body: { email, password } })
return login(email) 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<never> { export async function createAccount(email: string, displayName: string, password: string): Promise<User> {
// Placeholder until backend endpoints and OpenAPI are finalized const res = await api.POST('/api/v1/auth/register', { body: { email, password, displayName } })
throw new Error('createAccount not implemented yet') 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<never> { export async function handleGoogleLogin(_token: string): Promise<never> {
@ -48,4 +63,5 @@ export async function handleGoogleLogin(_token: string): Promise<never> {
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
// Clear local cache; server session is cookie-based and will be refreshed on next call // Clear local cache; server session is cookie-based and will be refreshed on next call
cachedUser = null cachedUser = null
authToken = null
} }

View file

@ -10,17 +10,24 @@ const defaultBase = ''
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase) const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
let householdSlugProvider: (() => string | null) | null = null let householdSlugProvider: (() => string | null) | null = null
let authTokenProvider: (() => string | null) | null = null
export function setHouseholdSlugProvider(provider: (() => string | null) | null) { export function setHouseholdSlugProvider(provider: (() => string | null) | null) {
householdSlugProvider = provider householdSlugProvider = provider
} }
export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider
}
export const api = createClient<paths>({ export const api = createClient<paths>({
baseUrl, baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => { fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {}) const headers = new Headers(init?.headers || {})
const slug = householdSlugProvider ? householdSlugProvider() : null const slug = householdSlugProvider ? householdSlugProvider() : null
if (slug) headers.set('X-Household-Slug', slug) if (slug) headers.set('X-Household-Slug', slug)
const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`)
return globalThis.fetch(input, { return globalThis.fetch(input, {
credentials: 'include', credentials: 'include',
...init, ...init,

View file

@ -291,8 +291,8 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
/** Login and set user_id cookie */ /** Login */
post: operations["login"]; post: operations["loginV2"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@ -316,6 +316,57 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/healthz": {
parameters: { parameters: {
query?: never; query?: never;
@ -337,6 +388,11 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** CreateHouseholdBody */
CreateHouseholdBody: {
/** Name */
name: string;
};
/** CurrentShoppingList */ /** CurrentShoppingList */
CurrentShoppingList: { CurrentShoppingList: {
/** Outstandingitems */ /** Outstandingitems */
@ -375,6 +431,15 @@ export interface components {
*/ */
status: string; status: string;
}; };
/** HouseholdResponse */
HouseholdResponse: {
/** Id */
id: number;
/** Name */
name: string;
/** Slug */
slug: string;
};
/** Ingredient */ /** Ingredient */
Ingredient: { Ingredient: {
/** /**
@ -447,11 +512,6 @@ export interface components {
/** Recipeid */ /** Recipeid */
recipeId?: number | null; recipeId?: number | null;
}; };
/** LoginBody */
LoginBody: {
/** Username */
username: string;
};
/** Meal */ /** Meal */
"Meal-Input": { "Meal-Input": {
/** /**
@ -776,6 +836,15 @@ export interface components {
hiddenById?: number | null; hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null; hiddenBy?: components["schemas"]["Person"] | null;
}; };
/** RegisterBody */
RegisterBody: {
/** Email */
email: string;
/** Password */
password: string;
/** Displayname */
displayName: string;
};
/** RequestedMealItem */ /** RequestedMealItem */
RequestedMealItem: { RequestedMealItem: {
/** /**
@ -825,6 +894,31 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
StoreEnum: "woolworths" | "coles" | ""; 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 */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@ -834,6 +928,18 @@ export interface components {
/** Error Type */ /** Error Type */
type: string; type: string;
}; };
/** LoginBody */
api__auth__LoginBody: {
/** Username */
username: string;
};
/** LoginBody */
api__auth_v2__LoginBody: {
/** Email */
email: string;
/** Password */
password: string;
};
/** /**
* StoreNameOut * StoreNameOut
* @enum {string} * @enum {string}
@ -1655,7 +1761,7 @@ export interface operations {
}; };
}; };
}; };
login: { loginV2: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -1664,7 +1770,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["LoginBody"]; "application/json": components["schemas"]["api__auth_v2__LoginBody"];
}; };
}; };
responses: { responses: {
@ -1674,10 +1780,9 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Person"]; "application/json": components["schemas"]["TokenResponse"];
}; };
}; };
404: components["responses"]["Problem404"];
/** @description Validation Error */ /** @description Validation Error */
422: { 422: {
headers: { 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: { healthz_healthz_get: {
parameters: { parameters: {
query?: never; query?: never;

View file

@ -1,10 +1,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout } from '@/api/auth' 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 } type Household = { id: number; name: string; slug: string }
const user = ref<Person | null>(null) const user = ref<User | null>(null)
const households = ref<Household[]>([]) const households = ref<Household[]>([])
const activeHousehold = ref<Household | null>(null) const activeHousehold = ref<Household | null>(null)
let initialized = false let initialized = false

View file

@ -18,6 +18,8 @@ export type RecipeOut = components['schemas']['RecipeOut']
export type MealOut = components['schemas']['MealOut'] export type MealOut = components['schemas']['MealOut']
export type Ingredient = components['schemas']['Ingredient'] export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product'] 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 Person = components['schemas']['Person']
export type RecipeInput = components['schemas']['Recipe-Input'] export type RecipeInput = components['schemas']['Recipe-Input']
export type MealInput = components['schemas']['Meal-Input'] export type MealInput = components['schemas']['Meal-Input']

View file

@ -1,42 +1,74 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' 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)', () => { describe('auth api (jwt + households)', () => {
it('loginWithPassword proxies to /auth/login with username=email', async () => { it('loginWithPassword posts email/password and returns user from token response', async () => {
server.use( server.use(
http.post('*/api/v1/auth/login', async ({ request }) => { http.post('*/api/v1/auth/login', async ({ request }) => {
const body = await request.json() const body = await request.json()
expect(body).toEqual({ username: 'test@example.com' }) expect(body).toEqual({ email: 'test@example.com', password: 'secret' })
return HttpResponse.json({ id: 123, name: 'Test User' }) 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') const user = await loginWithPassword('test@example.com', 'secret')
expect(person.id).toBe(123) expect(user.id).toBe(123)
expect(person.name).toBe('Test User') expect(user.displayName).toBe('Test User')
})
it('logout clears cached user; subsequent currentUser returns null', async () => { // Subsequent API call should include Authorization header
// First refresh returns a user
server.use( server.use(
http.post('*/api/v1/auth/refresh', () => { http.get('*/api/v1/users/me/households', ({ request }) => {
return HttpResponse.json({ id: 1, name: 'Ada' }) const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer abc123')
return HttpResponse.json([])
}) })
) )
const first = await currentUser() const res = await api.GET('/api/v1/users/me/households', { params: {} })
expect(first?.name).toBe('Ada') expect(res.response.ok).toBe(true)
})
// After logout, next refresh returns 401 and currentUser should resolve to null it('createAccount posts to /auth/register and sets token', async () => {
await logout()
server.use( server.use(
http.post('*/api/v1/auth/refresh', () => new HttpResponse(null, { status: 401 })) 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 second = await currentUser() const user = await createAccount('new@example.com', 'New User', 'pw')
expect(second).toBeNull() 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('createAccount and handleGoogleLogin exist but are not implemented yet', async () => { it('handleGoogleLogin exists (not implemented yet)', async () => {
await expect(createAccount('new@example.com', 'New User', 'pw')).rejects.toBeInstanceOf(Error)
await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error) 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)
})
}) })

View file

@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/auth', () => { vi.mock('@/api/auth', () => {
return { return {
currentUser: vi.fn(async () => ({ id: 42, name: 'Grace Hopper' })), currentUser: vi.fn(async () => ({ id: 42, email: 'grace@example.com', displayName: 'Grace Hopper' })),
login: vi.fn(async (username: string) => ({ id: 7, name: username })), login: vi.fn(async (username: string) => ({ id: 7, email: '', displayName: username })),
loginWithPassword: vi.fn(async (email: string) => ({ id: 8, name: email })), loginWithPassword: vi.fn(async (email: string) => ({ id: 8, email, displayName: email })),
logout: vi.fn(async () => {}), logout: vi.fn(async () => {}),
createAccount: vi.fn(async () => { createAccount: vi.fn(async () => {
throw new Error('createAccount not implemented yet') throw new Error('createAccount not implemented yet')
@ -45,10 +45,10 @@ describe('useAuth (multitenant state)', () => {
const { user, loginWithPassword, logout } = useAuth() const { user, loginWithPassword, logout } = useAuth()
// load current user // load current user
await loadUser() await loadUser()
expect(user.value?.name).toBe('Grace Hopper') expect(user.value?.displayName).toBe('Grace Hopper')
// login with email/password // login with email/password
await loginWithPassword('user@example.com', 'pw') await loginWithPassword('user@example.com', 'pw')
expect(user.value?.name).toBe('user@example.com') expect(user.value?.displayName).toBe('user@example.com')
// logout clears user // logout clears user
await logout() await logout()
expect(user.value).toBeNull() expect(user.value).toBeNull()
@ -57,6 +57,6 @@ describe('useAuth (multitenant state)', () => {
it('keeps legacy login(username) available for now', async () => { it('keeps legacy login(username) available for now', async () => {
const { user, login } = useAuth() const { user, login } = useAuth()
await login('legacy-user') await login('legacy-user')
expect(user.value?.name).toBe('legacy-user') expect(user.value?.displayName).toBe('legacy-user')
}) })
}) })