login confirmation and forget password done
parent
31592dc7f1
commit
92c06d9335
|
|
@ -0,0 +1,122 @@
|
||||||
|
---
|
||||||
|
description: Strict HR-ATS backend house style — layering, routes, serializers, auth
|
||||||
|
globs: backend/**/*.py
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# HR-ATS Backend — singular pattern (mandatory)
|
||||||
|
|
||||||
|
Match existing modules (`users/`, `inbox/`) exactly. Do not introduce alternate frameworks, layers, or response shapes.
|
||||||
|
|
||||||
|
## Package layout (every domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/<domain>/
|
||||||
|
app.py # routes only — HTTP in/out
|
||||||
|
views.py # service class — business logic
|
||||||
|
models.py # SQLModel table + classmethod DB accessors
|
||||||
|
serializers.py # hand-rolled dict builders (no Pydantic response models)
|
||||||
|
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
|
||||||
|
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mount with bare `router = APIRouter()`; register in `main.py` via `app.include_router(...)`.
|
||||||
|
- No package `__init__.py`; run uvicorn from `backend/` so imports are top-level (`users.app`, `db_setup`).
|
||||||
|
- Config: `load_dotenv()` + `os.getenv(...)` in the module that needs it. Do not extend `db_setup.Settings` for non-DB keys.
|
||||||
|
|
||||||
|
## Layer duties (strict)
|
||||||
|
|
||||||
|
| Layer | Owns | Must NOT |
|
||||||
|
|---|---|---|
|
||||||
|
| `app.py` | Routes, request Pydantic models (inline), `JSONResponse`, call serializers for HTTP payloads, inject `CurrentUser` / `session` | Business rules, DB queries, JWT encode logic beyond calling plugins |
|
||||||
|
| `views.py` | Validate rules, call models, raise `HTTPException`, return ORM rows or serialized dicts for CRUD | Build login/token HTTP envelopes; call `serialize_token` |
|
||||||
|
| `models.py` | Table fields, `select`/`insert`/`update`/`soft_delete`, `selectinload` when relations are needed | HTTPExceptions, serializers, FastAPI |
|
||||||
|
| `serializers.py` | `serialize_*` → plain `dict` (`str(uuid)`, `.isoformat()` dates, never password) | DB access, Depends |
|
||||||
|
| `plugins.py` | Pure functions; raise library errors (e.g. `jwt.*`), not HTTP | Import FastAPI |
|
||||||
|
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser = Annotated[...]`, `require_permission` | Route handlers |
|
||||||
|
|
||||||
|
## Route pattern (`app.py`)
|
||||||
|
|
||||||
|
- Verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — not `/auth/token`, not REST nouns-only.
|
||||||
|
- Every handler uses this wrapper:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
service=User(session=session)
|
||||||
|
data=await service.some_method(...)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
- List fetch: `{"data":items,"total":total,"status_code":200}`; single-by-id: `total: 1`.
|
||||||
|
- Login/refresh: build tokens in the route, then serialize:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user=await service.authenticate_user(...)
|
||||||
|
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
|
||||||
|
return JSONResponse(content={**tokens,"status_code":200})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Request schemas live **inline** in `app.py` (`UserCreate`, `TokenRefresh`, …). Not in `serializers.py`.
|
||||||
|
- Dependencies: default form `session: AsyncSession = Depends(get_session)` unless `Annotated` is required (auth form / `CurrentUser` before other defaults).
|
||||||
|
- Tight spacing house style: `service=User(session=session)`, `detail=str(e)`, `key=value` in calls — match neighbors, do not “pretty-reformat” whole files.
|
||||||
|
|
||||||
|
## Service pattern (`views.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User:
|
||||||
|
def __init__(self,session:AsyncSession):
|
||||||
|
self.session=session
|
||||||
|
|
||||||
|
async def create_user(self,payload):
|
||||||
|
...
|
||||||
|
return serialize_user(user) # CRUD returns serialized dict
|
||||||
|
```
|
||||||
|
|
||||||
|
- Untyped method args (match existing). Raise `HTTPException(status_code=...,detail="...")`.
|
||||||
|
- Auth methods (`authenticate_user`, `refresh_access_token`) return the **ORM user** only — never `serialize_token`.
|
||||||
|
- After email lookup that lacks `selectinload(role)`, re-fetch via `get_user_by_id` before anything that touches `user.role`.
|
||||||
|
|
||||||
|
## Model accessors (`models.py`)
|
||||||
|
|
||||||
|
- `@classmethod async def get_* / insert_* / update_* / soft_delete_* / count_*`.
|
||||||
|
- Soft delete: set `is_deleted=True`, `is_active=False`.
|
||||||
|
- Use `selectinload(cls.role)` on fetches that will be serialized with role fields.
|
||||||
|
- Commit inside model write methods (existing pattern).
|
||||||
|
|
||||||
|
## Auth pattern
|
||||||
|
|
||||||
|
- Access + refresh JWTs via `plugins` (`type` claim must be checked in `decode_token`).
|
||||||
|
- `permissions.CurrentUser` on every protected `/users/*` route; login + refresh stay open.
|
||||||
|
- `get_current_user`: decode access → DB load by `sub` → reject missing/deleted/inactive → `serialize_user`.
|
||||||
|
- Login uses `OAuth2PasswordRequestForm`; username field carries email.
|
||||||
|
- OAuth2 fields (`access_token`, `refresh_token`, `token_type`, `expires_in`) at **response root**; user under `data`.
|
||||||
|
- `tokenUrl="users/login"` (no leading slash).
|
||||||
|
- No FastAPI in `plugins.py`. Translate `jwt.PyJWTError` → 401 in `permissions` / `views`.
|
||||||
|
|
||||||
|
## Dependencies / env
|
||||||
|
|
||||||
|
- Pin in `requirements.txt` under comment banners with trailing rationale comments.
|
||||||
|
- Secrets in `backend/.env`; document keys in `backend/.env.example`.
|
||||||
|
- JWT times: `datetime.now(timezone.utc)` only (never naive `datetime.now()` for token `iat`/`exp`).
|
||||||
|
|
||||||
|
## Hard bans
|
||||||
|
|
||||||
|
- No new abstraction layers (repositories, use-cases, DTOs beyond inline Pydantic requests).
|
||||||
|
- No Pydantic response models; no `jsonable_encoder` for these routes.
|
||||||
|
- No changing response envelope (`data` + `status_code`) or inventing `/api/v1` prefixes.
|
||||||
|
- No drive-by refactors or reformatting unrelated code.
|
||||||
|
- No RBAC second scheme — vocabulary and `require_permission` live in `users/permissions.py` only.
|
||||||
|
- Do not touch `inbox/` when the task is `users/` (and vice versa) unless asked.
|
||||||
|
- No `__init__.py` packages; no moving request models into `serializers.py`.
|
||||||
|
|
||||||
|
## When adding a new domain endpoint
|
||||||
|
|
||||||
|
1. Accessor on `models.py` if DB changes.
|
||||||
|
2. Method on service in `views.py`.
|
||||||
|
3. `serialize_*` in `serializers.py` if new shape.
|
||||||
|
4. Route in `app.py` with the standard try/except + `JSONResponse`.
|
||||||
|
5. Protect with `current_user: CurrentUser` if under an authenticated router.
|
||||||
|
|
@ -36,6 +36,8 @@ env/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!frontend/.env.development
|
||||||
|
!frontend/.env.production
|
||||||
|
|
||||||
# Logs & temp
|
# Logs & temp
|
||||||
*.log
|
*.log
|
||||||
|
|
@ -43,4 +45,10 @@ tmp/
|
||||||
temp/
|
temp/
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Frontend / auth build
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
/auth/
|
||||||
|
|
||||||
**.pdf
|
**.pdf
|
||||||
|
**_**_**.py
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=
|
||||||
|
DB_NAME=
|
||||||
|
EMAIL_URL=
|
||||||
|
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
JWT_RESET_TOKEN_EXPIRE_MINUTES=10
|
||||||
|
|
||||||
|
TEAMS_MAIL_API_URL=
|
||||||
|
TEAMS_API_TOKEN=
|
||||||
|
|
||||||
|
RESET_CODE_TTL_SECONDS=60
|
||||||
|
RESET_CODE_RESEND_SECONDS=30
|
||||||
|
RESET_CODE_MAX_ATTEMPTS=5
|
||||||
|
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
CONFIRM_EMAIL_PATH=/auth/confirm-email
|
||||||
|
CONFIRM_TOKEN_TTL_SECONDS=86400
|
||||||
|
CONFIRM_TOKEN_RESEND_SECONDS=60
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
from fastapi import APIRouter,Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from db_setup import get_session
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from forget_password.views import ForgetPassword
|
||||||
|
from forget_password.permissions import ResetCredentials
|
||||||
|
from forget_password.serializers import serialize_reset_token
|
||||||
|
from users.plugins import create_reset_token
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetPasswordVerify(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetPasswordNew(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/forget-password")
|
||||||
|
async def request_reset_code(payload: ForgetPasswordRequest,session: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
service=ForgetPassword(session=session)
|
||||||
|
data=await service.request_code(payload.email)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/forget-password/verify-code")
|
||||||
|
async def verify_reset_code(payload: ForgetPasswordVerify,session: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
service=ForgetPassword(session=session)
|
||||||
|
email,code_id=await service.verify_code(payload.email,payload.code)
|
||||||
|
tokens=serialize_reset_token(create_reset_token(email,code_id=code_id),email)
|
||||||
|
return JSONResponse(content={**tokens,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/forget-password/new-password")
|
||||||
|
async def set_new_password(
|
||||||
|
payload: ForgetPasswordNew,
|
||||||
|
credentials: ResetCredentials,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=ForgetPassword(session=session)
|
||||||
|
data=await service.set_new_password(credentials.credentials,payload.password)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCodes(SQLModel, table=True):
|
||||||
|
__tablename__ = "password_reset_codes"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
email: str = Field(index=True)
|
||||||
|
code_hash: str
|
||||||
|
expires_at: datetime = Field(sa_type=DateTime(timezone=True))
|
||||||
|
attempts: int = Field(default=0)
|
||||||
|
is_used: bool = Field(default=False)
|
||||||
|
verified_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_code_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_active_code_by_email(cls, session: AsyncSession, email: str):
|
||||||
|
"""Newest unused code for email (expiry checked in Python by the service)."""
|
||||||
|
statement = (
|
||||||
|
select(cls)
|
||||||
|
.where(cls.email == email, cls.is_used == False) # noqa: E712
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_code(cls, session: AsyncSession, fields: dict):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_code_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def increment_attempts(cls, session: AsyncSession, record_id: str):
|
||||||
|
row = await cls.get_code_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.attempts = (row.attempts or 0) + 1
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_verified(cls, session: AsyncSession, record_id: str):
|
||||||
|
row = await cls.get_code_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.verified_at = _now()
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_used(cls, session: AsyncSession, record_id: str):
|
||||||
|
row = await cls.get_code_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.is_used = True
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def invalidate_codes_for_email(cls, session: AsyncSession, email: str):
|
||||||
|
statement = select(cls).where(cls.email == email, cls.is_used == False) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
now = _now()
|
||||||
|
for row in rows:
|
||||||
|
row.is_used = True
|
||||||
|
row.updated_at = now
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Bearer scheme for password-reset tokens (type=reset JWT)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
reset_scheme = HTTPBearer()
|
||||||
|
ResetCredentials = Annotated[HTTPAuthorizationCredentials, Depends(reset_scheme)]
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Forget-password helpers — OTP generation, hashing, and Teams mail send.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from users.plugins import hash_password, verify_password
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
|
||||||
|
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
|
||||||
|
RESET_CODE_TTL_SECONDS = int(os.getenv("RESET_CODE_TTL_SECONDS", "60"))
|
||||||
|
RESET_CODE_RESEND_SECONDS = int(os.getenv("RESET_CODE_RESEND_SECONDS", "30"))
|
||||||
|
RESET_CODE_MAX_ATTEMPTS = int(os.getenv("RESET_CODE_MAX_ATTEMPTS", "5"))
|
||||||
|
MAIL_ACCEPTED_STATUS = 202
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def code_expiry(*, now: datetime | None = None) -> datetime:
|
||||||
|
return (now or now_utc()) + timedelta(seconds=RESET_CODE_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code() -> str:
|
||||||
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_code(code: str) -> str:
|
||||||
|
return hash_password(code)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(code: str, code_hash: str) -> bool:
|
||||||
|
return verify_password(code, code_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def render_reset_email(code: str, ttl: int) -> tuple[str, str]:
|
||||||
|
subject = "Your TalentFlow password reset code"
|
||||||
|
html = (
|
||||||
|
f"<p>Your password reset code is <strong>{code}</strong>.</p>"
|
||||||
|
f"<p>It expires in {ttl} seconds. If you did not request this, ignore this email.</p>"
|
||||||
|
)
|
||||||
|
return subject, html
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reset_mail(to_email: str, subject: str, html: str) -> None:
|
||||||
|
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
|
||||||
|
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
|
||||||
|
fields = [
|
||||||
|
("subject", (None, subject)),
|
||||||
|
("body", (None, html)),
|
||||||
|
("content_type", (None, "html")),
|
||||||
|
("save_to_sent_items", (None, "false")),
|
||||||
|
("to", (None, to_email)),
|
||||||
|
]
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
TEAMS_MAIL_API_URL,
|
||||||
|
files=fields,
|
||||||
|
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
|
||||||
|
)
|
||||||
|
if response.status_code != MAIL_ACCEPTED_STATUS:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
from forget_password.plugins import RESET_CODE_RESEND_SECONDS,RESET_CODE_TTL_SECONDS
|
||||||
|
from users.plugins import RESET_TOKEN_EXPIRE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_reset_request(email: str,expires_at) -> dict:
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||||
|
"expires_in": RESET_CODE_TTL_SECONDS,
|
||||||
|
"resend_after": RESET_CODE_RESEND_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_reset_token(reset_token: str,email: str) -> dict:
|
||||||
|
return {
|
||||||
|
"reset_token": reset_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": RESET_TOKEN_EXPIRE_SECONDS,
|
||||||
|
"data": {"email": email},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_reset_result(email: str) -> dict:
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"password_updated": True,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from forget_password.models import PasswordResetCodes
|
||||||
|
from forget_password.plugins import (
|
||||||
|
RESET_CODE_MAX_ATTEMPTS,
|
||||||
|
RESET_CODE_RESEND_SECONDS,
|
||||||
|
code_expiry,
|
||||||
|
generate_code,
|
||||||
|
hash_code,
|
||||||
|
now_utc,
|
||||||
|
render_reset_email,
|
||||||
|
send_reset_mail,
|
||||||
|
verify_code,
|
||||||
|
RESET_CODE_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
from forget_password.serializers import serialize_reset_request,serialize_reset_result
|
||||||
|
from users.models import Users
|
||||||
|
from users.plugins import decode_token,hash_password
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetPassword:
|
||||||
|
def __init__(self,session:AsyncSession):
|
||||||
|
self.session=session
|
||||||
|
|
||||||
|
async def request_code(self,email):
|
||||||
|
user=await Users.get_user_by_email(self.session,email)
|
||||||
|
if not user or user.is_deleted or not user.is_active:
|
||||||
|
raise HTTPException(status_code=404,detail="No account found for this email")
|
||||||
|
|
||||||
|
active=await PasswordResetCodes.get_active_code_by_email(self.session,email)
|
||||||
|
if active:
|
||||||
|
age=(now_utc()-active.created_at).total_seconds()
|
||||||
|
if age<RESET_CODE_RESEND_SECONDS and active.expires_at>now_utc():
|
||||||
|
raise HTTPException(status_code=429,detail="Please wait before requesting another code")
|
||||||
|
|
||||||
|
await PasswordResetCodes.invalidate_codes_for_email(self.session,email)
|
||||||
|
|
||||||
|
code=generate_code()
|
||||||
|
expires_at=code_expiry()
|
||||||
|
row=await PasswordResetCodes.insert_code(self.session,{
|
||||||
|
"email":email,
|
||||||
|
"code_hash":hash_code(code),
|
||||||
|
"expires_at":expires_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
subject,html=render_reset_email(code,RESET_CODE_TTL_SECONDS)
|
||||||
|
try:
|
||||||
|
await send_reset_mail(email,subject,html)
|
||||||
|
except (httpx.HTTPError,RuntimeError) as e:
|
||||||
|
await PasswordResetCodes.mark_used(self.session,str(row.id))
|
||||||
|
raise HTTPException(status_code=502,detail="Failed to send reset email") from e
|
||||||
|
|
||||||
|
return serialize_reset_request(email,expires_at)
|
||||||
|
|
||||||
|
async def verify_code(self,email,code):
|
||||||
|
row=await PasswordResetCodes.get_active_code_by_email(self.session,email)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=400,detail="No active reset code for this email")
|
||||||
|
if row.expires_at<=now_utc():
|
||||||
|
raise HTTPException(status_code=400,detail="Reset code has expired")
|
||||||
|
if (row.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429,detail="Too many invalid attempts")
|
||||||
|
|
||||||
|
if not verify_code(code,row.code_hash):
|
||||||
|
updated=await PasswordResetCodes.increment_attempts(self.session,str(row.id))
|
||||||
|
if updated and (updated.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429,detail="Too many invalid attempts")
|
||||||
|
raise HTTPException(status_code=400,detail="Invalid reset code")
|
||||||
|
|
||||||
|
await PasswordResetCodes.mark_verified(self.session,str(row.id))
|
||||||
|
return email,str(row.id)
|
||||||
|
|
||||||
|
async def set_new_password(self,reset_token,password):
|
||||||
|
try:
|
||||||
|
payload=decode_token(reset_token,expected_type="reset")
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired reset token",
|
||||||
|
headers={"WWW-Authenticate":"Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
email=payload.get("sub")
|
||||||
|
code_id=payload.get("crid")
|
||||||
|
if not email or not code_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired reset token",
|
||||||
|
headers={"WWW-Authenticate":"Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
row=await PasswordResetCodes.get_code_by_id(self.session,code_id)
|
||||||
|
if not row or row.is_used or row.email!=email or not row.verified_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired reset token",
|
||||||
|
headers={"WWW-Authenticate":"Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user=await Users.get_user_by_email(self.session,email)
|
||||||
|
if not user or user.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="User not found")
|
||||||
|
|
||||||
|
await Users.update_user(self.session,str(user.id),{"password":hash_password(password)})
|
||||||
|
await PasswordResetCodes.mark_used(self.session,str(row.id))
|
||||||
|
return serialize_reset_result(email)
|
||||||
|
|
@ -8,6 +8,8 @@ from db_setup import lifespan
|
||||||
from inbox.app import router as inbox_router
|
from inbox.app import router as inbox_router
|
||||||
from users.app import router as users_router
|
from users.app import router as users_router
|
||||||
from role.app import router as role_router
|
from role.app import router as role_router
|
||||||
|
from forget_password.app import router as forget_password_router
|
||||||
|
from notifications.app import router as confirmation_router
|
||||||
# Without this the db/migration logs have no handler and are swallowed under uvicorn.
|
# Without this the db/migration logs have no handler and are swallowed under uvicorn.
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
|
@ -24,4 +26,6 @@ app.add_middleware(
|
||||||
|
|
||||||
app.include_router(inbox_router)
|
app.include_router(inbox_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(role_router)
|
app.include_router(role_router)
|
||||||
|
app.include_router(forget_password_router)
|
||||||
|
app.include_router(confirmation_router)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from fastapi import APIRouter,Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from db_setup import get_session
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from notifications.views import Confirmation
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmEmailRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmEmailResend(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/confirm-email")
|
||||||
|
async def confirm_email(payload: ConfirmEmailRequest,session: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
service=Confirmation(session=session)
|
||||||
|
data=await service.confirm(payload.token)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/confirm-email/resend")
|
||||||
|
async def resend_confirm_email(payload: ConfirmEmailResend,session: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
service=Confirmation(session=session)
|
||||||
|
data=await service.resend(payload.email)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailConfirmationTokens(SQLModel, table=True):
|
||||||
|
__tablename__ = "email_confirmation_tokens"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
user_id: uuid.UUID = Field(index=True, foreign_key="users.id")
|
||||||
|
email: str = Field(index=True)
|
||||||
|
token_hash: str
|
||||||
|
expires_at: datetime = Field(sa_type=DateTime(timezone=True))
|
||||||
|
is_used: bool = Field(default=False)
|
||||||
|
confirmed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id: str) -> uuid.UUID | None:
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_token_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
|
uid = cls._as_uuid(record_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
result = await session.execute(select(cls).where(cls.id == uid))
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_active_token_by_user(cls, session: AsyncSession, user_id: str):
|
||||||
|
"""Newest unused token for a user (expiry checked in Python by the service)."""
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
statement = (
|
||||||
|
select(cls)
|
||||||
|
.where(cls.user_id == uid, cls.is_used == False) # noqa: E712
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_token(cls, session: AsyncSession, fields: dict):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return await cls.get_token_by_id(session, row.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_confirmed(cls, session: AsyncSession, record_id: str):
|
||||||
|
row = await cls.get_token_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.is_used = True
|
||||||
|
row.confirmed_at = _now()
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_used(cls, session: AsyncSession, record_id: str):
|
||||||
|
"""Retire a token without confirming it — used when the mail send fails."""
|
||||||
|
row = await cls.get_token_by_id(session, record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.is_used = True
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def invalidate_tokens_for_user(cls, session: AsyncSession, user_id: str):
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return 0
|
||||||
|
statement = select(cls).where(cls.user_id == uid, cls.is_used == False) # noqa: E712
|
||||||
|
result = await session.execute(statement)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
now = _now()
|
||||||
|
for row in rows:
|
||||||
|
row.is_used = True
|
||||||
|
row.updated_at = now
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Confirmation helpers — token generation, hashing, link building, and Teams mail send.
|
||||||
|
|
||||||
|
Pure module: no FastAPI imports and no HTTPException.
|
||||||
|
|
||||||
|
`send_confirmation_mail` intentionally duplicates
|
||||||
|
`forget_password.plugins.send_reset_mail` rather than importing it: that helper is
|
||||||
|
domain-named, and each domain owns its own mail copy and its own env reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from users.plugins import hash_password, verify_password
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
|
||||||
|
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
|
||||||
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||||
|
CONFIRM_EMAIL_PATH = os.getenv("CONFIRM_EMAIL_PATH", "/auth/confirm-email")
|
||||||
|
CONFIRM_TOKEN_TTL_SECONDS = int(os.getenv("CONFIRM_TOKEN_TTL_SECONDS", "86400"))
|
||||||
|
CONFIRM_TOKEN_RESEND_SECONDS = int(os.getenv("CONFIRM_TOKEN_RESEND_SECONDS", "60"))
|
||||||
|
MAIL_ACCEPTED_STATUS = 202
|
||||||
|
|
||||||
|
# 32 bytes -> 43 url-safe characters, well under users.plugins.BCRYPT_MAX_BYTES.
|
||||||
|
TOKEN_SECRET_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_expiry(*, now: datetime | None = None) -> datetime:
|
||||||
|
return (now or now_utc()) + timedelta(seconds=CONFIRM_TOKEN_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token_secret() -> str:
|
||||||
|
return secrets.token_urlsafe(TOKEN_SECRET_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(secret: str) -> str:
|
||||||
|
return hash_password(secret)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(secret: str, token_hash: str) -> bool:
|
||||||
|
return verify_password(secret, token_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_token(record_id, secret: str) -> str:
|
||||||
|
"""Link token. A bcrypt hash cannot be looked up, so the row id rides along."""
|
||||||
|
return f"{record_id}.{secret}"
|
||||||
|
|
||||||
|
|
||||||
|
def split_token(token: str) -> tuple[str | None, str | None]:
|
||||||
|
"""('<uuid>','<secret>') or (None,None). token_urlsafe never emits a dot."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None, None
|
||||||
|
record_id, secret = token.split(".", 1)
|
||||||
|
if not record_id or not secret:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
uuid.UUID(record_id)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
return record_id, secret
|
||||||
|
|
||||||
|
|
||||||
|
def build_confirmation_link(token: str) -> str:
|
||||||
|
base = FRONTEND_URL.rstrip("/")
|
||||||
|
path = CONFIRM_EMAIL_PATH if CONFIRM_EMAIL_PATH.startswith("/") else f"/{CONFIRM_EMAIL_PATH}"
|
||||||
|
return f"{base}{path}?token={quote(token, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_confirmation_email(link: str, ttl: int) -> tuple[str, str]:
|
||||||
|
hours = max(1, ttl // 3600)
|
||||||
|
subject = "Confirm your TalentFlow account"
|
||||||
|
html = (
|
||||||
|
"<p>Welcome to TalentFlow. Confirm your email address to activate your account.</p>"
|
||||||
|
f'<p><a href="{link}">Confirm my email</a></p>'
|
||||||
|
f"<p>This link expires in {hours} hour(s). If you did not sign up, ignore this email.</p>"
|
||||||
|
f"<p>If the link does not open, paste this into your browser:<br>{link}</p>"
|
||||||
|
)
|
||||||
|
return subject, html
|
||||||
|
|
||||||
|
|
||||||
|
async def send_confirmation_mail(to_email: str, subject: str, html: str) -> None:
|
||||||
|
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
|
||||||
|
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
|
||||||
|
fields = [
|
||||||
|
("subject", (None, subject)),
|
||||||
|
("body", (None, html)),
|
||||||
|
("content_type", (None, "html")),
|
||||||
|
("save_to_sent_items", (None, "false")),
|
||||||
|
("to", (None, to_email)),
|
||||||
|
]
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
TEAMS_MAIL_API_URL,
|
||||||
|
files=fields,
|
||||||
|
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
|
||||||
|
)
|
||||||
|
if response.status_code != MAIL_ACCEPTED_STATUS:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from notifications.plugins import CONFIRM_TOKEN_RESEND_SECONDS,CONFIRM_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_confirmation_request(email: str,expires_at) -> dict:
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"confirmation_sent": True,
|
||||||
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||||
|
"expires_in": CONFIRM_TOKEN_TTL_SECONDS,
|
||||||
|
"resend_after": CONFIRM_TOKEN_RESEND_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_confirmation_result(user,*,already_confirmed: bool = False) -> dict:
|
||||||
|
"""Deliberately narrow: this is an unauthenticated response, so no role or permissions."""
|
||||||
|
return {
|
||||||
|
"email": user.email,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"already_confirmed": already_confirmed,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from notifications.models import EmailConfirmationTokens
|
||||||
|
from notifications.plugins import (
|
||||||
|
CONFIRM_TOKEN_RESEND_SECONDS,
|
||||||
|
CONFIRM_TOKEN_TTL_SECONDS,
|
||||||
|
build_confirmation_link,
|
||||||
|
compose_token,
|
||||||
|
confirmation_expiry,
|
||||||
|
generate_token_secret,
|
||||||
|
hash_token,
|
||||||
|
now_utc,
|
||||||
|
render_confirmation_email,
|
||||||
|
send_confirmation_mail,
|
||||||
|
split_token,
|
||||||
|
verify_token,
|
||||||
|
)
|
||||||
|
from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
|
||||||
|
class Confirmation:
|
||||||
|
def __init__(self,session:AsyncSession):
|
||||||
|
self.session=session
|
||||||
|
|
||||||
|
async def send_confirmation(self,user):
|
||||||
|
"""Issue a fresh token for an already committed Users row and mail the link."""
|
||||||
|
await EmailConfirmationTokens.invalidate_tokens_for_user(self.session,str(user.id))
|
||||||
|
|
||||||
|
secret=generate_token_secret()
|
||||||
|
expires_at=confirmation_expiry()
|
||||||
|
row=await EmailConfirmationTokens.insert_token(self.session,{
|
||||||
|
"user_id":user.id,
|
||||||
|
"email":user.email,
|
||||||
|
"token_hash":hash_token(secret),
|
||||||
|
"expires_at":expires_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
link=build_confirmation_link(compose_token(row.id,secret))
|
||||||
|
subject,html=render_confirmation_email(link,CONFIRM_TOKEN_TTL_SECONDS)
|
||||||
|
try:
|
||||||
|
await send_confirmation_mail(user.email,subject,html)
|
||||||
|
except (httpx.HTTPError,RuntimeError) as e:
|
||||||
|
await EmailConfirmationTokens.mark_used(self.session,str(row.id))
|
||||||
|
raise HTTPException(status_code=502,detail="Failed to send confirmation email") from e
|
||||||
|
|
||||||
|
return serialize_confirmation_request(user.email,expires_at)
|
||||||
|
|
||||||
|
async def confirm(self,token):
|
||||||
|
record_id,secret=split_token(token)
|
||||||
|
if not record_id:
|
||||||
|
raise HTTPException(status_code=400,detail="Invalid confirmation link")
|
||||||
|
|
||||||
|
row=await EmailConfirmationTokens.get_token_by_id(self.session,record_id)
|
||||||
|
if not row or not verify_token(secret,row.token_hash):
|
||||||
|
raise HTTPException(status_code=400,detail="Invalid confirmation link")
|
||||||
|
|
||||||
|
user=await Users.get_user_by_id(self.session,str(row.user_id))
|
||||||
|
if not user or user.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="User not found")
|
||||||
|
|
||||||
|
# Mail clients, link scanners and the back button all replay this link.
|
||||||
|
if row.is_used:
|
||||||
|
if row.confirmed_at and user.is_active:
|
||||||
|
return serialize_confirmation_result(user,already_confirmed=True)
|
||||||
|
raise HTTPException(status_code=400,detail="This confirmation link is no longer valid. Request a new one.")
|
||||||
|
|
||||||
|
if row.expires_at<=now_utc():
|
||||||
|
raise HTTPException(status_code=400,detail="Confirmation link has expired. Request a new one.")
|
||||||
|
|
||||||
|
if user.is_active:
|
||||||
|
await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id))
|
||||||
|
return serialize_confirmation_result(user,already_confirmed=True)
|
||||||
|
|
||||||
|
updated=await Users.update_user(self.session,str(user.id),{"is_active":True})
|
||||||
|
await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id))
|
||||||
|
return serialize_confirmation_result(updated)
|
||||||
|
|
||||||
|
async def resend(self,email):
|
||||||
|
user=await Users.get_user_by_email(self.session,email)
|
||||||
|
if not user or user.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="No account found for this email")
|
||||||
|
if user.is_active:
|
||||||
|
raise HTTPException(status_code=400,detail="This account is already confirmed")
|
||||||
|
|
||||||
|
active=await EmailConfirmationTokens.get_active_token_by_user(self.session,str(user.id))
|
||||||
|
if active:
|
||||||
|
age=(now_utc()-active.created_at).total_seconds()
|
||||||
|
if age<CONFIRM_TOKEN_RESEND_SECONDS and active.expires_at>now_utc():
|
||||||
|
raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email")
|
||||||
|
|
||||||
|
return await self.send_confirmation(user)
|
||||||
|
|
@ -24,5 +24,5 @@ PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins
|
||||||
python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py
|
python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py
|
||||||
|
|
||||||
# --- other -----------------------------------------------------------------
|
# --- other -----------------------------------------------------------------
|
||||||
httpx==0.28.1 # Graph email calls in inbox/views.py
|
httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py)
|
||||||
bcrypt==5.0.0 # password hashing in users/plugins.py
|
bcrypt==5.0.0 # password hashing in users/plugins.py
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ class UserCreate(BaseModel):
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserSignup(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
|
|
@ -62,6 +68,19 @@ async def login(payload: UserLogin,session: AsyncSession = Depends(get_session))
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/signup")
|
||||||
|
async def signup(payload: UserSignup,session: AsyncSession = Depends(get_session)):
|
||||||
|
try:
|
||||||
|
service=User(session=session)
|
||||||
|
user=await service.signup_user(payload.model_dump())
|
||||||
|
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
|
||||||
|
return JSONResponse(content={**tokens,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/refresh")
|
@router.post("/users/refresh")
|
||||||
async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)):
|
async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class Users(SQLModel, table=True):
|
||||||
password: str
|
password: str
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
updated_at: datetime = Field(default_factory=datetime.now)
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=False)
|
||||||
is_deleted: bool = Field(default=False)
|
is_deleted: bool = Field(default=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@ def require_permission(*required: PermissionTag, require_all: bool = True):
|
||||||
async def dependency(current_user: CurrentUser) -> dict:
|
async def dependency(current_user: CurrentUser) -> dict:
|
||||||
if current_user.get("role_id") is None:
|
if current_user.get("role_id") is None:
|
||||||
raise HTTPException(status_code=403, detail="User has no role assigned")
|
raise HTTPException(status_code=403, detail="User has no role assigned")
|
||||||
|
|
||||||
granted = current_user.get("permissions") or []
|
granted = current_user.get("permissions") or []
|
||||||
if not has_permission(granted, *required, require_all=require_all):
|
if not has_permission(granted, *required, require_all=require_all):
|
||||||
if require_all and len(required) == 1:
|
if require_all and len(required) == 1:
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
|
||||||
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||||
|
RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10"))
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
|
RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60
|
||||||
|
|
||||||
|
|
||||||
def _encode(raw: str) -> bytes:
|
def _encode(raw: str) -> bytes:
|
||||||
|
|
@ -112,6 +114,15 @@ def create_refresh_token(user) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_reset_token(email: str, *, code_id: str) -> str:
|
||||||
|
return _create_token(
|
||||||
|
email,
|
||||||
|
token_type="reset",
|
||||||
|
expires_delta=timedelta(minutes=RESET_TOKEN_EXPIRE_MINUTES),
|
||||||
|
claims={"crid": code_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str, *, expected_type: str) -> dict:
|
def decode_token(token: str, *, expected_type: str) -> dict:
|
||||||
payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM])
|
payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM])
|
||||||
if payload.get("type") != expected_type:
|
if payload.get("type") != expected_type:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from role.models import Roles
|
from notifications.views import Confirmation
|
||||||
|
from role.models import EnumRoles,Roles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from users.permissions import PermissionTag,has_permission
|
from users.permissions import PermissionTag,has_permission
|
||||||
from users.serializers import serialize_user
|
from users.serializers import serialize_user
|
||||||
|
|
@ -17,6 +18,8 @@ class User:
|
||||||
async def _check_role_assignment(self,current_user,role_id,existing_role_id=None):
|
async def _check_role_assignment(self,current_user,role_id,existing_role_id=None):
|
||||||
if role_id==existing_role_id:
|
if role_id==existing_role_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
#"Take the current user's permission list. Check if rbac_users.manage is in it. If it is not → reject with 403."
|
||||||
if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE):
|
if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE):
|
||||||
raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage")
|
raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage")
|
||||||
if role_id is None:
|
if role_id is None:
|
||||||
|
|
@ -42,6 +45,21 @@ class User:
|
||||||
raise HTTPException(status_code=400,detail="Password is required")
|
raise HTTPException(status_code=400,detail="Password is required")
|
||||||
return await Users.insert_user(self.session,fields)
|
return await Users.insert_user(self.session,fields)
|
||||||
|
|
||||||
|
async def signup_user(self,payload):
|
||||||
|
existing=await Users.get_user_by_email(self.session,payload.get("email"))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409,detail="Email already registered")
|
||||||
|
fields=clean_user_payload(payload)
|
||||||
|
if not fields.get("password"):
|
||||||
|
raise HTTPException(status_code=400,detail="Password is required")
|
||||||
|
role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value)
|
||||||
|
fields["role_id"]=role.id if role else 8
|
||||||
|
user=await Users.insert_user(self.session,fields)
|
||||||
|
# Signup lands inactive; the mailed link is what flips is_active.
|
||||||
|
service=Confirmation(session=self.session)
|
||||||
|
await service.send_confirmation(user)
|
||||||
|
return user
|
||||||
|
|
||||||
async def get_users(self,top,skip,search=None):
|
async def get_users(self,top,skip,search=None):
|
||||||
users=await Users.get_users(self.session,top,skip,search)
|
users=await Users.get_users(self.session,top,skip,search)
|
||||||
return [serialize_user(u) for u in users]
|
return [serialize_user(u) for u in users]
|
||||||
|
|
@ -99,8 +117,10 @@ class User:
|
||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
headers={"WWW-Authenticate":"Bearer"},
|
headers={"WWW-Authenticate":"Bearer"},
|
||||||
)
|
)
|
||||||
if user.is_deleted or not user.is_active:
|
if user.is_deleted:
|
||||||
raise HTTPException(status_code=401,detail="User is inactive")
|
raise HTTPException(status_code=401,detail="User is inactive")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account")
|
||||||
return await Users.get_user_by_id(self.session,user.id)
|
return await Users.get_user_by_id(self.session,user.id)
|
||||||
|
|
||||||
async def refresh_access_token(self,refresh_token):
|
async def refresh_access_token(self,refresh_token):
|
||||||
|
|
|
||||||
14
devserver.py
14
devserver.py
|
|
@ -6,12 +6,26 @@ server answers conditional requests from Last-Modified, which has one-second
|
||||||
granularity — so a file edited twice within the same second keeps serving the
|
granularity — so a file edited twice within the same second keeps serving the
|
||||||
stale copy and the browser never sees the change.
|
stale copy and the browser never sees the change.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
# Prefixes served by a client-side router rather than by files on disk.
|
||||||
|
SPA_ROOTS = ("/auth/",)
|
||||||
|
|
||||||
|
|
||||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||||
|
def send_head(self):
|
||||||
|
# Deep links like /auth/confirm-email?token=… are router paths, not files.
|
||||||
|
# Hand back the SPA shell and let react-router resolve them; its assets are
|
||||||
|
# referenced absolutely (/auth/assets/…), so nothing needs rewriting.
|
||||||
|
for root in SPA_ROOTS:
|
||||||
|
if self.path.startswith(root) and not os.path.exists(self.translate_path(self.path)):
|
||||||
|
self.path = root + "index.html"
|
||||||
|
break
|
||||||
|
return super().send_head()
|
||||||
|
|
||||||
def end_headers(self):
|
def end_headers(self):
|
||||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
self.send_header("Pragma", "no-cache")
|
self.send_header("Pragma", "no-cache")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_BASE=http://localhost:8000
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_BASE=http://localhost:8000
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>TalentFlow · Sign in</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "hr-ats-auth",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Signup from './pages/Signup'
|
||||||
|
import ForgotPassword from './pages/ForgotPassword'
|
||||||
|
import ConfirmEmail from './pages/ConfirmEmail'
|
||||||
|
|
||||||
|
const basename = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') || '/'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter basename={basename}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Login />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/confirm-email" element={<ConfirmEmail />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status, body) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetail(detail) {
|
||||||
|
if (detail == null || detail === '') return null
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return item.msg || item.message || JSON.stringify(item)
|
||||||
|
}
|
||||||
|
return String(item)
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
return detail.msg || detail.message || JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return String(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyAuthError(err, fallback = 'Something went wrong. Please try again.') {
|
||||||
|
if (!(err instanceof ApiError)) {
|
||||||
|
return err?.message || fallback
|
||||||
|
}
|
||||||
|
const fromServer = parseDetail(err.body?.detail)
|
||||||
|
switch (err.status) {
|
||||||
|
case 429:
|
||||||
|
return fromServer || 'Too many attempts. Please wait a moment and try again.'
|
||||||
|
case 404:
|
||||||
|
return fromServer || 'No account found for that email.'
|
||||||
|
case 400:
|
||||||
|
return fromServer || 'Please check your details and try again.'
|
||||||
|
case 401:
|
||||||
|
return fromServer || 'Incorrect email or password.'
|
||||||
|
case 409:
|
||||||
|
return fromServer || 'An account with that email already exists.'
|
||||||
|
case 502:
|
||||||
|
return fromServer || 'Email service is temporarily unavailable. Please try again later.'
|
||||||
|
default:
|
||||||
|
return fromServer || err.message || fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body, token } = {}) {
|
||||||
|
const headers = { Accept: 'application/json' }
|
||||||
|
if (body != null) headers['Content-Type'] = 'application/json'
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = null
|
||||||
|
const text = await res.text()
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
parseDetail(data?.detail) || res.statusText || 'Request failed',
|
||||||
|
res.status,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(email, password) {
|
||||||
|
return request('/users/login', { method: 'POST', body: { email, password } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signup(name, email, password) {
|
||||||
|
return request('/users/signup', { method: 'POST', body: { name, email, password } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmEmail(token) {
|
||||||
|
return request('/users/confirm-email', { method: 'POST', body: { token } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resendConfirmEmail(email) {
|
||||||
|
return request('/users/confirm-email/resend', { method: 'POST', body: { email } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetPassword(email) {
|
||||||
|
return request('/users/forget-password', { method: 'POST', body: { email } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyForgetCode(email, code) {
|
||||||
|
return request('/users/forget-password/verify-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, code },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNewPassword(password, resetToken) {
|
||||||
|
return request('/users/forget-password/new-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { password },
|
||||||
|
token: resetToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
/* ============================================================
|
||||||
|
Auth shell — TalentFlow / Utopia Brands
|
||||||
|
New classes only. Reuses .btn, .form-field, .card, .brand-*,
|
||||||
|
.link-btn, .page-title from shared styles.css.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 42%) 1fr;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 40px 44px;
|
||||||
|
background: var(--brand-green);
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: auto -20% -30% 20%;
|
||||||
|
height: 70%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, rgba(206, 255, 113, 0.28), transparent 65%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside .auth-brand {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside .brand-name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 28ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside-copy h1 {
|
||||||
|
font-family: 'Belleza', Georgia, serif;
|
||||||
|
font-size: clamp(32px, 4vw, 44px);
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside-foot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: var(--brand-lime);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px clamp(20px, 5vw, 64px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
left: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand .brand-logo {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand .brand-logo-lg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand .brand-name {
|
||||||
|
font-family: 'Belleza', Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .page-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sub {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .form-field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-row-end {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: -4px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-foot {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 18px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-resend {
|
||||||
|
margin-top: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-theme-toggle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-theme-toggle:hover {
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-theme-toggle svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Alerts ---- */
|
||||||
|
.alert {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
border-color: color-mix(in srgb, var(--success) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Password field ---- */
|
||||||
|
.pw-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- OTP ---- */
|
||||||
|
.otp-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 52px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input.err {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Countdown / spinner ---- */
|
||||||
|
.countdown {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--primary-fg) 35%, transparent);
|
||||||
|
border-top-color: var(--primary-fg);
|
||||||
|
animation: auth-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary .spinner-dot {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-fg) 35%, transparent);
|
||||||
|
border-top-color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.auth-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel-brand {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel-brand .brand-name {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
padding-top: 88px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px 0 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.otp-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input {
|
||||||
|
max-width: 46px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
const STORAGE_KEY = 'tf-auth'
|
||||||
|
|
||||||
|
export function getSession() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession(session) {
|
||||||
|
const payload = {
|
||||||
|
access_token: session.access_token,
|
||||||
|
refresh_token: session.refresh_token,
|
||||||
|
expires_in: session.expires_in,
|
||||||
|
data: session.data,
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default function Alert({ type = 'danger', children }) {
|
||||||
|
if (!children) return null
|
||||||
|
const cls = type === 'success' ? 'alert alert-success' : 'alert alert-danger'
|
||||||
|
return (
|
||||||
|
<div className={cls} role="alert">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import BrandMark from './BrandMark'
|
||||||
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
|
||||||
|
export default function AuthLayout({ title, subtitle, children, foot }) {
|
||||||
|
return (
|
||||||
|
<div className="auth-shell">
|
||||||
|
<aside className="auth-aside" aria-hidden="false">
|
||||||
|
<BrandMark size="lg" />
|
||||||
|
<div className="auth-aside-copy">
|
||||||
|
<h1>Hiring, streamlined.</h1>
|
||||||
|
<p>
|
||||||
|
TalentFlow keeps Utopia Brands recruiting in one place — roles, pipeline,
|
||||||
|
and people.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="auth-aside-foot">Utopia Brands · ATS</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="auth-panel">
|
||||||
|
<div className="auth-panel-top">
|
||||||
|
<div className="auth-panel-brand">
|
||||||
|
<BrandMark />
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card card">
|
||||||
|
{title ? <h2 className="page-title">{title}</h2> : null}
|
||||||
|
{subtitle ? <p className="auth-sub">{subtitle}</p> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{foot ? <div className="auth-foot">{foot}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormState(initial = {}) {
|
||||||
|
const [values, setValues] = useState(initial)
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
function setField(name, value) {
|
||||||
|
setValues((prev) => ({ ...prev, [name]: value }))
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev[name]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[name]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
setValues,
|
||||||
|
setField,
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
alert,
|
||||||
|
setAlert,
|
||||||
|
busy,
|
||||||
|
setBusy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default function BrandMark({ size = 'md', showName = true }) {
|
||||||
|
const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo'
|
||||||
|
return (
|
||||||
|
<div className="auth-brand">
|
||||||
|
<div className={logoClass}>
|
||||||
|
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||||
|
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{showName ? <span className="brand-name">TalentFlow</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
function formatSeconds(total) {
|
||||||
|
const s = Math.max(0, Math.floor(total))
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const r = s % 60
|
||||||
|
return `${m}:${String(r).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Countdown seeded from a server duration (seconds).
|
||||||
|
* Pass `expiresIn` from the API — never invent a client-side default window.
|
||||||
|
*/
|
||||||
|
export default function Countdown({ expiresIn, startedAt, onExpire, label = 'Code expires in' }) {
|
||||||
|
const [remaining, setRemaining] = useState(() => {
|
||||||
|
if (expiresIn == null || !Number.isFinite(Number(expiresIn))) return null
|
||||||
|
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
|
||||||
|
return Math.max(0, Number(expiresIn) - elapsed)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expiresIn == null || !Number.isFinite(Number(expiresIn))) {
|
||||||
|
setRemaining(null)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
|
||||||
|
const left = Math.max(0, Number(expiresIn) - elapsed)
|
||||||
|
setRemaining(left)
|
||||||
|
if (left <= 0 && onExpire) onExpire()
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
const id = setInterval(tick, 250)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [expiresIn, startedAt, onExpire])
|
||||||
|
|
||||||
|
if (remaining == null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="countdown" aria-live="polite">
|
||||||
|
{remaining > 0 ? (
|
||||||
|
<>
|
||||||
|
{label} <strong>{formatSeconds(remaining)}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Code expired</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResendGate(resendAfter, startedAt) {
|
||||||
|
const [remaining, setRemaining] = useState(() => {
|
||||||
|
if (resendAfter == null || !Number.isFinite(Number(resendAfter))) return 0
|
||||||
|
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
|
||||||
|
return Math.max(0, Number(resendAfter) - elapsed)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendAfter == null || !Number.isFinite(Number(resendAfter))) {
|
||||||
|
setRemaining(0)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0
|
||||||
|
setRemaining(Math.max(0, Number(resendAfter) - elapsed))
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
const id = setInterval(tick, 250)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [resendAfter, startedAt])
|
||||||
|
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const LENGTH = 6
|
||||||
|
|
||||||
|
export default function OtpInput({ value = '', onChange, disabled = false, error = false }) {
|
||||||
|
const refs = useRef([])
|
||||||
|
const digits = Array.from({ length: LENGTH }, (_, i) => value[i] || '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refs.current = refs.current.slice(0, LENGTH)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function commit(nextDigits) {
|
||||||
|
onChange(nextDigits.join('').slice(0, LENGTH))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAt(index, char) {
|
||||||
|
const next = digits.slice()
|
||||||
|
next[index] = char
|
||||||
|
commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(index, e) {
|
||||||
|
const raw = e.target.value.replace(/\D/g, '')
|
||||||
|
if (!raw) {
|
||||||
|
setAt(index, '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (raw.length > 1) {
|
||||||
|
const chars = raw.slice(0, LENGTH - index).split('')
|
||||||
|
const next = digits.slice()
|
||||||
|
chars.forEach((c, i) => {
|
||||||
|
next[index + i] = c
|
||||||
|
})
|
||||||
|
commit(next)
|
||||||
|
const focusIdx = Math.min(index + chars.length, LENGTH - 1)
|
||||||
|
refs.current[focusIdx]?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAt(index, raw)
|
||||||
|
if (index < LENGTH - 1) refs.current[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(index, e) {
|
||||||
|
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setAt(index - 1, '')
|
||||||
|
refs.current[index - 1]?.focus()
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft' && index > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
refs.current[index - 1]?.focus()
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight' && index < LENGTH - 1) {
|
||||||
|
e.preventDefault()
|
||||||
|
refs.current[index + 1]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, LENGTH)
|
||||||
|
if (!pasted) return
|
||||||
|
const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] || '')
|
||||||
|
commit(next)
|
||||||
|
refs.current[Math.min(pasted.length, LENGTH) - 1]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="otp-row" role="group" aria-label="One-time code">
|
||||||
|
{digits.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
refs.current[i] = el
|
||||||
|
}}
|
||||||
|
className={`otp-input${error ? ' err' : ''}`}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete={i === 0 ? 'one-time-code' : 'off'}
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Digit ${i + 1}`}
|
||||||
|
onChange={(e) => handleChange(i, e)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useId, useState } from 'react'
|
||||||
|
|
||||||
|
export default function PasswordField({
|
||||||
|
id,
|
||||||
|
label = 'Password',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
autoComplete = 'current-password',
|
||||||
|
placeholder = '••••••••',
|
||||||
|
required = true,
|
||||||
|
disabled = false,
|
||||||
|
}) {
|
||||||
|
const autoId = useId()
|
||||||
|
const fieldId = id || autoId
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor={fieldId}>{label}{required ? <span className="req"> *</span> : null}</label>
|
||||||
|
<div className="pw-wrap">
|
||||||
|
<input
|
||||||
|
id={fieldId}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={error ? 'err' : ''}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pw-toggle"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{visible ? (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className={`field-error${error ? ' show' : ''}`}>{error || ''}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function Spinner({ label = 'Loading' }) {
|
||||||
|
return (
|
||||||
|
<span className="spinner" role="status" aria-label={label}>
|
||||||
|
<span className="spinner-dot" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toggleTheme } from '../theme'
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState(
|
||||||
|
() => document.documentElement.getAttribute('data-theme') || 'light',
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTheme(document.documentElement.getAttribute('data-theme') || 'light')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
setTheme(toggleTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
const dark = theme === 'dark'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-theme-toggle"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={dark ? 'true' : 'false'}
|
||||||
|
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import '@shared-css'
|
||||||
|
import './auth.css'
|
||||||
|
import { initTheme } from './theme'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
|
import AuthLayout from '../components/AuthLayout'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import Spinner from '../components/Spinner'
|
||||||
|
import { confirmEmail, resendConfirmEmail, friendlyAuthError } from '../api'
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
verifying: 'Confirming your email',
|
||||||
|
success: 'Email confirmed',
|
||||||
|
error: 'Confirmation failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitles = {
|
||||||
|
verifying: 'Hold on while we activate your account.',
|
||||||
|
success: 'Your TalentFlow account is active.',
|
||||||
|
error: 'That link did not work. Request a new one below.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmEmail() {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const token = params.get('token') || ''
|
||||||
|
|
||||||
|
const [state, setState] = useState('verifying')
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [emailError, setEmailError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [resent, setResent] = useState(false)
|
||||||
|
|
||||||
|
// StrictMode runs mount effects twice in dev; the ref survives the remount so
|
||||||
|
// the POST fires once. The server is idempotent either way.
|
||||||
|
const firedRef = useRef(false)
|
||||||
|
|
||||||
|
const runConfirm = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setState('error')
|
||||||
|
setAlert({ type: 'danger', message: 'This confirmation link is missing its token.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await confirmEmail(token)
|
||||||
|
const data = res?.data || {}
|
||||||
|
if (data.email) setEmail(data.email)
|
||||||
|
setState('success')
|
||||||
|
setAlert({
|
||||||
|
type: 'success',
|
||||||
|
message: data.already_confirmed
|
||||||
|
? 'Your email was already confirmed. You can sign in.'
|
||||||
|
: 'Your email is confirmed. You can sign in now.',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setState('error')
|
||||||
|
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not confirm your email.') })
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (firedRef.current) return
|
||||||
|
firedRef.current = true
|
||||||
|
runConfirm()
|
||||||
|
}, [runConfirm])
|
||||||
|
|
||||||
|
async function onResend(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email.trim()) {
|
||||||
|
setEmailError('Email is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEmailError('')
|
||||||
|
setAlert(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await resendConfirmEmail(email.trim())
|
||||||
|
setResent(true)
|
||||||
|
setAlert({ type: 'success', message: 'We sent a new confirmation link to your email.' })
|
||||||
|
} catch (err) {
|
||||||
|
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not send a new link.') })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title={titles[state]}
|
||||||
|
subtitle={subtitles[state]}
|
||||||
|
foot={
|
||||||
|
<>
|
||||||
|
Back to{' '}
|
||||||
|
<Link className="link-btn" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert type={alert?.type}>{alert?.message}</Alert>
|
||||||
|
|
||||||
|
{state === 'verifying' && (
|
||||||
|
<p className="countdown" aria-live="polite">
|
||||||
|
<Spinner label="Confirming your email" /> Confirming…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'success' && (
|
||||||
|
<Link className="btn btn-primary btn-block" to="/login" style={{ textDecoration: 'none' }}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'error' && !resent && (
|
||||||
|
<form onSubmit={onResend} noValidate>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="confirm-email">
|
||||||
|
Email<span className="req"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirm-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
setEmailError('')
|
||||||
|
}}
|
||||||
|
className={emailError ? 'err' : ''}
|
||||||
|
placeholder="you@utopiabrands.com"
|
||||||
|
disabled={busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={`field-error${emailError ? ' show' : ''}`}>{emailError || ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? <Spinner label="Sending link" /> : null}
|
||||||
|
{busy ? 'Sending…' : 'Send a new link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import AuthLayout from '../components/AuthLayout'
|
||||||
|
import PasswordField from '../components/PasswordField'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import OtpInput from '../components/OtpInput'
|
||||||
|
import Countdown, { useResendGate } from '../components/Countdown'
|
||||||
|
import Spinner from '../components/Spinner'
|
||||||
|
import {
|
||||||
|
forgetPassword,
|
||||||
|
verifyForgetCode,
|
||||||
|
setNewPassword,
|
||||||
|
friendlyAuthError,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
function pickTiming(payload) {
|
||||||
|
const src = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {}
|
||||||
|
const expiresIn = src.expires_in
|
||||||
|
const resendAfter = src.resend_after
|
||||||
|
return {
|
||||||
|
expiresIn: expiresIn != null && Number.isFinite(Number(expiresIn)) ? Number(expiresIn) : null,
|
||||||
|
resendAfter:
|
||||||
|
resendAfter != null && Number.isFinite(Number(resendAfter)) ? Number(resendAfter) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickResetToken(payload) {
|
||||||
|
if (!payload) return null
|
||||||
|
if (payload.reset_token) return payload.reset_token
|
||||||
|
if (payload.access_token) return payload.access_token
|
||||||
|
if (payload.data?.reset_token) return payload.data.reset_token
|
||||||
|
if (payload.data?.access_token) return payload.data.access_token
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
const [alert, setAlert] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [expiresIn, setExpiresIn] = useState(null)
|
||||||
|
const [resendAfter, setResendAfter] = useState(null)
|
||||||
|
const [startedAt, setStartedAt] = useState(null)
|
||||||
|
const [resetToken, setResetToken] = useState(null)
|
||||||
|
const [expired, setExpired] = useState(false)
|
||||||
|
|
||||||
|
const resendLeft = useResendGate(resendAfter, startedAt)
|
||||||
|
const onExpire = useCallback(() => setExpired(true), [])
|
||||||
|
|
||||||
|
function applyChallengeTiming(payload) {
|
||||||
|
const { expiresIn: exp, resendAfter: ra } = pickTiming(payload)
|
||||||
|
setExpiresIn(exp)
|
||||||
|
setResendAfter(ra)
|
||||||
|
setStartedAt(Date.now())
|
||||||
|
setExpired(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCode(e) {
|
||||||
|
e?.preventDefault()
|
||||||
|
const nextErrors = {}
|
||||||
|
if (!email.trim()) nextErrors.email = 'Email is required'
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length) return
|
||||||
|
|
||||||
|
setAlert(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await forgetPassword(email.trim())
|
||||||
|
applyChallengeTiming(res)
|
||||||
|
setCode('')
|
||||||
|
setStep(2)
|
||||||
|
setAlert({
|
||||||
|
type: 'success',
|
||||||
|
message: res?.detail || res?.message || 'We sent a 6-digit code to your email.',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setAlert({ type: 'danger', message: friendlyAuthError(err) })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCode(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextErrors = {}
|
||||||
|
if (!/^\d{6}$/.test(code)) nextErrors.code = 'Enter the 6-digit code'
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length) return
|
||||||
|
if (expired) {
|
||||||
|
setAlert({ type: 'danger', message: 'That code has expired. Request a new one.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlert(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await verifyForgetCode(email.trim(), code)
|
||||||
|
const token = pickResetToken(res)
|
||||||
|
if (!token) {
|
||||||
|
setAlert({ type: 'danger', message: 'Verification succeeded but no reset token was returned.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResetToken(token)
|
||||||
|
setStep(3)
|
||||||
|
setAlert(null)
|
||||||
|
} catch (err) {
|
||||||
|
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Invalid or expired code.') })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPassword(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const nextErrors = {}
|
||||||
|
if (!password) nextErrors.password = 'Password is required'
|
||||||
|
else if (password.length < 8) nextErrors.password = 'Use at least 8 characters'
|
||||||
|
if (confirm !== password) nextErrors.confirm = 'Passwords do not match'
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length) return
|
||||||
|
|
||||||
|
setAlert(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await setNewPassword(password, resetToken)
|
||||||
|
setAlert({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Password updated. You can sign in with your new password.',
|
||||||
|
})
|
||||||
|
setStep(4)
|
||||||
|
} catch (err) {
|
||||||
|
setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not update password.') })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
1: 'Forgot password',
|
||||||
|
2: 'Enter code',
|
||||||
|
3: 'New password',
|
||||||
|
4: 'Password updated',
|
||||||
|
}
|
||||||
|
const subtitles = {
|
||||||
|
1: 'We’ll email you a one-time code to reset access.',
|
||||||
|
2: `Enter the 6-digit code sent to ${email}.`,
|
||||||
|
3: 'Choose a new password for your account.',
|
||||||
|
4: 'You’re all set.',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title={titles[step]}
|
||||||
|
subtitle={subtitles[step]}
|
||||||
|
foot={
|
||||||
|
step === 4 ? (
|
||||||
|
<Link className="link-btn" to="/login">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Remembered it?{' '}
|
||||||
|
<Link className="link-btn" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert type={alert?.type}>{alert?.message}</Alert>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<form onSubmit={requestCode} noValidate>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="fp-email">
|
||||||
|
Email<span className="req"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fp-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev.email) return prev
|
||||||
|
const n = { ...prev }
|
||||||
|
delete n.email
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className={errors.email ? 'err' : ''}
|
||||||
|
placeholder="you@utopiabrands.com"
|
||||||
|
disabled={busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={`field-error${errors.email ? ' show' : ''}`}>{errors.email || ''}</span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? <Spinner label="Sending code" /> : null}
|
||||||
|
{busy ? 'Sending…' : 'Send code'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<form onSubmit={verifyCode} noValidate>
|
||||||
|
<OtpInput
|
||||||
|
value={code}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCode(v)
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev.code) return prev
|
||||||
|
const n = { ...prev }
|
||||||
|
delete n.code
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
error={Boolean(errors.code)}
|
||||||
|
/>
|
||||||
|
<span className={`field-error${errors.code ? ' show' : ''}`}>{errors.code || ''}</span>
|
||||||
|
|
||||||
|
<Countdown expiresIn={expiresIn} startedAt={startedAt} onExpire={onExpire} />
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy || expired}>
|
||||||
|
{busy ? <Spinner label="Verifying" /> : null}
|
||||||
|
{busy ? 'Verifying…' : 'Verify code'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="auth-resend">
|
||||||
|
{resendLeft > 0 ? (
|
||||||
|
<span className="countdown">Resend available in {Math.ceil(resendLeft)}s</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => requestCode()}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<form onSubmit={submitPassword} noValidate>
|
||||||
|
<PasswordField
|
||||||
|
id="fp-password"
|
||||||
|
label="New password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
error={errors.password}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
id="fp-confirm"
|
||||||
|
label="Confirm password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={setConfirm}
|
||||||
|
error={errors.confirm}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? <Spinner label="Saving password" /> : null}
|
||||||
|
{busy ? 'Saving…' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<Link className="btn btn-primary btn-block" to="/login" style={{ textDecoration: 'none' }}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import AuthLayout, { useFormState } from '../components/AuthLayout'
|
||||||
|
import PasswordField from '../components/PasswordField'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import Spinner from '../components/Spinner'
|
||||||
|
import { login, friendlyAuthError } from '../api'
|
||||||
|
import { setSession } from '../auth'
|
||||||
|
|
||||||
|
function goToApp() {
|
||||||
|
window.location.assign('/index.html#dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const form = useFormState({ email: '', password: '' })
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const errors = {}
|
||||||
|
if (!form.values.email.trim()) errors.email = 'Email is required'
|
||||||
|
if (!form.values.password) errors.password = 'Password is required'
|
||||||
|
form.setErrors(errors)
|
||||||
|
if (Object.keys(errors).length) return
|
||||||
|
|
||||||
|
form.setAlert(null)
|
||||||
|
form.setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await login(form.values.email.trim(), form.values.password)
|
||||||
|
setSession(res)
|
||||||
|
goToApp()
|
||||||
|
} catch (err) {
|
||||||
|
form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') })
|
||||||
|
form.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Sign in"
|
||||||
|
subtitle="Welcome back to TalentFlow."
|
||||||
|
foot={
|
||||||
|
<>
|
||||||
|
New here?{' '}
|
||||||
|
<Link className="link-btn" to="/signup">
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="login-email">
|
||||||
|
Email<span className="req"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={form.values.email}
|
||||||
|
onChange={(e) => form.setField('email', e.target.value)}
|
||||||
|
className={form.errors.email ? 'err' : ''}
|
||||||
|
placeholder="you@utopiabrands.com"
|
||||||
|
disabled={form.busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={`field-error${form.errors.email ? ' show' : ''}`}>
|
||||||
|
{form.errors.email || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordField
|
||||||
|
id="login-password"
|
||||||
|
value={form.values.password}
|
||||||
|
onChange={(v) => form.setField('password', v)}
|
||||||
|
error={form.errors.password}
|
||||||
|
disabled={form.busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="auth-row-end">
|
||||||
|
<Link className="link-btn" to="/forgot-password">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={form.busy}>
|
||||||
|
{form.busy ? <Spinner label="Signing in" /> : null}
|
||||||
|
{form.busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import AuthLayout, { useFormState } from '../components/AuthLayout'
|
||||||
|
import PasswordField from '../components/PasswordField'
|
||||||
|
import Alert from '../components/Alert'
|
||||||
|
import Spinner from '../components/Spinner'
|
||||||
|
import { useResendGate } from '../components/Countdown'
|
||||||
|
import { signup, resendConfirmEmail, friendlyAuthError, ApiError } from '../api'
|
||||||
|
import { setSession } from '../auth'
|
||||||
|
|
||||||
|
function goToApp() {
|
||||||
|
window.location.assign('/index.html#dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Signup() {
|
||||||
|
const form = useFormState({ name: '', email: '', password: '', confirm: '' })
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [resendAfter, setResendAfter] = useState(null)
|
||||||
|
const [startedAt, setStartedAt] = useState(null)
|
||||||
|
const resendLeft = useResendGate(resendAfter, startedAt)
|
||||||
|
|
||||||
|
async function onResend() {
|
||||||
|
form.setAlert(null)
|
||||||
|
form.setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await resendConfirmEmail(form.values.email.trim())
|
||||||
|
const data = res?.data || {}
|
||||||
|
setResendAfter(Number.isFinite(Number(data.resend_after)) ? Number(data.resend_after) : null)
|
||||||
|
setStartedAt(Date.now())
|
||||||
|
form.setAlert({ type: 'success', message: 'We sent a new confirmation link.' })
|
||||||
|
} catch (err) {
|
||||||
|
form.setAlert({
|
||||||
|
type: 'danger',
|
||||||
|
message: friendlyAuthError(err, 'Could not resend the link.'),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
form.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const errors = {}
|
||||||
|
if (!form.values.name.trim()) errors.name = 'Name is required'
|
||||||
|
if (!form.values.email.trim()) errors.email = 'Email is required'
|
||||||
|
if (!form.values.password) errors.password = 'Password is required'
|
||||||
|
else if (form.values.password.length < 8) errors.password = 'Use at least 8 characters'
|
||||||
|
if (form.values.confirm !== form.values.password) errors.confirm = 'Passwords do not match'
|
||||||
|
form.setErrors(errors)
|
||||||
|
if (Object.keys(errors).length) return
|
||||||
|
|
||||||
|
form.setAlert(null)
|
||||||
|
form.setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await signup(
|
||||||
|
form.values.name.trim(),
|
||||||
|
form.values.email.trim(),
|
||||||
|
form.values.password,
|
||||||
|
)
|
||||||
|
// The account starts inactive; only the mailed link activates it, so the
|
||||||
|
// tokens in this response are unusable until then.
|
||||||
|
if (!res?.data?.is_active) {
|
||||||
|
setSent(true)
|
||||||
|
form.setAlert({
|
||||||
|
type: 'success',
|
||||||
|
message: `We sent a confirmation link to ${res?.data?.email || form.values.email.trim()}.`,
|
||||||
|
})
|
||||||
|
form.setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSession(res)
|
||||||
|
goToApp()
|
||||||
|
} catch (err) {
|
||||||
|
// 502 means the account was created but the email failed — offer a resend
|
||||||
|
// rather than a dead end.
|
||||||
|
if (err instanceof ApiError && err.status === 502) {
|
||||||
|
setSent(true)
|
||||||
|
form.setAlert({
|
||||||
|
type: 'danger',
|
||||||
|
message: 'Your account was created but we could not send the email. Try again below.',
|
||||||
|
})
|
||||||
|
form.setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.setAlert({
|
||||||
|
type: 'danger',
|
||||||
|
message: friendlyAuthError(err, 'Could not create your account.'),
|
||||||
|
})
|
||||||
|
form.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Check your email"
|
||||||
|
subtitle="Open the link we sent to activate your account."
|
||||||
|
foot={
|
||||||
|
<>
|
||||||
|
Already confirmed?{' '}
|
||||||
|
<Link className="link-btn" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
|
||||||
|
|
||||||
|
{resendLeft > 0 ? (
|
||||||
|
<p className="countdown" aria-live="polite">
|
||||||
|
Resend available in {Math.ceil(resendLeft)}s
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={form.busy}
|
||||||
|
onClick={onResend}
|
||||||
|
>
|
||||||
|
{form.busy ? <Spinner label="Sending link" /> : null}
|
||||||
|
{form.busy ? 'Sending…' : 'Resend confirmation email'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Create account"
|
||||||
|
subtitle="Join TalentFlow for Utopia Brands hiring."
|
||||||
|
foot={
|
||||||
|
<>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link className="link-btn" to="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<Alert type={form.alert?.type}>{form.alert?.message}</Alert>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="signup-name">
|
||||||
|
Full name<span className="req"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="signup-name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
value={form.values.name}
|
||||||
|
onChange={(e) => form.setField('name', e.target.value)}
|
||||||
|
className={form.errors.name ? 'err' : ''}
|
||||||
|
placeholder="Asfand Ahmed"
|
||||||
|
disabled={form.busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={`field-error${form.errors.name ? ' show' : ''}`}>
|
||||||
|
{form.errors.name || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="signup-email">
|
||||||
|
Email<span className="req"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="signup-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={form.values.email}
|
||||||
|
onChange={(e) => form.setField('email', e.target.value)}
|
||||||
|
className={form.errors.email ? 'err' : ''}
|
||||||
|
placeholder="you@utopiabrands.com"
|
||||||
|
disabled={form.busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={`field-error${form.errors.email ? ' show' : ''}`}>
|
||||||
|
{form.errors.email || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordField
|
||||||
|
id="signup-password"
|
||||||
|
label="Password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.values.password}
|
||||||
|
onChange={(v) => form.setField('password', v)}
|
||||||
|
error={form.errors.password}
|
||||||
|
disabled={form.busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordField
|
||||||
|
id="signup-confirm"
|
||||||
|
label="Confirm password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.values.confirm}
|
||||||
|
onChange={(v) => form.setField('confirm', v)}
|
||||||
|
error={form.errors.confirm}
|
||||||
|
disabled={form.busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={form.busy}>
|
||||||
|
{form.busy ? <Spinner label="Creating account" /> : null}
|
||||||
|
{form.busy ? 'Creating…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
const THEME_KEY = 'tf-theme'
|
||||||
|
|
||||||
|
export function applyTheme(theme, persist = true) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
if (persist) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, theme)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(THEME_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme() {
|
||||||
|
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
||||||
|
const saved = getStoredTheme()
|
||||||
|
applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
const cur = document.documentElement.getAttribute('data-theme')
|
||||||
|
applyTheme(cur === 'dark' ? 'light' : 'dark', true)
|
||||||
|
return document.documentElement.getAttribute('data-theme')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/auth/',
|
||||||
|
build: { outDir: path.resolve(repoRoot, 'auth'), emptyOutDir: true },
|
||||||
|
resolve: { alias: { '@shared-css': path.resolve(repoRoot, 'css/styles.css') } },
|
||||||
|
server: { port: 5173, fs: { allow: [repoRoot] } },
|
||||||
|
})
|
||||||
|
|
@ -233,7 +233,7 @@
|
||||||
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
|
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
|
||||||
<a class="dropdown-link" href="#help" data-route="help"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Help Center</a>
|
<a class="dropdown-link" href="#help" data-route="help"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Help Center</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-link danger" onclick="App.toast('Signed out (demo)','info')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Sign out</button>
|
<button class="dropdown-link danger" onclick="App.signOut()"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
39
js/app.js
39
js/app.js
|
|
@ -79,6 +79,44 @@ App.toggleTheme = function () {
|
||||||
App.setTheme(cur === 'dark' ? 'light' : 'dark');
|
App.setTheme(cur === 'dark' ? 'light' : 'dark');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------- Session (tf-auth from /auth/) ----------------
|
||||||
|
App.getSession = function () {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('tf-auth');
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialsFromName(name) {
|
||||||
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return '?';
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
App.hydrateProfile = function () {
|
||||||
|
const session = App.getSession();
|
||||||
|
const data = (session && session.data) || {};
|
||||||
|
const name = data.name || 'Guest';
|
||||||
|
const email = data.email || '';
|
||||||
|
const role = data.role_name || data.role || 'Member';
|
||||||
|
const initials = initialsFromName(name);
|
||||||
|
|
||||||
|
document.querySelectorAll('.profile-name').forEach(el => { el.textContent = name; });
|
||||||
|
document.querySelectorAll('.profile-role').forEach(el => { el.textContent = role; });
|
||||||
|
document.querySelectorAll('.dp-name').forEach(el => { el.textContent = name; });
|
||||||
|
document.querySelectorAll('.dp-email').forEach(el => { el.textContent = email; });
|
||||||
|
document.querySelectorAll('.avatar-grad').forEach(el => { el.textContent = initials; });
|
||||||
|
};
|
||||||
|
|
||||||
|
App.signOut = function () {
|
||||||
|
try { localStorage.removeItem('tf-auth'); } catch (e) {}
|
||||||
|
window.location.assign('/auth/');
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------- Toast passthrough ----------------
|
// ---------------- Toast passthrough ----------------
|
||||||
App.toast = function (msg, type, title) { UI.toast(msg, type, title); };
|
App.toast = function (msg, type, title) { UI.toast(msg, type, title); };
|
||||||
|
|
||||||
|
|
@ -263,6 +301,7 @@ function init() {
|
||||||
|
|
||||||
initDropdowns();
|
initDropdowns();
|
||||||
initNav();
|
initNav();
|
||||||
|
App.hydrateProfile();
|
||||||
App.renderNotifDropdown();
|
App.renderNotifDropdown();
|
||||||
App.renderMessages();
|
App.renderMessages();
|
||||||
App.updateBadges();
|
App.updateBadges();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue