Compare commits
23 Commits
3e39c709b7
...
2a21809066
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a21809066 | |||
| 0e0da4bd14 | |||
| 20d0c2ff57 | |||
| b04854a488 | |||
| 0c6ea1ccff | |||
| 6cd648f3a8 | |||
| e20c04ac4f | |||
| 63b3a3a073 | |||
| e7979afba3 | |||
| 8e922a1f1c | |||
| db2ec156e4 | |||
| 01aed12769 | |||
| e9d4ba384f | |||
| a30483fbbc | |||
| 247c701e12 | |||
| 59a4f67b42 | |||
| 4dc3c856b0 | |||
| 013f9ec010 | |||
| da61676fef | |||
| 3a456e56dd | |||
| e8109cef6b | |||
| 02efe04fc4 | |||
| 45f3788fb0 |
@ -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")
|
||||||
@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@ -9,9 +9,10 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, 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)
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
last_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)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
# MFA — populated in Track B
|
# MFA — populated in Track B
|
||||||
|
|||||||
@ -180,6 +180,7 @@ async def get_user(
|
|||||||
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
|
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
|
||||||
active_sessions=active_sessions,
|
active_sessions=active_sessions,
|
||||||
preferred_name=preferred_name,
|
preferred_name=preferred_name,
|
||||||
|
date_of_birth=user.date_of_birth,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from app.models.calendar import Calendar
|
|||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
SetupRequest, LoginRequest, RegisterRequest,
|
SetupRequest, LoginRequest, RegisterRequest,
|
||||||
ChangePasswordRequest, VerifyPasswordRequest,
|
ChangePasswordRequest, VerifyPasswordRequest,
|
||||||
|
ProfileUpdate, ProfileResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
hash_password,
|
hash_password,
|
||||||
@ -441,12 +442,22 @@ async def register(
|
|||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
|
raise HTTPException(status_code=400, detail="Registration could not be completed. Please 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)
|
password_hash = hash_password(data.password)
|
||||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role="standard",
|
role="standard",
|
||||||
|
email=data.email,
|
||||||
|
date_of_birth=data.date_of_birth,
|
||||||
last_password_change_at=datetime.now(),
|
last_password_change_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -457,7 +468,7 @@ async def register(
|
|||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
await db.flush()
|
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)
|
ip = get_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
@ -622,3 +633,55 @@ async def change_password(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Password changed successfully"}
|
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)
|
||||||
|
|||||||
@ -62,6 +62,14 @@ async def update_settings(
|
|||||||
"""Update settings."""
|
"""Update settings."""
|
||||||
update_data = settings_update.model_dump(exclude_unset=True)
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(current_settings, key, value)
|
setattr(current_settings, key, value)
|
||||||
|
|
||||||
|
|||||||
@ -5,12 +5,12 @@ All admin-facing request/response shapes live here to keep the admin router
|
|||||||
clean and testable in isolation.
|
clean and testable in isolation.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
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):
|
class UserDetailResponse(UserListItem):
|
||||||
preferred_name: Optional[str] = None
|
preferred_name: Optional[str] = None
|
||||||
|
date_of_birth: Optional[date] = None
|
||||||
must_change_password: bool = False
|
must_change_password: bool = False
|
||||||
locked_until: Optional[datetime] = None
|
locked_until: Optional[datetime] = None
|
||||||
|
|
||||||
@ -75,28 +76,12 @@ class CreateUserRequest(BaseModel):
|
|||||||
@field_validator("email")
|
@field_validator("email")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_email(cls, v: str | None) -> str | None:
|
def validate_email(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_email_format(v)
|
||||||
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
|
|
||||||
|
|
||||||
@field_validator("first_name", "last_name", "preferred_name")
|
@field_validator("first_name", "last_name", "preferred_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_name_fields(cls, v: str | None) -> str | None:
|
def validate_name_fields(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
return _validate_name_field(v)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateUserRoleRequest(BaseModel):
|
class UpdateUserRoleRequest(BaseModel):
|
||||||
|
|||||||
@ -1,5 +1,38 @@
|
|||||||
import re
|
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:
|
def _validate_password_strength(v: str) -> str:
|
||||||
@ -58,6 +91,9 @@ class RegisterRequest(BaseModel):
|
|||||||
|
|
||||||
username: str
|
username: str
|
||||||
password: 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")
|
@field_validator("username")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -69,6 +105,27 @@ class RegisterRequest(BaseModel):
|
|||||||
def validate_password(cls, v: str) -> str:
|
def validate_password(cls, v: str) -> str:
|
||||||
return _validate_password_strength(v)
|
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):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@ -106,3 +163,43 @@ class VerifyPasswordRequest(BaseModel):
|
|||||||
if len(v) > 128:
|
if len(v) > 128:
|
||||||
raise ValueError("Password must be 128 characters or fewer")
|
raise ValueError("Password must be 128 characters or fewer")
|
||||||
return v
|
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
|
||||||
|
|||||||
@ -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
|
# 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.
|
# is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk.
|
||||||
_BLOCKED_NETWORKS = [
|
_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("127.0.0.0/8"), # IPv4 loopback
|
||||||
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private
|
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("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x
|
||||||
|
|||||||
@ -100,6 +100,17 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
proxy_cache_bypass $http_upgrade;
|
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
|
# 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 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 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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
# PT-I03: Restrict unnecessary browser APIs
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,6 +117,16 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
|||||||
<DetailRow label="Last Name" value={user.last_name} />
|
<DetailRow label="Last Name" value={user.last_name} />
|
||||||
<DetailRow label="Email" value={user.email} />
|
<DetailRow label="Email" value={user.email} />
|
||||||
<DetailRow label="Preferred Name" value={user.preferred_name} />
|
<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
|
<DetailRow
|
||||||
label="Created"
|
label="Created"
|
||||||
value={getRelativeTime(user.created_at)}
|
value={getRelativeTime(user.created_at)}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -53,6 +54,11 @@ export default function LockScreen() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// ── Registration fields ──
|
||||||
|
const [regEmail, setRegEmail] = useState('');
|
||||||
|
const [regDateOfBirth, setRegDateOfBirth] = useState('');
|
||||||
|
const [regPreferredName, setRegPreferredName] = useState('');
|
||||||
|
|
||||||
// ── TOTP challenge ──
|
// ── TOTP challenge ──
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
@ -136,8 +142,16 @@ export default function LockScreen() {
|
|||||||
const err = validatePassword(password);
|
const err = validatePassword(password);
|
||||||
if (err) { toast.error(err); return; }
|
if (err) { toast.error(err); return; }
|
||||||
if (password !== confirmPassword) { toast.error('Passwords do not match'); 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 {
|
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
|
// On success useAuth invalidates query → Navigate handles redirect
|
||||||
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
|
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -557,6 +571,9 @@ export default function LockScreen() {
|
|||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setRegEmail('');
|
||||||
|
setRegDateOfBirth('');
|
||||||
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@ -598,6 +615,43 @@ export default function LockScreen() {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reg-password" required>Password</Label>
|
<Label htmlFor="reg-password" required>Password</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -641,6 +695,9 @@ export default function LockScreen() {
|
|||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setRegEmail('');
|
||||||
|
setRegDateOfBirth('');
|
||||||
|
setRegPreferredName('');
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
|
|||||||
import CopyableField from '@/components/shared/CopyableField';
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@ -633,22 +634,24 @@ export default function EventDetailPanel({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-start" required>Start</Label>
|
<Label htmlFor="panel-start" required>Start</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="panel-start"
|
id="panel-start"
|
||||||
type={editState.all_day ? 'date' : 'datetime-local'}
|
mode={editState.all_day ? 'date' : 'datetime'}
|
||||||
value={editState.start_datetime}
|
value={editState.start_datetime}
|
||||||
onChange={(e) => updateField('start_datetime', e.target.value)}
|
onChange={(v) => updateField('start_datetime', v)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-end">End</Label>
|
<Label htmlFor="panel-end">End</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="panel-end"
|
id="panel-end"
|
||||||
type={editState.all_day ? 'date' : 'datetime-local'}
|
mode={editState.all_day ? 'date' : 'datetime'}
|
||||||
value={editState.end_datetime}
|
value={editState.end_datetime}
|
||||||
onChange={(e) => updateField('end_datetime', e.target.value)}
|
onChange={(v) => updateField('end_datetime', v)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="start" required>Start</Label>
|
<Label htmlFor="start" required>Start</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="start"
|
id="start"
|
||||||
type={formData.all_day ? 'date' : 'datetime-local'}
|
mode={formData.all_day ? 'date' : 'datetime'}
|
||||||
value={formData.start_datetime}
|
value={formData.start_datetime}
|
||||||
onChange={(e) => setFormData({ ...formData, start_datetime: e.target.value })}
|
onChange={(v) => setFormData({ ...formData, start_datetime: v })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="end">End</Label>
|
<Label htmlFor="end">End</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="end"
|
id="end"
|
||||||
type={formData.all_day ? 'date' : 'datetime-local'}
|
mode={formData.all_day ? 'date' : 'datetime'}
|
||||||
value={formData.end_datetime}
|
value={formData.end_datetime}
|
||||||
onChange={(e) => setFormData({ ...formData, end_datetime: e.target.value })}
|
onChange={(v) => setFormData({ ...formData, end_datetime: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="birthday">Birthday</Label>
|
<Label htmlFor="birthday">Birthday</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="birthday"
|
id="birthday"
|
||||||
type="date"
|
|
||||||
value={formData.birthday}
|
value={formData.birthday}
|
||||||
onChange={(e) => set('birthday', e.target.value)}
|
onChange={(v) => set('birthday', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -12,11 +12,18 @@ import {
|
|||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface ProjectFormProps {
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -28,7 +35,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
|
|||||||
name: project?.name || '',
|
name: project?.name || '',
|
||||||
description: project?.description || '',
|
description: project?.description || '',
|
||||||
status: project?.status || 'not_started',
|
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({
|
const mutation = useMutation({
|
||||||
@ -121,11 +128,11 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="due_date">Due Date</Label>
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="due_date"
|
id="due_date"
|
||||||
type="date"
|
|
||||||
value={formData.due_date}
|
value={formData.due_date}
|
||||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
onChange={(v) => setFormData({ ...formData, due_date: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
|
|
||||||
const taskStatusColors: Record<string, string> = {
|
const taskStatusColors: Record<string, string> = {
|
||||||
@ -59,6 +60,12 @@ interface EditState {
|
|||||||
description: string;
|
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 {
|
function buildEditState(task: ProjectTask): EditState {
|
||||||
return {
|
return {
|
||||||
title: task.title,
|
title: task.title,
|
||||||
@ -83,7 +90,7 @@ export default function TaskDetailPanel({
|
|||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editState, setEditState] = useState<EditState>(() =>
|
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({
|
const { data: people = [] } = useQuery({
|
||||||
@ -350,10 +357,10 @@ export default function TaskDetailPanel({
|
|||||||
Due Date
|
Due Date
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Input
|
<DatePicker
|
||||||
type="date"
|
variant="input"
|
||||||
value={editState.due_date}
|
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"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -12,11 +12,18 @@ import {
|
|||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface TaskFormProps {
|
||||||
projectId: number;
|
projectId: number;
|
||||||
task: ProjectTask | null;
|
task: ProjectTask | null;
|
||||||
@ -32,7 +39,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
|
|||||||
description: task?.description || '',
|
description: task?.description || '',
|
||||||
status: task?.status || 'pending',
|
status: task?.status || 'pending',
|
||||||
priority: task?.priority || 'medium',
|
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() || '',
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="due_date">Due Date</Label>
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="due_date"
|
id="due_date"
|
||||||
type="date"
|
|
||||||
value={formData.due_date}
|
value={formData.due_date}
|
||||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
onChange={(v) => setFormData({ ...formData, due_date: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
|
|||||||
import CopyableField from '@/components/shared/CopyableField';
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@ -39,6 +40,12 @@ const recurrenceLabels: Record<string, string> = {
|
|||||||
monthly: 'Monthly',
|
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;
|
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
|
||||||
|
|
||||||
function buildEditState(reminder: Reminder): EditState {
|
function buildEditState(reminder: Reminder): EditState {
|
||||||
@ -54,7 +61,7 @@ function buildCreateState(): EditState {
|
|||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
remind_at: '',
|
remind_at: nowLocal(),
|
||||||
recurrence_rule: '',
|
recurrence_rule: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -340,11 +347,12 @@ export default function ReminderDetailPanel({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="reminder-at">Remind At</Label>
|
<Label htmlFor="reminder-at">Remind At</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="reminder-at"
|
id="reminder-at"
|
||||||
type="datetime-local"
|
mode="datetime"
|
||||||
value={editState.remind_at}
|
value={editState.remind_at}
|
||||||
onChange={(e) => updateField('remind_at', e.target.value)}
|
onChange={(v) => updateField('remind_at', v)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,11 +12,18 @@ import {
|
|||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface ReminderFormProps {
|
||||||
reminder: Reminder | null;
|
reminder: Reminder | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -27,7 +34,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: reminder?.title || '',
|
title: reminder?.title || '',
|
||||||
description: reminder?.description || '',
|
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 || '',
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="remind_at">Remind At</Label>
|
<Label htmlFor="remind_at">Remind At</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="remind_at"
|
id="remind_at"
|
||||||
type="datetime-local"
|
mode="datetime"
|
||||||
value={formData.remind_at}
|
value={formData.remind_at}
|
||||||
onChange={(e) => setFormData({ ...formData, remind_at: e.target.value })}
|
onChange={(v) => setFormData({ ...formData, remind_at: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
@ -18,10 +18,11 @@ import {
|
|||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation } from '@/types';
|
import type { GeoLocation, UserProfile } from '@/types';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import NtfySettingsSection from './NtfySettingsSection';
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
@ -54,6 +55,29 @@ export default function SettingsPage() {
|
|||||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
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
|
// Sync state when settings load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
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) => {
|
const handleColorChange = async (color: string) => {
|
||||||
setSelectedColor(color);
|
setSelectedColor(color);
|
||||||
try {
|
try {
|
||||||
@ -233,11 +286,11 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Profile</CardTitle>
|
<CardTitle>Profile</CardTitle>
|
||||||
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
<CardDescription>Your profile and display preferences</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preferred_name">Preferred Name</Label>
|
<Label htmlFor="preferred_name">Preferred Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -254,6 +307,62 @@ export default function SettingsPage() {
|
|||||||
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils';
|
|||||||
import CopyableField from '@/components/shared/CopyableField';
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@ -57,6 +58,18 @@ const recurrenceLabels: Record<string, string> = {
|
|||||||
monthly: 'Monthly',
|
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;
|
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
|
||||||
|
|
||||||
function buildEditState(todo: Todo): EditState {
|
function buildEditState(todo: Todo): EditState {
|
||||||
@ -76,8 +89,8 @@ function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
|
|||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
due_date: '',
|
due_date: todayLocal(),
|
||||||
due_time: '',
|
due_time: nowTimeLocal(),
|
||||||
category: defaults?.category || '',
|
category: defaults?.category || '',
|
||||||
recurrence_rule: '',
|
recurrence_rule: '',
|
||||||
};
|
};
|
||||||
@ -385,26 +398,18 @@ export default function TodoDetailPanel({
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="todo-due-date">Due Date</Label>
|
<Label htmlFor="todo-due-date">Due Date</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="todo-due-date"
|
id="todo-due-date"
|
||||||
type="date"
|
mode="datetime"
|
||||||
value={editState.due_date}
|
value={editState.due_date ? (editState.due_date + 'T' + (editState.due_time || '00:00')) : ''}
|
||||||
onChange={(e) => updateField('due_date', e.target.value)}
|
onChange={(v) => {
|
||||||
|
updateField('due_date', v ? v.slice(0, 10) : '');
|
||||||
|
updateField('due_time', v ? v.slice(11, 16) : '');
|
||||||
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="todo-recurrence">Recurrence</Label>
|
<Label htmlFor="todo-recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -419,6 +424,7 @@ export default function TodoDetailPanel({
|
|||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Save / Cancel at bottom */}
|
{/* Save / Cancel at bottom */}
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||||
|
|||||||
@ -12,11 +12,24 @@ import {
|
|||||||
SheetClose,
|
SheetClose,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface TodoFormProps {
|
||||||
todo: Todo | null;
|
todo: Todo | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -28,8 +41,8 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
title: todo?.title || '',
|
title: todo?.title || '',
|
||||||
description: todo?.description || '',
|
description: todo?.description || '',
|
||||||
priority: todo?.priority || 'medium',
|
priority: todo?.priority || 'medium',
|
||||||
due_date: todo?.due_date || '',
|
due_date: todo?.due_date || todayLocal(),
|
||||||
due_time: todo?.due_time ? todo.due_time.slice(0, 5) : '',
|
due_time: todo?.due_time ? todo.due_time.slice(0, 5) : nowTimeLocal(),
|
||||||
category: todo?.category || '',
|
category: todo?.category || '',
|
||||||
recurrence_rule: todo?.recurrence_rule || '',
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="due_date">Due Date</Label>
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
<Input
|
<DatePicker
|
||||||
|
variant="input"
|
||||||
id="due_date"
|
id="due_date"
|
||||||
type="date"
|
mode="datetime"
|
||||||
value={formData.due_date}
|
value={formData.due_date ? (formData.due_date + 'T' + (formData.due_time || '00:00')) : ''}
|
||||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
onChange={(v) => setFormData({
|
||||||
|
...formData,
|
||||||
|
due_date: v ? v.slice(0, 10) : '',
|
||||||
|
due_time: v ? v.slice(11, 16) : '',
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="recurrence">Recurrence</Label>
|
<Label htmlFor="recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -162,6 +169,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
|||||||
538
frontend/src/components/ui/date-picker.tsx
Normal file
538
frontend/src/components/ui/date-picker.tsx
Normal 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 };
|
||||||
@ -47,8 +47,13 @@ export function useAuth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: async ({ username, password }: { username: string; password: string }) => {
|
mutationFn: async ({ username, password, email, date_of_birth, preferred_name }: {
|
||||||
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', { username, password });
|
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;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
|||||||
@ -193,6 +193,22 @@
|
|||||||
font-weight: 600;
|
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 validation — red outline only after submit attempt ── */
|
||||||
form[data-submitted] input:invalid,
|
form[data-submitted] input:invalid,
|
||||||
form[data-submitted] select: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);
|
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 ── */
|
/* ── Ambient background animations ── */
|
||||||
|
|
||||||
@keyframes drift-1 {
|
@keyframes drift-1 {
|
||||||
|
|||||||
@ -237,6 +237,7 @@ export interface AdminUser {
|
|||||||
export interface AdminUserDetail extends AdminUser {
|
export interface AdminUserDetail extends AdminUser {
|
||||||
active_sessions: number;
|
active_sessions: number;
|
||||||
preferred_name?: string | null;
|
preferred_name?: string | null;
|
||||||
|
date_of_birth?: string | null;
|
||||||
must_change_password?: boolean;
|
must_change_password?: boolean;
|
||||||
locked_until?: string | null;
|
locked_until?: string | null;
|
||||||
}
|
}
|
||||||
@ -345,6 +346,14 @@ export interface UpcomingResponse {
|
|||||||
cutoff_date: string;
|
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 {
|
export interface EventTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user