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/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, ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b982e48..85a854e 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,22 @@ 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 check your details and try again.") + 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, + date_of_birth=data.date_of_birth, last_password_change_at=datetime.now(), ) @@ -457,7 +468,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 +633,55 @@ 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, + 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, date_of_birth).""" + 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"] + 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 — 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"] + 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, + detail={"fields": list(update_data.keys())}, + ip=get_client_ip(request), + ) + + await db.commit() + await db.refresh(current_user) + return ProfileResponse.model_validate(current_user) 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/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index c24160d..fa52d2a 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -5,12 +5,12 @@ 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 -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 # --------------------------------------------------------------------------- @@ -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 @@ -75,28 +76,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 ad86a15..29e178b 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,38 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +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,6 +91,9 @@ class RegisterRequest(BaseModel): username: str password: str + email: str = Field(..., max_length=254) + date_of_birth: date + preferred_name: str | None = Field(None, max_length=100) @field_validator("username") @classmethod @@ -69,6 +105,27 @@ 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) -> str: + result = _validate_email_format(v, required=True) + 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: + return _validate_name_field(v) + class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -106,3 +163,43 @@ 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) + date_of_birth: date | None = None + + @field_validator("email") + @classmethod + 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: + return _validate_name_field(v) + + +class ProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + username: str + email: str | None + first_name: str | None + last_name: str | None + date_of_birth: date | None 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 3a39185..17a0f09 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -100,6 +100,17 @@ 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; + # 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 @@ -124,4 +135,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; } 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} + /> +
+ + setRegPreferredName(e.target.value)} + placeholder="What should we call you?" + maxLength={100} + autoComplete="given-name" + /> +
+
+ + setRegEmail(e.target.value)} + placeholder="your@email.com" + required + maxLength={254} + autoComplete="email" + /> +
+
+ + setRegDateOfBirth(v)} + required + name="bday" + autoComplete="bday" + max={(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; })()} + /> +
- updateField('start_datetime', e.target.value)} + onChange={(v) => updateField('start_datetime', v)} className="text-xs" required />
- updateField('end_datetime', e.target.value)} + onChange={(v) => updateField('end_datetime', v)} className="text-xs" />
diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx index f3168e3..4bae0fc 100644 --- a/frontend/src/components/calendar/EventForm.tsx +++ b/frontend/src/components/calendar/EventForm.tsx @@ -13,6 +13,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'; @@ -281,22 +282,24 @@ export default function EventForm({ event, templateData, templateName, initialSt
- setFormData({ ...formData, start_datetime: e.target.value })} + onChange={(v) => setFormData({ ...formData, start_datetime: v })} required />
- setFormData({ ...formData, end_datetime: e.target.value })} + onChange={(v) => setFormData({ ...formData, end_datetime: v })} />
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index c08df8c..2683ae2 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,11 @@ 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..7148a10 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -12,11 +12,18 @@ 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'; 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; @@ -28,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({ @@ -121,11 +128,11 @@ 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..2f7c144 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 = { @@ -59,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, @@ -83,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({ @@ -350,10 +357,10 @@ 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..9cf4385 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -12,11 +12,18 @@ 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'; 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; @@ -32,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() || '', }); @@ -154,11 +161,11 @@ 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..e6dfe6a 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'; @@ -39,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 { @@ -54,7 +61,7 @@ function buildCreateState(): EditState { return { title: '', description: '', - remind_at: '', + remind_at: nowLocal(), recurrence_rule: '', }; } @@ -340,11 +347,12 @@ 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..a95e560 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.tsx @@ -12,11 +12,18 @@ 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'; 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; @@ -27,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 || '', }); @@ -96,11 +103,12 @@ 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 6137f9b..6019c78 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Settings, User, @@ -18,10 +18,11 @@ 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'; -import type { GeoLocation } from '@/types'; +import type { GeoLocation, UserProfile } from '@/types'; import { Switch } from '@/components/ui/switch'; import TotpSetupSection from './TotpSetupSection'; import NtfySettingsSection from './NtfySettingsSection'; @@ -54,6 +55,29 @@ export default function SettingsPage() { const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); const [autoLockMinutes, setAutoLockMinutes] = useState(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 [dateOfBirth, setDateOfBirth] = useState(''); + const [emailError, setEmailError] = useState(null); + + useEffect(() => { + if (profileQuery.data) { + setFirstName(profileQuery.data.first_name ?? ''); + setLastName(profileQuery.data.last_name ?? ''); + setProfileEmail(profileQuery.data.email ?? ''); + setDateOfBirth(profileQuery.data.date_of_birth ?? ''); + } + }, [profileQuery.dataUpdatedAt]); + // Sync state when settings load useEffect(() => { if (settings) { @@ -149,6 +173,35 @@ export default function SettingsPage() { } }; + 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; + + // 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 +286,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}

+ )} +
+
+ + 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..8b6a503 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'; @@ -57,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 { @@ -76,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: '', }; @@ -385,41 +398,34 @@ export default function TodoDetailPanel({
- updateField('due_date', e.target.value)} + mode="datetime" + value={editState.due_date ? (editState.due_date + 'T' + (editState.due_time || '00:00')) : ''} + onChange={(v) => { + updateField('due_date', v ? v.slice(0, 10) : ''); + updateField('due_time', v ? v.slice(11, 16) : ''); + }} className="text-xs" />
- - updateField('due_time', e.target.value)} + +
-
- - -
- {/* Save / Cancel at bottom */}
diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx new file mode 100644 index 0000000..b846ad2 --- /dev/null +++ b/frontend/src/components/ui/date-picker.tsx @@ -0,0 +1,538 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ── Browser detection (stable — checked once at module load) ── + +const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent); + +// ── 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)}`; +} + +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; +} + +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 ── + +export interface DatePickerProps { + variant?: 'button' | 'input'; + 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( + ( + { + variant = 'button', + 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 ISO value into parts + 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); + + // Refs + 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 }); + + React.useImperativeHandle(ref, () => triggerRef.current!); + + // Sync popup view state when value changes (only when popup is closed) + React.useEffect(() => { + if (open) return; + 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, open]); + + // Position popup + // Firefox + input variant falls through to button variant, so use triggerRef + const usesNativeInput = variant === 'input' && !isFirefox; + const updatePosition = React.useCallback(() => { + const el = usesNativeInput ? 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; + + setPos({ + top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, + left: Math.min(rect.left, window.innerWidth - 290), + }); + }, [mode, usesNativeInput]); + + React.useLayoutEffect(() => { + 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]); + + const closePopup = React.useCallback( + (refocusTrigger = true) => { + setOpen(false); + if (!usesNativeInput) { + onBlur?.(); + } else if (refocusTrigger) { + setTimeout(() => inputElRef.current?.focus(), 0); + } + }, + [usesNativeInput, onBlur] + ); + + // Dismiss on click outside + React.useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (popupRef.current?.contains(e.target as Node)) return; + if (!usesNativeInput && triggerRef.current?.contains(e.target as Node)) return; + if (usesNativeInput && wrapperRef.current?.contains(e.target as Node)) return; + closePopup(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open, usesNativeInput, closePopup]); + + // Dismiss on Escape + React.useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closePopup(true); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, closePopup]); + + // 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(() => { + const active = document.activeElement; + if ( + popupRef.current?.contains(active) || + wrapperRef.current?.contains(active) + ) return; + onBlur?.(); + }, 10); + }, [onBlur]); + + React.useEffect(() => { + return () => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }; + }, []); + + const openPopup = () => { + if (disabled) return; + const p = parseDateValue(); + if (p) { + setViewYear(p.year); + setViewMonth(p.month); + setHour(p.hour); + setMinute(p.minute); + } + setOpen(true); + }; + + const togglePopup = () => { + if (open) closePopup(true); + else openPopup(); + }; + + // Min/Max 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; + }; + + 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(true); + } + }; + + 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)}`); + } + }; + + 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); + }; + + // 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 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; + + // 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') { + const { hour: dh, ampm: da } = to12Hour(parsed.hour); + return `${base} ${dh}:${pad(parsed.minute)} ${da}`; + } + return base; + })(); + + // Year options + 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 — 12-hour with AM/PM */} + {mode === 'datetime' && ( +
+ + + : + + + +
+ )} +
, + document.body + ) + : null; + + // ── Input variant (Chromium only) ── + // Firefox: falls through to the button variant below because Firefox has no + // CSS pseudo-element to hide its native calendar icon (Mozilla bug 1830890). + // Chromium: uses native type="date"/"datetime-local" for segmented editing UX, + // with the native icon hidden via CSS in index.css (.datepicker-wrapper rule). + if (variant === 'input' && !isFirefox) { + return ( + <> +
+ onChange(e.target.value)} + onBlur={handleInputBlur} + onKeyDown={(e) => { + if (open && e.key === 'Enter') { + e.preventDefault(); + return; + } + onKeyDown?.(e); + }} + required={required} + disabled={disabled} + min={min} + max={max} + 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 ( + <> + + {required && ( + {}} + style={{ position: 'absolute' }} + /> + )} + + + + {popup} + + ); + } +); +DatePicker.displayName = 'DatePicker'; + +export { DatePicker }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 7c181f5..a7acc78 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, date_of_birth, preferred_name }: { + username: string; password: string; email: string; date_of_birth: string; + preferred_name?: string; + }) => { + 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; }, onSuccess: (data) => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 1753ba3..8718d43 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -193,6 +193,22 @@ 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; +} + +/* Hide native picker icon inside DatePicker wrapper (custom icon replaces it) */ +/* Chromium: remove the native calendar icon entirely */ +.datepicker-wrapper input::-webkit-calendar-picker-indicator { + display: none; +} +/* Firefox: No CSS pseudo-element exists to hide the native calendar icon. + The custom button covers the native icon zone with a solid background so + only one icon is visible regardless of browser. */ + /* ── Form validation — red outline only after submit attempt ── */ form[data-submitted] input:invalid, form[data-submitted] select:invalid, @@ -201,6 +217,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 { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fed8531..20e209f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -237,6 +237,7 @@ export interface AdminUser { export interface AdminUserDetail extends AdminUser { active_sessions: number; preferred_name?: string | null; + date_of_birth?: string | null; must_change_password?: boolean; locked_until?: string | null; } @@ -345,6 +346,14 @@ export interface UpcomingResponse { cutoff_date: string; } +export interface UserProfile { + username: string; + email: string | null; + first_name: string | null; + last_name: string | null; + date_of_birth: string | null; +} + export interface EventTemplate { id: number; name: string;