diff --git a/backend/alembic/versions/002_create_users_table.py b/backend/alembic/versions/002_create_users_table.py new file mode 100644 index 0000000..872756f --- /dev/null +++ b/backend/alembic/versions/002_create_users_table.py @@ -0,0 +1,40 @@ +""" +create users table + +Revision ID: 002 +Revises: 001 +Create Date: 2025-09-02 00:01:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '002' +down_revision = '001' +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('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), + ) + 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_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..f713d61 --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.schemas.user import UserCreate, UserResponse, UserUpdate +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 + + +@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/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..99f976f --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, DateTime, Date +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) + dob = Column(Date, 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..ad27921 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional +from datetime import datetime, date + + +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) + dob: Optional[date] = None + + +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 + + +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 new file mode 100644 index 0000000..1230a99 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,73 @@ +from sqlalchemy.orm import Session +from sqlalchemy import select +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +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, + dob=payload.dob, + ) + 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 + # 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: + db.add(user) + db.commit() + 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/.env.example b/frontend/.env.example new file mode 100644 index 0000000..74f14d8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +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/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..5acdc7a --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,138 @@ +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({ + 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?: GoogleGlobalApi; + } +} + +// + +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: 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() as Promise<{ + sub: string; + email: string; + name: string; + picture?: string; + }>, + ) + .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(); + // Save to backend (best-effort) + const apiBaseEnv = import.meta.env.VITE_API_BASE_URL as string | undefined; + let apiBase: string = 'http://localhost:8300'; + if (apiBaseEnv && apiBaseEnv.length > 0) { + apiBase = apiBaseEnv; + } + 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, + // only send dob if already known locally + ...(authState.user?.dob ? { dob: authState.user.dob } : {}), + }), + }) + .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)); + } else if (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..e125c9b --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,16 @@ +export interface AuthUserProfile { + id: string; + email: string; + name: string; + avatarUrl?: string; + dob?: string; // ISO date string +} + +export interface AuthState { + isAuthenticated: boolean; + accessToken?: string; + idToken?: string; + user?: AuthUserProfile; +} + +// 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 {}; diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..61806b9 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..5b1b292 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,67 @@ + + + +