From 78d04f75d1ed4adc8beeae4c050695ee0b5a92fb Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 22 Sep 2026 00:50:30 +0500 Subject: [PATCH] register and sign in pages --- .build/prod/README.md | 85 +- .build/prod/compose.yml | 36 +- .build/prod/entrypoint.sh | 0 .build/prod/frontend.Dockerfile | 27 - .build/prod/nginx.Dockerfile | 3 - .build/prod/nginx.conf | 45 +- .build/prod/web.Dockerfile | 19 + .dockerignore | 11 + .gitignore | 19 +- README.md | 74 + backend/.env.example | 45 + backend/.gitignore | 9 + backend/Dockerfile | 20 + backend/README.md | 181 ++ backend/apibase/__init__.py | 0 backend/apibase/asgi.py | 16 + backend/apibase/settings.py | 218 ++ backend/apibase/settings_test.py | 19 + backend/apibase/tasks.py | 5 + backend/apibase/urls.py | 24 + backend/apibase/wsgi.py | 16 + backend/compose.yml | 58 + backend/core/__init__.py | 0 backend/core/apps.py | 6 + backend/core/exceptions.py | 32 + backend/core/pagination.py | 6 + backend/core/tests/__init__.py | 0 backend/core/urls.py | 7 + backend/core/views.py | 17 + backend/justfile | 26 + backend/locations/__init__.py | 0 backend/locations/admin.py | 22 + backend/locations/api.py | 17 + backend/locations/apps.py | 6 + backend/locations/management/__init__.py | 0 .../locations/management/commands/__init__.py | 0 .../management/commands/seed_locations.py | 16 + backend/locations/migrations/0001_initial.py | 40 + .../migrations/0002_seed_locations.py | 32 + backend/locations/migrations/__init__.py | 0 backend/locations/models.py | 31 + backend/locations/seed.py | 37 + backend/locations/serializers.py | 23 + backend/locations/tests/__init__.py | 0 backend/locations/urls.py | 7 + backend/manage.py | 22 + backend/pyproject.toml | 11 + backend/requirements.txt | 26 + backend/users/__init__.py | 0 backend/users/admin.py | 141 ++ backend/users/apps.py | 6 + backend/users/managers.py | 38 + backend/users/migrations/0001_initial.py | 89 + backend/users/migrations/0002_idnumber.py | 14 + backend/users/migrations/__init__.py | 0 backend/users/mobile.py | 29 + backend/users/models.py | 279 +++ backend/users/serializers.py | 170 ++ backend/users/sms.py | 73 + backend/users/tests/__init__.py | 0 backend/users/tests/test_auth_flow.py | 185 ++ backend/users/tests/test_mobile.py | 17 + backend/users/tests/test_registration.py | 250 +++ backend/users/urls.py | 22 + backend/users/views.py | 262 +++ frontend/.dockerignore | 5 + frontend/.env.example | 3 + frontend/.gitignore | 26 + frontend/.oxlintrc.json | 8 + frontend/Dockerfile | 13 + frontend/README.md | 100 + frontend/compose.yml | 22 + frontend/index.html | 14 + frontend/package-lock.json | 1965 +++++++++++++++++ frontend/package.json | 29 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/components/AppLayout.tsx | 37 + frontend/src/components/DateField.tsx | 103 + frontend/src/components/RequireAuth.tsx | 15 + frontend/src/components/Select.tsx | 13 + frontend/src/components/ui.tsx | 86 + frontend/src/index.css | 14 + frontend/src/lib/api.ts | 236 ++ frontend/src/lib/auth-context.ts | 20 + frontend/src/lib/auth.tsx | 61 + frontend/src/lib/date.ts | 6 + frontend/src/main.tsx | 37 + frontend/src/pages/Dashboard.tsx | 103 + frontend/src/pages/Login.tsx | 238 ++ frontend/src/pages/NotFound.tsx | 13 + frontend/src/pages/Register.tsx | 320 +++ frontend/tsconfig.app.json | 30 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 31 + 96 files changed, 6383 insertions(+), 109 deletions(-) mode change 100644 => 100755 .build/prod/entrypoint.sh delete mode 100644 .build/prod/frontend.Dockerfile delete mode 100644 .build/prod/nginx.Dockerfile create mode 100644 .build/prod/web.Dockerfile create mode 100644 .dockerignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/apibase/__init__.py create mode 100644 backend/apibase/asgi.py create mode 100644 backend/apibase/settings.py create mode 100644 backend/apibase/settings_test.py create mode 100644 backend/apibase/tasks.py create mode 100644 backend/apibase/urls.py create mode 100644 backend/apibase/wsgi.py create mode 100644 backend/compose.yml create mode 100644 backend/core/__init__.py create mode 100644 backend/core/apps.py create mode 100644 backend/core/exceptions.py create mode 100644 backend/core/pagination.py create mode 100644 backend/core/tests/__init__.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/views.py create mode 100644 backend/justfile create mode 100644 backend/locations/__init__.py create mode 100644 backend/locations/admin.py create mode 100644 backend/locations/api.py create mode 100644 backend/locations/apps.py create mode 100644 backend/locations/management/__init__.py create mode 100644 backend/locations/management/commands/__init__.py create mode 100644 backend/locations/management/commands/seed_locations.py create mode 100644 backend/locations/migrations/0001_initial.py create mode 100644 backend/locations/migrations/0002_seed_locations.py create mode 100644 backend/locations/migrations/__init__.py create mode 100644 backend/locations/models.py create mode 100644 backend/locations/seed.py create mode 100644 backend/locations/serializers.py create mode 100644 backend/locations/tests/__init__.py create mode 100644 backend/locations/urls.py create mode 100755 backend/manage.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/users/__init__.py create mode 100644 backend/users/admin.py create mode 100644 backend/users/apps.py create mode 100644 backend/users/managers.py create mode 100644 backend/users/migrations/0001_initial.py create mode 100644 backend/users/migrations/0002_idnumber.py create mode 100644 backend/users/migrations/__init__.py create mode 100644 backend/users/mobile.py create mode 100644 backend/users/models.py create mode 100644 backend/users/serializers.py create mode 100644 backend/users/sms.py create mode 100644 backend/users/tests/__init__.py create mode 100644 backend/users/tests/test_auth_flow.py create mode 100644 backend/users/tests/test_mobile.py create mode 100644 backend/users/tests/test_registration.py create mode 100644 backend/users/urls.py create mode 100644 backend/users/views.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/components/AppLayout.tsx create mode 100644 frontend/src/components/DateField.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/components/Select.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth-context.ts create mode 100644 frontend/src/lib/auth.tsx create mode 100644 frontend/src/lib/date.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.build/prod/README.md b/.build/prod/README.md index 0d72c30..7a7c45c 100644 --- a/.build/prod/README.md +++ b/.build/prod/README.md @@ -1,79 +1,70 @@ # Production deployment -Unified build for the SAR Link portal monorepo. One `compose.yml` builds all -services from the repo root, wiring together the two submodules: +One `compose.yml` builds every service from the repo root: -- **`backend/`** — Django API (gunicorn on `:5000`, static via WhiteNoise) -- **`frontend/`** — Next.js portal (standalone server on `:3000`) - -A single **nginx** container is the only published entrypoint. The browser only -ever talks to Next.js; Next.js reaches Django **server-side** over the compose -network (`SARLINK_API_BASE_URL=http://backend:5000`). nginx only exposes -Django's browser-facing surface — the admin, its static assets, and media. +- **`backend/`** — Django API on gunicorn `:5000`, static via WhiteNoise +- **`frontend/`** — Vite SPA, built to static files at image build time +- **`web`** — the only published container: nginx serving the SPA and reverse + proxying the API. No node or bun at runtime. ``` -host.com/ -> frontend (Next.js) # incl. its own /api/* route handlers -host.com/admin/ -> backend (Django admin) -host.com/static/ -> backend (WhiteNoise) -host.com/media/ -> nginx (shared `media` volume) +host.com/ -> nginx (SPA, history fallback to index.html) +host.com/api/ -> backend (Django REST API) +host.com/admin/ -> backend (Django admin) +host.com/static/ -> backend (WhiteNoise: admin/DRF assets) +host.com/media/ -> nginx (shared `media` volume) ``` -> `/api/` is **not** proxied to Django — it belongs to Next.js (NextAuth etc.). -> Django's own `/api/...` is reached only internally via `SARLINK_API_BASE_URL`. +Because nginx fronts both, the browser sees one origin and there is no CORS in +production. ## Files -| File | Purpose | -| -------------------- | --------------------------------------------------- | -| `compose.yml` | postgres + backend + frontend + nginx | -| `api.Dockerfile` | Django image (collectstatic at build) | -| `frontend.Dockerfile`| Next.js standalone image | -| `nginx.Dockerfile` | nginx + `nginx.conf` | -| `entrypoint.sh` | backend: wait for postgres, `migrate`, then gunicorn| -| `nginx.conf` | front reverse proxy | +| File | Purpose | +| ----------------- | -------------------------------------------------------- | +| `compose.yml` | database + backend + worker + web | +| `api.Dockerfile` | Django image (collectstatic at build) | +| `web.Dockerfile` | node builds `frontend/dist`, nginx serves it | +| `nginx.conf` | SPA + API/admin/static/media routing, asset cache headers | +| `entrypoint.sh` | backend: wait for postgres, `migrate`, then gunicorn | ## Configure -Fill each submodule's `.env` (copy from its `.env.example`); compose reads -`backend/.env` and `frontend/.env`. For the compose network set: +Fill `backend/.env` (copy from `backend/.env.example`). For the compose network: -**`backend/.env`** ``` +DJANGO_DEBUG=False +SECRET_KEY= POSTGRES_HOST=database POSTGRES_PORT=5432 POSTGRES_DATABASE=sarlink POSTGRES_USER=sarlink -POSTGRES_PASSWORD=changeme -ALLOWED_HOSTS=localhost,127.0.0.1,backend # + your public host -CSRF_TRUSTED_ORIGINS=https://portal.example.com +POSTGRES_PASSWORD= +ALLOWED_HOSTS=localhost,127.0.0.1,backend,portal.sarlink.net +CSRF_TRUSTED_ORIGINS=https://portal.sarlink.net +FRONTEND_URL=https://portal.sarlink.net +SMS_API_URL=... +SMS_API_KEY=... ``` -**`frontend/.env`** -``` -SARLINK_API_BASE_URL=http://backend:5000 -NEXTAUTH_URL=https://portal.example.com -NEXTAUTH_SECRET=... -``` +The `POSTGRES_*` values also feed the `database` service through compose +defaults, so keep them in sync or export them before `up`. -The `POSTGRES_*` values also feed the `database` service (via compose defaults), -so keep them in sync — or export them in the shell before `up`. +The frontend needs no runtime configuration: it calls a relative `/api/...` +which nginx routes to the backend. ## Build & run ```sh docker compose -f .build/prod/compose.yml up -d --build -``` - -The published site is on `http://localhost:8080` (remap the `nginx` port in -`compose.yml` behind your TLS terminator). The backend runs migrations on -startup; create an admin user once with: - -```sh docker compose -f .build/prod/compose.yml exec backend python manage.py createsuperuser ``` +The site is on `http://localhost:8080`; remap the `web` port behind your TLS +terminator. Migrations run on backend startup. + ## Running from published images -The build pushes to `git.shihaam.dev/sarlink/sarlinkportal/{backend,frontend,nginx}`. -To deploy without building, replace each service's `build:` block with its -`image:` and keep the `database`, volumes, `env_file`, and `nginx` port mapping. +The build pushes `git.shihaam.dev/sarlink/sarlinkportal/{backend,web}`. To deploy +without building, replace each service's `build:` block with its `image:` and +keep `database`, the volumes, `env_file` and the `web` port mapping. diff --git a/.build/prod/compose.yml b/.build/prod/compose.yml index 215ca35..461101b 100644 --- a/.build/prod/compose.yml +++ b/.build/prod/compose.yml @@ -2,12 +2,18 @@ services: database: image: postgres:16 hostname: database + restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink} POSTGRES_USER: ${POSTGRES_USER:-sarlink} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"] + interval: 5s + timeout: 3s + retries: 10 backend: build: @@ -15,36 +21,40 @@ services: dockerfile: .build/prod/api.Dockerfile hostname: backend image: git.shihaam.dev/sarlink/sarlinkportal/backend + restart: unless-stopped env_file: - ../../backend/.env volumes: - media:/app/media depends_on: - - database + database: + condition: service_healthy - frontend: - build: - context: ../../ - dockerfile: .build/prod/frontend.Dockerfile - hostname: frontend - image: git.shihaam.dev/sarlink/sarlinkportal/frontend + # Background tasks: same image, procrastinate worker instead of gunicorn. + worker: + image: git.shihaam.dev/sarlink/sarlinkportal/backend + restart: unless-stopped + command: python manage.py procrastinate worker env_file: - - ../../frontend/.env + - ../../backend/.env + volumes: + - media:/app/media depends_on: - backend - nginx: + # The only published container: static SPA + reverse proxy to the API. + web: build: context: ../../ - dockerfile: .build/prod/nginx.Dockerfile - hostname: nginx - image: git.shihaam.dev/sarlink/sarlinkportal/nginx + dockerfile: .build/prod/web.Dockerfile + hostname: web + image: git.shihaam.dev/sarlink/sarlinkportal/web + restart: unless-stopped ports: - "8080:80" volumes: - media:/app/media:ro depends_on: - - frontend - backend volumes: diff --git a/.build/prod/entrypoint.sh b/.build/prod/entrypoint.sh old mode 100644 new mode 100755 diff --git a/.build/prod/frontend.Dockerfile b/.build/prod/frontend.Dockerfile deleted file mode 100644 index a96e875..0000000 --- a/.build/prod/frontend.Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM node:22-slim AS builder - -WORKDIR /var/www/html -ENV NEXT_TELEMETRY_DISABLED=1 - -COPY frontend/package.json frontend/package-lock.json ./ -RUN npm ci --legacy-peer-deps - -COPY frontend/ . -RUN npm run build - -# ---- runtime ---- -# next.config.ts sets `output: "standalone"`, so we ship only the traced server. -FROM node:22-slim AS runner - -WORKDIR /var/www/html -ENV NODE_ENV=production \ - NEXT_TELEMETRY_DISABLED=1 \ - HOSTNAME=0.0.0.0 \ - PORT=3000 - -COPY --from=builder /var/www/html/public ./public -COPY --from=builder /var/www/html/.next/standalone ./ -COPY --from=builder /var/www/html/.next/static ./.next/static - -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/.build/prod/nginx.Dockerfile b/.build/prod/nginx.Dockerfile deleted file mode 100644 index 8ee13cb..0000000 --- a/.build/prod/nginx.Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM nginx:alpine - -COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.build/prod/nginx.conf b/.build/prod/nginx.conf index d081aac..b3738bb 100644 --- a/.build/prod/nginx.conf +++ b/.build/prod/nginx.conf @@ -1,17 +1,27 @@ -upstream frontend { server frontend:3000; } -upstream backend { server backend:5000; } +upstream backend { server backend:5000; } server { listen 80; server_name _; + root /usr/share/nginx/html; + index index.html; + access_log /dev/stdout; error_log /dev/stderr; - # Matches the frontend's serverActions bodySizeLimit (20mb). client_max_body_size 20M; - # --- Django admin + its static assets (served by WhiteNoise from gunicorn) --- + # --- Django: API, admin, its static assets --- + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + location /admin/ { proxy_pass http://backend; proxy_set_header Host $host; @@ -19,31 +29,30 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + # Django's own static files (admin, DRF, swagger) via WhiteNoise. location /static/ { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } - # --- Django-uploaded media (written to the shared `media` volume) --- + # --- user uploads, from the shared `media` volume --- location /media/ { alias /app/media/; access_log off; } - # --- Next.js app (everything else, including its own /api/* route handlers) --- - # The browser only ever talks to Next.js; Next.js reaches Django server-side - # over the compose network via SARLINK_API_BASE_URL=http://backend:5000. + # --- the SPA --- + # Hashed bundles are immutable; the entry document must never be cached, + # or a deploy leaves browsers asking for chunks that no longer exist. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + location / { - proxy_pass http://frontend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - # Honour the X-Accel-Buffering: no header the app sets for streamed responses. - proxy_buffering off; + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; } } diff --git a/.build/prod/web.Dockerfile b/.build/prod/web.Dockerfile new file mode 100644 index 0000000..7e6ebc9 --- /dev/null +++ b/.build/prod/web.Dockerfile @@ -0,0 +1,19 @@ +# The SPA is built with node here and shipped as static files. +# The runtime image is nginx only - no node, no bun. +FROM node:22-slim AS builder + +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ . +RUN npm run build + +# ---- runtime ---- +FROM nginx:1.27-alpine + +COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2f6c61 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/.git +**/.venv +**/venv +**/node_modules +**/dist +**/__pycache__ +**/.ruff_cache +**/staticfiles +**/media +**/.env +!**/.env.example diff --git a/.gitignore b/.gitignore index 5ceb386..336cc49 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ -venv +# python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.ruff_cache/ + +# node +node_modules/ +dist/ + +# secrets + local state +.env +*.local + +# collected/uploaded +backend/staticfiles/ +backend/media/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4671671 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# sarlinkportal + +Monorepo for the SAR Link member portal. + +``` +backend/ Django 5.2 + DRF API (knox auth, postgres, procrastinate) +frontend/ Vite + React + TypeScript SPA (served by nginx in production) +.build/prod/ production images and compose +``` + +## Quick start + +```sh +cp backend/.env.example backend/.env # add SMS_API_KEY for real texts +docker compose up --build +docker compose exec backend python manage.py createsuperuser # asks for a mobile number +``` + +Atolls and islands are seeded by migration. With `SMS_API_KEY` empty, OTP codes +are printed to the backend log instead of being texted. + +| | | +|---|---| +| SPA | http://localhost:5173 | +| API | http://localhost:8000/api/ | +| API docs (DEBUG) | http://localhost:8000/api/docs/ | +| Django admin | http://localhost:8000/admin/ | + +The root `compose.yml` just includes `backend/compose.yml` and +`frontend/compose.yml`, so each side can also be brought up on its own. + +## How the two talk + +The SPA only ever calls a relative `/api/...`: + +- **dev** — the vite dev server proxies `/api`, `/admin`, `/static`, `/media` + to the backend container. +- **prod** — one nginx serves the built SPA and proxies the same prefixes to + gunicorn, so there's a single origin and no CORS. + +## Authentication + +One entry point - the mobile number - and the API decides the second step: + +``` +POST /api/auth/start/ {mobile} -> next: "password" | "otp" + + password -> POST /api/auth/login/password/ {mobile, password} + -> next: "dashboard" + knox token + + otp -> POST /api/auth/verify/ {mobile, code} + -> next: "dashboard" + knox token + -> next: "register" + ticket + -> POST /api/auth/register/ {ticket, ...form} -> pending account +``` + +`start` gives nothing away: a number with no account gets the same "code sent" +response as a member's. Only a confirmed code reveals which it was. New numbers +then go form -> **pending**, and an admin approves the application in the Django +admin before the account is usable. + +`backend/README.md` documents the responses, OTP/ticket policy, the SMS gateway +and the approval actions; `frontend/README.md` documents the forms. + +## Tests + +```sh +docker compose exec backend python manage.py test --settings=apibase.settings_test +cd frontend && npm run build && npm run lint +``` + +## Production + +See [`.build/prod/README.md`](.build/prod/README.md). diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..45de361 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,45 @@ +# ============================================================================= +# Django +# ============================================================================= +SECRET_KEY="change-me" +DJANGO_DEBUG=True +# Comma-separated. Include the backend service name for the compose network. +ALLOWED_HOSTS="localhost,127.0.0.1,backend" +# Comma-separated absolute origins the browser calls the API from. +CORS_ALLOWED_ORIGINS="http://localhost:5173" +CSRF_TRUSTED_ORIGINS="http://localhost:5173" + +# --- only read when DJANGO_DEBUG=False --- +SECURE_SSL_REDIRECT=False +SECURE_HSTS_SECONDS=0 + +# ============================================================================= +# Database (PostgreSQL) - matches the `database` service in compose.yml +# ============================================================================= +POSTGRES_DATABASE=sarlink +POSTGRES_USER=sarlink +POSTGRES_PASSWORD=changeme +POSTGRES_HOST=database +POSTGRES_PORT=5432 + +# ============================================================================= +# SMS gateway - login/registration OTP +# Leave empty in dev: codes are written to the log instead of being sent. +# ============================================================================= +SMS_API_URL="" +SMS_API_KEY="" +SMS_SENDER="SARLink" + +# ============================================================================= +# OTP policy +# ============================================================================= +OTP_TTL_SECONDS=300 +OTP_MAX_ATTEMPTS=5 +OTP_RESEND_COOLDOWN_SECONDS=60 +# How long a number stays verified for the registration form (30 min). +REGISTRATION_TICKET_TTL_SECONDS=1800 + +# ============================================================================= +# Public frontend base URL (used in SMS links) +# ============================================================================= +FRONTEND_URL=http://localhost:5173 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..0d98393 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +db.sqlite3 +/staticfiles/ +/media/ +.ruff_cache/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..078a902 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +# Development image. Production is built from .build/prod/api.Dockerfile. +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# The source is bind-mounted over this in compose. +COPY . . + +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..57b5100 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,181 @@ +# backend + +Django 5.2 + DRF API for the SAR Link portal. + +| | | +|---|---| +| Auth | knox tokens, two-step login (mobile -> password or SMS OTP) | +| Database | PostgreSQL 16 | +| Background tasks | procrastinate (postgres-backed, no broker) | +| Serving | gunicorn + WhiteNoise for `/static/` | + +## Layout + +``` +apibase/ settings, urls, procrastinate app +core/ healthcheck, pagination, unified error shape +users/ custom User (mobile is the identifier), OtpCode, auth endpoints +``` + +## Run it + +From the repo root (`docker compose up` starts backend + database + frontend): + +```sh +cp backend/.env.example backend/.env +docker compose up --build +docker compose exec backend python manage.py createsuperuser # asks for a mobile number +``` + +The API is on `http://localhost:8000`, the admin on `http://localhost:8000/admin/`, +and Swagger (DEBUG only) on `http://localhost:8000/api/docs/`. + +Without Docker: + +```sh +python -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +POSTGRES_HOST=localhost python manage.py migrate +POSTGRES_HOST=localhost python manage.py runserver +``` + +## Tests + +`apibase/settings_test.py` turns off throttling and uses a fast password hasher. +It still runs on postgres, because procrastinate's migrations are postgres-only. + +```sh +python manage.py test --settings=apibase.settings_test +``` + +## Authentication + +Everything starts with the mobile number. One request decides which second box +the SPA renders: + +``` +POST /api/auth/start/ {"mobile": "7712345"} + + -> {"next": "password", "mobile": "+9607712345"} + the account signs in with a password + + -> {"next": "otp", "mobile": ..., "expires_at": ..., + "resend_available_at": ..., "code_length": 6} + a code was sent by SMS +``` + +**`start` never says whether a number has an account.** Every number that gets +a code gets the same response, so the endpoint can't be used to enumerate +members. That answer comes only after the code is confirmed: + +``` +POST /api/auth/login/password/ {"mobile", "password"} + -> {"next": "dashboard", "token", "expiry", "user"} + +POST /api/auth/verify/ {"mobile", "code"} + -> {"next": "dashboard", "token", "expiry", "user"} the account signs in + -> {"next": "register", "registration_token", "mobile", "expires_at"} + no account: go register + +POST /api/auth/otp/resend/ {"mobile"} +GET /api/auth/me/ Authorization: Token +POST /api/auth/logout/ Authorization: Token +``` + +A disabled account is also only reported at `verify/`, for the same reason. + +Which method an account uses is `User.auth_method` (`otp` by default, or +`password`). An account set to `password` with no usable password falls back to +OTP, so nobody gets locked out — see `User.effective_auth_method`. + +Numbers are normalised to E.164 (`+960XXXXXXX`) at the serializer, so +`7712345`, `960 771 2345` and `+9607712345` are all the same account. + +Codes are 6 digits, stored only as a hash, single-use, valid for +`OTP_TTL_SECONDS` (5 min), at most `OTP_MAX_ATTEMPTS` (5) guesses, with a +`OTP_RESEND_COOLDOWN_SECONDS` (60s) resend cooldown on top of per-IP throttles. +Issuing a new code invalidates the outstanding one. + +### SMS + +`users/sms.py` posts to the SAR Link gateway: + +``` +POST {SMS_API_URL} # https://smsapi.sarlink.net/api/sms/send +X-API-Key: {SMS_API_KEY} +{"to": "+9607712345", "text": "..."} +``` + +With `SMS_API_KEY` empty the message is written to the log instead of being +sent, so every flow works in dev — the code is in the backend log. The real key +belongs in `backend/.env` (gitignored), never in `.env.example`. + +### Errors + +Every error has the same shape, and the SPA branches on `code`: + +```json +{"detail": "That code is not correct.", "code": "invalid_code", "attempts_left": 4} +``` + +## Registration + +`verify/` returns a `registration_token` when the number has no account: proof +that the number was confirmed by SMS, good for +`REGISTRATION_TICKET_TTL_SECONDS` (30 min) and redeemable once. The form then +posts it back: + +``` +POST /api/auth/register/ +{ + "registration_token": "...", + "full_name": "Mariyam Ibrahim", + "idnumber": "A123456", + "date_of_birth": "1998-02-11", + "atoll": 1, + "island": 1, + "terms_accepted": true, + "policy_accepted": true +} +-> 201 {"status": "pending", "mobile", "full_name", "detail"} +``` + +The mobile number is **not** read from the form - it comes from the ticket, so +the account always gets a number the applicant proved they control and the +prefilled field can't be tampered with. Both agreements must be `true`, and the +island must belong to the chosen atoll. + +Registering does **not** sign anyone in and does **not** produce a usable +account: the row is created with `status = "pending"`, no password, and +`auth_method = "otp"`. The applicant can sign in with an SMS code to watch the +status, but nothing is provisioned until an admin approves. + +### Approving + +In the Django admin, filter `status = pending`, review, then use the +**Approve selected registrations** / **Reject selected registrations** actions. +Both record `reviewed_at`/`reviewed_by` and text the applicant. In code: +`user.approve(reviewer=admin)` / `user.reject(reviewer=admin, reason="...")`. + +## Locations + +The registration form's atoll/island dropdowns come from the database: + +``` +GET /api/locations/atolls/ # public, islands nested +``` + +Seed data lives in `locations/seed.py` (currently just Faafu -> +Dharanboodhoo) and is applied by migration `locations/0002_seed_locations`, so +a fresh database has it. To re-apply after editing: + +```sh +python manage.py seed_locations # idempotent +``` + +Anything else is managed in the admin. + +## Not built yet + +Devices, billing, password self-service, and the RADIUS access-control +integration. diff --git a/backend/apibase/__init__.py b/backend/apibase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apibase/asgi.py b/backend/apibase/asgi.py new file mode 100644 index 0000000..a3b7e49 --- /dev/null +++ b/backend/apibase/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for apibase project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings') + +application = get_asgi_application() diff --git a/backend/apibase/settings.py b/backend/apibase/settings.py new file mode 100644 index 0000000..9fdffaf --- /dev/null +++ b/backend/apibase/settings.py @@ -0,0 +1,218 @@ +"""Django settings for the SAR Link portal API.""" + +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env( + DJANGO_DEBUG=(bool, True), + ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1", "backend"]), + CORS_ALLOWED_ORIGINS=(list, ["http://localhost:5173"]), + CSRF_TRUSTED_ORIGINS=(list, []), + SECURE_SSL_REDIRECT=(bool, False), + SECURE_HSTS_SECONDS=(int, 0), + SECRET_KEY=(str, "insecure-dev-key-change-me"), + POSTGRES_DATABASE=(str, "sarlink"), + POSTGRES_USER=(str, "sarlink"), + POSTGRES_PASSWORD=(str, "changeme"), + POSTGRES_HOST=(str, "database"), + POSTGRES_PORT=(int, 5432), + SMS_API_URL=(str, "https://smsapi.sarlink.net/api/sms/send"), + SMS_API_KEY=(str, ""), + OTP_TTL_SECONDS=(int, 300), + OTP_MAX_ATTEMPTS=(int, 5), + OTP_RESEND_COOLDOWN_SECONDS=(int, 60), + REGISTRATION_TICKET_TTL_SECONDS=(int, 1800), + FRONTEND_URL=(str, "http://localhost:5173"), +) +environ.Env.read_env(BASE_DIR / ".env") + +SECRET_KEY = env("SECRET_KEY") +DEBUG = env("DJANGO_DEBUG") +ALLOWED_HOSTS = env("ALLOWED_HOSTS") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "whitenoise.runserver_nostatic", + "django.contrib.staticfiles", + # third party + "rest_framework", + "knox", + "django_filters", + "corsheaders", + "procrastinate.contrib.django", + # applications + "core", + "locations", + "users", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "apibase.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "apibase.wsgi.application" +ASGI_APPLICATION = "apibase.asgi.application" + +# ----------------------------------------------------------------------------- +# Database +# ----------------------------------------------------------------------------- +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("POSTGRES_DATABASE"), + "USER": env("POSTGRES_USER"), + "PASSWORD": env("POSTGRES_PASSWORD"), + "HOST": env("POSTGRES_HOST"), + "PORT": env("POSTGRES_PORT"), + } +} +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# ----------------------------------------------------------------------------- +# Auth +# ----------------------------------------------------------------------------- +AUTH_USER_MODEL = "users.User" + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# Knox: the token itself is only ever returned once, at login. +REST_KNOX = { + "TOKEN_TTL": None, # tokens live until logout + "TOKEN_LIMIT_PER_USER": None, + "AUTO_REFRESH": False, +} + +# ----------------------------------------------------------------------------- +# DRF +# ----------------------------------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ["knox.auth.TokenAuthentication"], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_PAGINATION_CLASS": "core.pagination.DefaultPagination", + "PAGE_SIZE": 25, + "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"], + "DEFAULT_THROTTLE_RATES": { + # Unauthenticated auth endpoints, keyed per IP. + "auth_start": "30/hour", + "auth_login": "20/hour", + "otp_request": "10/hour", + "register": "10/hour", + }, + "EXCEPTION_HANDLER": "core.exceptions.exception_handler", +} + +if DEBUG: + INSTALLED_APPS.append("drf_spectacular") + REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" + +SPECTACULAR_SETTINGS = { + "TITLE": "SAR Link Portal API", + "VERSION": "2.0.0", + "SERVE_INCLUDE_SCHEMA": False, +} + +# ----------------------------------------------------------------------------- +# CORS / CSRF - the SPA is served from a different origin in dev +# ----------------------------------------------------------------------------- +CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS") +CORS_ALLOW_CREDENTIALS = False +CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS") + +# ----------------------------------------------------------------------------- +# i18n / tz +# ----------------------------------------------------------------------------- +LANGUAGE_CODE = "en-us" +TIME_ZONE = "Indian/Maldives" +USE_I18N = True +USE_TZ = True + +# ----------------------------------------------------------------------------- +# Static / media +# ----------------------------------------------------------------------------- +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" + }, +} + +# ----------------------------------------------------------------------------- +# Background tasks (procrastinate) +# ----------------------------------------------------------------------------- +PROCRASTINATE_APP = "apibase.tasks.app" + +# ----------------------------------------------------------------------------- +# SMS / OTP +# ----------------------------------------------------------------------------- +SMS_API_URL = env("SMS_API_URL") +SMS_API_KEY = env("SMS_API_KEY") +OTP_TTL_SECONDS = env("OTP_TTL_SECONDS") +OTP_MAX_ATTEMPTS = env("OTP_MAX_ATTEMPTS") +OTP_RESEND_COOLDOWN_SECONDS = env("OTP_RESEND_COOLDOWN_SECONDS") +# How long a verified number stays redeemable for the registration form. +REGISTRATION_TICKET_TTL_SECONDS = env("REGISTRATION_TICKET_TTL_SECONDS") +FRONTEND_URL = env("FRONTEND_URL") + +# ----------------------------------------------------------------------------- +# Production hardening +# ----------------------------------------------------------------------------- +if not DEBUG: + SECURE_SSL_REDIRECT = env("SECURE_SSL_REDIRECT") + SECURE_HSTS_SECONDS = env("SECURE_HSTS_SECONDS") + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "simple": {"format": "{levelname} {asctime} {name} {message}", "style": "{"}, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "simple"}, + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} diff --git a/backend/apibase/settings_test.py b/backend/apibase/settings_test.py new file mode 100644 index 0000000..bb6797d --- /dev/null +++ b/backend/apibase/settings_test.py @@ -0,0 +1,19 @@ +"""Test settings. + +Same postgres engine as production (procrastinate's migrations are +postgres-only), with throttling off and a cheap password hasher so the auth +tests aren't dominated by bcrypt. + + python manage.py test --settings=apibase.settings_test +""" + +from .settings import * # noqa: F401,F403 +from .settings import DATABASES, REST_FRAMEWORK, env + +DATABASES["default"]["HOST"] = env("POSTGRES_HOST", default="localhost") + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = dict.fromkeys( + REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"], None +) + +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] diff --git a/backend/apibase/tasks.py b/backend/apibase/tasks.py new file mode 100644 index 0000000..b61a3dc --- /dev/null +++ b/backend/apibase/tasks.py @@ -0,0 +1,5 @@ +"""Procrastinate app - postgres-backed background tasks, no broker.""" + +from procrastinate.contrib.django import app + +__all__ = ["app"] diff --git a/backend/apibase/urls.py b/backend/apibase/urls.py new file mode 100644 index 0000000..fb655d3 --- /dev/null +++ b/backend/apibase/urls.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("core.urls")), + path("api/auth/", include("users.urls")), + path("api/locations/", include("locations.urls")), +] + +if settings.DEBUG: + from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + + urlpatterns += [ + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + ] + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/apibase/wsgi.py b/backend/apibase/wsgi.py new file mode 100644 index 0000000..ea036f8 --- /dev/null +++ b/backend/apibase/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for apibase project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings') + +application = get_wsgi_application() diff --git a/backend/compose.yml b/backend/compose.yml new file mode 100644 index 0000000..81f567a --- /dev/null +++ b/backend/compose.yml @@ -0,0 +1,58 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile + hostname: backend + command: > + sh -c "python manage.py migrate --noinput && + python manage.py runserver 0.0.0.0:8000" + volumes: + - .:/app + ports: + - "8000:8000" + env_file: + - path: .env + required: false + environment: + POSTGRES_HOST: database + depends_on: + database: + condition: service_healthy + + # Postgres-backed background tasks. Same image, different entrypoint. + worker: + build: + context: . + dockerfile: Dockerfile + command: python manage.py procrastinate worker + volumes: + - .:/app + env_file: + - path: .env + required: false + environment: + POSTGRES_HOST: database + depends_on: + backend: + condition: service_started + + database: + image: postgres:16 + hostname: database + environment: + POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink} + POSTGRES_USER: ${POSTGRES_USER:-sarlink} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-sarlink}"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + pgdata: diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..c0ce093 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py new file mode 100644 index 0000000..ba41ffa --- /dev/null +++ b/backend/core/exceptions.py @@ -0,0 +1,32 @@ +"""A single response shape for every error the API returns. + + {"detail": "...", "code": "...", "errors": {"field": ["..."]}} + +`errors` is only present for validation failures. The SPA reads `code` to +branch (e.g. `registration_required`) and `detail` to show a message. +""" + +from rest_framework import exceptions +from rest_framework.views import exception_handler as drf_exception_handler + + +def exception_handler(exc, context): + response = drf_exception_handler(exc, context) + if response is None: + return None + + code = getattr(exc, "default_code", "error") + data = response.data + + if isinstance(exc, exceptions.ValidationError): + detail = "The submitted data was invalid." + if isinstance(data, dict): + non_field = data.get("detail") or data.get("non_field_errors") + if non_field: + detail = non_field[0] if isinstance(non_field, list) else str(non_field) + response.data = {"detail": str(detail), "code": code, "errors": data} + return response + + detail = data.get("detail") if isinstance(data, dict) else data + response.data = {"detail": str(detail), "code": code} + return response diff --git a/backend/core/pagination.py b/backend/core/pagination.py new file mode 100644 index 0000000..5373319 --- /dev/null +++ b/backend/core/pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import PageNumberPagination + + +class DefaultPagination(PageNumberPagination): + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/backend/core/tests/__init__.py b/backend/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..cebdf54 --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import healthcheck + +urlpatterns = [ + path("health/", healthcheck, name="healthcheck"), +] diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 0000000..d152bc0 --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,17 @@ +from django.db import connection +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.response import Response + + +@api_view(["GET"]) +@authentication_classes([]) +@permission_classes([]) +def healthcheck(request): + """Liveness + database reachability, for compose/nginx health probes.""" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + database = "up" + except Exception: # pragma: no cover - reported, not raised + database = "down" + return Response({"status": "ok", "database": database}) diff --git a/backend/justfile b/backend/justfile new file mode 100644 index 0000000..881736d --- /dev/null +++ b/backend/justfile @@ -0,0 +1,26 @@ +default: + just --list + +# --- dev --- +run: + python manage.py runserver 0.0.0.0:8000 +worker: + python manage.py procrastinate worker +shell: + python manage.py shell + +# --- database --- +migrate: + python manage.py migrate +migrations: + python manage.py makemigrations +superuser: + python manage.py createsuperuser + +# --- quality --- +test: + python manage.py test --settings=apibase.settings_test +lint: + ruff check . +fmt: + ruff format . diff --git a/backend/locations/__init__.py b/backend/locations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/locations/admin.py b/backend/locations/admin.py new file mode 100644 index 0000000..fc4e683 --- /dev/null +++ b/backend/locations/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from .models import Atoll, Island + + +class IslandInline(admin.TabularInline): + model = Island + extra = 0 + + +@admin.register(Atoll) +class AtollAdmin(admin.ModelAdmin): + list_display = ["name", "code", "is_active"] + search_fields = ["name", "code"] + inlines = [IslandInline] + + +@admin.register(Island) +class IslandAdmin(admin.ModelAdmin): + list_display = ["name", "atoll", "is_active"] + list_filter = ["atoll", "is_active"] + search_fields = ["name"] diff --git a/backend/locations/api.py b/backend/locations/api.py new file mode 100644 index 0000000..e8bba0b --- /dev/null +++ b/backend/locations/api.py @@ -0,0 +1,17 @@ +from rest_framework.generics import ListAPIView +from rest_framework.permissions import AllowAny + +from .models import Atoll +from .serializers import AtollSerializer + + +class AtollListView(ListAPIView): + """Public: the registration form needs this before anyone has a token.""" + + authentication_classes = [] + permission_classes = [AllowAny] + serializer_class = AtollSerializer + pagination_class = None + + def get_queryset(self): + return Atoll.objects.filter(is_active=True).prefetch_related("islands") diff --git a/backend/locations/apps.py b/backend/locations/apps.py new file mode 100644 index 0000000..caad8f9 --- /dev/null +++ b/backend/locations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LocationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "locations" diff --git a/backend/locations/management/__init__.py b/backend/locations/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/locations/management/commands/__init__.py b/backend/locations/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/locations/management/commands/seed_locations.py b/backend/locations/management/commands/seed_locations.py new file mode 100644 index 0000000..ca1097b --- /dev/null +++ b/backend/locations/management/commands/seed_locations.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand + +from locations.models import Atoll, Island +from locations.seed import seed + + +class Command(BaseCommand): + help = "Create the atolls and islands SAR Link serves (idempotent)." + + def handle(self, *args, **options): + atolls, islands = seed(Atoll, Island) + self.stdout.write( + self.style.SUCCESS( + f"Locations seeded: {atolls} atoll(s), {islands} island(s) created." + ) + ) diff --git a/backend/locations/migrations/0001_initial.py b/backend/locations/migrations/0001_initial.py new file mode 100644 index 0000000..a996a36 --- /dev/null +++ b/backend/locations/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2026-09-21 19:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Atoll', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('code', models.CharField(blank=True, max_length=8)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Island', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('atoll', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='islands', to='locations.atoll')), + ], + options={ + 'ordering': ['name'], + 'constraints': [models.UniqueConstraint(fields=('atoll', 'name'), name='unique_island_name_per_atoll')], + }, + ), + ] diff --git a/backend/locations/migrations/0002_seed_locations.py b/backend/locations/migrations/0002_seed_locations.py new file mode 100644 index 0000000..a088d7d --- /dev/null +++ b/backend/locations/migrations/0002_seed_locations.py @@ -0,0 +1,32 @@ +from django.db import migrations + +from locations.seed import seed + + +def seed_locations(apps, schema_editor): + seed(apps.get_model("locations", "Atoll"), apps.get_model("locations", "Island")) + + +def unseed_locations(apps, schema_editor): + """Only removes rows nothing references.""" + Atoll = apps.get_model("locations", "Atoll") + Island = apps.get_model("locations", "Island") + from locations.seed import ATOLLS + + for entry in ATOLLS: + Island.objects.filter( + atoll__name=entry["name"], name__in=entry["islands"], users__isnull=True + ).delete() + Atoll.objects.filter( + name=entry["name"], islands__isnull=True, users__isnull=True + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("locations", "0001_initial"), + # Islands/atolls are referenced by users; keep the tables in step. + ("users", "0001_initial"), + ] + + operations = [migrations.RunPython(seed_locations, unseed_locations)] diff --git a/backend/locations/migrations/__init__.py b/backend/locations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/locations/models.py b/backend/locations/models.py new file mode 100644 index 0000000..66c805a --- /dev/null +++ b/backend/locations/models.py @@ -0,0 +1,31 @@ +from django.db import models + + +class Atoll(models.Model): + name = models.CharField(max_length=100, unique=True) + # Maldivian administrative code, e.g. "F" for Faafu. + code = models.CharField(max_length=8, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + +class Island(models.Model): + atoll = models.ForeignKey(Atoll, on_delete=models.PROTECT, related_name="islands") + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + fields=["atoll", "name"], name="unique_island_name_per_atoll" + ) + ] + + def __str__(self): + return f"{self.name}, {self.atoll.name}" diff --git a/backend/locations/seed.py b/backend/locations/seed.py new file mode 100644 index 0000000..aa03059 --- /dev/null +++ b/backend/locations/seed.py @@ -0,0 +1,37 @@ +"""Seed data for atolls and islands. + +Only the areas SAR Link actually serves are listed. Add more here (or in the +admin) as coverage grows; `seed()` is idempotent, so re-running is safe. +""" + +ATOLLS = [ + { + "name": "Faafu", + "code": "F", + "islands": ["Dharanboodhoo"], + }, +] + + +def seed(atoll_model, island_model) -> tuple[int, int]: + """Create any missing atolls/islands. Returns (atolls, islands) created. + + Takes the models as arguments so both the management command and the data + migration can call it, the latter with historical models. + """ + atolls_created = 0 + islands_created = 0 + + for entry in ATOLLS: + atoll, created = atoll_model.objects.get_or_create( + name=entry["name"], defaults={"code": entry.get("code", "")} + ) + atolls_created += int(created) + + for island_name in entry["islands"]: + _, created = island_model.objects.get_or_create( + atoll=atoll, name=island_name + ) + islands_created += int(created) + + return atolls_created, islands_created diff --git a/backend/locations/serializers.py b/backend/locations/serializers.py new file mode 100644 index 0000000..a49a094 --- /dev/null +++ b/backend/locations/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from .models import Atoll, Island + + +class IslandSerializer(serializers.ModelSerializer): + class Meta: + model = Island + fields = ["id", "name", "atoll"] + + +class AtollSerializer(serializers.ModelSerializer): + """Atolls with their islands nested - the registration form needs both.""" + + islands = serializers.SerializerMethodField() + + class Meta: + model = Atoll + fields = ["id", "name", "code", "islands"] + + def get_islands(self, obj) -> list[dict]: + islands = [island for island in obj.islands.all() if island.is_active] + return IslandSerializer(islands, many=True).data diff --git a/backend/locations/tests/__init__.py b/backend/locations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/locations/urls.py b/backend/locations/urls.py new file mode 100644 index 0000000..2a3a1ce --- /dev/null +++ b/backend/locations/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .api import AtollListView + +urlpatterns = [ + path("atolls/", AtollListView.as_view(), name="atoll-list"), +] diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..b5729a6 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'apibase.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..685398c --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,11 @@ +[tool.ruff] +line-length = 90 +target-version = "py312" +exclude = [".venv", "*/migrations/*"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "DJ"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["apibase", "core", "users"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8ad161e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,26 @@ +# --- core --- +django==5.2.7 +djangorestframework==3.16.1 +django-rest-knox==5.0.2 +django-filter==25.2 +django-cors-headers==4.9.0 +django-environ==0.12.0 + +# --- database --- +psycopg[binary]==3.2.10 + +# --- background tasks (postgres-backed, no broker) --- +procrastinate[django]==3.6.0 + +# --- serving / static --- +gunicorn==23.0.0 +whitenoise==6.11.0 + +# --- integrations --- +requests==2.32.5 + +# --- api docs (DEBUG only) --- +drf-spectacular==0.28.0 + +# --- dev tooling --- +ruff==0.14.0 diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/admin.py b/backend/users/admin.py new file mode 100644 index 0000000..9873523 --- /dev/null +++ b/backend/users/admin.py @@ -0,0 +1,141 @@ +from django.contrib import admin, messages +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from .models import OtpCode, RegistrationTicket, User +from .sms import send_registration_approved, send_registration_rejected + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + ordering = ["-date_joined"] + list_display = [ + "mobile", + "full_name", + "status", + "idnumber", + "island", + "auth_method", + "date_joined", + ] + list_filter = ["status", "auth_method", "atoll", "is_staff", "is_active"] + search_fields = ["mobile", "full_name", "email", "idnumber"] + readonly_fields = [ + "date_joined", + "updated_at", + "last_login", + "terms_accepted_at", + "policy_accepted_at", + "reviewed_at", + "reviewed_by", + ] + autocomplete_fields = ["atoll", "island"] + actions = ["approve_registrations", "reject_registrations"] + fieldsets = [ + (None, {"fields": ["mobile", "password"]}), + ( + "Applicant", + { + "fields": [ + "full_name", + "email", + "idnumber", + "date_of_birth", + "atoll", + "island", + ] + }, + ), + ( + "Registration review", + { + "fields": [ + "status", + "rejection_reason", + "terms_accepted_at", + "policy_accepted_at", + "reviewed_at", + "reviewed_by", + ] + }, + ), + ("Portal", {"fields": ["auth_method", "mobile_verified"]}), + ( + "Permissions", + { + "fields": [ + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ] + }, + ), + ("Dates", {"fields": ["last_login", "date_joined", "updated_at"]}), + ] + add_fieldsets = [ + ( + None, + { + "classes": ["wide"], + "fields": [ + "mobile", + "full_name", + "auth_method", + "password1", + "password2", + ], + }, + ), + ] + + @admin.action(description="Approve selected registrations") + def approve_registrations(self, request, queryset): + pending = queryset.exclude(status=User.Status.APPROVED) + for user in pending: + user.approve(reviewer=request.user) + send_registration_approved(user.mobile) + self.message_user( + request, f"Approved {pending.count()} registration(s).", messages.SUCCESS + ) + + @admin.action(description="Reject selected registrations") + def reject_registrations(self, request, queryset): + pending = queryset.exclude(status=User.Status.REJECTED) + for user in pending: + user.reject(reviewer=request.user) + send_registration_rejected(user.mobile, user.rejection_reason) + self.message_user( + request, f"Rejected {pending.count()} registration(s).", messages.WARNING + ) + + +@admin.register(OtpCode) +class OtpCodeAdmin(admin.ModelAdmin): + list_display = [ + "mobile", + "purpose", + "attempts", + "created_at", + "expires_at", + "consumed_at", + ] + list_filter = ["purpose"] + search_fields = ["mobile"] + readonly_fields = [ + "user", + "mobile", + "purpose", + "code_hash", + "attempts", + "created_at", + "expires_at", + "consumed_at", + ] + + +@admin.register(RegistrationTicket) +class RegistrationTicketAdmin(admin.ModelAdmin): + list_display = ["mobile", "created_at", "expires_at", "consumed_at"] + search_fields = ["mobile"] + readonly_fields = ["key", "mobile", "created_at", "expires_at", "consumed_at"] diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/backend/users/managers.py b/backend/users/managers.py new file mode 100644 index 0000000..dec1bde --- /dev/null +++ b/backend/users/managers.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import BaseUserManager + +from .mobile import normalize_mobile + + +class UserManager(BaseUserManager): + """Users are identified by mobile number, not username.""" + + use_in_migrations = True + + def _create_user(self, mobile, password=None, **extra_fields): + if not mobile: + raise ValueError("A mobile number is required.") + user = self.model(mobile=normalize_mobile(mobile), **extra_fields) + if password: + user.set_password(password) + else: + user.set_unusable_password() + user.save(using=self._db) + return user + + def create_user(self, mobile, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(mobile, password, **extra_fields) + + def create_superuser(self, mobile, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("is_active", True) + extra_fields.setdefault("status", self.model.Status.APPROVED) + extra_fields.setdefault("auth_method", self.model.AuthMethod.PASSWORD) + if not extra_fields["is_staff"] or not extra_fields["is_superuser"]: + raise ValueError("Superusers must have is_staff and is_superuser set.") + return self._create_user(mobile, password, **extra_fields) + + def get_by_natural_key(self, username): + return self.get(mobile=normalize_mobile(username)) diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 0000000..0c8400f --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.7 on 2026-09-21 19:05 + +import django.db.models.deletion +import django.utils.timezone +import users.managers +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('locations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='RegistrationTicket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(db_index=True, max_length=64, unique=True)), + ('mobile', models.CharField(db_index=True, max_length=16)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField()), + ('consumed_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('mobile', models.CharField(db_index=True, max_length=16, unique=True)), + ('full_name', models.CharField(blank=True, max_length=255)), + ('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)), + ('document_type', models.CharField(choices=[('id_card', 'ID card'), ('passport', 'Passport'), ('work_permit', 'Work permit')], default='id_card', max_length=16)), + ('document_number', models.CharField(blank=True, db_index=True, max_length=32)), + ('date_of_birth', models.DateField(blank=True, null=True)), + ('auth_method', models.CharField(choices=[('otp', 'SMS one-time code'), ('password', 'Password')], default='otp', max_length=16)), + ('mobile_verified', models.BooleanField(default=False)), + ('status', models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)), + ('terms_accepted_at', models.DateTimeField(blank=True, null=True)), + ('policy_accepted_at', models.DateTimeField(blank=True, null=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('rejection_reason', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('atoll', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.atoll')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('island', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.island')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_users', to=settings.AUTH_USER_MODEL)), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'ordering': ['-date_joined'], + }, + managers=[ + ('objects', users.managers.UserManager()), + ], + ), + migrations.CreateModel( + name='OtpCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mobile', models.CharField(db_index=True, max_length=16)), + ('purpose', models.CharField(choices=[('login', 'Login'), ('registration', 'Registration')], default='login', max_length=16)), + ('code_hash', models.CharField(max_length=128)), + ('attempts', models.PositiveSmallIntegerField(default=0)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField()), + ('consumed_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='otp_codes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['mobile', 'purpose', '-created_at'], name='users_otpco_mobile_0ef94c_idx')], + }, + ), + ] diff --git a/backend/users/migrations/0002_idnumber.py b/backend/users/migrations/0002_idnumber.py new file mode 100644 index 0000000..4ecb546 --- /dev/null +++ b/backend/users/migrations/0002_idnumber.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.RemoveField(model_name="user", name="document_type"), + migrations.RenameField( + model_name="user", old_name="document_number", new_name="idnumber" + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/mobile.py b/backend/users/mobile.py new file mode 100644 index 0000000..2162d2e --- /dev/null +++ b/backend/users/mobile.py @@ -0,0 +1,29 @@ +"""Maldives mobile number normalisation. + +Everything past the serializer layer deals in E.164 (`+960XXXXXXX`) so a +number is stored and looked up exactly one way. +""" + +import re + +from django.core.exceptions import ValidationError + +COUNTRY_CODE = "960" +LOCAL_LENGTH = 7 +# Maldives mobile prefixes are 7xx and 9xx. +LOCAL_RE = re.compile(r"^[79]\d{6}$") + + +def normalize_mobile(value: str) -> str: + """Return `value` as +960XXXXXXX, or raise ValidationError.""" + digits = re.sub(r"[\s()-]", "", str(value or "")).lstrip("+") + + if digits.startswith("00" + COUNTRY_CODE): + digits = digits[len("00" + COUNTRY_CODE) :] + elif digits.startswith(COUNTRY_CODE) and len(digits) > LOCAL_LENGTH: + digits = digits[len(COUNTRY_CODE) :] + + if not LOCAL_RE.match(digits): + raise ValidationError("Enter a valid Maldives mobile number.") + + return f"+{COUNTRY_CODE}{digits}" diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..476d657 --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,279 @@ +import secrets +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.db import models +from django.utils import timezone + +from .managers import UserManager +from .mobile import normalize_mobile + + +class User(AbstractBaseUser, PermissionsMixin): + """Portal account. The mobile number is the login identifier. + + A self-registered account starts at `status = PENDING` and only becomes + usable once an admin approves it - see `approve()` / `reject()`. + """ + + class AuthMethod(models.TextChoices): + OTP = "otp", "SMS one-time code" + PASSWORD = "password", "Password" + + class Status(models.TextChoices): + PENDING = "pending", "Pending approval" + APPROVED = "approved", "Approved" + REJECTED = "rejected", "Rejected" + + mobile = models.CharField(max_length=16, unique=True, db_index=True) + full_name = models.CharField(max_length=255, blank=True) + email = models.EmailField(blank=True, null=True, unique=True) + + # ID card, passport or work permit number. + idnumber = models.CharField(max_length=32, blank=True, db_index=True) + date_of_birth = models.DateField(null=True, blank=True) + + # Address + atoll = models.ForeignKey( + "locations.Atoll", + on_delete=models.PROTECT, + related_name="users", + null=True, + blank=True, + ) + island = models.ForeignKey( + "locations.Island", + on_delete=models.PROTECT, + related_name="users", + null=True, + blank=True, + ) + + # Which second step /auth/start/ asks the SPA to render. + auth_method = models.CharField( + max_length=16, choices=AuthMethod.choices, default=AuthMethod.OTP + ) + mobile_verified = models.BooleanField(default=False) + + # Registration review + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True + ) + terms_accepted_at = models.DateTimeField(null=True, blank=True) + policy_accepted_at = models.DateTimeField(null=True, blank=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + reviewed_by = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="reviewed_users", + null=True, + blank=True, + ) + rejection_reason = models.TextField(blank=True) + + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + date_joined = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + objects = UserManager() + + USERNAME_FIELD = "mobile" + REQUIRED_FIELDS = [] + + class Meta: + ordering = ["-date_joined"] + + def __str__(self): + return f"{self.full_name or 'Unnamed'} ({self.mobile})" + + def save(self, *args, **kwargs): + self.mobile = normalize_mobile(self.mobile) + super().save(*args, **kwargs) + + @property + def is_admin_user(self) -> bool: + """Admin surface gate: staff or superuser.""" + return self.is_staff or self.is_superuser + + @property + def is_approved(self) -> bool: + return self.status == self.Status.APPROVED + + def can_use_password_login(self) -> bool: + return ( + self.auth_method == self.AuthMethod.PASSWORD and self.has_usable_password() + ) + + @property + def effective_auth_method(self) -> str: + """`auth_method`, falling back to OTP if no password is actually set.""" + if self.can_use_password_login(): + return self.AuthMethod.PASSWORD + return self.AuthMethod.OTP + + @property + def address(self) -> str: + parts = [part for part in [self.island_id and self.island.name, self.atoll_id and self.atoll.name] if part] + return ", ".join(parts) + + def approve(self, reviewer=None) -> None: + self.status = self.Status.APPROVED + self.rejection_reason = "" + self.reviewed_at = timezone.now() + self.reviewed_by = reviewer + self.save( + update_fields=[ + "status", + "rejection_reason", + "reviewed_at", + "reviewed_by", + "updated_at", + ] + ) + + def reject(self, reviewer=None, reason: str = "") -> None: + self.status = self.Status.REJECTED + self.rejection_reason = reason + self.reviewed_at = timezone.now() + self.reviewed_by = reviewer + self.save( + update_fields=[ + "status", + "rejection_reason", + "reviewed_at", + "reviewed_by", + "updated_at", + ] + ) + + +def _generate_code() -> str: + return f"{secrets.randbelow(1_000_000):06d}" + + +class OtpCodeQuerySet(models.QuerySet): + def active(self): + return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now()) + + +class OtpCode(models.Model): + """A single-use SMS code. Only the hash of the code is stored.""" + + class Purpose(models.TextChoices): + LOGIN = "login", "Login" + REGISTRATION = "registration", "Registration" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="otp_codes", + null=True, + blank=True, + ) + mobile = models.CharField(max_length=16, db_index=True) + purpose = models.CharField( + max_length=16, choices=Purpose.choices, default=Purpose.LOGIN + ) + code_hash = models.CharField(max_length=128) + attempts = models.PositiveSmallIntegerField(default=0) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + consumed_at = models.DateTimeField(null=True, blank=True) + + objects = OtpCodeQuerySet.as_manager() + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["mobile", "purpose", "-created_at"])] + + def __str__(self): + return f"{self.purpose} code for {self.mobile}" + + @classmethod + def issue(cls, mobile: str, purpose: str, user=None) -> tuple["OtpCode", str]: + """Invalidate any outstanding codes and return (row, plaintext code).""" + cls.objects.filter( + mobile=mobile, purpose=purpose, consumed_at__isnull=True + ).update(consumed_at=timezone.now()) + + code = _generate_code() + otp = cls.objects.create( + user=user, + mobile=mobile, + purpose=purpose, + code_hash=make_password(code), + expires_at=timezone.now() + timedelta(seconds=settings.OTP_TTL_SECONDS), + ) + return otp, code + + @property + def is_expired(self) -> bool: + return self.expires_at <= timezone.now() + + @property + def is_exhausted(self) -> bool: + return self.attempts >= settings.OTP_MAX_ATTEMPTS + + @property + def resend_available_at(self): + return self.created_at + timedelta( + seconds=settings.OTP_RESEND_COOLDOWN_SECONDS + ) + + def verify(self, code: str) -> bool: + """Check `code`, counting the attempt. Consumes the row on success.""" + self.attempts += 1 + if check_password(str(code), self.code_hash): + self.consumed_at = timezone.now() + self.save(update_fields=["attempts", "consumed_at"]) + return True + self.save(update_fields=["attempts"]) + return False + + +class RegistrationTicketQuerySet(models.QuerySet): + def active(self): + return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now()) + + +class RegistrationTicket(models.Model): + """Proof that a mobile number was verified by SMS, redeemable once. + + Issued when a registration code is confirmed and required by the + registration submit, so the form can't be posted for a number the caller + never proved they control. + """ + + key = models.CharField(max_length=64, unique=True, db_index=True) + mobile = models.CharField(max_length=16, db_index=True) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + consumed_at = models.DateTimeField(null=True, blank=True) + + objects = RegistrationTicketQuerySet.as_manager() + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"registration ticket for {self.mobile}" + + @classmethod + def issue(cls, mobile: str) -> "RegistrationTicket": + cls.objects.filter(mobile=mobile, consumed_at__isnull=True).update( + consumed_at=timezone.now() + ) + return cls.objects.create( + key=secrets.token_urlsafe(32), + mobile=mobile, + expires_at=timezone.now() + + timedelta(seconds=settings.REGISTRATION_TICKET_TTL_SECONDS), + ) + + def consume(self) -> None: + self.consumed_at = timezone.now() + self.save(update_fields=["consumed_at"]) diff --git a/backend/users/serializers.py b/backend/users/serializers.py new file mode 100644 index 0000000..cc4ba9b --- /dev/null +++ b/backend/users/serializers.py @@ -0,0 +1,170 @@ +from datetime import date + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from locations.models import Atoll, Island + +from .mobile import normalize_mobile +from .models import RegistrationTicket, User + +MAX_AGE_YEARS = 120 + + +class MobileField(serializers.CharField): + """Accepts 7712345 / 9607712345 / +960 771 2345 and stores +9607712345.""" + + def to_internal_value(self, data): + value = super().to_internal_value(data) + try: + return normalize_mobile(value) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) from exc + + +class AuthStartSerializer(serializers.Serializer): + mobile = MobileField() + + +class PasswordLoginSerializer(serializers.Serializer): + mobile = MobileField() + password = serializers.CharField(trim_whitespace=False, write_only=True) + + +class OtpVerifySerializer(serializers.Serializer): + mobile = MobileField() + code = serializers.RegexField(r"^\d{6}$", write_only=True) + + +class UserSerializer(serializers.ModelSerializer): + is_admin = serializers.BooleanField(source="is_admin_user", read_only=True) + has_password = serializers.SerializerMethodField() + atoll_name = serializers.CharField(source="atoll.name", default=None, read_only=True) + island_name = serializers.CharField( + source="island.name", default=None, read_only=True + ) + + class Meta: + model = User + fields = [ + "id", + "mobile", + "full_name", + "email", + "idnumber", + "date_of_birth", + "atoll", + "atoll_name", + "island", + "island_name", + "auth_method", + "status", + "rejection_reason", + "mobile_verified", + "is_admin", + "has_password", + "date_joined", + ] + read_only_fields = fields + + def get_has_password(self, obj) -> bool: + return obj.has_usable_password() + + +class RegistrationSerializer(serializers.Serializer): + """The registration form. + + The mobile number is not accepted from the client: it comes from the + `registration_token`, which is only issued after that number confirmed an + SMS code. So the number on the account is always one the applicant proved + they control, and the form's prefilled field can't be tampered with. + """ + + registration_token = serializers.CharField(write_only=True) + full_name = serializers.CharField(max_length=255) + idnumber = serializers.CharField(max_length=32) + date_of_birth = serializers.DateField() + atoll = serializers.PrimaryKeyRelatedField( + queryset=Atoll.objects.filter(is_active=True) + ) + island = serializers.PrimaryKeyRelatedField( + queryset=Island.objects.filter(is_active=True) + ) + terms_accepted = serializers.BooleanField() + policy_accepted = serializers.BooleanField() + + def validate_registration_token(self, value): + ticket = RegistrationTicket.objects.active().filter(key=value).first() + if ticket is None: + raise serializers.ValidationError( + "Your number needs to be verified again." + ) + return ticket + + def validate_full_name(self, value): + name = " ".join(value.split()) + if len(name) < 3: + raise serializers.ValidationError("Enter your full name.") + return name + + def validate_idnumber(self, value): + return value.strip().upper() + + def validate_date_of_birth(self, value): + today = date.today() + if value > today: + raise serializers.ValidationError("Date of birth can't be in the future.") + if value.year < today.year - MAX_AGE_YEARS: + raise serializers.ValidationError("Enter a valid date of birth.") + return value + + def validate_terms_accepted(self, value): + if not value: + raise serializers.ValidationError( + "You must agree to the terms and conditions." + ) + return value + + def validate_policy_accepted(self, value): + if not value: + raise serializers.ValidationError( + "You must confirm you understand the privacy policy." + ) + return value + + def validate(self, attrs): + island = attrs["island"] + if island.atoll_id != attrs["atoll"].pk: + raise serializers.ValidationError( + {"island": "That island isn't in the selected atoll."} + ) + + ticket = attrs["registration_token"] + if User.objects.filter(mobile=ticket.mobile).exists(): + raise serializers.ValidationError( + {"mobile": "An account already exists for this number."} + ) + return attrs + + @transaction.atomic + def create(self, validated_data): + ticket = validated_data["registration_token"] + now = timezone.now() + + user = User.objects.create_user( + mobile=ticket.mobile, + full_name=validated_data["full_name"], + idnumber=validated_data["idnumber"], + date_of_birth=validated_data["date_of_birth"], + atoll=validated_data["atoll"], + island=validated_data["island"], + auth_method=User.AuthMethod.OTP, + status=User.Status.PENDING, + mobile_verified=True, + terms_accepted_at=now, + policy_accepted_at=now, + ) + ticket.consume() + return user diff --git a/backend/users/sms.py b/backend/users/sms.py new file mode 100644 index 0000000..f2431fd --- /dev/null +++ b/backend/users/sms.py @@ -0,0 +1,73 @@ +"""SMS delivery via the SAR Link SMS gateway. + + POST {SMS_API_URL} + X-API-Key: {SMS_API_KEY} + {"to": "+9607712345", "text": "..."} + +With `SMS_API_URL`/`SMS_API_KEY` unset (dev) nothing is sent and the message is +logged instead, so the OTP flows stay usable without the gateway. +""" + +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +TIMEOUT_SECONDS = 10 + + +def send_sms(mobile: str, text: str) -> bool: + if not settings.SMS_API_URL or not settings.SMS_API_KEY: + logger.warning("SMS not configured; would send to %s: %s", mobile, text) + return False + + try: + response = requests.post( + settings.SMS_API_URL, + json={"to": mobile, "text": text}, + headers={ + "X-API-Key": settings.SMS_API_KEY, + "Content-Type": "application/json", + }, + timeout=TIMEOUT_SECONDS, + ) + response.raise_for_status() + except requests.RequestException: + logger.exception("Failed to send SMS to %s", mobile) + return False + + logger.info("Sent SMS to %s", mobile) + return True + + +def send_otp(mobile: str, code: str, purpose: str) -> bool: + minutes = max(1, settings.OTP_TTL_SECONDS // 60) + what = "registration" if purpose == "registration" else "login" + return send_sms( + mobile, + f"{code} is your SAR Link {what} code. It expires in {minutes} minutes.", + ) + + +def send_registration_submitted(mobile: str) -> bool: + return send_sms( + mobile, + "Thanks for registering with SAR Link. Your application is being " + "reviewed and we'll text you once it's approved.", + ) + + +def send_registration_approved(mobile: str) -> bool: + return send_sms( + mobile, + f"Your SAR Link registration is approved. Sign in at {settings.FRONTEND_URL}", + ) + + +def send_registration_rejected(mobile: str, reason: str = "") -> bool: + tail = f" Reason: {reason}" if reason else "" + return send_sms( + mobile, f"Your SAR Link registration could not be approved.{tail}" + ) diff --git a/backend/users/tests/__init__.py b/backend/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/tests/test_auth_flow.py b/backend/users/tests/test_auth_flow.py new file mode 100644 index 0000000..293e654 --- /dev/null +++ b/backend/users/tests/test_auth_flow.py @@ -0,0 +1,185 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from users.models import OtpCode, User + + +class AuthFlowTests(TestCase): + def setUp(self): + self.client = APIClient() + self.otp_user = User.objects.create_user(mobile="7712345", full_name="Otp User") + self.password_user = User.objects.create_user( + mobile="7798765", + full_name="Password User", + password="correct-horse-battery", + auth_method=User.AuthMethod.PASSWORD, + ) + + # --- step 1 --------------------------------------------------------- + def test_start_reports_password_method(self): + response = self.client.post(reverse("auth-start"), {"mobile": "7798765"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "password") + self.assertFalse(OtpCode.objects.exists()) + + def test_start_sends_a_code_for_otp_accounts(self): + response = self.client.post(reverse("auth-start"), {"mobile": "771 2345"}) + self.assertEqual(response.data["next"], "otp") + self.assertEqual(response.data["mobile"], "+9607712345") + self.assertEqual(OtpCode.objects.filter(user=self.otp_user).count(), 1) + + def test_start_does_not_say_whether_a_number_has_an_account(self): + known = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) + unknown = self.client.post(reverse("auth-start"), {"mobile": "7700000"}) + + self.assertEqual(unknown.status_code, known.status_code) + self.assertEqual(set(unknown.data), set(known.data)) + self.assertEqual(unknown.data["next"], "otp") + + def test_start_never_returns_a_masked_number(self): + for mobile in ["7798765", "7712345", "7700000"]: + with self.subTest(mobile=mobile): + response = self.client.post(reverse("auth-start"), {"mobile": mobile}) + self.assertNotIn("mobile_masked", response.data) + + def test_start_falls_back_to_a_code_when_no_password_is_set(self): + self.otp_user.auth_method = User.AuthMethod.PASSWORD + self.otp_user.save(update_fields=["auth_method"]) + response = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) + self.assertEqual(response.data["next"], "otp") + + def test_start_rejects_a_malformed_number(self): + response = self.client.post(reverse("auth-start"), {"mobile": "123"}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "invalid") + self.assertIn("mobile", response.data["errors"]) + + # --- step 2: password ---------------------------------------------- + def test_password_login_returns_a_token(self): + response = self.client.post( + reverse("auth-login-password"), + {"mobile": "7798765", "password": "correct-horse-battery"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "dashboard") + self.assertIn("token", response.data) + self.assertEqual(response.data["user"]["mobile"], "+9607798765") + + def test_password_login_rejects_a_wrong_password(self): + response = self.client.post( + reverse("auth-login-password"), + {"mobile": "7798765", "password": "nope"}, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "invalid_credentials") + + # --- step 2: code --------------------------------------------------- + def _issue_code(self, user=None): + target = user or self.otp_user + return OtpCode.issue( + mobile=target.mobile, purpose=OtpCode.Purpose.LOGIN, user=target + ) + + def test_verifying_a_code_signs_the_account_in(self): + _, code = self._issue_code() + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": code} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "dashboard") + self.assertIn("token", response.data) + self.otp_user.refresh_from_db() + self.assertTrue(self.otp_user.mobile_verified) + + def test_a_code_works_only_once(self): + _, code = self._issue_code() + self.client.post(reverse("auth-verify"), {"mobile": "7712345", "code": code}) + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": code} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "code_expired") + + def test_wrong_code_counts_attempts(self): + self._issue_code() + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "invalid_code") + self.assertEqual(response.data["attempts_left"], 4) + + def test_code_is_exhausted_after_max_attempts(self): + self._issue_code() + for _ in range(5): + self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} + ) + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": "000000"} + ) + self.assertEqual(response.status_code, 429) + self.assertEqual(response.data["code"], "code_exhausted") + + def test_issuing_a_new_code_invalidates_the_previous_one(self): + _, first = self._issue_code() + _, second = self._issue_code() + self.assertEqual(OtpCode.objects.active().count(), 1) + + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": first} + ) + self.assertEqual(response.status_code, 400) + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": second} + ) + self.assertEqual(response.status_code, 200) + + def test_a_disabled_account_is_told_only_after_verifying(self): + self.otp_user.is_active = False + self.otp_user.save(update_fields=["is_active"]) + + start = self.client.post(reverse("auth-start"), {"mobile": "7712345"}) + self.assertEqual(start.data["next"], "otp") + + _, code = self._issue_code() + response = self.client.post( + reverse("auth-verify"), {"mobile": "7712345", "code": code} + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data["code"], "account_disabled") + + # --- resend ---------------------------------------------------------- + def test_resend_is_rate_limited_by_the_cooldown(self): + self.client.post(reverse("auth-start"), {"mobile": "7712345"}) + response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7712345"}) + self.assertEqual(response.status_code, 429) + self.assertEqual(response.data["code"], "resend_cooldown") + + def test_resend_works_for_a_number_without_an_account(self): + response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7700000"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "otp") + self.assertTrue(OtpCode.objects.filter(mobile="+9607700000").exists()) + + def test_resend_does_not_reveal_that_an_account_uses_a_password(self): + response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7798765"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "otp") + self.assertFalse(OtpCode.objects.exists()) + + # --- session -------------------------------------------------------- + def test_me_requires_a_token(self): + self.assertEqual(self.client.get(reverse("auth-me")).status_code, 401) + + def test_me_returns_the_signed_in_account(self): + login = self.client.post( + reverse("auth-login-password"), + {"mobile": "7798765", "password": "correct-horse-battery"}, + ) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}") + response = self.client.get(reverse("auth-me")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["mobile"], "+9607798765") + self.assertFalse(response.data["is_admin"]) diff --git a/backend/users/tests/test_mobile.py b/backend/users/tests/test_mobile.py new file mode 100644 index 0000000..cfd6c94 --- /dev/null +++ b/backend/users/tests/test_mobile.py @@ -0,0 +1,17 @@ +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase + +from users.mobile import normalize_mobile + + +class NormalizeMobileTests(SimpleTestCase): + def test_accepts_every_way_a_number_is_typed(self): + for value in ["7712345", "+9607712345", "9607712345", "960 771 2345", "009607712345", "771-2345"]: + with self.subTest(value=value): + self.assertEqual(normalize_mobile(value), "+9607712345") + + def test_rejects_invalid_numbers(self): + for value in ["", "123", "1712345", "77123456", "abcdefg", None]: + with self.subTest(value=value): + with self.assertRaises(ValidationError): + normalize_mobile(value) \ No newline at end of file diff --git a/backend/users/tests/test_registration.py b/backend/users/tests/test_registration.py new file mode 100644 index 0000000..b456495 --- /dev/null +++ b/backend/users/tests/test_registration.py @@ -0,0 +1,250 @@ +from datetime import date, timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from locations.models import Atoll, Island +from users.models import OtpCode, RegistrationTicket, User + + +class RegistrationFlowTests(TestCase): + def setUp(self): + self.client = APIClient() + self.atoll = Atoll.objects.get(name="Faafu") + self.island = Island.objects.get(name="Dharanboodhoo") + self.other_atoll = Atoll.objects.create(name="Kaafu", code="K") + self.other_island = Island.objects.create(atoll=self.other_atoll, name="Male") + + # --- verifying the number ------------------------------------------- + def test_unknown_number_gets_a_code_without_being_told_anything(self): + response = self.client.post(reverse("auth-start"), {"mobile": "7700000"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "otp") + self.assertEqual(response.data["mobile"], "+9607700000") + + otp = OtpCode.objects.get(mobile="+9607700000") + self.assertEqual(otp.purpose, OtpCode.Purpose.REGISTRATION) + self.assertIsNone(otp.user) + + def test_verifying_an_unknown_number_hands_back_a_ticket(self): + code = self._send_registration_code("7700000") + response = self.client.post( + reverse("auth-verify"), {"mobile": "7700000", "code": code} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "register") + self.assertTrue(response.data["registration_token"]) + self.assertEqual(response.data["mobile"], "+9607700000") + self.assertNotIn("token", response.data) + + def test_verify_rejects_a_wrong_code(self): + self._send_registration_code("7700000") + response = self.client.post( + reverse("auth-verify"), {"mobile": "7700000", "code": "000000"} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "invalid_code") + self.assertFalse(RegistrationTicket.objects.exists()) + + def test_a_code_signs_in_an_account_created_in_the_meantime(self): + code = self._send_registration_code("7700000") + User.objects.create_user(mobile="7700000") + + response = self.client.post( + reverse("auth-verify"), {"mobile": "7700000", "code": code} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "dashboard") + self.assertFalse(RegistrationTicket.objects.exists()) + + # --- the form ------------------------------------------------------- + def test_registration_creates_a_pending_account(self): + token = self._verified_token("7700000") + response = self.client.post(reverse("auth-register"), self._form(token)) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["status"], "pending") + # No token: registering does not sign anyone in. + self.assertNotIn("token", response.data) + + user = User.objects.get(mobile="+9607700000") + self.assertEqual(user.status, User.Status.PENDING) + self.assertEqual(user.full_name, "Ahmed Ali") + self.assertEqual(user.idnumber, "WP123456") + self.assertEqual(user.date_of_birth, date(1995, 4, 17)) + self.assertEqual(user.island, self.island) + self.assertTrue(user.mobile_verified) + self.assertFalse(user.has_usable_password()) + self.assertIsNotNone(user.terms_accepted_at) + self.assertIsNotNone(user.policy_accepted_at) + + def test_the_number_comes_from_the_token_not_the_form(self): + token = self._verified_token("7700000") + payload = self._form(token) | {"mobile": "7711111"} + self.client.post(reverse("auth-register"), payload) + + self.assertTrue(User.objects.filter(mobile="+9607700000").exists()) + self.assertFalse(User.objects.filter(mobile="+9607711111").exists()) + + def test_a_token_works_only_once(self): + token = self._verified_token("7700000") + self.client.post(reverse("auth-register"), self._form(token)) + User.objects.all().delete() + + response = self.client.post(reverse("auth-register"), self._form(token)) + self.assertEqual(response.status_code, 400) + self.assertIn("registration_token", response.data["errors"]) + + def test_an_expired_token_is_refused(self): + token = self._verified_token("7700000") + RegistrationTicket.objects.filter(key=token).update( + expires_at=timezone.now() - timedelta(minutes=1) + ) + response = self.client.post(reverse("auth-register"), self._form(token)) + self.assertEqual(response.status_code, 400) + self.assertIn("registration_token", response.data["errors"]) + + def test_registration_requires_a_token(self): + payload = self._form("nope") + response = self.client.post(reverse("auth-register"), payload) + self.assertEqual(response.status_code, 400) + self.assertIn("registration_token", response.data["errors"]) + + def test_both_agreements_are_required(self): + for field in ["terms_accepted", "policy_accepted"]: + with self.subTest(field=field): + token = self._verified_token("7700000") + payload = self._form(token) | {field: False} + response = self.client.post(reverse("auth-register"), payload) + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.data["errors"]) + self.assertFalse(User.objects.exists()) + + def test_island_must_belong_to_the_selected_atoll(self): + token = self._verified_token("7700000") + payload = self._form(token) | {"island": self.other_island.pk} + response = self.client.post(reverse("auth-register"), payload) + self.assertEqual(response.status_code, 400) + self.assertIn("island", response.data["errors"]) + + def test_required_fields(self): + token = self._verified_token("7700000") + response = self.client.post( + reverse("auth-register"), {"registration_token": token} + ) + self.assertEqual(response.status_code, 400) + for field in [ + "full_name", + "idnumber", + "date_of_birth", + "atoll", + "island", + ]: + self.assertIn(field, response.data["errors"]) + + def test_future_date_of_birth_is_refused(self): + token = self._verified_token("7700000") + payload = self._form(token) | { + "date_of_birth": (date.today() + timedelta(days=1)).isoformat() + } + response = self.client.post(reverse("auth-register"), payload) + self.assertEqual(response.status_code, 400) + self.assertIn("date_of_birth", response.data["errors"]) + + def test_cannot_register_a_number_that_already_has_an_account(self): + token = self._verified_token("7700000") + User.objects.create_user(mobile="7700000") + response = self.client.post(reverse("auth-register"), self._form(token)) + self.assertEqual(response.status_code, 400) + self.assertIn("mobile", response.data["errors"]) + + # --- after submitting ------------------------------------------------ + def test_a_pending_applicant_can_sign_in_and_see_their_status(self): + token = self._verified_token("7700000") + self.client.post(reverse("auth-register"), self._form(token)) + + start = self.client.post(reverse("auth-start"), {"mobile": "7700000"}) + self.assertEqual(start.data["next"], "otp") + + otp = OtpCode.objects.active().filter(mobile="+9607700000").first() + self.assertIsNotNone(otp) + self.assertEqual(otp.purpose, OtpCode.Purpose.LOGIN) + + _, code = OtpCode.issue( + mobile="+9607700000", + purpose=OtpCode.Purpose.LOGIN, + user=User.objects.get(mobile="+9607700000"), + ) + response = self.client.post( + reverse("auth-verify"), {"mobile": "7700000", "code": code} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["next"], "dashboard") + self.assertEqual(response.data["user"]["status"], "pending") + + def test_approval_flips_the_status_and_records_the_reviewer(self): + admin = User.objects.create_superuser(mobile="7711111", password="x") + token = self._verified_token("7700000") + self.client.post(reverse("auth-register"), self._form(token)) + + user = User.objects.get(mobile="+9607700000") + user.approve(reviewer=admin) + + user.refresh_from_db() + self.assertEqual(user.status, User.Status.APPROVED) + self.assertTrue(user.is_approved) + self.assertEqual(user.reviewed_by, admin) + self.assertIsNotNone(user.reviewed_at) + + def test_rejection_records_the_reason(self): + token = self._verified_token("7700000") + self.client.post(reverse("auth-register"), self._form(token)) + + user = User.objects.get(mobile="+9607700000") + user.reject(reason="Document unreadable") + + user.refresh_from_db() + self.assertEqual(user.status, User.Status.REJECTED) + self.assertEqual(user.rejection_reason, "Document unreadable") + + # --- helpers --------------------------------------------------------- + def _send_registration_code(self, mobile: str) -> str: + otp, code = OtpCode.issue( + mobile=f"+960{mobile}", purpose=OtpCode.Purpose.REGISTRATION + ) + self.assertIsNotNone(otp) + return code + + def _verified_token(self, mobile: str) -> str: + code = self._send_registration_code(mobile) + response = self.client.post( + reverse("auth-verify"), {"mobile": mobile, "code": code} + ) + self.assertEqual(response.status_code, 200, response.data) + return response.data["registration_token"] + + def _form(self, token: str) -> dict: + return { + "registration_token": token, + "full_name": "Ahmed Ali", + "idnumber": "wp123456", + "date_of_birth": "1995-04-17", + "atoll": self.atoll.pk, + "island": self.island.pk, + "terms_accepted": True, + "policy_accepted": True, + } + + +class LocationsApiTests(TestCase): + def test_atolls_are_public_and_include_islands(self): + response = APIClient().get(reverse("atoll-list")) + self.assertEqual(response.status_code, 200) + names = {atoll["name"]: atoll for atoll in response.data} + self.assertIn("Faafu", names) + self.assertEqual( + [island["name"] for island in names["Faafu"]["islands"]], + ["Dharanboodhoo"], + ) diff --git a/backend/users/urls.py b/backend/users/urls.py new file mode 100644 index 0000000..f4b7ec8 --- /dev/null +++ b/backend/users/urls.py @@ -0,0 +1,22 @@ +from django.urls import path +from knox import views as knox_views + +from .views import ( + AuthStartView, + MeView, + OtpResendView, + PasswordLoginView, + RegisterView, + VerifyCodeView, +) + +urlpatterns = [ + path("start/", AuthStartView.as_view(), name="auth-start"), + path("login/password/", PasswordLoginView.as_view(), name="auth-login-password"), + path("verify/", VerifyCodeView.as_view(), name="auth-verify"), + path("otp/resend/", OtpResendView.as_view(), name="auth-otp-resend"), + path("register/", RegisterView.as_view(), name="auth-register"), + path("me/", MeView.as_view(), name="auth-me"), + path("logout/", knox_views.LogoutView.as_view(), name="auth-logout"), + path("logout-all/", knox_views.LogoutAllView.as_view(), name="auth-logout-all"), +] diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 0000000..9cfb158 --- /dev/null +++ b/backend/users/views.py @@ -0,0 +1,262 @@ +"""Authentication and registration. + + 1. POST /api/auth/start/ {mobile} + -> {"next": "password"} the account signs in with a password + -> {"next": "otp", ...} a code was sent by SMS + + 2. POST /api/auth/login/password/ {mobile, password} + -> {"next": "dashboard", "token", "expiry", "user"} + + POST /api/auth/verify/ {mobile, code} + -> {"next": "dashboard", "token", "expiry", "user"} + -> {"next": "register", "registration_token", "mobile", "expires_at"} + + 3. POST /api/auth/register/ {registration_token, ...form} + -> 201 {"status": "pending", ...} + +Whether a number has an account is only answered once its owner has confirmed +a code, so `start` looks the same for every valid number. +""" + +import logging + +from django.conf import settings +from django.contrib.auth import authenticate +from django.utils import timezone +from knox.views import LoginView as KnoxLoginView +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import OtpCode, RegistrationTicket, User +from .serializers import ( + AuthStartSerializer, + OtpVerifySerializer, + PasswordLoginSerializer, + RegistrationSerializer, + UserSerializer, +) +from .sms import send_otp, send_registration_submitted + +logger = logging.getLogger(__name__) + +CODE_LENGTH = 6 + + +def _issue_code(mobile: str, user: User | None) -> OtpCode: + purpose = OtpCode.Purpose.LOGIN if user else OtpCode.Purpose.REGISTRATION + otp, code = OtpCode.issue(mobile=mobile, purpose=purpose, user=user) + send_otp(mobile, code, purpose) + return otp + + +def _code_payload(mobile: str, otp: OtpCode | None = None) -> dict: + payload = {"next": "otp", "mobile": mobile, "code_length": CODE_LENGTH} + if otp is not None: + payload["expires_at"] = otp.expires_at + payload["resend_available_at"] = otp.resend_available_at + return payload + + +class AuthStartView(APIView): + """Step 1: password box, or a code sent by SMS.""" + + authentication_classes = [] + permission_classes = [AllowAny] + throttle_scope = "auth_start" + + def post(self, request): + serializer = AuthStartSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + mobile = serializer.validated_data["mobile"] + + user = User.objects.filter(mobile=mobile).first() + if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD: + return Response({"next": "password", "mobile": mobile}) + + return Response(_code_payload(mobile, _issue_code(mobile, user))) + + +class OtpResendView(APIView): + """Send a fresh code, honouring the cooldown.""" + + authentication_classes = [] + permission_classes = [AllowAny] + throttle_scope = "otp_request" + + def post(self, request): + serializer = AuthStartSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + mobile = serializer.validated_data["mobile"] + + user = User.objects.filter(mobile=mobile).first() + if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD: + return Response(_code_payload(mobile)) + + latest = OtpCode.objects.active().filter(mobile=mobile).first() + if latest and latest.resend_available_at > timezone.now(): + return Response( + { + "detail": "A code was just sent. Try again shortly.", + "code": "resend_cooldown", + "resend_available_at": latest.resend_available_at, + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + return Response(_code_payload(mobile, _issue_code(mobile, user))) + + +class BaseLoginView(KnoxLoginView): + """Issues a knox token, with the account serialised alongside it.""" + + authentication_classes = [] + permission_classes = [AllowAny] + + def issue_token(self, request, user): + request.user = user + return super().post(request, format=None) + + def get_post_response_data(self, request, token, instance): + data = super().get_post_response_data(request, token, instance) + data["next"] = "dashboard" + data["user"] = UserSerializer(request.user).data + return data + + +class PasswordLoginView(BaseLoginView): + """Step 2, password.""" + + throttle_scope = "auth_login" + + def post(self, request, format=None): + serializer = PasswordLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = authenticate( + request, + username=serializer.validated_data["mobile"], + password=serializer.validated_data["password"], + ) + if user is None: + return Response( + { + "detail": "Incorrect mobile number or password.", + "code": "invalid_credentials", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return self.issue_token(request, user) + + +class VerifyCodeView(BaseLoginView): + """Step 2, SMS code. + + A confirmed code either signs the account in or, when the number has no + account, hands back the ticket the registration form needs. + """ + + throttle_scope = "auth_login" + + def post(self, request, format=None): + serializer = OtpVerifySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + mobile = serializer.validated_data["mobile"] + + otp = ( + OtpCode.objects.active() + .filter(mobile=mobile) + .select_related("user") + .first() + ) + if otp is None: + return Response( + { + "detail": "That code has expired. Request a new one.", + "code": "code_expired", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if otp.is_exhausted: + return Response( + { + "detail": "Too many incorrect attempts. Request a new code.", + "code": "code_exhausted", + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + if not otp.verify(serializer.validated_data["code"]): + return Response( + { + "detail": "That code is not correct.", + "code": "invalid_code", + "attempts_left": max(0, settings.OTP_MAX_ATTEMPTS - otp.attempts), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = otp.user or User.objects.filter(mobile=mobile).first() + if user is None: + ticket = RegistrationTicket.issue(mobile) + return Response( + { + "next": "register", + "registration_token": ticket.key, + "mobile": mobile, + "expires_at": ticket.expires_at, + } + ) + + if not user.is_active: + return Response( + {"detail": "This account is disabled.", "code": "account_disabled"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not user.mobile_verified: + user.mobile_verified = True + user.save(update_fields=["mobile_verified", "updated_at"]) + + return self.issue_token(request, user) + + +class RegisterView(APIView): + """Submit the registration form. Creates a pending account - no token.""" + + authentication_classes = [] + permission_classes = [AllowAny] + throttle_scope = "register" + + def post(self, request): + serializer = RegistrationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + send_registration_submitted(user.mobile) + logger.info("Registration submitted for %s (user %s)", user.mobile, user.pk) + + return Response( + { + "status": user.status, + "mobile": user.mobile, + "full_name": user.full_name, + "detail": ( + "Your registration is pending approval. We'll text you " + "once it has been reviewed." + ), + }, + status=status.HTTP_201_CREATED, + ) + + +class MeView(APIView): + """The signed-in account, for the SPA to hydrate its session.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response(UserSerializer(request.user).data) diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..af45a21 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.* +!.env.example diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..beec1d3 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Where the dev server proxies /api, /admin, /static and /media. +# In compose this is the backend service; on the host it's the mapped port. +VITE_API_PROXY_TARGET=http://localhost:8000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..50c8dda --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a8cb224 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +# Development image: the vite dev server with HMR. +# Production is a static build served by nginx - see .build/prod/web.Dockerfile. +FROM node:22-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7524c4f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,100 @@ +# frontend + +Vite + React + TypeScript SPA for the SAR Link portal. No Node at runtime: +production is a static bundle served by nginx. + +| | | +|---|---| +| Build | Vite 8 | +| UI | React 19, Tailwind CSS v4 | +| Routing | React Router 7 (SPA, `BrowserRouter`) | +| Auth | knox token in `localStorage`, `AuthProvider` in `src/lib/auth.tsx` | + +## Layout + +``` +src/lib/api.ts fetch wrapper, typed endpoints, ApiError +src/lib/auth.tsx AuthProvider (session bootstrap, sign in/out) +src/lib/auth-context.ts AuthContext + useAuth +src/lib/date.ts local-time YYYY-MM-DD formatting +src/components/ ui primitives, Select, DateField, AppLayout, RequireAuth +src/pages/ Login (two-step), Register (form), Dashboard, NotFound +``` + +## Run it + +From the repo root (starts the API and database too): + +```sh +docker compose up --build +``` + +Or on the host, against a backend on `localhost:8000`: + +```sh +npm install +npm run dev +``` + +Then `http://localhost:5173`. + +## Talking to the API + +The app always calls a relative `/api/...`. Nothing hardcodes a backend URL: + +- **dev** — `vite.config.ts` proxies `/api`, `/admin`, `/static` and `/media` to + `VITE_API_PROXY_TARGET` (`http://backend:8000` in compose). +- **prod** — nginx serves `dist/` and proxies the same prefixes to gunicorn. + +So there is no CORS in production, and no API origin to configure at build time. + +## Sign-in and registration + +`src/pages/Login.tsx` is one form whose second step the API chooses: + +1. Mobile number -> `POST /api/auth/start/` +2. The response's `next` decides what appears under it: + - `password` -> password field -> `POST /api/auth/login/password/` + - `otp` -> 6-digit code, with a resend cooldown -> `POST /api/auth/verify/` +3. `verify/` answers with `next`: `dashboard` (token stored, on to the portal) + or `register` (the returned ticket goes to `/register` in router state). + +The code step looks the same whether or not the number has an account - the UI +has no idea until the code is confirmed, which is the point. + +`src/pages/Register.tsx` requires that ticket (no ticket -> back to `/login`), +shows the verified number read-only, and collects name, ID card/passport/work +permit number, date of birth (`DateField`, a react-day-picker popover with +month and year dropdowns), atoll + island (from `GET /api/locations/atolls/`, island +list filtered by the chosen atoll) and the two agreement checkboxes linking to +sarlink.net/terms and /policy. Submitting shows a "pending approval" panel - it +does not sign the applicant in. + +A pending or rejected account that signs in later sees a status banner on the +dashboard instead of services. + +On reload `AuthProvider` calls `GET /api/auth/me/` to turn the stored token +back into a user, and clears it if the API rejects it. + +## Adding a dependency + +`node_modules` lives in a Docker volume that outlives image rebuilds, so a new +entry in `package.json` isn't in the container until it's installed there. The +dev service runs `npm install` on every start, so: + +```sh +npm install # updates package.json + lock on the host +docker compose restart frontend # installs it in the container +``` + +If it still can't resolve the import, the volume is stale — recreate it with +`docker compose up -d -V frontend`. + +## Scripts + +```sh +npm run dev # dev server on :5173 +npm run build # tsc -b && vite build -> dist/ +npm run preview # serve dist/ locally +npm run lint # oxlint +``` diff --git a/frontend/compose.yml b/frontend/compose.yml new file mode 100644 index 0000000..cbcec93 --- /dev/null +++ b/frontend/compose.yml @@ -0,0 +1,22 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile + hostname: frontend + # `npm install` runs on every start because the node_modules volume below + # outlives image rebuilds: without this, a dependency added to + # package.json since the volume was created is missing in the container + # ("Failed to resolve import ..."). It's incremental, so it's a no-op once + # everything is installed. + command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0" + volumes: + - .:/app + # Keep the container's own node_modules: the host's are built for a + # different platform, and the bind mount above would otherwise hide them. + - /app/node_modules + ports: + - "5173:5173" + environment: + # The dev server proxies /api, /admin, /static and /media here. + VITE_API_PROXY_TARGET: http://backend:8000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f228b15 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + SAR Link Portal + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..011a567 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1965 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "react": "^19.2.8", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..039e299 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "react": "^19.2.8", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..b710d45 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,37 @@ +import { Outlet } from "react-router-dom"; + +import { Button } from "@/components/ui"; +import { useAuth } from "@/lib/auth-context"; + +export default function AppLayout() { + const { user, signOut } = useAuth(); + + return ( +
+
+
+ + SAR Link + +
+ {user?.is_admin ? ( + + Admin + + ) : null} + +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/DateField.tsx b/frontend/src/components/DateField.tsx new file mode 100644 index 0000000..6d7d131 --- /dev/null +++ b/frontend/src/components/DateField.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { DayPicker } from "react-day-picker"; + +import "react-day-picker/style.css"; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +function format(date: Date): string { + return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`; +} + +/** + * Date-of-birth picker: a button that opens a calendar, with the year and + * month selectable so nobody has to click back through 30 years of months. + */ +export default function DateField({ + value, + onChange, + id, +}: { + value: Date | undefined; + onChange: (date: Date | undefined) => void; + id?: string; +}) { + const [open, setOpen] = useState(false); + const container = useRef(null); + const today = new Date(); + + // Close on an outside click or Escape, like any other popover. + useEffect(() => { + if (!open) return; + + function onPointerDown(event: MouseEvent) { + if (!container.current?.contains(event.target as Node)) setOpen(false); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
+ + + {open ? ( +
+ { + onChange(date); + if (date) setOpen(false); + }} + captionLayout="dropdown" + defaultMonth={value ?? new Date(today.getFullYear() - 25, 0)} + startMonth={new Date(today.getFullYear() - 100, 0)} + endMonth={today} + disabled={{ after: today }} + className="[--rdp-accent-color:var(--color-sky-600)] [--rdp-accent-background-color:var(--color-sky-50)] text-slate-900 dark:text-slate-100 dark:[--rdp-accent-background-color:var(--color-slate-800)]" + /> +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..572776f --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,15 @@ +import { Navigate, useLocation } from "react-router-dom"; +import type { ReactNode } from "react"; + +import { Spinner } from "@/components/ui"; +import { useAuth } from "@/lib/auth-context"; + +/** Blocks a route until the stored token has been resolved to a user. */ +export default function RequireAuth({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return ; + if (!user) return ; + return <>{children}; +} diff --git a/frontend/src/components/Select.tsx b/frontend/src/components/Select.tsx new file mode 100644 index 0000000..4f76324 --- /dev/null +++ b/frontend/src/components/Select.tsx @@ -0,0 +1,13 @@ +import type { ComponentPropsWithRef } from "react"; + +export default function Select({ + className = "", + ...props +}: ComponentPropsWithRef<"select">) { + return ( + + ); +} + +export function Alert({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Spinner({ label = "Loading" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..da30ecd --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,14 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..5e2598c --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,236 @@ +/** + * Thin client over the Django API. + * + * Every error the backend returns has the same shape + * (`{detail, code, errors?}`), so callers branch on `ApiError.code` rather + * than on status codes or message text. + */ + +const TOKEN_KEY = "sarlink.token"; + +export type AuthMethod = "password" | "otp"; +export type AccountStatus = "pending" | "approved" | "rejected"; + +export interface User { + id: number; + mobile: string; + full_name: string; + email: string | null; + idnumber: string; + date_of_birth: string | null; + atoll: number | null; + atoll_name: string | null; + island: number | null; + island_name: string | null; + auth_method: AuthMethod; + status: AccountStatus; + rejection_reason: string; + mobile_verified: boolean; + is_admin: boolean; + has_password: boolean; + date_joined: string; +} + +/** Which second step to render. Identical for every number that gets a code, + * whether or not it has an account. */ +export interface AuthStartResult { + next: "password" | "otp"; + mobile: string; + expires_at?: string; + resend_available_at?: string; + code_length?: number; +} + +export interface Island { + id: number; + name: string; + atoll: number; +} + +export interface Atoll { + id: number; + name: string; + code: string; + islands: Island[]; +} + +/** Proof that a number was verified by SMS, required by the register submit. */ +export interface RegistrationTicket { + registration_token: string; + mobile: string; + expires_at: string; +} + +export interface RegistrationForm { + registration_token: string; + full_name: string; + idnumber: string; + date_of_birth: string; + atoll: number; + island: number; + terms_accepted: boolean; + policy_accepted: boolean; +} + +export interface RegistrationResult { + status: AccountStatus; + mobile: string; + full_name: string; + detail: string; +} + +export interface LoginResult { + next: "dashboard"; + token: string; + expiry: string | null; + user: User; +} + +export interface RegistrationHandoff extends RegistrationTicket { + next: "register"; +} + +/** A confirmed code either signs the account in or unlocks registration. */ +export type VerifyResult = LoginResult | RegistrationHandoff; + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly errors?: Record; + readonly data: Record; + + constructor(status: number, data: Record) { + super( + typeof data.detail === "string" ? data.detail : "Something went wrong.", + ); + this.name = "ApiError"; + this.status = status; + this.code = typeof data.code === "string" ? data.code : "error"; + this.errors = data.errors as Record | undefined; + this.data = data; + } + + /** First message for `field`, if the failure was a validation error. */ + fieldError(field: string): string | undefined { + return this.errors?.[field]?.[0]; + } +} + +export function getToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function setToken(token: string): void { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + /* private mode: the session just won't survive a reload */ + } +} + +export function clearToken(): void { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } +} + +interface RequestOptions { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: unknown; + /** Send the stored token. Defaults to true when one exists. */ + auth?: boolean; +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = "GET", body, auth = true } = options; + const token = auth ? getToken() : null; + + const headers: Record = {}; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (token) headers.Authorization = `Token ${token}`; + + let response: Response; + try { + response = await fetch(`/api${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ApiError(0, { + detail: "Can't reach the server. Check your connection.", + code: "network_error", + }); + } + + if (response.status === 204) return undefined as T; + + const text = await response.text(); + let data: Record = {}; + if (text) { + try { + data = JSON.parse(text) as Record; + } catch { + data = { detail: text }; + } + } + + if (!response.ok) throw new ApiError(response.status, data); + return data as T; +} + +export const api = { + /** Step 1: which second step does this number use? */ + authStart: (mobile: string) => + request("/auth/start/", { + method: "POST", + body: { mobile }, + auth: false, + }), + + /** Step 2a. */ + loginWithPassword: (mobile: string, password: string) => + request("/auth/login/password/", { + method: "POST", + body: { mobile, password }, + auth: false, + }), + + /** Step 2b: the code, whichever purpose it was issued for. */ + verifyCode: (mobile: string, code: string) => + request("/auth/verify/", { + method: "POST", + body: { mobile, code }, + auth: false, + }), + + resendOtp: (mobile: string) => + request("/auth/otp/resend/", { + method: "POST", + body: { mobile }, + auth: false, + }), + + /** Submit the registration form. Creates a pending account, no session. */ + register: (form: RegistrationForm) => + request("/auth/register/", { + method: "POST", + body: form, + auth: false, + }), + + atolls: () => request("/locations/atolls/", { auth: false }), + + me: () => request("/auth/me/"), + + logout: () => request("/auth/logout/", { method: "POST" }), + + health: () => + request<{ status: string; database: string }>("/health/", { auth: false }), +}; diff --git a/frontend/src/lib/auth-context.ts b/frontend/src/lib/auth-context.ts new file mode 100644 index 0000000..360c7dd --- /dev/null +++ b/frontend/src/lib/auth-context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; + +import type { LoginResult, User } from "@/lib/api"; + +export interface AuthState { + user: User | null; + /** True until the stored token has been checked against the API. */ + loading: boolean; + signIn: (result: LoginResult) => void; + signOut: () => Promise; + refresh: () => Promise; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used inside "); + return context; +} diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..add83fd --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +import { api, clearToken, getToken, setToken } from "@/lib/api"; +import type { LoginResult, User } from "@/lib/api"; +import { AuthContext } from "@/lib/auth-context"; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + // Nothing to resolve when there's no stored token. + const [loading, setLoading] = useState(() => getToken() !== null); + + // A token in localStorage is only a hint - ask the API who it belongs to. + useEffect(() => { + if (!getToken()) return; + + let active = true; + api + .me() + .then((me) => { + if (active) setUser(me); + }) + .catch(() => { + clearToken(); + if (active) setUser(null); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, []); + + const signIn = useCallback((result: LoginResult) => { + setToken(result.token); + setUser(result.user); + }, []); + + const signOut = useCallback(async () => { + try { + await api.logout(); + } catch { + /* the token is going away locally either way */ + } + clearToken(); + setUser(null); + }, []); + + const refresh = useCallback(async () => { + setUser(await api.me()); + }, []); + + const value = useMemo( + () => ({ user, loading, signIn, signOut, refresh }), + [user, loading, signIn, signOut, refresh], + ); + + return {children}; +} diff --git a/frontend/src/lib/date.ts b/frontend/src/lib/date.ts new file mode 100644 index 0000000..7d61289 --- /dev/null +++ b/frontend/src/lib/date.ts @@ -0,0 +1,6 @@ +/** `Date` -> "YYYY-MM-DD" in local time (never UTC-shifted by toISOString). */ +export function toIsoDate(date: Date): string { + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c30c78b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,37 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; + +import AppLayout from "@/components/AppLayout"; +import RequireAuth from "@/components/RequireAuth"; +import { AuthProvider } from "@/lib/auth"; +import Dashboard from "@/pages/Dashboard"; +import Login from "@/pages/Login"; +import NotFound from "@/pages/NotFound"; +import Register from "@/pages/Register"; + +import "@/index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + } /> + } /> + + + + } + > + } /> + } /> + + } /> + + + + , +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..c27be13 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,103 @@ +import type { ReactNode } from "react"; + +import { useAuth } from "@/lib/auth-context"; + +export default function Dashboard() { + const { user } = useAuth(); + if (!user) return null; + + const rows: [string, ReactNode][] = [ + ["Mobile", user.mobile], + ["Name", user.full_name || "—"], + ["ID number", user.idnumber || "—"], + ["Date of birth", user.date_of_birth ?? "—"], + [ + "Address", + [user.island_name, user.atoll_name].filter(Boolean).join(", ") || "—", + ], + ["Sign-in method", user.auth_method === "password" ? "Password" : "SMS code"], + ]; + + return ( +
+
+

+ Welcome{user.full_name ? `, ${user.full_name.split(" ")[0]}` : ""} +

+

+ Your account details as the portal has them. +

+
+ + + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +/** Pending and rejected accounts get no services yet - say so plainly. */ +function StatusBanner() { + const { user } = useAuth(); + if (!user) return null; + + if (user.status === "pending") { + return ( + + ); + } + + if (user.status === "rejected") { + return ( + + ); + } + + return ( +

+ Devices and billing land here next. +

+ ); +} + +function Banner({ + tone, + title, + body, +}: { + tone: "amber" | "rose"; + title: string; + body: string; +}) { + const tones = { + amber: + "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200", + rose: "border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200", + }; + return ( +
+

{title}

+

{body}

+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..cef05ea --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { Alert, Button, Field, TextInput } from "@/components/ui"; +import { api, ApiError } from "@/lib/api"; +import type { AuthStartResult } from "@/lib/api"; +import { useAuth } from "@/lib/auth-context"; + +type Step = "mobile" | "password" | "otp"; + +export default function Login() { + const navigate = useNavigate(); + const { signIn } = useAuth(); + + const [step, setStep] = useState("mobile"); + const [mobile, setMobile] = useState(""); + const [secret, setSecret] = useState(""); + const [start, setStart] = useState(null); + const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + const [busy, setBusy] = useState(false); + const [cooldown, setCooldown] = useState(0); + + const secretRef = useRef(null); + + useEffect(() => { + if (step !== "mobile") secretRef.current?.focus(); + }, [step]); + + useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + function secondsUntil(timestamp?: string): number { + if (!timestamp) return 0; + return Math.max(0, Math.ceil((Date.parse(timestamp) - Date.now()) / 1000)); + } + + function handleFailure(err: unknown, field?: string) { + if (err instanceof ApiError) { + const fieldMessage = field ? err.fieldError(field) : undefined; + if (fieldMessage) { + setFieldError(fieldMessage); + return; + } + setError(err.message); + if (err.code === "code_expired" || err.code === "code_exhausted") { + setSecret(""); + setCooldown(0); + } + return; + } + setError("Something went wrong. Try again."); + } + + async function submitMobile(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + setFieldError(null); + try { + const result = await api.authStart(mobile); + setStart(result); + setSecret(""); + setStep(result.next); + setCooldown(secondsUntil(result.resend_available_at)); + } catch (err) { + handleFailure(err, "mobile"); + } finally { + setBusy(false); + } + } + + async function submitSecret(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + setFieldError(null); + try { + if (step === "password") { + signIn(await api.loginWithPassword(mobile, secret)); + navigate("/", { replace: true }); + return; + } + + const result = await api.verifyCode(mobile, secret); + if (result.next === "register") { + navigate("/register", { replace: true, state: result }); + return; + } + + signIn(result); + navigate("/", { replace: true }); + } catch (err) { + handleFailure(err, step === "password" ? "password" : "code"); + } finally { + setBusy(false); + } + } + + async function resend() { + setBusy(true); + setError(null); + try { + const result = await api.resendOtp(mobile); + setStart(result); + setSecret(""); + setCooldown(secondsUntil(result.resend_available_at) || 60); + } catch (err) { + if (err instanceof ApiError && err.code === "resend_cooldown") { + setCooldown( + secondsUntil(err.data.resend_available_at as string | undefined) || 60, + ); + } + handleFailure(err); + } finally { + setBusy(false); + } + } + + function restart() { + setStep("mobile"); + setSecret(""); + setStart(null); + setError(null); + setFieldError(null); + } + + const shownMobile = start?.mobile ?? mobile; + + return ( +
+
+
+

+ SAR Link +

+

+ Sign in to the member portal +

+
+ +
+ {error ? {error} : null} + + {step === "mobile" ? ( +
+ + setMobile(event.target.value)} + required + /> + + +
+ ) : ( +
+

+ {step === "password" + ? `Signing in as ${shownMobile}` + : `We sent a ${start?.code_length ?? 6}-digit code to ${shownMobile}`} +

+ + {step === "password" ? ( + + setSecret(event.target.value)} + required + /> + + ) : ( + + + setSecret(event.target.value.replace(/\D/g, "")) + } + required + /> + + )} + + + +
+ + + {step === "otp" ? ( + + ) : null} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..63c5a8e --- /dev/null +++ b/frontend/src/pages/NotFound.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; + +export default function NotFound() { + return ( +
+

404

+

This page doesn't exist.

+ + Go to the portal + +
+ ); +} diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..fa38d6e --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from "react"; +import { Link, Navigate, useLocation } from "react-router-dom"; + +import DateField from "@/components/DateField"; +import Select from "@/components/Select"; +import { Alert, Button, Field, TextInput } from "@/components/ui"; +import { api, ApiError } from "@/lib/api"; +import { toIsoDate } from "@/lib/date"; +import type { Atoll, RegistrationTicket } from "@/lib/api"; + +const TERMS_URL = "https://sarlink.net/terms"; +const POLICY_URL = "https://sarlink.net/policy"; + +type Errors = Record; + +/** + * The registration form, reached only after the number was confirmed by SMS: + * `location.state` carries the registration ticket from /login. + * + * Submitting does not create a usable account - it files an application an + * admin has to approve. + */ +export default function Register() { + const location = useLocation(); + const ticket = location.state as RegistrationTicket | null; + + const [atolls, setAtolls] = useState(null); + const [loadError, setLoadError] = useState(null); + + const [fullName, setFullName] = useState(""); + const [idnumber, setIdnumber] = useState(""); + const [dateOfBirth, setDateOfBirth] = useState(undefined); + const [atollId, setAtollId] = useState(""); + const [islandId, setIslandId] = useState(""); + const [terms, setTerms] = useState(false); + const [policy, setPolicy] = useState(false); + + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(null); + // The ticket expired or was already used: the number has to be re-verified. + const [needsReverify, setNeedsReverify] = useState(false); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(null); + + useEffect(() => { + let active = true; + api + .atolls() + .then((result) => { + if (active) setAtolls(result); + }) + .catch(() => { + if (active) setLoadError("Couldn't load the island list. Reload to retry."); + }); + return () => { + active = false; + }; + }, []); + + // No ticket means the number was never verified - start over. + if (!ticket?.registration_token && !submitted) { + return ; + } + + const islands = atolls?.find((atoll) => `${atoll.id}` === atollId)?.islands ?? []; + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setErrors({}); + setFormError(null); + + try { + const result = await api.register({ + registration_token: ticket!.registration_token, + full_name: fullName, + idnumber: idnumber, + date_of_birth: dateOfBirth ? toIsoDate(dateOfBirth) : "", + atoll: Number(atollId), + island: Number(islandId), + terms_accepted: terms, + policy_accepted: policy, + }); + setSubmitted(result.detail); + } catch (err) { + if (err instanceof ApiError) { + const fieldErrors: Errors = {}; + for (const [field, messages] of Object.entries(err.errors ?? {})) { + if (Array.isArray(messages)) fieldErrors[field] = messages[0]; + } + setErrors(fieldErrors); + + const ticketError = fieldErrors.registration_token ?? fieldErrors.mobile; + if (ticketError) { + setNeedsReverify(true); + setFormError(ticketError); + } else { + // Field errors are shown inline; only a general failure needs the banner. + setFormError(Object.keys(fieldErrors).length > 0 ? null : err.message); + } + } else { + setFormError("Something went wrong. Try again."); + } + } finally { + setBusy(false); + } + } + + if (submitted) { + return ( + +
+
+ ✓ +
+

{submitted}

+ + Back to sign in + +
+
+ ); + } + + return ( + + {formError ? ( + + {formError} + {needsReverify ? ( + <> + {" "} + + Start over + + + ) : null} + + ) : null} + {loadError ? {loadError} : null} + +
+ + + + + + setFullName(event.target.value)} + required + /> + + + + setIdnumber(event.target.value)} + required + /> + + + + + + +
+ + + + + + + +
+ +
+ + I agree to the{" "} + terms and conditions. + + + + I understand the privacy policy. + +
+ + +
+
+ ); +} + +function Shell({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+
+

+ {title} +

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+
+ {children} +
+
+
+ ); +} + +function Checkbox({ + name, + checked, + onChange, + error, + children, +}: { + name: string; + checked: boolean; + onChange: (value: boolean) => void; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {error ? ( +

{error}

+ ) : null} +
+ ); +} + +function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..ec9998d --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ea7e63d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath, URL } from "node:url"; + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; + +// The SPA always calls the API at a relative `/api/...`: +// dev -> this proxy forwards to Django +// prod -> nginx serves the built files and proxies /api to gunicorn +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const apiTarget = env.VITE_API_PROXY_TARGET ?? "http://localhost:8000"; + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + }, + server: { + host: true, + port: 5173, + proxy: { + "/api": { target: apiTarget, changeOrigin: true }, + "/admin": { target: apiTarget, changeOrigin: true }, + "/static": { target: apiTarget, changeOrigin: true }, + "/media": { target: apiTarget, changeOrigin: true }, + }, + }, + build: { outDir: "dist", sourcemap: mode !== "production" }, + }; +});