Compare commits

..

23 Commits

Author SHA1 Message Date
2a21809066 Merge feature/registration-profile-fields into main
- Registration profile fields (preferred name, email, DOB)
- Custom DatePicker component replacing all native date inputs
- Default date/time fields to today/now on create forms
- Pentest hardening: Cache-Control, SSRF save-time validation,
  Permissions-Policy, nginx header inheritance fix, 0.0.0.0/8 block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:52:26 +08:00
0e0da4bd14 Fix nginx header inheritance regression and add 0.0.0.0/8 to SSRF blocklist
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 <noreply@anthropic.com>
2026-03-03 18:41:16 +08:00
20d0c2ff57 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 <noreply@anthropic.com>
2026-03-03 17:52:28 +08:00
b04854a488 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 <noreply@anthropic.com>
2026-03-03 17:08:55 +08:00
0c6ea1ccff 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 <noreply@anthropic.com>
2026-03-03 16:59:54 +08:00
6cd648f3a8 Replace native date/time inputs with DatePicker across calendar and todo forms
- EventForm + EventDetailPanel: native <Input type=date|datetime-local> → DatePicker with dynamic mode via all_day toggle
- TodoForm + TodoDetailPanel: merge date + time into single datetime DatePicker, remove separate time input, move recurrence select into 2-col grid beside date picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:43:14 +08:00
e20c04ac4f Fix DatePicker popup flashing at top-left in Chromium
Latent bug: useEffect runs after paint, so the popup rendered at
{top:0, left:0} before repositioning. Switched to useLayoutEffect
which runs synchronously before paint, ensuring correct position
on first frame. Both Chromium and Firefox unaffected by the change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:26:49 +08:00
63b3a3a073 Fix Firefox DatePicker popup positioning at top-left
When Firefox input variant falls through to button variant, the
positioning logic, close handler, and click-outside handler still
checked variant==='input' and used wrapperRef (which is unattached).
Introduced usesNativeInput flag (input variant + not Firefox) so all
three handlers correctly use triggerRef for Firefox fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:56:05 +08:00
e7979afba3 Firefox DatePicker input variant falls through to button variant
Instead of type=text with raw ISO strings, Firefox users now get
the same button-style picker used on the registration screen.
Chromium keeps native date/datetime-local for segmented editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:49:03 +08:00
8e922a1f1c Use type=text for DatePicker in Firefox to eliminate double icon
Firefox has no CSS pseudo-element to hide its native date picker
calendar icon (Mozilla bug 1830890, open P3). Firefox's date input
doesn't provide Chrome's segmented editing anyway — it renders as
a plain text field with an appended icon.

Fix: detect Firefox via user agent at module load, render type=text
with ISO format placeholder. Chromium keeps native date/datetime-local
for segmented editing UX. min/max omitted for Firefox (only valid on
native date inputs). Custom popup handles all date selection in both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:01:38 +08:00
db2ec156e4 Fix Firefox double calendar icon with opaque cover button
@-moz-document url-prefix() was dead since Firefox 61 and
-moz-appearance: textfield has no effect on date inputs.
Firefox has no CSS pseudo-element for the date picker icon.

Fix: custom Calendar button resized to a full-height w-9 panel
with bg-background + rounded-r-md that completely occludes
Firefox's native icon underneath. Chromium still uses
::-webkit-calendar-picker-indicator to remove its native icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:53:36 +08:00
01aed12769 Fix Firefox duplicate calendar icon with -moz-appearance: textfield
The opaque background overlay approach didn't fully cover Firefox's
native icon. Instead, use @-moz-document url-prefix() to apply
-moz-appearance: textfield which strips all native date input chrome
(including the calendar icon) in Firefox. Safe because the DatePicker
provides its own custom popup. Removed the bg-background z-[1]
workaround from the custom button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:45:48 +08:00
e9d4ba384f Fix duplicate calendar icon in Firefox DatePicker
Chromium's icon is hidden via ::-webkit-calendar-picker-indicator.
Firefox doesn't support that pseudo-element, so the custom Calendar
button now has bg-background + z-[1] to opaquely cover Firefox's
native icon. Removed invalid -moz pseudo-element rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:40:40 +08:00
a30483fbbc Switch DatePicker input variant to native date/datetime-local types
Replaces <input type="text"> with custom display format conversion
with native <input type="date"> / <input type="datetime-local"> for
exact visual parity with Chrome's built-in segmented editing UI.
Removes ~50 lines of isoToDisplay/displayToIso conversion code.
Hides native picker icon inside .datepicker-wrapper via CSS so only
the custom Calendar icon (opening the popup) is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:33:02 +08:00
247c701e12 Match native datetime-local format in DatePicker input variant
Pad 12-hour display to 2 digits to match Chrome native input format:
03/03/2026 03:12 AM (was 3:12 AM). Relax day/month parser to accept
1-2 digit input while still outputting zero-padded ISO strings.
Update placeholder to DD/MM/YYYY hh:mm AM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:21:47 +08:00
59a4f67b42 Display DD/MM/YYYY and 12-hour AM/PM in DatePicker
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 <noreply@anthropic.com>
2026-03-03 03:07:07 +08:00
4dc3c856b0 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 <noreply@anthropic.com>
2026-03-03 02:43:45 +08:00
013f9ec010 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 <input type="date"> and
<input type="datetime-local"> 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 <noreply@anthropic.com>
2026-03-03 02:30:52 +08:00
da61676fef 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 <noreply@anthropic.com>
2026-03-03 01:42:06 +08:00
3a456e56dd 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 <noreply@anthropic.com>
2026-03-02 19:58:21 +08:00
e8109cef6b Add required email + date of birth to registration, shared validators, partial index
- 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 <noreply@anthropic.com>
2026-03-02 19:21:11 +08:00
02efe04fc4 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 <noreply@anthropic.com>
2026-03-02 19:09:15 +08:00
45f3788fb0 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 <noreply@anthropic.com>
2026-03-02 19:02:42 +08:00
26 changed files with 1134 additions and 123 deletions

View File

@ -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")

View File

@ -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

View File

@ -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,
)

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -117,6 +117,16 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
<DetailRow label="Last Name" value={user.last_name} />
<DetailRow label="Email" value={user.email} />
<DetailRow label="Preferred Name" value={user.preferred_name} />
<DetailRow
label="Date of Birth"
value={user.date_of_birth ? (() => {
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}
/>
<DetailRow
label="Created"
value={getRelativeTime(user.created_at)}

View File

@ -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';
@ -53,6 +54,11 @@ export default function LockScreen() {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// ── Registration fields ──
const [regEmail, setRegEmail] = useState('');
const [regDateOfBirth, setRegDateOfBirth] = useState('');
const [regPreferredName, setRegPreferredName] = useState('');
// ── TOTP challenge ──
const [totpCode, setTotpCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false);
@ -136,8 +142,16 @@ 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 });
await register({
username,
password,
email: regEmail.trim(),
date_of_birth: regDateOfBirth,
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 +571,9 @@ export default function LockScreen() {
setUsername('');
setPassword('');
setConfirmPassword('');
setRegEmail('');
setRegDateOfBirth('');
setRegPreferredName('');
setLoginError(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@ -598,6 +615,43 @@ export default function LockScreen() {
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-preferred-name">Preferred Name</Label>
<Input
id="reg-preferred-name"
type="text"
value={regPreferredName}
onChange={(e) => setRegPreferredName(e.target.value)}
placeholder="What should we call you?"
maxLength={100}
autoComplete="given-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-email" required>Email</Label>
<Input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="your@email.com"
required
maxLength={254}
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-dob" required>Date of Birth</Label>
<DatePicker
id="reg-dob"
value={regDateOfBirth}
onChange={(v) => 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')}`; })()}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-password" required>Password</Label>
<Input
@ -641,6 +695,9 @@ export default function LockScreen() {
setUsername('');
setPassword('');
setConfirmPassword('');
setRegEmail('');
setRegDateOfBirth('');
setRegPreferredName('');
setLoginError(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"

View File

@ -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';
@ -633,22 +634,24 @@ export default function EventDetailPanel({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<Input
<DatePicker
variant="input"
id="panel-start"
type={editState.all_day ? 'date' : 'datetime-local'}
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.start_datetime}
onChange={(e) => updateField('start_datetime', e.target.value)}
onChange={(v) => updateField('start_datetime', v)}
className="text-xs"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<Input
<DatePicker
variant="input"
id="panel-end"
type={editState.all_day ? 'date' : 'datetime-local'}
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.end_datetime}
onChange={(e) => updateField('end_datetime', e.target.value)}
onChange={(v) => updateField('end_datetime', v)}
className="text-xs"
/>
</div>

View File

@ -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
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start" required>Start</Label>
<Input
<DatePicker
variant="input"
id="start"
type={formData.all_day ? 'date' : 'datetime-local'}
mode={formData.all_day ? 'date' : 'datetime'}
value={formData.start_datetime}
onChange={(e) => setFormData({ ...formData, start_datetime: e.target.value })}
onChange={(v) => setFormData({ ...formData, start_datetime: v })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="end">End</Label>
<Input
<DatePicker
variant="input"
id="end"
type={formData.all_day ? 'date' : 'datetime-local'}
mode={formData.all_day ? 'date' : 'datetime'}
value={formData.end_datetime}
onChange={(e) => setFormData({ ...formData, end_datetime: e.target.value })}
onChange={(v) => setFormData({ ...formData, end_datetime: v })}
/>
</div>
</div>

View File

@ -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
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Input
<DatePicker
variant="input"
id="birthday"
type="date"
value={formData.birthday}
onChange={(e) => set('birthday', e.target.value)}
onChange={(v) => set('birthday', v)}
/>
</div>
<div className="space-y-2">

View File

@ -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) {
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
<DatePicker
variant="input"
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
onChange={(v) => setFormData({ ...formData, due_date: v })}
/>
</div>
</div>

View File

@ -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<string, string> = {
@ -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<EditState>(() =>
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
</div>
{isEditing ? (
<Input
type="date"
<DatePicker
variant="input"
value={editState.due_date}
onChange={(e) => setEditState((s) => ({ ...s, due_date: e.target.value }))}
onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))}
className="h-8 text-xs"
/>
) : (

View File

@ -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
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
<DatePicker
variant="input"
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
onChange={(v) => setFormData({ ...formData, due_date: v })}
/>
</div>

View File

@ -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<string, string> = {
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({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label>
<Input
<DatePicker
variant="input"
id="reminder-at"
type="datetime-local"
mode="datetime"
value={editState.remind_at}
onChange={(e) => updateField('remind_at', e.target.value)}
onChange={(v) => updateField('remind_at', v)}
className="text-xs"
/>
</div>

View File

@ -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) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="remind_at">Remind At</Label>
<Input
<DatePicker
variant="input"
id="remind_at"
type="datetime-local"
mode="datetime"
value={formData.remind_at}
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
onChange={(v) => setFormData({ ...formData, remind_at: v })}
/>
</div>

View File

@ -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<number | string>(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<UserProfile>('/auth/profile');
return data;
},
});
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(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<string, string> = { 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() {
</div>
<div>
<CardTitle>Profile</CardTitle>
<CardDescription>Personalize how UMBRA greets you</CardDescription>
<CardDescription>Your profile and display preferences</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<Input
@ -254,6 +307,62 @@ export default function SettingsPage() {
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
type="text"
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => handleProfileSave('first_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('first_name'); }}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
type="text"
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onBlur={() => handleProfileSave('last_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('last_name'); }}
maxLength={100}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="profile_email">Email</Label>
<Input
id="profile_email"
type="email"
placeholder="your@email.com"
value={profileEmail}
onChange={(e) => { 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 && (
<p className="text-xs text-red-400">{emailError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<DatePicker
variant="input"
id="date_of_birth"
value={dateOfBirth}
onChange={(v) => setDateOfBirth(v)}
onBlur={() => handleProfileSave('date_of_birth')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
/>
</div>
</CardContent>
</Card>

View File

@ -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<string, string> = {
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({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<Input
<DatePicker
variant="input"
id="todo-due-date"
type="date"
value={editState.due_date}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<Label htmlFor="todo-due-time">Due Time</Label>
<Input
id="todo-due-time"
type="time"
value={editState.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
/>
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>

View File

@ -12,11 +12,24 @@ 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())}`;
}
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;
@ -28,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 || '',
});
@ -129,38 +142,33 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
<DatePicker
variant="input"
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
mode="datetime"
value={formData.due_date ? (formData.due_date + 'T' + (formData.due_time || '00:00')) : ''}
onChange={(v) => setFormData({
...formData,
due_date: v ? v.slice(0, 10) : '',
due_time: v ? v.slice(11, 16) : '',
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="due_time">Due Time</Label>
<Input
id="due_time"
type="time"
value={formData.due_time}
onChange={(e) => setFormData({ ...formData, due_time: e.target.value })}
/>
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
<SheetFooter>

View File

@ -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<HTMLButtonElement, DatePickerProps>(
(
{
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<HTMLButtonElement>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const inputElRef = React.useRef<HTMLInputElement>(null);
const popupRef = React.useRef<HTMLDivElement>(null);
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
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(
<div
ref={popupRef}
onMouseDown={(e) => 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 */}
<div className="flex items-center justify-between px-3 pt-3 pb-2">
<button type="button" onClick={prevMonth} className="p-1 rounded-md hover:bg-accent/10 transition-colors">
<ChevronLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-1.5">
<select
value={viewMonth}
onChange={(e) => setViewMonth(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none pr-1"
>
{MONTH_NAMES.map((n, i) => (
<option key={i} value={i} className="bg-card text-foreground">{n}</option>
))}
</select>
<select
value={viewYear}
onChange={(e) => setViewYear(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none"
>
{yearOptions.map((y) => (
<option key={y} value={y} className="bg-card text-foreground">{y}</option>
))}
</select>
</div>
<button type="button" onClick={nextMonth} className="p-1 rounded-md hover:bg-accent/10 transition-colors">
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 px-3 pb-1">
{DAY_HEADERS.map((d) => (
<div key={d} className="text-center text-[11px] font-medium text-muted-foreground py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 px-3 pb-3">
{cells.map((day, i) =>
day === null ? (
<div key={`empty-${i}`} />
) : (
<button
key={day}
type="button"
disabled={isDayDisabled(viewYear, viewMonth, day)}
onClick={() => selectDay(day)}
className={cn(
'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',
isTodayDay(day) && !isSelected(day) && 'border border-accent/50 text-accent',
isDayDisabled(viewYear, viewMonth, day) &&
'opacity-30 cursor-not-allowed hover:bg-transparent'
)}
>
{day}
</button>
)
)}
</div>
{/* Time selectors — 12-hour with AM/PM */}
{mode === 'datetime' && (
<div className="flex items-center gap-1.5 px-3 pb-3 border-t border-border pt-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<select
value={h12}
onChange={(e) => handleTimeChange(to24Hour(parseInt(e.target.value, 10), currentAmpm), minute)}
className="w-14 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{HOUR_OPTIONS.map((h) => (
<option key={h} value={h} className="bg-card text-foreground">{h}</option>
))}
</select>
<span className="text-muted-foreground font-medium">:</span>
<select
value={minute}
onChange={(e) => handleTimeChange(hour, parseInt(e.target.value, 10))}
className="w-14 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{Array.from({ length: 60 }, (_, i) => (
<option key={i} value={i} className="bg-card text-foreground">{pad(i)}</option>
))}
</select>
<select
value={currentAmpm}
onChange={(e) => handleTimeChange(to24Hour(h12, e.target.value), minute)}
className="w-16 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
<option value="AM" className="bg-card text-foreground">AM</option>
<option value="PM" className="bg-card text-foreground">PM</option>
</select>
<button
type="button"
onClick={() => closePopup(true)}
className="ml-auto px-2 py-1 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
Done
</button>
</div>
)}
</div>,
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 (
<>
<div ref={wrapperRef} className="datepicker-wrapper relative">
<input
ref={inputElRef}
type={mode === 'datetime' ? 'datetime-local' : 'date'}
id={id}
name={name}
autoComplete={autoComplete}
value={value}
onChange={(e) => 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
)}
/>
<button
type="button"
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={togglePopup}
className="absolute right-px top-px bottom-px w-9 flex items-center justify-center rounded-r-md bg-background hover:bg-accent/10 transition-colors disabled:opacity-50"
tabIndex={-1}
aria-label="Open calendar"
>
<Calendar className="h-4 w-4 opacity-70" />
</button>
</div>
{popup}
</>
);
}
// ── Button variant: non-editable trigger (registration DOB) ──
return (
<>
<input type="hidden" name={name} autoComplete={autoComplete} value={value} required={required} />
{required && (
<input
tabIndex={-1}
aria-hidden
className="absolute w-0 h-0 opacity-0 pointer-events-none"
value={value}
required
onChange={() => {}}
style={{ position: 'absolute' }}
/>
)}
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={togglePopup}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePopup();
}
if (open) return;
onKeyDown?.(e);
}}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
!value && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayText || placeholder || (mode === 'datetime' ? 'Pick date & time' : 'Pick a date')}
</span>
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
</button>
{popup}
</>
);
}
);
DatePicker.displayName = 'DatePicker';
export { DatePicker };

View File

@ -47,8 +47,13 @@ export function useAuth() {
});
const registerMutation = useMutation({
mutationFn: async ({ username, password }: { username: string; password: string }) => {
const { data } = await api.post<LoginResponse & { message?: string }>('/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<string, string> = { username, password, email, date_of_birth };
if (preferred_name) payload.preferred_name = preferred_name;
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', payload);
return data;
},
onSuccess: (data) => {

View File

@ -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 {

View File

@ -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;