Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
72cd4ce
Add Google OAuth login with Vue router and authentication store
cursoragent Sep 2, 2025
26eb507
Improve type safety for Google OAuth and error handling in auth flow
cursoragent Sep 2, 2025
04459a9
Checkpoint before follow-up message
cursoragent Sep 2, 2025
1b0b14f
Add Google OAuth user sync and backend user management
cursoragent Sep 2, 2025
02d9d36
Checkpoint before follow-up message
cursoragent Sep 2, 2025
15196e8
Add date of birth (DOB) field to user profile and related components
cursoragent Sep 2, 2025
3daceca
Refactor auth store imports for cleaner code
cursoragent Sep 3, 2025
b586e09
Refactor DOB edit UI with improved formatting and readability
cursoragent Sep 3, 2025
74dfcc5
Improve date of birth editing with better state management and API ha…
cursoragent Sep 3, 2025
5894b51
Fix formatting and whitespace in Dashboard.vue
cursoragent Sep 3, 2025
2be5b12
Add fallback API base URL when environment variable is empty
cursoragent Sep 3, 2025
d11d14e
Refactor API base URL fallback logic for readability
cursoragent Sep 3, 2025
cec31a5
Simplify API base URL logic in Google sign-in function
cursoragent Sep 3, 2025
e27aa0c
Refactor API base URL handling in Google sign-in logic
cursoragent Sep 3, 2025
a21d121
Modify Alembic migration revision IDs and references
cursoragent Sep 3, 2025
5b9991f
Rename and reorganize Alembic migration files for users table
cursoragent Sep 3, 2025
c89986b
Add date of birth column to users table in initial migration
cursoragent Sep 3, 2025
8c790ab
Remove redundant index on users table id column
cursoragent Sep 3, 2025
97fa12f
Add user update endpoint with optional fields and PUT method
cursoragent Sep 3, 2025
e607633
Improve DOB handling in user profile update and Google sign-in
cursoragent Sep 4, 2025
01f1964
Improve dashboard and login layout responsiveness and text handling
cursoragent Sep 4, 2025
df8deb6
Refactor DOB editing to use dialog and improve Google login button
cursoragent Sep 4, 2025
4fbdf73
Refactor DOB dialog cancel logic into separate method
cursoragent Sep 4, 2025
2ca00f6
Enhance Google login button styling for better visibility and interac…
cursoragent Sep 4, 2025
a791e0d
Remove unnecessary height unit from Google login button style
cursoragent Sep 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/alembic/versions/002_create_users_table.py
Original file line number Diff line number Diff line change
@@ -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')

3 changes: 2 additions & 1 deletion backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -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"])
23 changes: 23 additions & 0 deletions backend/app/api/v1/endpoints/users.py
Original file line number Diff line number Diff line change
@@ -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

3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Models Package
from .state import State
from .user import User
20 changes: 20 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -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"<User(id={self.id}, email='{self.email}')>"

33 changes: 33 additions & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
@@ -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

73 changes: 73 additions & 0 deletions backend/app/services/user_service.py
Original file line number Diff line number Diff line change
@@ -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

4 changes: 4 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</head>
<body>
<div id="app"></div>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
54 changes: 30 additions & 24 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue';
import { computed } from 'vue';
import { useRouter, RouterView } from 'vue-router';
import { authState, logout } from './store/auth';

const isAuthenticated = computed(() => authState.isAuthenticated);
const user = computed(() => authState.user);
const router = useRouter();

function handleLogout(): void {
logout();
router.replace({ name: 'login' });
}
</script>

<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
<v-app>
<v-app-bar density="comfortable" color="primary" dark>
<v-app-bar-title>App</v-app-bar-title>
<template #append>
<div v-if="isAuthenticated" class="d-flex align-center" style="gap: 8px">
<v-avatar size="28" v-if="user?.avatarUrl">
<img :src="user?.avatarUrl" alt="Avatar" />
</v-avatar>
<span class="mr-2">{{ user?.name }}</span>
<v-btn variant="text" @click="handleLogout">Logout</v-btn>
</div>
</template>
</v-app-bar>
<v-main>
<RouterView />
</v-main>
</v-app>
</template>

<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
<style scoped></style>
2 changes: 2 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
27 changes: 25 additions & 2 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -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<RouteRecordRaw> = [{ path: '/', name: 'home', component: HelloWorld }];
const routes: Array<RouteRecordRaw> = [
{ 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;
Loading
Loading