From 72cd4ce074b9cdf4688f54d0878ac4afd7390b18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Sep 2025 19:53:56 +0000 Subject: [PATCH 01/25] Add Google OAuth login with Vue router and authentication store Co-authored-by: ann.jacob --- frontend/.env.example | 3 + frontend/index.html | 1 + frontend/src/App.vue | 54 ++++++++-------- frontend/src/main.ts | 2 + frontend/src/router/index.ts | 27 +++++++- frontend/src/store/auth.ts | 102 +++++++++++++++++++++++++++++++ frontend/src/types/auth.ts | 15 +++++ frontend/src/views/Dashboard.vue | 31 ++++++++++ frontend/src/views/Login.vue | 62 +++++++++++++++++++ 9 files changed, 271 insertions(+), 26 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/store/auth.ts create mode 100644 frontend/src/types/auth.ts create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Login.vue diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b6048c6 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Copy this file to .env and fill in your Google OAuth Client ID +# Obtain Client ID from Google Cloud Console -> Credentials -> OAuth 2.0 Client IDs +VITE_GOOGLE_CLIENT_ID= diff --git a/frontend/index.html b/frontend/index.html index dde16aa..bf749d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@
+ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8c9655e..25300a6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,30 +1,36 @@ - + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 762dcee..4d78348 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,8 +3,10 @@ import App from './App.vue'; import vuetify from './plugins/vuetify'; import router from './router'; import './styles/main.scss'; +import { restoreAuthFromStorage } from './store/auth'; const app = createApp(App); app.use(vuetify); app.use(router); +restoreAuthFromStorage(); app.mount('#app'); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e416ec6..749efe3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,13 +1,36 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router'; +import { authState, restoreAuthFromStorage } from '../store/auth'; -import HelloWorld from '../components/HelloWorld.vue'; +const Login = () => import('../views/Login.vue'); +const Dashboard = () => import('../views/Dashboard.vue'); -const routes: Array = [{ path: '/', name: 'home', component: HelloWorld }]; +const routes: Array = [ + { path: '/', redirect: { name: 'dashboard' } }, + { path: '/login', name: 'login', component: Login, meta: { public: true } }, + { path: '/dashboard', name: 'dashboard', component: Dashboard, meta: { requiresAuth: true } }, +]; export const router = createRouter({ history: createWebHashHistory(), routes, }); +let isRestored = false; +router.beforeEach((to) => { + if (!isRestored) { + restoreAuthFromStorage(); + isRestored = true; + } + + if (to.meta.public) return true; + if (to.meta.requiresAuth && !authState.isAuthenticated) { + return { name: 'login', query: { redirect: to.fullPath } }; + } + if (to.name === 'login' && authState.isAuthenticated) { + return { name: 'dashboard' }; + } + return true; +}); + export default router; diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000..289cd0b --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,102 @@ +import { reactive } from 'vue'; +import type { AuthState, AuthUserProfile } from '../types/auth'; + +// Basic global auth store using Vue reactive. For larger apps, switch to Pinia. +export const authState = reactive({ + isAuthenticated: false, +}); + +// + +export function restoreAuthFromStorage(): void { + const raw = localStorage.getItem('authState'); + if (!raw) return; + try { + const saved = JSON.parse(raw) as AuthState; + authState.isAuthenticated = !!saved.isAuthenticated; + authState.accessToken = saved.accessToken; + authState.idToken = saved.idToken; + authState.user = saved.user; + } catch {} +} + +export function persistAuthToStorage(): void { + const toSave: AuthState = { + isAuthenticated: authState.isAuthenticated, + accessToken: authState.accessToken, + idToken: authState.idToken, + user: authState.user, + }; + localStorage.setItem('authState', JSON.stringify(toSave)); +} + +declare global { + interface Window { + google?: any; + } +} + +export interface GoogleAuthConfig { + clientId: string; + scope?: string; + prompt?: 'none' | 'consent' | 'select_account' | string; +} + +export async function signInWithGoogle(config: GoogleAuthConfig): Promise { + // Use One Tap or Button with Google Identity Services OAuth 2.0 Token Client + return new Promise((resolve, reject) => { + try { + const { clientId, scope, prompt } = config; + if (!window.google?.accounts?.oauth2) { + reject(new Error('Google Identity Services not loaded')); + return; + } + + const tokenClient = window.google.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: scope ?? 'openid email profile', + prompt: prompt ?? 'select_account', + callback: (response: any) => { + if (response && response.access_token) { + // Get ID token via code flow not supported here; alternatively use "initCodeClient". + // For basic profile: fetch userinfo endpoint. + fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { Authorization: `Bearer ${response.access_token}` }, + }) + .then((r) => r.json()) + .then((profile) => { + const user: AuthUserProfile = { + id: profile.sub, + email: profile.email, + name: profile.name, + avatarUrl: profile.picture, + }; + authState.isAuthenticated = true; + authState.accessToken = response.access_token; + authState.user = user; + persistAuthToStorage(); + resolve(); + }) + .catch((err) => reject(err)); + } else if (response && response.error) { + reject(new Error(response.error)); + } else { + reject(new Error('Unknown Google response')); + } + }, + }); + + tokenClient.requestAccessToken(); + } catch (err) { + reject(err); + } + }); +} + +export function logout(): void { + authState.isAuthenticated = false; + authState.accessToken = undefined; + authState.idToken = undefined; + authState.user = undefined; + localStorage.removeItem('authState'); +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..5107565 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,15 @@ +export interface AuthUserProfile { + id: string; + email: string; + name: string; + avatarUrl?: string; +} + +export interface AuthState { + isAuthenticated: boolean; + accessToken?: string; + idToken?: string; + user?: AuthUserProfile; +} + +// diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..7a25dd3 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..60eb93a --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,62 @@ + + + + From 26eb507054e32cf373c684313b94803e5e88d1d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Sep 2025 22:22:19 +0000 Subject: [PATCH 02/25] Improve type safety for Google OAuth and error handling in auth flow Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 48 +++++++++++++++++++++++++++++++----- frontend/src/views/Login.vue | 5 ++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 289cd0b..7c05f9c 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -30,9 +30,37 @@ export function persistAuthToStorage(): void { localStorage.setItem('authState', JSON.stringify(toSave)); } +type GoogleTokenResponse = { + access_token?: string; + error?: string; +}; + +interface GoogleTokenClient { + requestAccessToken: () => void; +} + +interface GoogleOAuth2InitConfig { + client_id: string; + scope: string; + prompt?: string; + callback: (response: GoogleTokenResponse) => void; +} + +interface GoogleOAuth2Api { + initTokenClient: (config: GoogleOAuth2InitConfig) => GoogleTokenClient; +} + +interface GoogleAccountsApi { + oauth2?: GoogleOAuth2Api; +} + +interface GoogleGlobalApi { + accounts?: GoogleAccountsApi; +} + declare global { interface Window { - google?: any; + google?: GoogleGlobalApi; } } @@ -56,14 +84,22 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise client_id: clientId, scope: scope ?? 'openid email profile', prompt: prompt ?? 'select_account', - callback: (response: any) => { - if (response && response.access_token) { + callback: (response: GoogleTokenResponse) => { + if (response?.access_token) { // Get ID token via code flow not supported here; alternatively use "initCodeClient". // For basic profile: fetch userinfo endpoint. fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${response.access_token}` }, }) - .then((r) => r.json()) + .then( + (r) => + r.json() as Promise<{ + sub: string; + email: string; + name: string; + picture?: string; + }>, + ) .then((profile) => { const user: AuthUserProfile = { id: profile.sub, @@ -77,8 +113,8 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise persistAuthToStorage(); resolve(); }) - .catch((err) => reject(err)); - } else if (response && response.error) { + .catch((err: unknown) => reject(err)); + } else if (response?.error) { reject(new Error(response.error)); } else { reject(new Error('Unknown Google response')); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 60eb93a..acfc0d2 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -22,8 +22,9 @@ async function handleGoogleLogin(): Promise { await signInWithGoogle({ clientId, prompt: 'select_account' }); const redirect = (route.query.redirect as string) || '/dashboard'; router.replace(redirect); - } catch (err: any) { - errorMessage.value = err?.message ?? 'Login failed'; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Login failed'; + errorMessage.value = message; } finally { isLoading.value = false; } From 04459a987066213509c8f208724173309d4afde4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Sep 2025 22:24:43 +0000 Subject: [PATCH 03/25] Checkpoint before follow-up message Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 39 ++++++---------------------------- frontend/src/types/google.ts | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 frontend/src/types/google.ts diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 7c05f9c..31b1d3b 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,5 +1,10 @@ import { reactive } from 'vue'; import type { AuthState, AuthUserProfile } from '../types/auth'; +import type { + GoogleAuthConfig, + GoogleGlobalApi, + GoogleTokenResponse, +} from '../types/google'; // Basic global auth store using Vue reactive. For larger apps, switch to Pinia. export const authState = reactive({ @@ -30,45 +35,13 @@ export function persistAuthToStorage(): void { localStorage.setItem('authState', JSON.stringify(toSave)); } -type GoogleTokenResponse = { - access_token?: string; - error?: string; -}; - -interface GoogleTokenClient { - requestAccessToken: () => void; -} - -interface GoogleOAuth2InitConfig { - client_id: string; - scope: string; - prompt?: string; - callback: (response: GoogleTokenResponse) => void; -} - -interface GoogleOAuth2Api { - initTokenClient: (config: GoogleOAuth2InitConfig) => GoogleTokenClient; -} - -interface GoogleAccountsApi { - oauth2?: GoogleOAuth2Api; -} - -interface GoogleGlobalApi { - accounts?: GoogleAccountsApi; -} - declare global { interface Window { google?: GoogleGlobalApi; } } -export interface GoogleAuthConfig { - clientId: string; - scope?: string; - prompt?: 'none' | 'consent' | 'select_account' | string; -} +// export async function signInWithGoogle(config: GoogleAuthConfig): Promise { // Use One Tap or Button with Google Identity Services OAuth 2.0 Token Client diff --git a/frontend/src/types/google.ts b/frontend/src/types/google.ts new file mode 100644 index 0000000..121e873 --- /dev/null +++ b/frontend/src/types/google.ts @@ -0,0 +1,41 @@ +export type GoogleTokenResponse = { + access_token?: string; + error?: string; +}; + +export interface GoogleTokenClient { + requestAccessToken: () => void; +} + +export interface GoogleOAuth2InitConfig { + client_id: string; + scope: string; + prompt?: string; + callback: (response: GoogleTokenResponse) => void; +} + +export interface GoogleOAuth2Api { + initTokenClient: (config: GoogleOAuth2InitConfig) => GoogleTokenClient; +} + +export interface GoogleAccountsApi { + oauth2?: GoogleOAuth2Api; +} + +export interface GoogleGlobalApi { + accounts?: GoogleAccountsApi; +} + +declare global { + interface Window { + google?: GoogleGlobalApi; + } +} + +export interface GoogleAuthConfig { + clientId: string; + scope?: string; + prompt?: 'none' | 'consent' | 'select_account' | string; +} + +export {}; From 1b0b14f5dc5c4c6f38f6aeb4ba772fd5e711129f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Sep 2025 22:27:58 +0000 Subject: [PATCH 04/25] Add Google OAuth user sync and backend user management Co-authored-by: ann.jacob --- .../20250902_0001_create_users_table.py | 41 +++++++++++++++++ backend/app/api/v1/api.py | 3 +- backend/app/api/v1/endpoints/users.py | 15 +++++++ backend/app/models/__init__.py | 3 +- backend/app/models/user.py | 19 ++++++++ backend/app/schemas/user.py | 25 +++++++++++ backend/app/services/user_service.py | 44 +++++++++++++++++++ frontend/.env.example | 1 + frontend/src/store/auth.ts | 16 +++++++ 9 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/20250902_0001_create_users_table.py create mode 100644 backend/app/api/v1/endpoints/users.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/alembic/versions/20250902_0001_create_users_table.py b/backend/alembic/versions/20250902_0001_create_users_table.py new file mode 100644 index 0000000..92d6895 --- /dev/null +++ b/backend/alembic/versions/20250902_0001_create_users_table.py @@ -0,0 +1,41 @@ +""" +create users table + +Revision ID: 20250902_0001 +Revises: +Create Date: 2025-09-02 00:01:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250902_0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'users', + sa.Column('id', sa.Integer(), primary_key=True, index=True), + sa.Column('google_sub', sa.String(length=64), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('avatar_url', sa.String(length=512), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('ix_users_id', 'users', ['id'], unique=False) + op.create_index('ix_users_google_sub', 'users', ['google_sub'], unique=True) + op.create_index('ix_users_email', 'users', ['email'], unique=True) + + +def downgrade() -> None: + op.drop_index('ix_users_email', table_name='users') + op.drop_index('ix_users_google_sub', table_name='users') + op.drop_index('ix_users_id', table_name='users') + op.drop_table('users') + diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 71262a8..7210a14 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.v1.endpoints import states +from app.api.v1.endpoints import states, users api_router = APIRouter() # Include all endpoint routers api_router.include_router(states.router, prefix="/states", tags=["states"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..5c3eadf --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from app.database import get_db +from app.schemas.user import UserCreate, UserResponse +from app.services.user_service import UserService + + +router = APIRouter() + + +@router.post("/google", response_model=UserResponse, status_code=201, summary="Upsert Google user") +async def upsert_google_user(payload: UserCreate, db: Session = Depends(get_db)): + user = UserService.create_or_update_by_google(db, payload) + return user + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8b1111b..7fae346 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,2 @@ -# Models Package +from .state import State +from .user import User diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..601ce40 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + google_sub = Column(String(64), unique=True, nullable=False, index=True) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + avatar_url = Column(String(512), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def __repr__(self): + return f"" + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..7ca3f6d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional +from datetime import datetime + + +class CustomBaseModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class UserBase(CustomBaseModel): + email: str = Field(..., max_length=255) + name: str = Field(..., max_length=255) + avatar_url: Optional[str] = Field(None, max_length=512) + + +class UserCreate(UserBase): + google_sub: str = Field(..., max_length=64) + + +class UserResponse(UserBase): + id: int + google_sub: str + created_at: datetime + updated_at: Optional[datetime] = None + diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..07e68a1 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,44 @@ +from sqlalchemy.orm import Session +from sqlalchemy import select +from app.models.user import User +from app.schemas.user import UserCreate + + +class UserService: + @staticmethod + def get_by_google_sub(db: Session, google_sub: str) -> User | None: + stmt = select(User).where(User.google_sub == google_sub) + return db.execute(stmt).scalars().first() + + @staticmethod + def create_or_update_by_google(db: Session, payload: UserCreate) -> User: + user = UserService.get_by_google_sub(db, payload.google_sub) + if user is None: + user = User( + google_sub=payload.google_sub, + email=payload.email, + name=payload.name, + avatar_url=payload.avatar_url, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + # Update existing record if any field changed + changed = False + if user.email != payload.email: + user.email = payload.email + changed = True + if user.name != payload.name: + user.name = payload.name + changed = True + if user.avatar_url != payload.avatar_url: + user.avatar_url = payload.avatar_url + changed = True + if changed: + db.add(user) + db.commit() + db.refresh(user) + return user + diff --git a/frontend/.env.example b/frontend/.env.example index b6048c6..74f14d8 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ # Copy this file to .env and fill in your Google OAuth Client ID # Obtain Client ID from Google Cloud Console -> Credentials -> OAuth 2.0 Client IDs VITE_GOOGLE_CLIENT_ID= +VITE_API_BASE_URL=http://localhost:8300 diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 31b1d3b..e17cc10 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -84,6 +84,22 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise authState.accessToken = response.access_token; authState.user = user; persistAuthToStorage(); + // Save to backend (best-effort) + const apiBase = import.meta.env.VITE_API_BASE_URL as string | undefined; + if (apiBase) { + fetch(`${apiBase}/api/v1/users/google`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + google_sub: profile.sub, + email: profile.email, + name: profile.name, + avatar_url: profile.picture, + }), + }).catch(() => undefined); + } resolve(); }) .catch((err: unknown) => reject(err)); From 02d9d36160d1f4da1c917312f72a12f87a293930 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Sep 2025 22:30:29 +0000 Subject: [PATCH 05/25] Checkpoint before follow-up message Co-authored-by: ann.jacob --- frontend/src/views/Login.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index acfc0d2..f156f4a 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -34,8 +34,8 @@ async function handleGoogleLogin(): Promise { From 2be5b128485d25925e975ff523a37271f0d22370 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 16:41:41 +0000 Subject: [PATCH 11/25] Add fallback API base URL when environment variable is empty Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 31 +++++++++++++++---------------- frontend/src/views/Dashboard.vue | 5 +++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 7cf3e6e..7389001 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -81,22 +81,21 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise authState.user = user; persistAuthToStorage(); // Save to backend (best-effort) - const apiBase = import.meta.env.VITE_API_BASE_URL as string | undefined; - if (apiBase) { - fetch(`${apiBase}/api/v1/users/google`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - google_sub: profile.sub, - email: profile.email, - name: profile.name, - avatar_url: profile.picture, - dob: authState.user?.dob ?? null, - }), - }).catch(() => undefined); - } + const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; + const apiBase = apiBaseEnv && apiBaseEnv.length > 0 ? apiBaseEnv : 'http://localhost:8300'; + fetch(`${apiBase}/api/v1/users/google`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + google_sub: profile.sub, + email: profile.email, + name: profile.name, + avatar_url: profile.picture, + dob: authState.user?.dob ?? null, + }), + }).catch(() => undefined); resolve(); }) .catch((err: unknown) => reject(err)); diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 908c110..62d2113 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -12,8 +12,9 @@ function startEditDob(): void { } async function saveDob(): Promise { - const apiBase = import.meta.env.VITE_API_BASE_URL as string | undefined; - if (!apiBase || !authState.user) { + const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; + const apiBase = apiBaseEnv && apiBaseEnv.length > 0 ? apiBaseEnv : 'http://localhost:8300'; + if (!authState.user) { editingDob.value = false; return; } From d11d14ee29c98beef0866713ab13c18723c8ec9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 16:51:33 +0000 Subject: [PATCH 12/25] Refactor API base URL fallback logic for readability Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 7389001..73bf38c 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -82,7 +82,10 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise persistAuthToStorage(); // Save to backend (best-effort) const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; - const apiBase = apiBaseEnv && apiBaseEnv.length > 0 ? apiBaseEnv : 'http://localhost:8300'; + const apiBase = + apiBaseEnv && apiBaseEnv.length > 0 + ? apiBaseEnv + : 'http://localhost:8300'; fetch(`${apiBase}/api/v1/users/google`, { method: 'POST', headers: { From cec31a55777a19f1a13509c96d82547d3dc668ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 16:55:18 +0000 Subject: [PATCH 13/25] Simplify API base URL logic in Google sign-in function Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 73bf38c..7389001 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -82,10 +82,7 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise persistAuthToStorage(); // Save to backend (best-effort) const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; - const apiBase = - apiBaseEnv && apiBaseEnv.length > 0 - ? apiBaseEnv - : 'http://localhost:8300'; + const apiBase = apiBaseEnv && apiBaseEnv.length > 0 ? apiBaseEnv : 'http://localhost:8300'; fetch(`${apiBase}/api/v1/users/google`, { method: 'POST', headers: { From e27aa0c860e3ec17b13d9684ef2ae1f574000679 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 16:59:06 +0000 Subject: [PATCH 14/25] Refactor API base URL handling in Google sign-in logic Co-authored-by: ann.jacob --- frontend/src/store/auth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 7389001..747c084 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -82,7 +82,10 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise persistAuthToStorage(); // Save to backend (best-effort) const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; - const apiBase = apiBaseEnv && apiBaseEnv.length > 0 ? apiBaseEnv : 'http://localhost:8300'; + let apiBase: string = 'http://localhost:8300'; + if (apiBaseEnv && apiBaseEnv.length > 0) { + apiBase = apiBaseEnv; + } fetch(`${apiBase}/api/v1/users/google`, { method: 'POST', headers: { From a21d12152a56899edf0f21ef6be38617088f5e1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 22:17:40 +0000 Subject: [PATCH 15/25] Modify Alembic migration revision IDs and references Co-authored-by: ann.jacob --- .../alembic/versions/20250902_0001_create_users_table.py | 8 ++++---- .../alembic/versions/20250902_0002_add_dob_to_users.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/alembic/versions/20250902_0001_create_users_table.py b/backend/alembic/versions/20250902_0001_create_users_table.py index 92d6895..5516622 100644 --- a/backend/alembic/versions/20250902_0001_create_users_table.py +++ b/backend/alembic/versions/20250902_0001_create_users_table.py @@ -1,8 +1,8 @@ """ create users table -Revision ID: 20250902_0001 -Revises: +Revision ID: 002 +Revises: 001 Create Date: 2025-09-02 00:01:00.000000 """ @@ -11,8 +11,8 @@ # revision identifiers, used by Alembic. -revision = '20250902_0001' -down_revision = None +revision = '002' +down_revision = '001' branch_labels = None depends_on = None diff --git a/backend/alembic/versions/20250902_0002_add_dob_to_users.py b/backend/alembic/versions/20250902_0002_add_dob_to_users.py index c9e3dce..a6f2524 100644 --- a/backend/alembic/versions/20250902_0002_add_dob_to_users.py +++ b/backend/alembic/versions/20250902_0002_add_dob_to_users.py @@ -1,8 +1,8 @@ """ add dob to users -Revision ID: 20250902_0002 -Revises: 20250902_0001 +Revision ID: 003 +Revises: 002 Create Date: 2025-09-02 00:02:00.000000 """ @@ -11,8 +11,8 @@ # revision identifiers, used by Alembic. -revision = '20250902_0002' -down_revision = '20250902_0001' +revision = '003' +down_revision = '002' branch_labels = None depends_on = None From 5b9991fbdad3b8fd5836a6740e03d284bfb760d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 23:00:58 +0000 Subject: [PATCH 16/25] Rename and reorganize Alembic migration files for users table Co-authored-by: ann.jacob --- ...50902_0001_create_users_table.py => 002_create_users_table.py} | 0 ...{20250902_0002_add_dob_to_users.py => 003_add_dob_to_users.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/alembic/versions/{20250902_0001_create_users_table.py => 002_create_users_table.py} (100%) rename backend/alembic/versions/{20250902_0002_add_dob_to_users.py => 003_add_dob_to_users.py} (100%) diff --git a/backend/alembic/versions/20250902_0001_create_users_table.py b/backend/alembic/versions/002_create_users_table.py similarity index 100% rename from backend/alembic/versions/20250902_0001_create_users_table.py rename to backend/alembic/versions/002_create_users_table.py diff --git a/backend/alembic/versions/20250902_0002_add_dob_to_users.py b/backend/alembic/versions/003_add_dob_to_users.py similarity index 100% rename from backend/alembic/versions/20250902_0002_add_dob_to_users.py rename to backend/alembic/versions/003_add_dob_to_users.py From c89986b9320ea461f9aa29dd1f1ab1d1a9204765 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 23:06:39 +0000 Subject: [PATCH 17/25] Add date of birth column to users table in initial migration Co-authored-by: ann.jacob --- .../versions/002_create_users_table.py | 1 + .../alembic/versions/003_add_dob_to_users.py | 26 ------------------- 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 backend/alembic/versions/003_add_dob_to_users.py diff --git a/backend/alembic/versions/002_create_users_table.py b/backend/alembic/versions/002_create_users_table.py index 5516622..e3bde68 100644 --- a/backend/alembic/versions/002_create_users_table.py +++ b/backend/alembic/versions/002_create_users_table.py @@ -25,6 +25,7 @@ def upgrade() -> None: sa.Column('email', sa.String(length=255), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('avatar_url', sa.String(length=512), nullable=True), + sa.Column('dob', sa.Date(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), ) diff --git a/backend/alembic/versions/003_add_dob_to_users.py b/backend/alembic/versions/003_add_dob_to_users.py deleted file mode 100644 index a6f2524..0000000 --- a/backend/alembic/versions/003_add_dob_to_users.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -add dob to users - -Revision ID: 003 -Revises: 002 -Create Date: 2025-09-02 00:02:00.000000 -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '003' -down_revision = '002' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column('users', sa.Column('dob', sa.Date(), nullable=True)) - - -def downgrade() -> None: - op.drop_column('users', 'dob') - From 8c790abc94fc2adca00d1e0e6843eb722ea8e7b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 23:41:30 +0000 Subject: [PATCH 18/25] Remove redundant index on users table id column Co-authored-by: ann.jacob --- backend/alembic/versions/002_create_users_table.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/alembic/versions/002_create_users_table.py b/backend/alembic/versions/002_create_users_table.py index e3bde68..872756f 100644 --- a/backend/alembic/versions/002_create_users_table.py +++ b/backend/alembic/versions/002_create_users_table.py @@ -29,7 +29,6 @@ def upgrade() -> None: sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), ) - op.create_index('ix_users_id', 'users', ['id'], unique=False) op.create_index('ix_users_google_sub', 'users', ['google_sub'], unique=True) op.create_index('ix_users_email', 'users', ['email'], unique=True) @@ -37,6 +36,5 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index('ix_users_email', table_name='users') op.drop_index('ix_users_google_sub', table_name='users') - op.drop_index('ix_users_id', table_name='users') op.drop_table('users') From 97fa12faf2dad4d4eb72b55c18aaca06393c23a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Sep 2025 23:52:55 +0000 Subject: [PATCH 19/25] Add user update endpoint with optional fields and PUT method Co-authored-by: ann.jacob --- backend/app/api/v1/endpoints/users.py | 12 ++++++++++-- backend/app/schemas/user.py | 7 +++++++ backend/app/services/user_service.py | 26 +++++++++++++++++++++++++- frontend/src/views/Dashboard.vue | 8 ++------ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 5c3eadf..f713d61 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db -from app.schemas.user import UserCreate, UserResponse +from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.services.user_service import UserService @@ -13,3 +13,11 @@ async def upsert_google_user(payload: UserCreate, db: Session = Depends(get_db)) user = UserService.create_or_update_by_google(db, payload) return user + +@router.put("/{google_sub}", response_model=UserResponse, summary="Update user by google_sub") +async def update_user(google_sub: str, payload: UserUpdate, db: Session = Depends(get_db)): + user = UserService.update_by_google_sub(db, google_sub, payload) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 33cdeb7..ad27921 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -24,3 +24,10 @@ class UserResponse(UserBase): created_at: datetime updated_at: Optional[datetime] = None + +class UserUpdate(CustomBaseModel): + email: Optional[str] = Field(None, max_length=255) + name: Optional[str] = Field(None, max_length=255) + avatar_url: Optional[str] = Field(None, max_length=512) + dob: Optional[date] = None + diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index b0203b7..ff61cf2 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from sqlalchemy import select from app.models.user import User -from app.schemas.user import UserCreate +from app.schemas.user import UserCreate, UserUpdate class UserService: @@ -46,3 +46,27 @@ def create_or_update_by_google(db: Session, payload: UserCreate) -> User: db.refresh(user) return user + @staticmethod + def update_by_google_sub(db: Session, google_sub: str, payload: UserUpdate) -> User | None: + user = UserService.get_by_google_sub(db, google_sub) + if user is None: + return None + changed = False + if payload.email is not None and payload.email != user.email: + user.email = payload.email + changed = True + if payload.name is not None and payload.name != user.name: + user.name = payload.name + changed = True + if payload.avatar_url is not None and payload.avatar_url != user.avatar_url: + user.avatar_url = payload.avatar_url + changed = True + if payload.dob is not None and payload.dob != user.dob: + user.dob = payload.dob + changed = True + if changed: + db.add(user) + db.commit() + db.refresh(user) + return user + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 62d2113..96b0330 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -19,14 +19,10 @@ async function saveDob(): Promise { return; } authState.user.dob = dobLocal.value ?? undefined; - fetch(`${apiBase}/api/v1/users/google`, { - method: 'POST', + fetch(`${apiBase}/api/v1/users/${authState.user.id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - google_sub: authState.user.id, - email: authState.user.email, - name: authState.user.name, - avatar_url: authState.user.avatarUrl, dob: dobLocal.value, }), }) From e6076334ba03968643e39738146986a3ba3f42e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Sep 2025 00:00:05 +0000 Subject: [PATCH 20/25] Improve DOB handling in user profile update and Google sign-in Co-authored-by: ann.jacob --- backend/app/services/user_service.py | 3 ++- frontend/src/store/auth.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index ff61cf2..1230a99 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -37,7 +37,8 @@ def create_or_update_by_google(db: Session, payload: UserCreate) -> User: if user.avatar_url != payload.avatar_url: user.avatar_url = payload.avatar_url changed = True - if user.dob != payload.dob: + # Only update DOB if an explicit non-null value is provided + if payload.dob is not None and user.dob != payload.dob: user.dob = payload.dob changed = True if changed: diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 747c084..5acdc7a 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -96,9 +96,21 @@ export async function signInWithGoogle(config: GoogleAuthConfig): Promise email: profile.email, name: profile.name, avatar_url: profile.picture, - dob: authState.user?.dob ?? null, + // only send dob if already known locally + ...(authState.user?.dob ? { dob: authState.user.dob } : {}), }), - }).catch(() => undefined); + }) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (saved && authState.user) { + authState.user.dob = saved.dob ?? authState.user.dob; + authState.user.name = saved.name ?? authState.user.name; + authState.user.email = saved.email ?? authState.user.email; + authState.user.avatarUrl = saved.avatar_url ?? authState.user.avatarUrl; + persistAuthToStorage(); + } + }) + .catch(() => undefined); resolve(); }) .catch((err: unknown) => reject(err)); From 01f1964cb53314c4ac615fd3294a8d97d14ca57c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Sep 2025 00:04:00 +0000 Subject: [PATCH 21/25] Improve dashboard and login layout responsiveness and text handling Co-authored-by: ann.jacob --- frontend/src/views/Dashboard.vue | 28 +++++++++++++++++++--------- frontend/src/views/Login.vue | 6 +++--- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 96b0330..63063df 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -41,27 +41,37 @@ async function saveDob(): Promise {