Auth api updates for v2
This commit is contained in:
parent
a446bc1c11
commit
22174ebf79
7 changed files with 317 additions and 68 deletions
|
|
@ -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<Person | null> {
|
||||
export async function currentUser(): Promise<User | null> {
|
||||
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<Person> {
|
||||
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<User> {
|
||||
// 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<Person> {
|
||||
// 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<User> {
|
||||
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<never> {
|
||||
// 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<User> {
|
||||
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<never> {
|
||||
|
|
@ -48,4 +63,5 @@ export async function handleGoogleLogin(_token: string): Promise<never> {
|
|||
export async function logout(): Promise<void> {
|
||||
// Clear local cache; server session is cookie-based and will be refreshed on next call
|
||||
cachedUser = null
|
||||
authToken = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<paths>({
|
||||
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,
|
||||
|
|
|
|||
214
src/api/types.ts
214
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<string, never>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Person | null>(null)
|
||||
const user = ref<User | null>(null)
|
||||
const households = ref<Household[]>([])
|
||||
const activeHousehold = ref<Household | null>(null)
|
||||
let initialized = false
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
||||
expect(res.response.ok).toBe(true)
|
||||
})
|
||||
|
||||
// After logout, next refresh returns 401 and currentUser should resolve to null
|
||||
await logout()
|
||||
it('createAccount posts to /auth/register and sets token', async () => {
|
||||
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()
|
||||
expect(second).toBeNull()
|
||||
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('createAccount and handleGoogleLogin exist but are not implemented yet', async () => {
|
||||
await expect(createAccount('new@example.com', 'New User', 'pw')).rejects.toBeInstanceOf(Error)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
expect(user.value?.displayName).toBe('user@example.com')
|
||||
// logout clears user
|
||||
await logout()
|
||||
expect(user.value).toBeNull()
|
||||
|
|
@ -57,6 +57,6 @@ 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')
|
||||
expect(user.value?.displayName).toBe('legacy-user')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue