login confirmation and forget password done

pull/2/head
ahmed.mujtaba 2026-08-04 20:40:42 +05:00
parent 31592dc7f1
commit 92c06d9335
48 changed files with 4677 additions and 6 deletions

View File

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

8
.gitignore vendored
View File

@ -36,6 +36,8 @@ env/
.env
.env.*
!.env.example
!frontend/.env.development
!frontend/.env.production
# Logs & temp
*.log
@ -43,4 +45,10 @@ tmp/
temp/
.cache/
# Frontend / auth build
node_modules/
frontend/dist/
/auth/
**.pdf
**_**_**.py

24
backend/.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ from db_setup import lifespan
from inbox.app import router as inbox_router
from users.app import router as users_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.
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
@ -25,3 +27,5 @@ app.add_middleware(
app.include_router(inbox_router)
app.include_router(users_router)
app.include_router(role_router)
app.include_router(forget_password_router)
app.include_router(confirmation_router)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,12 @@ class UserCreate(BaseModel):
is_active: bool = True
class UserSignup(BaseModel):
name: str
email: EmailStr
password: str
class UserUpdate(BaseModel):
name: str | 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))
@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")
async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)):
try:

View File

@ -20,7 +20,7 @@ class Users(SQLModel, table=True):
password: str
created_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)
@classmethod

View File

@ -194,6 +194,7 @@ def require_permission(*required: PermissionTag, require_all: bool = True):
async def dependency(current_user: CurrentUser) -> dict:
if current_user.get("role_id") is None:
raise HTTPException(status_code=403, detail="User has no role assigned")
granted = current_user.get("permissions") or []
if not has_permission(granted, *required, require_all=require_all):
if require_all and len(required) == 1:

View File

@ -28,7 +28,9 @@ JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
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"))
RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10"))
ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60
RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60
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:
payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM])
if payload.get("type") != expected_type:

View File

@ -1,5 +1,6 @@
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.permissions import PermissionTag,has_permission
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):
if role_id==existing_role_id:
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):
raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage")
if role_id is None:
@ -42,6 +45,21 @@ class User:
raise HTTPException(status_code=400,detail="Password is required")
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):
users=await Users.get_users(self.session,top,skip,search)
return [serialize_user(u) for u in users]
@ -99,8 +117,10 @@ class User:
detail="Incorrect email or password",
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")
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)
async def refresh_access_token(self,refresh_token):

View File

@ -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
stale copy and the browser never sees the change.
"""
import os
import sys
from functools import partial
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):
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):
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")

View File

@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8000

1
frontend/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8000

17
frontend/index.html Normal file
View File

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

1897
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

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

22
frontend/src/App.jsx Normal file
View File

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

125
frontend/src/api.js Normal file
View File

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

367
frontend/src/auth.css Normal file
View File

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

30
frontend/src/auth.js Normal file
View File

@ -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 */
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
export default function Spinner({ label = 'Loading' }) {
return (
<span className="spinner" role="status" aria-label={label}>
<span className="spinner-dot" />
</span>
)
}

View File

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

14
frontend/src/main.jsx Normal file
View File

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

View File

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

View File

@ -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: 'Well 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: 'Youre 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>
)
}

View File

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

View File

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

32
frontend/src/theme.js Normal file
View File

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

15
frontend/vite.config.js Normal file
View File

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

View File

@ -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="#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>
<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>

View File

@ -79,6 +79,44 @@ App.toggleTheme = function () {
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 ----------------
App.toast = function (msg, type, title) { UI.toast(msg, type, title); };
@ -263,6 +301,7 @@ function init() {
initDropdowns();
initNav();
App.hydrateProfile();
App.renderNotifDropdown();
App.renderMessages();
App.updateBadges();