From 5586e2eede640390be8aab23d4708a9c333e568d Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 22:14:36 +1100 Subject: [PATCH] Implement handleGoogleLogin (returns backend OAuth start URL) --- frontend-spec.md | 6 ++++++ src/api/auth.ts | 32 ++++++++++++++++++++++++---- src/components/LoginPage.vue | 6 ++++-- tests/auth.google.api.test.ts | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/auth.google.api.test.ts diff --git a/frontend-spec.md b/frontend-spec.md index bc728e6..f128446 100644 --- a/frontend-spec.md +++ b/frontend-spec.md @@ -179,6 +179,12 @@ Refinements (Nov 1, 2025, later): - Raw parse endpoints now use small runtime guards and normalize to strict RecipeOut before decoding. - UI polish: MemberList and EditMealPage CSS class names unified (person-* → member-*). Login page shows a Google sign-in button wired to the placeholder handler. +Google OAuth (Nov 1, 2025, later): +- Added tests for Google OAuth start and callback. +- Implemented `handleGoogleLogin()` to retrieve an OAuth start URL and navigate. +- Implemented `completeGoogleLogin(code, state?)` using raw fetch to exchange the code and set the token/user; verified Authorization header on subsequent calls. +- Current UI: Login page button uses the returned URL to redirect. Callback route/UI still pending wiring (next). + --- ## Detailed Tasks by File/Module diff --git a/src/api/auth.ts b/src/api/auth.ts index 763ed87..4b2e555 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,4 @@ -import { api } from '@/api/client' +import { api, fetchApi } from '@/api/client' import { setAuthTokenProvider } from '@/api/client' import type { User } from '@/domain/types' @@ -69,9 +69,33 @@ export async function createAccount(email: string, displayName: string, password return cachedUser } -export async function handleGoogleLogin(): Promise { - // Placeholder until Google OAuth flow is wired - throw new Error('handleGoogleLogin not implemented yet') +// Google OAuth +export async function handleGoogleLogin(): Promise { + // Ask backend for the Google OAuth start URL + const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`) + const data = (await res.json()) as unknown + const url = typeof (data as any)?.url === 'string' ? (data as any).url : null + if (!url) throw new Error('Invalid google start response') + return url +} + +export async function completeGoogleLogin(code: string, state?: string): Promise { + const body: Record = { code } + if (state) body.state = state + const res = await fetchApi('/api/v1/auth/google/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`) + const data: unknown = await res.json() + const token = (data && typeof (data as any).accessToken === 'string') ? (data as any).accessToken as string : null + const user = (data && (data as any).user && typeof (data as any).user.id === 'number') ? (data as any).user as { id: number; email?: string; displayName?: string } : null + if (!token || !user) throw new Error('Invalid token response') + authToken = token + cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' } + return cachedUser } export async function logout(): Promise { diff --git a/src/components/LoginPage.vue b/src/components/LoginPage.vue index 564c83d..4394129 100644 --- a/src/components/LoginPage.vue +++ b/src/components/LoginPage.vue @@ -91,9 +91,11 @@ async function onSubmitLogin() { async function onGoogleLogin() { try { - await handleGoogleLogin() + const url = await handleGoogleLogin() + if (typeof window !== 'undefined') { + window.location.href = url + } } catch (e) { - // Surface the placeholder message for now; real implementation will redirect alert(e instanceof Error ? e.message : 'Google login not available') } } diff --git a/tests/auth.google.api.test.ts b/tests/auth.google.api.test.ts new file mode 100644 index 0000000..d2e859f --- /dev/null +++ b/tests/auth.google.api.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { server, http, HttpResponse } from './test-setup' +import { handleGoogleLogin, completeGoogleLogin } from '@/api/auth' +import { api } from '@/api/client' + +describe('auth google oauth', () => { + it('handleGoogleLogin returns start URL', async () => { + server.use( + http.get('*/api/v1/auth/google/start', () => { + return HttpResponse.json({ url: 'https://accounts.google.com/o/oauth2/v2/auth?x=y' }) + }) + ) + const url = await handleGoogleLogin() + expect(url).toContain('https://accounts.google.com') + }) + + it('completeGoogleLogin exchanges code for token and sets auth header', async () => { + server.use( + http.post('*/api/v1/auth/google/callback', async ({ request }) => { + const body = await request.json() + expect(body).toEqual({ code: 'abc', state: 'state-1' }) + return HttpResponse.json({ accessToken: 'goog-123', tokenType: 'bearer', user: { id: 42, email: 'g@example.com', displayName: 'G User' } }) + }) + ) + const user = await completeGoogleLogin('abc', 'state-1') + expect(user.id).toBe(42) + + // subsequent requests include bearer + server.use( + http.get('*/api/v1/users/me/households', ({ request }) => { + const auth = request.headers.get('authorization') + expect(auth?.toLowerCase()).toBe('bearer goog-123') + return HttpResponse.json([]) + }) + ) + const res = await api.GET('/api/v1/users/me/households', { params: {} }) + expect(res.response.ok).toBe(true) + }) +})