import re from datetime import datetime from pydantic import BaseModel, EmailStr, Field, field_validator from app.core.sanitize import normalize_email, sanitize_str # Common words that must not appear as whole words inside a password. # Checked case-insensitively with word boundaries. _FORBIDDEN_WORDS = { "password", "passwort", "secret", "welcome", "admin", "administrator", "login", "user", "test", "guest", "master", "dragon", "monkey", "shadow", "sunshine", "princess", "letmein", "football", "baseball", "soccer", "hockey", "abc", "qwerty", "keyboard", "computer", "internet", "access", "hello", "summer", "winter", "spring", "autumn", "flower", "mustang", "batman", "superman", "donald", "michael", "jessica", "charlie", } def _validate_password(v: str) -> str: errors = [] if len(v) < 8: errors.append("at least 8 characters") if not re.search(r"[A-Z]", v): errors.append("at least one uppercase letter") if not re.search(r"[a-z]", v): errors.append("at least one lowercase letter") if not re.search(r"\d", v): errors.append("at least one digit") if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', v): errors.append("at least one special character") lower = v.lower() for word in _FORBIDDEN_WORDS: # Match the word as a standalone token (surrounded by non-alpha or string boundary) if re.search(rf"(? str: return normalize_email(v) @field_validator("full_name", mode="before") @classmethod def sanitize_full_name(cls, v: str | None) -> str | None: return sanitize_str(v, max_len=128) @field_validator("password") @classmethod def password_strength(cls, v: str) -> str: return _validate_password(v) class UserOut(BaseModel): id: str email: str full_name: str | None is_active: bool # validation_alias reads is_superuser from the ORM object; the JSON key # in the response is the field name "is_admin" (not the alias). is_admin: bool = Field(validation_alias="is_superuser", default=False) color_mode: str | None = None model_config = {"from_attributes": True, "populate_by_name": True} # ── Admin-facing schemas ─────────────────────────────────────────────────────── class UserAdminOut(BaseModel): """Full user record returned to admin endpoints.""" id: str email: str full_name: str | None is_active: bool is_admin: bool = Field(validation_alias="is_superuser", default=False) model_config = {"from_attributes": True, "populate_by_name": True} class UserAdminCreate(UserCreate): """Admin creates a user and can optionally grant admin rights.""" is_admin: bool = False class Token(BaseModel): access_token: str token_type: str = "bearer" # ── Dashboard preferences ────────────────────────────────────────────────────── class DashboardPrefsOut(BaseModel): app_ids: list[str] class ColorModeUpdate(BaseModel): color_mode: str @field_validator("color_mode") @classmethod def validate_mode(cls, v: str) -> str: if v not in ("light", "dark", "system"): raise ValueError("color_mode must be 'light', 'dark', or 'system'") return v class UserGroupOut(BaseModel): """A group the current user belongs to — used for the share picker.""" id: str name: str description: str | None is_group_admin: bool = False model_config = {"from_attributes": True} class DashboardPrefsUpdate(BaseModel): app_ids: list[str] = Field(default_factory=list) @field_validator("app_ids") @classmethod def validate_app_ids(cls, v: list[str]) -> list[str]: if len(v) > 50: raise ValueError("Cannot pin more than 50 apps") for item in v: # Service IDs are alphanumeric slugs or UUIDs — no HTML/script allowed. if not re.match(r'^[a-zA-Z0-9_\-]{1,64}$', item): raise ValueError(f"Invalid app ID: {item!r}") return v