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>
This commit is contained in:
Kyle 2026-03-03 18:52:26 +08:00
commit 2a21809066
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,6 +21,7 @@ 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("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

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,26 +398,18 @@ 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)}
className="text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
@ -419,6 +424,7 @@ export default function TodoDetailPanel({
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">

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,25 +142,19 @@ 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 })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label>
<Select
@ -162,6 +169,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</Select>
</div>
</div>
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={onClose}>

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;