From 45f3788fb0b5cf61f10e1ec7c03c244e5f09922d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:02:42 +0800 Subject: [PATCH 01/22] Add preferred name + email to registration, profile card to settings Registration form now collects optional preferred_name and email fields. Settings page Profile card expanded with first name, last name, and email (editable via new GET/PUT /api/auth/profile endpoints). Email uniqueness enforced on both registration and profile update. No migrations needed. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 48 +++++++- backend/app/schemas/auth.py | 69 +++++++++++- frontend/src/components/auth/LockScreen.tsx | 39 ++++++- .../src/components/settings/SettingsPage.tsx | 103 +++++++++++++++++- frontend/src/hooks/useAuth.ts | 9 +- frontend/src/types/index.ts | 7 ++ 6 files changed, 266 insertions(+), 9 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b982e48..45239dc 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -33,6 +33,7 @@ from app.models.calendar import Calendar from app.schemas.auth import ( SetupRequest, LoginRequest, RegisterRequest, ChangePasswordRequest, VerifyPasswordRequest, + ProfileUpdate, ProfileResponse, ) from app.services.auth import ( hash_password, @@ -441,12 +442,21 @@ async def register( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + # Check email uniqueness (generic error to prevent enumeration) + if data.email: + existing_email = await db.execute( + select(User).where(User.email == data.email) + ) + if existing_email.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + password_hash = hash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, password_hash=password_hash, role="standard", + email=data.email, last_password_change_at=datetime.now(), ) @@ -457,7 +467,7 @@ async def register( db.add(new_user) await db.flush() - await _create_user_defaults(db, new_user.id) + await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name) ip = get_client_ip(request) user_agent = request.headers.get("user-agent") @@ -622,3 +632,39 @@ async def change_password( await db.commit() return {"message": "Password changed successfully"} + + +@router.get("/profile", response_model=ProfileResponse) +async def get_profile( + current_user: User = Depends(get_current_user), +): + """Return the current user's profile fields.""" + return ProfileResponse.model_validate(current_user) + + +@router.put("/profile", response_model=ProfileResponse) +async def update_profile( + data: ProfileUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user's profile fields (first_name, last_name, email).""" + update_data = data.model_dump(exclude_unset=True) + + # Email uniqueness check if email is changing + if "email" in update_data and update_data["email"] != current_user.email: + new_email = update_data["email"] + if new_email: + existing = await db.execute( + select(User).where(User.email == new_email, User.id != current_user.id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email is already in use") + + # SEC-01: Explicit field assignment + for key, value in update_data.items(): + setattr(current_user, key, value) + + await db.commit() + await db.refresh(current_user) + return ProfileResponse.model_validate(current_user) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index ad86a15..378e6e0 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator def _validate_password_strength(v: str) -> str: @@ -58,6 +58,8 @@ class RegisterRequest(BaseModel): username: str password: str + email: str | None = Field(None, max_length=254) + preferred_name: str | None = Field(None, max_length=100) @field_validator("username") @classmethod @@ -69,6 +71,30 @@ class RegisterRequest(BaseModel): def validate_password(cls, v: str) -> str: return _validate_password_strength(v) + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("preferred_name") + @classmethod + def validate_preferred_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -106,3 +132,44 @@ class VerifyPasswordRequest(BaseModel): if len(v) > 128: raise ValueError("Password must be 128 characters or fewer") return v + + +class ProfileUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: str | None = Field(None, max_length=254) + + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("first_name", "last_name") + @classmethod + def validate_name_fields(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + + +class ProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + username: str + email: str | None + first_name: str | None + last_name: str | None diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 95c9e5e..430f0ef 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -53,6 +53,10 @@ export default function LockScreen() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + // ── Registration optional fields ── + const [regEmail, setRegEmail] = useState(''); + const [regPreferredName, setRegPreferredName] = useState(''); + // ── TOTP challenge ── const [totpCode, setTotpCode] = useState(''); const [useBackupCode, setUseBackupCode] = useState(false); @@ -137,7 +141,12 @@ export default function LockScreen() { if (err) { toast.error(err); return; } if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } try { - await register({ username, password }); + await register({ + username, + password, + email: regEmail.trim() || undefined, + preferred_name: regPreferredName.trim() || undefined, + }); // On success useAuth invalidates query → Navigate handles redirect // If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches } catch (error) { @@ -557,6 +566,8 @@ export default function LockScreen() { setUsername(''); setPassword(''); setConfirmPassword(''); + setRegEmail(''); + setRegPreferredName(''); setLoginError(null); }} className="text-xs text-muted-foreground hover:text-foreground transition-colors" @@ -598,6 +609,30 @@ export default function LockScreen() { autoComplete="username" /> +
+ + setRegPreferredName(e.target.value)} + placeholder="What should we call you?" + maxLength={100} + autoComplete="given-name" + /> +
+
+ + setRegEmail(e.target.value)} + placeholder="your@email.com" + maxLength={254} + autoComplete="email" + /> +
(settings?.auto_lock_minutes ?? 5); + // Profile fields (stored on User model, fetched from /auth/profile) + const profileQuery = useQuery({ + queryKey: ['profile'], + queryFn: async () => { + const { data } = await api.get('/auth/profile'); + return data; + }, + }); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [profileEmail, setProfileEmail] = useState(''); + const [emailError, setEmailError] = useState(null); + + useEffect(() => { + if (profileQuery.data) { + setFirstName(profileQuery.data.first_name ?? ''); + setLastName(profileQuery.data.last_name ?? ''); + setProfileEmail(profileQuery.data.email ?? ''); + } + }, [profileQuery.data?.username]); + // Sync state when settings load useEffect(() => { if (settings) { @@ -149,6 +170,35 @@ export default function SettingsPage() { } }; + const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail }; + const current = values[field].trim(); + const original = profileQuery.data?.[field] ?? ''; + if (current === (original || '')) return; + + // Client-side email validation + if (field === 'email' && current) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) { + setEmailError('Invalid email format'); + return; + } + } + setEmailError(null); + + try { + await api.put('/auth/profile', { [field]: current || null }); + queryClient.invalidateQueries({ queryKey: ['profile'] }); + toast.success('Profile updated'); + } catch (err: any) { + const detail = err?.response?.data?.detail; + if (field === 'email' && detail) { + setEmailError(typeof detail === 'string' ? detail : 'Failed to update email'); + } else { + toast.error(typeof detail === 'string' ? detail : 'Failed to update profile'); + } + } + }; + const handleColorChange = async (color: string) => { setSelectedColor(color); try { @@ -233,11 +283,11 @@ export default function SettingsPage() {
Profile - Personalize how UMBRA greets you + Your profile and display preferences
- +
+
+
+ + setFirstName(e.target.value)} + onBlur={() => handleProfileSave('first_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }} + maxLength={100} + /> +
+
+ + setLastName(e.target.value)} + onBlur={() => handleProfileSave('last_name')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }} + maxLength={100} + /> +
+
+
+ + { setProfileEmail(e.target.value); setEmailError(null); }} + onBlur={() => handleProfileSave('email')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('email'); }} + maxLength={254} + className={emailError ? 'border-red-500/50' : ''} + /> + {emailError && ( +

{emailError}

+ )} +
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 7c181f5..f287c5c 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -47,8 +47,13 @@ export function useAuth() { }); const registerMutation = useMutation({ - mutationFn: async ({ username, password }: { username: string; password: string }) => { - const { data } = await api.post('/auth/register', { username, password }); + mutationFn: async ({ username, password, email, preferred_name }: { + username: string; password: string; email?: string; preferred_name?: string; + }) => { + const payload: Record = { username, password }; + if (email) payload.email = email; + if (preferred_name) payload.preferred_name = preferred_name; + const { data } = await api.post('/auth/register', payload); return data; }, onSuccess: (data) => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fed8531..659479b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -345,6 +345,13 @@ export interface UpcomingResponse { cutoff_date: string; } +export interface UserProfile { + username: string; + email: string | null; + first_name: string | null; + last_name: string | null; +} + export interface EventTemplate { id: number; name: string; From 02efe04fc432257a0e815b123a35aee9506a35cd Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:09:15 +0800 Subject: [PATCH 02/22] Fix QA critical/warning findings on profile feature C-01: Replace setattr loop with explicit field assignment in update_profile C-02: Fix useEffect dependency to profileQuery.dataUpdatedAt for re-sync W-01: Add audit log entry for profile updates W-02: Use less misleading generic error for email uniqueness on registration W-03: Early return on empty PUT body to avoid unnecessary commit Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 22 +++++++++++++++---- .../src/components/settings/SettingsPage.tsx | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 45239dc..f16c1b3 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -448,7 +448,7 @@ async def register( select(User).where(User.email == data.email) ) if existing_email.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") + raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.") password_hash = hash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() @@ -645,12 +645,16 @@ async def get_profile( @router.put("/profile", response_model=ProfileResponse) async def update_profile( data: ProfileUpdate, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update the current user's profile fields (first_name, last_name, email).""" update_data = data.model_dump(exclude_unset=True) + if not update_data: + return ProfileResponse.model_validate(current_user) + # Email uniqueness check if email is changing if "email" in update_data and update_data["email"] != current_user.email: new_email = update_data["email"] @@ -661,9 +665,19 @@ async def update_profile( if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email is already in use") - # SEC-01: Explicit field assignment - for key, value in update_data.items(): - setattr(current_user, key, value) + # SEC-01: Explicit field assignment — only allowed profile fields + if "first_name" in update_data: + current_user.first_name = update_data["first_name"] + if "last_name" in update_data: + current_user.last_name = update_data["last_name"] + if "email" in update_data: + current_user.email = update_data["email"] + + await log_audit_event( + db, action="auth.profile_updated", actor_id=current_user.id, + detail={"fields": list(update_data.keys())}, + ip=get_client_ip(request), + ) await db.commit() await db.refresh(current_user) diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index bc187c3..235ba89 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -73,7 +73,7 @@ export default function SettingsPage() { setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); } - }, [profileQuery.data?.username]); + }, [profileQuery.dataUpdatedAt]); // Sync state when settings load useEffect(() => { From e8109cef6bd0dd1ed4435a76a04325c21a0bd8a0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:21:11 +0800 Subject: [PATCH 03/22] Add required email + date of birth to registration, shared validators, partial index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S-01: Extract _EMAIL_REGEX, _validate_email_format, _validate_name_field shared helpers in schemas/auth.py — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest (eliminates 3x duplicated regex) - S-04: Migration 038 replaces plain unique constraint on email with a partial unique index WHERE email IS NOT NULL - Email is now required on registration (was optional) - Date of birth is now required on registration, editable in settings - User model gains date_of_birth (Date, nullable) column - ProfileUpdate/ProfileResponse include date_of_birth - Registration form adds required Email, Date of Birth fields - Settings Profile card adds Date of Birth input (save-on-blur) Co-Authored-By: Claude Opus 4.6 --- ...d_date_of_birth_and_email_partial_index.py | 34 ++++++++ backend/app/models/user.py | 7 +- backend/app/routers/auth.py | 5 +- backend/app/schemas/admin.py | 22 +----- backend/app/schemas/auth.py | 78 +++++++++++-------- frontend/src/components/auth/LockScreen.tsx | 24 +++++- .../src/components/settings/SettingsPage.tsx | 17 +++- frontend/src/hooks/useAuth.ts | 8 +- frontend/src/types/index.ts | 1 + 9 files changed, 130 insertions(+), 66 deletions(-) create mode 100644 backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py diff --git a/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py b/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py new file mode 100644 index 0000000..7586cfe --- /dev/null +++ b/backend/alembic/versions/038_add_date_of_birth_and_email_partial_index.py @@ -0,0 +1,34 @@ +"""Add date_of_birth column and partial unique index on email. + +Revision ID: 038 +Revises: 037 +""" +from alembic import op +import sqlalchemy as sa + +revision = "038" +down_revision = "037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add date_of_birth column (nullable — existing users won't have one) + op.add_column("users", sa.Column("date_of_birth", sa.Date, nullable=True)) + + # S-04: Replace plain unique constraint with partial unique index + # so multiple NULLs are allowed explicitly (PostgreSQL already allows this + # with a plain unique constraint, but a partial index is more intentional + # and performs better for email lookups on non-NULL values). + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_index("ix_users_email", table_name="users") + op.execute( + 'CREATE UNIQUE INDEX "ix_users_email_unique" ON users (email) WHERE email IS NOT NULL' + ) + + +def downgrade() -> None: + op.execute('DROP INDEX IF EXISTS "ix_users_email_unique"') + op.create_index("ix_users_email", "users", ["email"]) + op.create_unique_constraint("uq_users_email", "users", ["email"]) + op.drop_column("users", "date_of_birth") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 92623dd..cc9a181 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,6 @@ -from sqlalchemy import String, Boolean, Integer, func +from sqlalchemy import String, Boolean, Integer, Date, func from sqlalchemy.orm import Mapped, mapped_column -from datetime import datetime +from datetime import datetime, date from app.database import Base @@ -9,9 +9,10 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) - email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + date_of_birth: Mapped[date | None] = mapped_column(Date, nullable=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) # MFA — populated in Track B diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f16c1b3..85a854e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -457,6 +457,7 @@ async def register( password_hash=password_hash, role="standard", email=data.email, + date_of_birth=data.date_of_birth, last_password_change_at=datetime.now(), ) @@ -649,7 +650,7 @@ async def update_profile( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Update the current user's profile fields (first_name, last_name, email).""" + """Update the current user's profile fields (first_name, last_name, email, date_of_birth).""" update_data = data.model_dump(exclude_unset=True) if not update_data: @@ -672,6 +673,8 @@ async def update_profile( current_user.last_name = update_data["last_name"] if "email" in update_data: current_user.email = update_data["email"] + if "date_of_birth" in update_data: + current_user.date_of_birth = update_data["date_of_birth"] await log_audit_event( db, action="auth.profile_updated", actor_id=current_user.id, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index c24160d..ca0c781 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -10,7 +10,7 @@ from typing import Optional, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.schemas.auth import _validate_username, _validate_password_strength +from app.schemas.auth import _validate_username, _validate_password_strength, _validate_email_format, _validate_name_field # --------------------------------------------------------------------------- @@ -75,28 +75,12 @@ class CreateUserRequest(BaseModel): @field_validator("email") @classmethod def validate_email(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip().lower() - if not v: - return None - # Basic format check: must have exactly one @, with non-empty local and domain parts - if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): - raise ValueError("Invalid email format") - return v + return _validate_email_format(v) @field_validator("first_name", "last_name", "preferred_name") @classmethod def validate_name_fields(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not v: - return None - # Reject ASCII control characters - if re.search(r"[\x00-\x1f]", v): - raise ValueError("Name must not contain control characters") - return v + return _validate_name_field(v) class UpdateUserRoleRequest(BaseModel): diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 378e6e0..2675141 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,6 +1,39 @@ import re +from datetime import date + from pydantic import BaseModel, ConfigDict, Field, field_validator +# Shared email format regex — used by RegisterRequest, ProfileUpdate, and admin.CreateUserRequest +_EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + + +def _validate_email_format(v: str | None, *, required: bool = False) -> str | None: + """Shared email validation. Returns normalised email or None.""" + if v is None: + if required: + raise ValueError("Email is required") + return None + v = v.strip().lower() + if not v: + if required: + raise ValueError("Email is required") + return None + if not _EMAIL_REGEX.match(v): + raise ValueError("Invalid email format") + return v + + +def _validate_name_field(v: str | None) -> str | None: + """Shared name field validation (strips, rejects control chars).""" + if v is None: + return None + v = v.strip() + if not v: + return None + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + def _validate_password_strength(v: str) -> str: """ @@ -58,7 +91,8 @@ class RegisterRequest(BaseModel): username: str password: str - email: str | None = Field(None, max_length=254) + email: str = Field(..., max_length=254) + date_of_birth: date preferred_name: str | None = Field(None, max_length=100) @field_validator("username") @@ -73,27 +107,15 @@ class RegisterRequest(BaseModel): @field_validator("email") @classmethod - def validate_email(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip().lower() - if not v: - return None - if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): - raise ValueError("Invalid email format") - return v + def validate_email(cls, v: str) -> str: + result = _validate_email_format(v, required=True) + assert result is not None # required=True guarantees non-None + return result @field_validator("preferred_name") @classmethod def validate_preferred_name(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not v: - return None - if re.search(r"[\x00-\x1f]", v): - raise ValueError("Name must not contain control characters") - return v + return _validate_name_field(v) class LoginRequest(BaseModel): @@ -140,30 +162,17 @@ class ProfileUpdate(BaseModel): first_name: str | None = Field(None, max_length=100) last_name: str | None = Field(None, max_length=100) email: str | None = Field(None, max_length=254) + date_of_birth: date | None = None @field_validator("email") @classmethod def validate_email(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip().lower() - if not v: - return None - if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): - raise ValueError("Invalid email format") - return v + return _validate_email_format(v) @field_validator("first_name", "last_name") @classmethod def validate_name_fields(cls, v: str | None) -> str | None: - if v is None: - return None - v = v.strip() - if not v: - return None - if re.search(r"[\x00-\x1f]", v): - raise ValueError("Name must not contain control characters") - return v + return _validate_name_field(v) class ProfileResponse(BaseModel): @@ -173,3 +182,4 @@ class ProfileResponse(BaseModel): email: str | None first_name: str | None last_name: str | None + date_of_birth: date | None diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 430f0ef..6235e92 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -53,8 +53,9 @@ export default function LockScreen() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // ── Registration optional fields ── + // ── Registration fields ── const [regEmail, setRegEmail] = useState(''); + const [regDateOfBirth, setRegDateOfBirth] = useState(''); const [regPreferredName, setRegPreferredName] = useState(''); // ── TOTP challenge ── @@ -140,11 +141,14 @@ export default function LockScreen() { const err = validatePassword(password); if (err) { toast.error(err); return; } if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } + if (!regEmail.trim()) { toast.error('Email is required'); return; } + if (!regDateOfBirth) { toast.error('Date of birth is required'); return; } try { await register({ username, password, - email: regEmail.trim() || undefined, + email: regEmail.trim(), + date_of_birth: regDateOfBirth, preferred_name: regPreferredName.trim() || undefined, }); // On success useAuth invalidates query → Navigate handles redirect @@ -567,6 +571,7 @@ export default function LockScreen() { setPassword(''); setConfirmPassword(''); setRegEmail(''); + setRegDateOfBirth(''); setRegPreferredName(''); setLoginError(null); }} @@ -622,17 +627,29 @@ export default function LockScreen() { />
- + setRegEmail(e.target.value)} placeholder="your@email.com" + required maxLength={254} autoComplete="email" />
+
+ + setRegDateOfBirth(e.target.value)} + required + autoComplete="bday" + /> +
(null); useEffect(() => { @@ -72,6 +73,7 @@ export default function SettingsPage() { setFirstName(profileQuery.data.first_name ?? ''); setLastName(profileQuery.data.last_name ?? ''); setProfileEmail(profileQuery.data.email ?? ''); + setDateOfBirth(profileQuery.data.date_of_birth ?? ''); } }, [profileQuery.dataUpdatedAt]); @@ -170,8 +172,8 @@ export default function SettingsPage() { } }; - const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email') => { - const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail }; + const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => { + const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth }; const current = values[field].trim(); const original = profileQuery.data?.[field] ?? ''; if (current === (original || '')) return; @@ -349,6 +351,17 @@ export default function SettingsPage() {

{emailError}

)}
+
+ + setDateOfBirth(e.target.value)} + onBlur={() => handleProfileSave('date_of_birth')} + onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} + /> +
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index f287c5c..a7acc78 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -47,11 +47,11 @@ export function useAuth() { }); const registerMutation = useMutation({ - mutationFn: async ({ username, password, email, preferred_name }: { - username: string; password: string; email?: string; preferred_name?: string; + mutationFn: async ({ username, password, email, date_of_birth, preferred_name }: { + username: string; password: string; email: string; date_of_birth: string; + preferred_name?: string; }) => { - const payload: Record = { username, password }; - if (email) payload.email = email; + const payload: Record = { username, password, email, date_of_birth }; if (preferred_name) payload.preferred_name = preferred_name; const { data } = await api.post('/auth/register', payload); return data; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 659479b..378962d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -350,6 +350,7 @@ export interface UserProfile { email: string | null; first_name: string | null; last_name: string | null; + date_of_birth: string | null; } export interface EventTemplate { From 3a456e56ddce817c1a8ca0bc87075dc6b8a3847d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 2 Mar 2026 19:58:21 +0800 Subject: [PATCH 04/22] Show date of birth with calculated age in IAM user detail Adds date_of_birth to UserDetailResponse schema, AdminUserDetail TypeScript type, and the User Information card in UserDetailSection. Displays formatted date with age in parentheses (e.g. "3/02/2000 (26)"). Co-Authored-By: Claude Opus 4.6 --- backend/app/schemas/admin.py | 3 ++- frontend/src/components/admin/UserDetailSection.tsx | 10 ++++++++++ frontend/src/types/index.ts | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index ca0c781..fa52d2a 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -5,7 +5,7 @@ All admin-facing request/response shapes live here to keep the admin router clean and testable in isolation. """ import re -from datetime import datetime +from datetime import date, datetime from typing import Optional, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -42,6 +42,7 @@ class UserListResponse(BaseModel): class UserDetailResponse(UserListItem): preferred_name: Optional[str] = None + date_of_birth: Optional[date] = None must_change_password: bool = False locked_until: Optional[datetime] = None diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index 0f09563..83bdc7a 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -117,6 +117,16 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection + { + const dob = new Date(user.date_of_birth + 'T00:00:00'); + const now = new Date(); + let age = now.getFullYear() - dob.getFullYear(); + if (now.getMonth() < dob.getMonth() || (now.getMonth() === dob.getMonth() && now.getDate() < dob.getDate())) age--; + return `${dob.toLocaleDateString()} (${age})`; + })() : null} + /> Date: Tue, 3 Mar 2026 01:42:06 +0800 Subject: [PATCH 05/22] Fix missing date_of_birth in admin user detail API response UserDetailResponse was built from UserListItem (which excludes date_of_birth), so the field always returned null. Explicitly pass user.date_of_birth to the response constructor. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index b355344..35e863c 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -180,6 +180,7 @@ async def get_user( **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), active_sessions=active_sessions, preferred_name=preferred_name, + date_of_birth=user.date_of_birth, ) From 013f9ec0102367613b3db3a86bf9e39441cef7ce Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 02:30:52 +0800 Subject: [PATCH 06/22] Add custom DatePicker component, replace all native date inputs Custom date-picker.tsx with date/datetime modes, portal popup with month/year dropdowns, min/max constraints, and hidden input for form validation. Replaces all 10 native and across LockScreen, SettingsPage, PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel, ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium calendar icon invert CSS fallback. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/auth/LockScreen.tsx | 8 +- frontend/src/components/people/PersonForm.tsx | 6 +- .../src/components/projects/ProjectForm.tsx | 6 +- .../components/projects/TaskDetailPanel.tsx | 6 +- frontend/src/components/projects/TaskForm.tsx | 6 +- .../reminders/ReminderDetailPanel.tsx | 7 +- .../src/components/reminders/ReminderForm.tsx | 7 +- .../src/components/settings/SettingsPage.tsx | 6 +- .../src/components/todos/TodoDetailPanel.tsx | 6 +- frontend/src/components/todos/TodoForm.tsx | 6 +- frontend/src/components/ui/date-picker.tsx | 475 ++++++++++++++++++ frontend/src/index.css | 13 + 12 files changed, 522 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/ui/date-picker.tsx diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 6235e92..9b5383a 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth'; import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; @@ -641,13 +642,14 @@ export default function LockScreen() {
- setRegDateOfBirth(e.target.value)} + onChange={(v) => setRegDateOfBirth(v)} required + name="bday" autoComplete="bday" + max={new Date().toISOString().slice(0, 10)} />
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index c08df8c..792efbb 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -13,6 +13,7 @@ import { SheetFooter, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -165,11 +166,10 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
- set('birthday', e.target.value)} + onChange={(v) => set('birthday', v)} />
diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index 6d8adc2..ad71109 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -121,11 +122,10 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index f3a36f6..8ee2ebf 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Select } from '@/components/ui/select'; const taskStatusColors: Record = { @@ -350,10 +351,9 @@ export default function TaskDetailPanel({ Due Date
{isEditing ? ( - setEditState((s) => ({ ...s, due_date: e.target.value }))} + onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))} className="h-8 text-xs" /> ) : ( diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index 75d6af4..feb3dcf 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -154,11 +155,10 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 1d60e03..1358008 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -340,11 +341,11 @@ export default function ReminderDetailPanel({
- updateField('remind_at', e.target.value)} + onChange={(v) => updateField('remind_at', v)} className="text-xs" />
diff --git a/frontend/src/components/reminders/ReminderForm.tsx b/frontend/src/components/reminders/ReminderForm.tsx index 435f06d..c6a8d2f 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -96,11 +97,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
- setFormData({ ...formData, remind_at: e.target.value })} + onChange={(v) => setFormData({ ...formData, remind_at: v })} />
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 6f7d70c..5e480cd 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; @@ -353,11 +354,10 @@ export default function SettingsPage() {
- setDateOfBirth(e.target.value)} + onChange={(v) => setDateOfBirth(v)} onBlur={() => handleProfileSave('date_of_birth')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} /> diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index ce6c88b..dd30a4c 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; @@ -385,11 +386,10 @@ export default function TodoDetailPanel({
- updateField('due_date', e.target.value)} + onChange={(v) => updateField('due_date', v)} className="text-xs" />
diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 29913ef..0e35ff7 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -129,11 +130,10 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx new file mode 100644 index 0000000..b365216 --- /dev/null +++ b/frontend/src/components/ui/date-picker.tsx @@ -0,0 +1,475 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ── Helpers ── + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfWeek(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +function pad(n: number): string { + return n.toString().padStart(2, '0'); +} + +function formatDate(y: number, m: number, d: number): string { + return `${y}-${pad(m + 1)}-${pad(d)}`; +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +// ── Props ── + +export interface DatePickerProps { + mode?: 'date' | 'datetime'; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + id?: string; + name?: string; + autoComplete?: string; + required?: boolean; + disabled?: boolean; + className?: string; + placeholder?: string; + min?: string; + max?: string; + yearRange?: [number, number]; +} + +// ── Component ── + +const DatePicker = React.forwardRef( + ( + { + mode = 'date', + value, + onChange, + onBlur, + onKeyDown, + id, + name, + autoComplete, + required, + disabled, + className, + placeholder, + min, + max, + yearRange, + }, + ref + ) => { + const currentYear = new Date().getFullYear(); + const [startYear, endYear] = yearRange ?? [1900, currentYear + 20]; + + // Parse current value + const parseDateValue = () => { + if (!value) return null; + const parts = value.split('T'); + const dateParts = parts[0]?.split('-'); + if (!dateParts || dateParts.length !== 3) return null; + const y = parseInt(dateParts[0], 10); + const m = parseInt(dateParts[1], 10) - 1; + const d = parseInt(dateParts[2], 10); + if (isNaN(y) || isNaN(m) || isNaN(d)) return null; + const timeParts = parts[1]?.split(':'); + const hour = timeParts ? parseInt(timeParts[0], 10) : 0; + const minute = timeParts ? parseInt(timeParts[1], 10) : 0; + return { year: y, month: m, day: d, hour, minute }; + }; + + const parsed = parseDateValue(); + const today = new Date(); + + const [open, setOpen] = React.useState(false); + const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear()); + const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth()); + const [hour, setHour] = React.useState(parsed?.hour ?? 0); + const [minute, setMinute] = React.useState(parsed?.minute ?? 0); + + const triggerRef = React.useRef(null); + const popupRef = React.useRef(null); + const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({ + top: 0, + left: 0, + flipped: false, + }); + + // Merge forwarded ref with internal ref + React.useImperativeHandle(ref, () => triggerRef.current!); + + // Sync internal state when value changes externally + React.useEffect(() => { + const p = parseDateValue(); + if (p) { + setViewYear(p.year); + setViewMonth(p.month); + setHour(p.hour); + setMinute(p.minute); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + // Position popup + const updatePosition = React.useCallback(() => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const popupHeight = mode === 'datetime' ? 370 : 320; + const spaceBelow = window.innerHeight - rect.bottom; + const flipped = spaceBelow < popupHeight && rect.top > popupHeight; + + setPos({ + top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, + left: Math.min(rect.left, window.innerWidth - 290), + flipped, + }); + }, [mode]); + + React.useEffect(() => { + if (!open) return; + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open, updatePosition]); + + // Dismiss on click outside + React.useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + popupRef.current?.contains(e.target as Node) || + triggerRef.current?.contains(e.target as Node) + ) + return; + closePopup(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Dismiss on Escape + React.useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closePopup(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const closePopup = () => { + setOpen(false); + // Fire onBlur once when popup closes + onBlur?.(); + }; + + const openPopup = () => { + if (disabled) return; + // Re-sync view to current value when opening + const p = parseDateValue(); + if (p) { + setViewYear(p.year); + setViewMonth(p.month); + setHour(p.hour); + setMinute(p.minute); + } + setOpen(true); + }; + + const togglePopup = () => { + if (open) closePopup(); + else openPopup(); + }; + + // Min/Max date boundaries + const minDate = min ? new Date(min + 'T00:00:00') : null; + const maxDate = max ? new Date(max + 'T00:00:00') : null; + + const isDayDisabled = (y: number, m: number, d: number) => { + const date = new Date(y, m, d); + if (minDate && date < minDate) return true; + if (maxDate && date > maxDate) return true; + return false; + }; + + // Select a day + const selectDay = (day: number) => { + if (isDayDisabled(viewYear, viewMonth, day)) return; + const dateStr = formatDate(viewYear, viewMonth, day); + if (mode === 'datetime') { + onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); + } else { + onChange(dateStr); + closePopup(); + } + }; + + const handleTimeChange = (newHour: number, newMinute: number) => { + setHour(newHour); + setMinute(newMinute); + if (parsed) { + const dateStr = formatDate(parsed.year, parsed.month, parsed.day); + onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`); + } + }; + + // Navigation + const prevMonth = () => { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } else { + setViewMonth((m) => m - 1); + } + }; + + const nextMonth = () => { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear((y) => y + 1); + } else { + setViewMonth((m) => m + 1); + } + }; + + // Build calendar grid + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfWeek(viewYear, viewMonth); + const cells: (number | null)[] = []; + for (let i = 0; i < firstDay; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const isToday = (d: number) => + d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); + + const isSelected = (d: number) => + parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; + + // Display text + const displayText = (() => { + if (!parsed) return ''; + const monthName = MONTH_NAMES[parsed.month]; + const base = `${monthName} ${parsed.day}, ${parsed.year}`; + if (mode === 'datetime') { + return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`; + } + return base; + })(); + + // Year options + const yearOptions: number[] = []; + for (let y = startYear; y <= endYear; y++) yearOptions.push(y); + + return ( + <> + {/* Hidden input for native form validation + autofill */} + + {required && ( + {}} + style={{ position: 'absolute' }} + /> + )} + + {/* Trigger button */} + + + {/* Popup (portalled) */} + {open && + createPortal( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: pos.top, + left: pos.left, + zIndex: 60, + }} + className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" + > + {/* Month/Year Nav */} +
+ + +
+ + +
+ + +
+ + {/* Day headers */} +
+ {DAY_HEADERS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => + day === null ? ( +
+ ) : ( + + ) + )} +
+ + {/* Time selectors (datetime mode only) */} + {mode === 'datetime' && ( +
+ + + : + + +
+ )} +
, + document.body + )} + + ); + } +); +DatePicker.displayName = 'DatePicker'; + +export { DatePicker }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 1753ba3..9dfb4e7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -193,6 +193,13 @@ font-weight: 600; } +/* ── Chromium native date picker icon fix (safety net) ── */ +input[type="date"]::-webkit-calendar-picker-indicator, +input[type="datetime-local"]::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; +} + /* ── Form validation — red outline only after submit attempt ── */ form[data-submitted] input:invalid, form[data-submitted] select:invalid, @@ -201,6 +208,12 @@ form[data-submitted] textarea:invalid { box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25); } +/* DatePicker trigger inherits red border from its hidden required sibling */ +form[data-submitted] input:invalid + button { + border-color: hsl(0 62.8% 50%); + box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25); +} + /* ── Ambient background animations ── */ @keyframes drift-1 { From 4dc3c856b0eb6f323cbf079cd26945d08ce84a42 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 02:43:45 +0800 Subject: [PATCH 07/22] Add input variant to DatePicker for typeable date fields DatePicker now supports variant="button" (default, registration DOB) and variant="input" (typeable text input + calendar icon trigger). Input variant lets users type dates manually while the calendar icon opens the same popup picker. Smart blur management prevents onBlur from firing when focus moves between input, icon, and popup. 9 non-registration usages updated to variant="input". Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/people/PersonForm.tsx | 1 + .../src/components/projects/ProjectForm.tsx | 1 + .../components/projects/TaskDetailPanel.tsx | 1 + frontend/src/components/projects/TaskForm.tsx | 1 + .../reminders/ReminderDetailPanel.tsx | 1 + .../src/components/reminders/ReminderForm.tsx | 1 + .../src/components/settings/SettingsPage.tsx | 1 + .../src/components/todos/TodoDetailPanel.tsx | 1 + frontend/src/components/todos/TodoForm.tsx | 1 + frontend/src/components/ui/date-picker.tsx | 413 +++++++++++------- 10 files changed, 254 insertions(+), 168 deletions(-) diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 792efbb..2683ae2 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -167,6 +167,7 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
set('birthday', v)} diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index ad71109..3c3329d 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -123,6 +123,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index 8ee2ebf..d18ca84 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -352,6 +352,7 @@ export default function TaskDetailPanel({
{isEditing ? ( setEditState((s) => ({ ...s, due_date: v }))} className="h-8 text-xs" diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index feb3dcf..8809e32 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -156,6 +156,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 1358008..3cfff13 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -342,6 +342,7 @@ export default function ReminderDetailPanel({
setDateOfBirth(v)} diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index dd30a4c..e65021e 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -387,6 +387,7 @@ export default function TodoDetailPanel({
updateField('due_date', v)} diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 0e35ff7..48cbfc1 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -131,6 +131,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index b365216..1326d66 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -31,6 +31,7 @@ const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; // ── Props ── export interface DatePickerProps { + variant?: 'button' | 'input'; mode?: 'date' | 'datetime'; value: string; onChange: (value: string) => void; @@ -53,6 +54,7 @@ export interface DatePickerProps { const DatePicker = React.forwardRef( ( { + variant = 'button', mode = 'date', value, onChange, @@ -99,19 +101,22 @@ const DatePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0); - const triggerRef = React.useRef(null); + // Refs + const triggerRef = React.useRef(null); // button variant + const wrapperRef = React.useRef(null); // input variant + const inputElRef = React.useRef(null); // input variant const popupRef = React.useRef(null); - const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({ - top: 0, - left: 0, - flipped: false, - }); + const blurTimeoutRef = React.useRef>(); - // Merge forwarded ref with internal ref + const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); + + // Merge forwarded ref with internal ref (button variant only) React.useImperativeHandle(ref, () => triggerRef.current!); - // Sync internal state when value changes externally + // Sync internal state when value changes (only when popup is closed to avoid + // jumping the calendar view while the user is navigating months or typing) React.useEffect(() => { + if (open) return; const p = parseDateValue(); if (p) { setViewYear(p.year); @@ -120,12 +125,13 @@ const DatePicker = React.forwardRef( setMinute(p.minute); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); + }, [value, open]); - // Position popup + // Position popup relative to trigger (button) or wrapper (input) const updatePosition = React.useCallback(() => { - if (!triggerRef.current) return; - const rect = triggerRef.current.getBoundingClientRect(); + const el = variant === 'input' ? wrapperRef.current : triggerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); const popupHeight = mode === 'datetime' ? 370 : 320; const spaceBelow = window.innerHeight - rect.bottom; const flipped = spaceBelow < popupHeight && rect.top > popupHeight; @@ -133,9 +139,8 @@ const DatePicker = React.forwardRef( setPos({ top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 290), - flipped, }); - }, [mode]); + }, [mode, variant]); React.useEffect(() => { if (!open) return; @@ -148,21 +153,33 @@ const DatePicker = React.forwardRef( }; }, [open, updatePosition]); + // Close popup. refocusTrigger=true returns focus to the trigger/input + // (used for day selection, Escape, Done). false lets focus go wherever the + // user clicked (used for outside-click dismiss). + const closePopup = React.useCallback( + (refocusTrigger = true) => { + setOpen(false); + if (variant === 'button') { + onBlur?.(); + } else if (refocusTrigger) { + setTimeout(() => inputElRef.current?.focus(), 0); + } + }, + [variant, onBlur] + ); + // Dismiss on click outside React.useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if ( - popupRef.current?.contains(e.target as Node) || - triggerRef.current?.contains(e.target as Node) - ) - return; - closePopup(); + if (popupRef.current?.contains(e.target as Node)) return; + if (variant === 'button' && triggerRef.current?.contains(e.target as Node)) return; + if (variant === 'input' && wrapperRef.current?.contains(e.target as Node)) return; + closePopup(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, variant, closePopup]); // Dismiss on Escape React.useEffect(() => { @@ -170,19 +187,34 @@ const DatePicker = React.forwardRef( const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); - closePopup(); + closePopup(true); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, closePopup]); - const closePopup = () => { - setOpen(false); - // Fire onBlur once when popup closes - onBlur?.(); - }; + // Input variant: smart blur — only fires onBlur when focus truly leaves the + // component group (input + icon + popup). Uses a short timeout to let focus + // settle on the new target before checking. + const handleInputBlur = React.useCallback(() => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = setTimeout(() => { + const active = document.activeElement; + if ( + popupRef.current?.contains(active) || + wrapperRef.current?.contains(active) + ) return; + onBlur?.(); + }, 10); + }, [onBlur]); + + // Cleanup blur timeout on unmount + React.useEffect(() => { + return () => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }; + }, []); const openPopup = () => { if (disabled) return; @@ -198,7 +230,7 @@ const DatePicker = React.forwardRef( }; const togglePopup = () => { - if (open) closePopup(); + if (open) closePopup(true); else openPopup(); }; @@ -221,7 +253,7 @@ const DatePicker = React.forwardRef( onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); } else { onChange(dateStr); - closePopup(); + closePopup(true); } }; @@ -266,7 +298,7 @@ const DatePicker = React.forwardRef( const isSelected = (d: number) => parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; - // Display text + // Display text (button variant only) const displayText = (() => { if (!parsed) return ''; const monthName = MONTH_NAMES[parsed.month]; @@ -281,6 +313,185 @@ const DatePicker = React.forwardRef( const yearOptions: number[] = []; for (let y = startYear; y <= endYear; y++) yearOptions.push(y); + // ── Shared popup ── + const popup = open + ? createPortal( +
e.stopPropagation()} + style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }} + className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" + > + {/* Month/Year Nav */} +
+ + +
+ + +
+ + +
+ + {/* Day headers */} +
+ {DAY_HEADERS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => + day === null ? ( +
+ ) : ( + + ) + )} +
+ + {/* Time selectors (datetime mode only) */} + {mode === 'datetime' && ( +
+ + + : + + +
+ )} +
, + document.body + ) + : null; + + // ── Input variant: typeable input + calendar icon trigger ── + if (variant === 'input') { + return ( + <> +
+ onChange(e.target.value)} + onBlur={handleInputBlur} + onKeyDown={(e) => { + if (open && e.key === 'Enter') { + e.preventDefault(); + return; + } + onKeyDown?.(e); + }} + required={required} + disabled={disabled} + placeholder={placeholder || (mode === 'datetime' ? 'YYYY-MM-DDThh:mm' : 'YYYY-MM-DD')} + className={cn( + 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + className + )} + /> + +
+ {popup} + + ); + } + + // ── Button variant: non-editable trigger (registration DOB) ── return ( <> {/* Hidden input for native form validation + autofill */} @@ -331,141 +542,7 @@ const DatePicker = React.forwardRef( - {/* Popup (portalled) */} - {open && - createPortal( -
e.stopPropagation()} - style={{ - position: 'fixed', - top: pos.top, - left: pos.left, - zIndex: 60, - }} - className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" - > - {/* Month/Year Nav */} -
- - -
- - -
- - -
- - {/* Day headers */} -
- {DAY_HEADERS.map((d) => ( -
- {d} -
- ))} -
- - {/* Day grid */} -
- {cells.map((day, i) => - day === null ? ( -
- ) : ( - - ) - )} -
- - {/* Time selectors (datetime mode only) */} - {mode === 'datetime' && ( -
- - - : - - -
- )} -
, - document.body - )} + {popup} ); } From 59a4f67b42b10117caa88524c29e890f6626a9ce Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 03:07:07 +0800 Subject: [PATCH 08/22] Display DD/MM/YYYY and 12-hour AM/PM in DatePicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input variant now shows user-friendly format (DD/MM/YYYY for date, DD/MM/YYYY h:mm AM/PM for datetime) instead of raw ISO strings. Internal display state syncs bidirectionally with ISO value prop using a ref flag to avoid overwriting during active typing. Popup time selectors changed from 24-hour to 12-hour with AM/PM dropdown. Button variant datetime display also updated to AM/PM. Backend contract unchanged — onChange still emits ISO strings. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ui/date-picker.tsx | 217 ++++++++++++--------- 1 file changed, 130 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index 1326d66..1ad42b3 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -21,12 +21,63 @@ function formatDate(y: number, m: number, d: number): string { return `${y}-${pad(m + 1)}-${pad(d)}`; } +function to12Hour(h24: number): { hour: number; ampm: 'AM' | 'PM' } { + if (h24 === 0) return { hour: 12, ampm: 'AM' }; + if (h24 < 12) return { hour: h24, ampm: 'AM' }; + if (h24 === 12) return { hour: 12, ampm: 'PM' }; + return { hour: h24 - 12, ampm: 'PM' }; +} + +function to24Hour(h12: number, ampm: string): number { + const isPM = ampm.toUpperCase() === 'PM'; + if (h12 === 12) return isPM ? 12 : 0; + return isPM ? h12 + 12 : h12; +} + +/** ISO string → user-friendly display: DD/MM/YYYY or DD/MM/YYYY h:mm AM/PM */ +function isoToDisplay(iso: string, mode: 'date' | 'datetime'): string { + if (!iso) return ''; + const parts = iso.split('T'); + const dp = parts[0]?.split('-'); + if (!dp || dp.length !== 3) return ''; + const dateStr = `${dp[2]}/${dp[1]}/${dp[0]}`; + if (mode === 'date') return dateStr; + const tp = parts[1]?.split(':'); + if (!tp || tp.length < 2) return dateStr; + const h = parseInt(tp[0], 10); + if (isNaN(h)) return dateStr; + const { hour: h12, ampm } = to12Hour(h); + return `${dateStr} ${h12}:${tp[1].slice(0, 2)} ${ampm}`; +} + +/** User-friendly display → ISO string, or null if incomplete/invalid */ +function displayToIso(display: string, mode: 'date' | 'datetime'): string | null { + if (!display) return null; + if (mode === 'date') { + const m = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!m) return null; + const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10); + if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null; + return `${m[3]}-${m[2]}-${m[1]}`; + } + const m = display.match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)$/i); + if (!m) return null; + const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10); + if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null; + let h = parseInt(m[4], 10); + const min = parseInt(m[5], 10); + if (h < 1 || h > 12 || min < 0 || min > 59) return null; + h = to24Hour(h, m[6]); + return `${m[3]}-${m[2]}-${m[1]}T${pad(h)}:${pad(min)}`; +} + const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; +const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // ── Props ── @@ -76,7 +127,7 @@ const DatePicker = React.forwardRef( const currentYear = new Date().getFullYear(); const [startYear, endYear] = yearRange ?? [1900, currentYear + 20]; - // Parse current value + // Parse ISO value into parts const parseDateValue = () => { if (!value) return null; const parts = value.split('T'); @@ -101,20 +152,24 @@ const DatePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0); + // Input variant: user-friendly display string (DD/MM/YYYY or DD/MM/YYYY h:mm AM/PM) + const [displayValue, setDisplayValue] = React.useState(() => + variant === 'input' ? isoToDisplay(value, mode) : '' + ); + const isInternalChange = React.useRef(false); + // Refs - const triggerRef = React.useRef(null); // button variant - const wrapperRef = React.useRef(null); // input variant - const inputElRef = React.useRef(null); // input variant + const triggerRef = React.useRef(null); + const wrapperRef = React.useRef(null); + const inputElRef = React.useRef(null); const popupRef = React.useRef(null); const blurTimeoutRef = React.useRef>(); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); - // Merge forwarded ref with internal ref (button variant only) React.useImperativeHandle(ref, () => triggerRef.current!); - // Sync internal state when value changes (only when popup is closed to avoid - // jumping the calendar view while the user is navigating months or typing) + // Sync popup view state when value changes (only when popup is closed) React.useEffect(() => { if (open) return; const p = parseDateValue(); @@ -127,7 +182,19 @@ const DatePicker = React.forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, open]); - // Position popup relative to trigger (button) or wrapper (input) + // Sync display string from ISO value (input variant). + // Skips when the change originated from the text input to avoid + // overwriting the user's in-progress typing. + React.useEffect(() => { + if (variant !== 'input') return; + if (isInternalChange.current) { + isInternalChange.current = false; + return; + } + setDisplayValue(value ? isoToDisplay(value, mode) : ''); + }, [value, mode, variant]); + + // Position popup const updatePosition = React.useCallback(() => { const el = variant === 'input' ? wrapperRef.current : triggerRef.current; if (!el) return; @@ -153,9 +220,6 @@ const DatePicker = React.forwardRef( }; }, [open, updatePosition]); - // Close popup. refocusTrigger=true returns focus to the trigger/input - // (used for day selection, Escape, Done). false lets focus go wherever the - // user clicked (used for outside-click dismiss). const closePopup = React.useCallback( (refocusTrigger = true) => { setOpen(false); @@ -194,9 +258,7 @@ const DatePicker = React.forwardRef( return () => document.removeEventListener('keydown', handler); }, [open, closePopup]); - // Input variant: smart blur — only fires onBlur when focus truly leaves the - // component group (input + icon + popup). Uses a short timeout to let focus - // settle on the new target before checking. + // Input variant: smart blur — only fires when focus truly leaves the component const handleInputBlur = React.useCallback(() => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = setTimeout(() => { @@ -209,7 +271,6 @@ const DatePicker = React.forwardRef( }, 10); }, [onBlur]); - // Cleanup blur timeout on unmount React.useEffect(() => { return () => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); @@ -218,7 +279,6 @@ const DatePicker = React.forwardRef( const openPopup = () => { if (disabled) return; - // Re-sync view to current value when opening const p = parseDateValue(); if (p) { setViewYear(p.year); @@ -234,7 +294,7 @@ const DatePicker = React.forwardRef( else openPopup(); }; - // Min/Max date boundaries + // Min/Max boundaries const minDate = min ? new Date(min + 'T00:00:00') : null; const maxDate = max ? new Date(max + 'T00:00:00') : null; @@ -245,7 +305,6 @@ const DatePicker = React.forwardRef( return false; }; - // Select a day const selectDay = (day: number) => { if (isDayDisabled(viewYear, viewMonth, day)) return; const dateStr = formatDate(viewYear, viewMonth, day); @@ -266,45 +325,40 @@ const DatePicker = React.forwardRef( } }; - // Navigation const prevMonth = () => { - if (viewMonth === 0) { - setViewMonth(11); - setViewYear((y) => y - 1); - } else { - setViewMonth((m) => m - 1); - } + if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } + else setViewMonth((m) => m - 1); }; const nextMonth = () => { - if (viewMonth === 11) { - setViewMonth(0); - setViewYear((y) => y + 1); - } else { - setViewMonth((m) => m + 1); - } + if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } + else setViewMonth((m) => m + 1); }; - // Build calendar grid + // Calendar grid const daysInMonth = getDaysInMonth(viewYear, viewMonth); const firstDay = getFirstDayOfWeek(viewYear, viewMonth); const cells: (number | null)[] = []; for (let i = 0; i < firstDay; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); - const isToday = (d: number) => + const isTodayDay = (d: number) => d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); const isSelected = (d: number) => parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; - // Display text (button variant only) + // 12-hour display values for time selectors + const { hour: h12, ampm: currentAmpm } = to12Hour(hour); + + // Button variant display text const displayText = (() => { if (!parsed) return ''; const monthName = MONTH_NAMES[parsed.month]; const base = `${monthName} ${parsed.day}, ${parsed.year}`; if (mode === 'datetime') { - return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`; + const { hour: dh, ampm: da } = to12Hour(parsed.hour); + return `${base} ${dh}:${pad(parsed.minute)} ${da}`; } return base; })(); @@ -322,16 +376,11 @@ const DatePicker = React.forwardRef( style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }} className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" > - {/* Month/Year Nav */} + {/* Month/Year nav */}
- -
- -
@@ -369,12 +409,7 @@ const DatePicker = React.forwardRef( {/* Day headers */}
{DAY_HEADERS.map((d) => ( -
- {d} -
+
{d}
))}
@@ -393,7 +428,7 @@ const DatePicker = React.forwardRef( 'h-8 w-full rounded-md text-sm transition-colors', 'hover:bg-accent/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring', isSelected(day) && 'bg-accent text-accent-foreground font-medium', - isToday(day) && !isSelected(day) && 'border border-accent/50 text-accent', + isTodayDay(day) && !isSelected(day) && 'border border-accent/50 text-accent', isDayDisabled(viewYear, viewMonth, day) && 'opacity-30 cursor-not-allowed hover:bg-transparent' )} @@ -404,33 +439,37 @@ const DatePicker = React.forwardRef( )}
- {/* Time selectors (datetime mode only) */} + {/* Time selectors — 12-hour with AM/PM */} {mode === 'datetime' && ( -
+
: +
- - setFormData({ ...formData, due_time: e.target.value })} - /> + +
- -
- - -
From 0c6ea1ccffcc4ab96b39e7df138b0b111ea177b5 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 16:59:54 +0800 Subject: [PATCH 19/22] Fix QA review findings: server-side DOB validation, naive date max prop - W-01: Add date_of_birth validators to RegisterRequest and ProfileUpdate (reject future dates and years before 1900) - W-05: Replace .toISOString().slice() with local date formatting for DatePicker max prop on registration form Co-Authored-By: Claude Opus 4.6 --- backend/app/schemas/auth.py | 20 ++++++++++++++++++++ frontend/src/components/auth/LockScreen.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 2675141..29e178b 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -112,6 +112,15 @@ class RegisterRequest(BaseModel): assert result is not None # required=True guarantees non-None return result + @field_validator("date_of_birth") + @classmethod + def validate_date_of_birth(cls, v: date) -> date: + if v > date.today(): + raise ValueError("Date of birth cannot be in the future") + if v.year < 1900: + raise ValueError("Date of birth is not valid") + return v + @field_validator("preferred_name") @classmethod def validate_preferred_name(cls, v: str | None) -> str | None: @@ -169,6 +178,17 @@ class ProfileUpdate(BaseModel): def validate_email(cls, v: str | None) -> str | None: return _validate_email_format(v) + @field_validator("date_of_birth") + @classmethod + def validate_date_of_birth(cls, v: date | None) -> date | None: + if v is None: + return v + if v > date.today(): + raise ValueError("Date of birth cannot be in the future") + if v.year < 1900: + raise ValueError("Date of birth is not valid") + return v + @field_validator("first_name", "last_name") @classmethod def validate_name_fields(cls, v: str | None) -> str | None: diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 9b5383a..0a5bd03 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -649,7 +649,7 @@ export default function LockScreen() { required name="bday" autoComplete="bday" - max={new Date().toISOString().slice(0, 10)} + max={(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; })()} />
From b04854a4882034e40d6a82826f423294dfe03057 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 17:08:55 +0800 Subject: [PATCH 20/22] Default date/time fields to today/now on create forms Todo, reminder, project, and task forms now pre-fill date/time fields with today's date and current time when creating new items. Edit mode still uses stored values. DOB fields excluded. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/projects/ProjectForm.tsx | 8 +++++++- .../src/components/projects/TaskDetailPanel.tsx | 8 +++++++- frontend/src/components/projects/TaskForm.tsx | 8 +++++++- .../components/reminders/ReminderDetailPanel.tsx | 8 +++++++- .../src/components/reminders/ReminderForm.tsx | 8 +++++++- .../src/components/todos/TodoDetailPanel.tsx | 16 ++++++++++++++-- frontend/src/components/todos/TodoForm.tsx | 16 ++++++++++++++-- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index 3c3329d..7148a10 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -18,6 +18,12 @@ import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + interface ProjectFormProps { project: Project | null; onClose: () => void; @@ -29,7 +35,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) { name: project?.name || '', description: project?.description || '', status: project?.status || 'not_started', - due_date: project?.due_date ? project.due_date.slice(0, 10) : '', + due_date: project?.due_date ? project.due_date.slice(0, 10) : todayLocal(), }); const mutation = useMutation({ diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index d18ca84..2f7c144 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -60,6 +60,12 @@ interface EditState { description: string; } +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + function buildEditState(task: ProjectTask): EditState { return { title: task.title, @@ -84,7 +90,7 @@ export default function TaskDetailPanel({ const [commentText, setCommentText] = useState(''); const [isEditing, setIsEditing] = useState(false); const [editState, setEditState] = useState(() => - task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: '', person_id: '', description: '' } + task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' } ); const { data: people = [] } = useQuery({ diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index 8809e32..9cf4385 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -18,6 +18,12 @@ import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + interface TaskFormProps { projectId: number; task: ProjectTask | null; @@ -33,7 +39,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate description: task?.description || '', status: task?.status || 'pending', priority: task?.priority || 'medium', - due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : ''), + due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : todayLocal()), person_id: task?.person_id?.toString() || '', }); diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 3cfff13..e6dfe6a 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -40,6 +40,12 @@ const recurrenceLabels: Record = { monthly: 'Monthly', }; +function nowLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const; function buildEditState(reminder: Reminder): EditState { @@ -55,7 +61,7 @@ function buildCreateState(): EditState { return { title: '', description: '', - remind_at: '', + remind_at: nowLocal(), recurrence_rule: '', }; } diff --git a/frontend/src/components/reminders/ReminderForm.tsx b/frontend/src/components/reminders/ReminderForm.tsx index 193ce94..a95e560 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.tsx @@ -18,6 +18,12 @@ import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +function nowLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + interface ReminderFormProps { reminder: Reminder | null; onClose: () => void; @@ -28,7 +34,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) { const [formData, setFormData] = useState({ title: reminder?.title || '', description: reminder?.description || '', - remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '', + remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : nowLocal(), recurrence_rule: reminder?.recurrence_rule || '', }); diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index a7893ec..8b6a503 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -58,6 +58,18 @@ const recurrenceLabels: Record = { monthly: 'Monthly', }; +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function nowTimeLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const; function buildEditState(todo: Todo): EditState { @@ -77,8 +89,8 @@ function buildCreateState(defaults?: TodoCreateDefaults | null): EditState { title: '', description: '', priority: 'medium', - due_date: '', - due_time: '', + due_date: todayLocal(), + due_time: nowTimeLocal(), category: defaults?.category || '', recurrence_rule: '', }; diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 6a56032..39744f9 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -18,6 +18,18 @@ import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function nowTimeLocal(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + interface TodoFormProps { todo: Todo | null; onClose: () => void; @@ -29,8 +41,8 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) { title: todo?.title || '', description: todo?.description || '', priority: todo?.priority || 'medium', - due_date: todo?.due_date || '', - due_time: todo?.due_time ? todo.due_time.slice(0, 5) : '', + due_date: todo?.due_date || todayLocal(), + due_time: todo?.due_time ? todo.due_time.slice(0, 5) : nowTimeLocal(), category: todo?.category || '', recurrence_rule: todo?.recurrence_rule || '', }); From 20d0c2ff573a84dda86603e78dd6ed45ba25c999 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 17:52:28 +0800 Subject: [PATCH 21/22] Fix pentest findings: Cache-Control, SSRF save-time validation, Permissions-Policy L-01: Add Cache-Control: no-store to all /api/ responses via nginx L-02: Validate ntfy_server_url against blocked networks at save time I-03: Add Permissions-Policy header to restrict unused browser APIs Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/settings.py | 8 ++++++++ frontend/nginx.conf | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index afba67d..e6b205a 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -62,6 +62,14 @@ async def update_settings( """Update settings.""" update_data = settings_update.model_dump(exclude_unset=True) + # PT-L02: SSRF-validate ntfy_server_url at save time, not just at dispatch + if "ntfy_server_url" in update_data and update_data["ntfy_server_url"]: + from app.services.ntfy import validate_ntfy_host + try: + validate_ntfy_host(update_data["ntfy_server_url"]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + for key, value in update_data.items(): setattr(current_settings, key, value) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3a39185..bae1954 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -100,6 +100,9 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_cache_bypass $http_upgrade; + + # PT-L01: Prevent browser caching of authenticated API responses + add_header Cache-Control "no-store, no-cache, must-revalidate" always; } # SPA fallback - serve index.html for all routes @@ -124,4 +127,6 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + # PT-I03: Restrict unnecessary browser APIs + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; } From 0e0da4bd147c738995915fccf63ef5b511cca10d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 18:41:16 +0800 Subject: [PATCH 22/22] Fix nginx header inheritance regression and add 0.0.0.0/8 to SSRF blocklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW-1: add_header in location /api block suppressed server-level security headers (HSTS, CSP, X-Frame-Options, etc). Duplicate all security headers into the /api block explicitly per nginx inheritance rules. NEW-2: Add 0.0.0.0/8 to _BLOCKED_NETWORKS — on Linux 0.0.0.0 connects to localhost, bypassing the existing loopback check. Co-Authored-By: Claude Opus 4.6 --- backend/app/services/ntfy.py | 3 ++- frontend/nginx.conf | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/app/services/ntfy.py b/backend/app/services/ntfy.py index 3fb4f2b..d1574ab 100644 --- a/backend/app/services/ntfy.py +++ b/backend/app/services/ntfy.py @@ -21,7 +21,8 @@ NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests # SSRF against Docker-internal services. If a self-hosted ntfy server on the LAN # is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk. _BLOCKED_NETWORKS = [ - ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback + ipaddress.ip_network("0.0.0.0/8"), # "This network" — 0.0.0.0 maps to localhost on Linux + ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private diff --git a/frontend/nginx.conf b/frontend/nginx.conf index bae1954..17a0f09 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -103,6 +103,14 @@ server { # PT-L01: Prevent browser caching of authenticated API responses add_header Cache-Control "no-store, no-cache, must-revalidate" always; + # Security headers (must be repeated — nginx add_header in a location block + # overrides server-level add_header directives, so all headers must be explicit) + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; } # SPA fallback - serve index.html for all routes