register and sign in pages

This commit is contained in:
2026-09-22 00:50:30 +05:00
parent f313867653
commit 78d04f75d1
96 changed files with 6383 additions and 109 deletions
+38 -47
View File
@@ -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=<generate one>
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=<strong>
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.
+23 -13
View File
@@ -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:
Regular → Executable
View File
-27
View File
@@ -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"]
-3
View File
@@ -1,3 +0,0 @@
FROM nginx:alpine
COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf
+27 -18
View File
@@ -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";
}
}
+19
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
**/.git
**/.venv
**/venv
**/node_modules
**/dist
**/__pycache__
**/.ruff_cache
**/staticfiles
**/media
**/.env
!**/.env.example
+18 -1
View File
@@ -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/
+74
View File
@@ -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).
+45
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.venv/
venv/
.env
db.sqlite3
/staticfiles/
/media/
.ruff_cache/
+20
View File
@@ -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"]
+181
View File
@@ -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 <token>
POST /api/auth/logout/ Authorization: Token <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.
View File
+16
View File
@@ -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()
+218
View File
@@ -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"},
}
+19
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
"""Procrastinate app - postgres-backed background tasks, no broker."""
from procrastinate.contrib.django import app
__all__ = ["app"]
+24
View File
@@ -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)
+16
View File
@@ -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()
+58
View File
@@ -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:
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"
+32
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class DefaultPagination(PageNumberPagination):
page_size_query_param = "page_size"
max_page_size = 100
View File
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import healthcheck
urlpatterns = [
path("health/", healthcheck, name="healthcheck"),
]
+17
View File
@@ -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})
+26
View File
@@ -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 .
View File
+22
View File
@@ -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"]
+17
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "locations"
@@ -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."
)
)
@@ -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')],
},
),
]
@@ -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)]
+31
View File
@@ -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}"
+37
View File
@@ -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
+23
View File
@@ -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
View File
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .api import AtollListView
urlpatterns = [
path("atolls/", AtollListView.as_view(), name="atoll-list"),
]
+22
View File
@@ -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()
+11
View File
@@ -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"]
+26
View File
@@ -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
View File
+141
View File
@@ -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"]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
+38
View File
@@ -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))
+89
View File
@@ -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')],
},
),
]
+14
View File
@@ -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"
),
]
+29
View File
@@ -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}"
+279
View File
@@ -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"])
+170
View File
@@ -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
+73
View File
@@ -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}"
)
View File
+185
View File
@@ -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"])
+17
View File
@@ -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)
+250
View File
@@ -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"],
)
+22
View File
@@ -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"),
]
+262
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.*
!.env.example
+3
View File
@@ -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
+26
View File
@@ -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
+8
View File
@@ -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 }]
}
}
+13
View File
@@ -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"]
+100
View File
@@ -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 <pkg> # 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
```
+22
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>SAR Link Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1965
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+37
View File
@@ -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 (
<div className="min-h-dvh bg-slate-50 dark:bg-slate-950">
<header className="border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto flex h-14 max-w-3xl items-center justify-between px-4">
<span className="font-semibold text-slate-900 dark:text-white">
SAR Link
</span>
<div className="flex items-center gap-3">
{user?.is_admin ? (
<a
href="/admin/"
className="text-sm text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
Admin
</a>
) : null}
<Button variant="ghost" onClick={() => void signOut()}>
Sign out
</Button>
</div>
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">
<Outlet />
</main>
</div>
);
}
+103
View File
@@ -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<HTMLDivElement>(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 (
<div ref={container} className="relative">
<button
id={id}
type="button"
onClick={() => setOpen((isOpen) => !isOpen)}
aria-haspopup="dialog"
aria-expanded={open}
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-base text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{value ? (
format(value)
) : (
<span className="text-slate-400">Select your date of birth</span>
)}
</button>
{open ? (
<div
role="dialog"
aria-label="Choose a date"
className="absolute left-0 z-20 mt-2 rounded-xl border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-700 dark:bg-slate-900"
>
<DayPicker
mode="single"
required={false}
selected={value}
onSelect={(date) => {
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)]"
/>
</div>
) : null}
</div>
);
}
+15
View File
@@ -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 <Spinner label="Loading your account…" />;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { ComponentPropsWithRef } from "react";
export default function Select({
className = "",
...props
}: ComponentPropsWithRef<"select">) {
return (
<select
className={`h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-base text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 disabled:bg-slate-50 disabled:text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:disabled:bg-slate-800 ${className}`}
{...props}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ComponentPropsWithRef, ReactNode } from "react";
export function Button({
children,
variant = "primary",
className = "",
...props
}: ComponentPropsWithRef<"button"> & { variant?: "primary" | "ghost" }) {
const base =
"inline-flex h-11 items-center justify-center rounded-lg px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500";
const variants = {
primary: "bg-sky-600 text-white hover:bg-sky-700",
ghost:
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
};
return (
<button className={`${base} ${variants[variant]} ${className}`} {...props}>
{children}
</button>
);
}
export function Field({
label,
hint,
error,
children,
}: {
label: string;
hint?: ReactNode;
error?: string;
children: ReactNode;
}) {
return (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{children}
{error ? (
<span className="block text-sm text-rose-600 dark:text-rose-400">
{error}
</span>
) : hint ? (
<span className="block text-sm text-slate-500 dark:text-slate-400">
{hint}
</span>
) : null}
</label>
);
}
export function TextInput({
className = "",
...props
}: ComponentPropsWithRef<"input">) {
return (
<input
className={`h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-base text-slate-900 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 disabled:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:disabled:bg-slate-800 ${className}`}
{...props}
/>
);
}
export function Alert({ children }: { children: ReactNode }) {
return (
<div
role="alert"
className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-300"
>
{children}
</div>
);
}
export function Spinner({ label = "Loading" }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-slate-500">
<span
aria-hidden
className="size-4 animate-spin rounded-full border-2 border-slate-300 border-t-sky-600"
/>
<span className="text-sm">{label}</span>
</div>
);
}
+14
View File
@@ -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);
}
+236
View File
@@ -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<string, string[]>;
readonly data: Record<string, unknown>;
constructor(status: number, data: Record<string, unknown>) {
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<string, string[]> | 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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, auth = true } = options;
const token = auth ? getToken() : null;
const headers: Record<string, string> = {};
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<string, unknown> = {};
if (text) {
try {
data = JSON.parse(text) as Record<string, unknown>;
} 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<AuthStartResult>("/auth/start/", {
method: "POST",
body: { mobile },
auth: false,
}),
/** Step 2a. */
loginWithPassword: (mobile: string, password: string) =>
request<LoginResult>("/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<VerifyResult>("/auth/verify/", {
method: "POST",
body: { mobile, code },
auth: false,
}),
resendOtp: (mobile: string) =>
request<AuthStartResult>("/auth/otp/resend/", {
method: "POST",
body: { mobile },
auth: false,
}),
/** Submit the registration form. Creates a pending account, no session. */
register: (form: RegistrationForm) =>
request<RegistrationResult>("/auth/register/", {
method: "POST",
body: form,
auth: false,
}),
atolls: () => request<Atoll[]>("/locations/atolls/", { auth: false }),
me: () => request<User>("/auth/me/"),
logout: () => request<void>("/auth/logout/", { method: "POST" }),
health: () =>
request<{ status: string; database: string }>("/health/", { auth: false }),
};
+20
View File
@@ -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<void>;
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used inside <AuthProvider>");
return context;
}
+61
View File
@@ -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<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+6
View File
@@ -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}`;
}
+37
View File
@@ -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(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="/portal" element={<Navigate to="/" replace />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+103
View File
@@ -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 (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Welcome{user.full_name ? `, ${user.full_name.split(" ")[0]}` : ""}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Your account details as the portal has them.
</p>
</div>
<StatusBanner />
<dl className="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 px-4 py-3">
<dt className="text-sm text-slate-500 dark:text-slate-400">{label}</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-slate-100">
{value}
</dd>
</div>
))}
</dl>
</div>
);
}
/** 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 (
<Banner
tone="amber"
title="Registration pending approval"
body="An admin is reviewing your application. We'll text you as soon as it's approved."
/>
);
}
if (user.status === "rejected") {
return (
<Banner
tone="rose"
title="Registration not approved"
body={
user.rejection_reason ||
"Contact SAR Link support to find out what's needed."
}
/>
);
}
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Devices and billing land here next.
</p>
);
}
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 (
<div className={`space-y-1 rounded-xl border px-4 py-3 ${tones[tone]}`}>
<p className="text-sm font-medium">{title}</p>
<p className="text-sm opacity-90">{body}</p>
</div>
);
}
+238
View File
@@ -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<Step>("mobile");
const [mobile, setMobile] = useState("");
const [secret, setSecret] = useState("");
const [start, setStart] = useState<AuthStartResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldError, setFieldError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [cooldown, setCooldown] = useState(0);
const secretRef = useRef<HTMLInputElement>(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 (
<main className="flex min-h-dvh items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="w-full max-w-sm space-y-6">
<header className="space-y-1 text-center">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
SAR Link
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Sign in to the member portal
</p>
</header>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{error ? <Alert>{error}</Alert> : null}
{step === "mobile" ? (
<form onSubmit={submitMobile} className="space-y-4">
<Field label="Mobile number" error={fieldError ?? undefined}>
<TextInput
autoFocus
name="mobile"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="7712345"
value={mobile}
onChange={(event) => setMobile(event.target.value)}
required
/>
</Field>
<Button type="submit" className="w-full" disabled={busy || !mobile}>
{busy ? "Checking…" : "Continue"}
</Button>
</form>
) : (
<form onSubmit={submitSecret} className="space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "password"
? `Signing in as ${shownMobile}`
: `We sent a ${start?.code_length ?? 6}-digit code to ${shownMobile}`}
</p>
{step === "password" ? (
<Field label="Password" error={fieldError ?? undefined}>
<TextInput
ref={secretRef}
name="password"
type="password"
autoComplete="current-password"
value={secret}
onChange={(event) => setSecret(event.target.value)}
required
/>
</Field>
) : (
<Field label="Verification code" error={fieldError ?? undefined}>
<TextInput
ref={secretRef}
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
placeholder="123456"
className="tracking-[0.4em]"
value={secret}
onChange={(event) =>
setSecret(event.target.value.replace(/\D/g, ""))
}
required
/>
</Field>
)}
<Button type="submit" className="w-full" disabled={busy || !secret}>
{busy ? "Verifying…" : "Continue"}
</Button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={restart}
className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
Use another number
</button>
{step === "otp" ? (
<button
type="button"
onClick={resend}
disabled={busy || cooldown > 0}
className="text-sky-600 hover:underline disabled:text-slate-400 disabled:no-underline dark:text-sky-400"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
</button>
) : null}
</div>
</form>
)}
</div>
</div>
</main>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from "react-router-dom";
export default function NotFound() {
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-3 bg-slate-50 px-4 text-center dark:bg-slate-950">
<p className="text-5xl font-semibold text-slate-300 dark:text-slate-700">404</p>
<p className="text-slate-600 dark:text-slate-300">This page doesn't exist.</p>
<Link to="/" className="text-sm text-sky-600 hover:underline dark:text-sky-400">
Go to the portal
</Link>
</main>
);
}
+320
View File
@@ -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<string, string>;
/**
* 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<Atoll[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [fullName, setFullName] = useState("");
const [idnumber, setIdnumber] = useState("");
const [dateOfBirth, setDateOfBirth] = useState<Date | undefined>(undefined);
const [atollId, setAtollId] = useState("");
const [islandId, setIslandId] = useState("");
const [terms, setTerms] = useState(false);
const [policy, setPolicy] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [formError, setFormError] = useState<string | null>(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<string | null>(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 <Navigate to="/login" replace />;
}
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 (
<Shell title="Registration received">
<div className="space-y-4 text-center">
<div
aria-hidden
className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-2xl text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{submitted}</p>
<Link
to="/login"
className="inline-block text-sm text-sky-600 hover:underline dark:text-sky-400"
>
Back to sign in
</Link>
</div>
</Shell>
);
}
return (
<Shell title="Create your account" subtitle="An admin reviews every application.">
{formError ? (
<Alert>
{formError}
{needsReverify ? (
<>
{" "}
<Link to="/login" className="font-medium underline">
Start over
</Link>
</>
) : null}
</Alert>
) : null}
{loadError ? <Alert>{loadError}</Alert> : null}
<form onSubmit={submit} className="space-y-4">
<Field label="Mobile number" hint="Verified by SMS">
<TextInput value={ticket!.mobile} readOnly disabled />
</Field>
<Field label="Full name" error={errors.full_name}>
<TextInput
autoFocus
name="full_name"
autoComplete="name"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
required
/>
</Field>
<Field label="ID Card/Passport/Work Permit Number" error={errors.idnumber}>
<TextInput
name="idnumber"
value={idnumber}
onChange={(event) => setIdnumber(event.target.value)}
required
/>
</Field>
<Field label="Date of birth" error={errors.date_of_birth}>
<DateField value={dateOfBirth} onChange={setDateOfBirth} id="date_of_birth" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Atoll" error={errors.atoll}>
<Select
name="atoll"
value={atollId}
disabled={!atolls}
onChange={(event) => {
setAtollId(event.target.value);
setIslandId("");
}}
required
>
<option value="">{atolls ? "Select" : "Loading…"}</option>
{(atolls ?? []).map((atoll) => (
<option key={atoll.id} value={atoll.id}>
{atoll.name}
</option>
))}
</Select>
</Field>
<Field label="Island" error={errors.island}>
<Select
name="island"
value={islandId}
disabled={!atollId}
onChange={(event) => setIslandId(event.target.value)}
required
>
<option value="">{atollId ? "Select" : "Pick an atoll"}</option>
{islands.map((island) => (
<option key={island.id} value={island.id}>
{island.name}
</option>
))}
</Select>
</Field>
</div>
<div className="space-y-3 rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<Checkbox
name="terms_accepted"
checked={terms}
onChange={setTerms}
error={errors.terms_accepted}
>
I agree to the{" "}
<ExternalLink href={TERMS_URL}>terms and conditions</ExternalLink>.
</Checkbox>
<Checkbox
name="policy_accepted"
checked={policy}
onChange={setPolicy}
error={errors.policy_accepted}
>
I understand the <ExternalLink href={POLICY_URL}>privacy policy</ExternalLink>.
</Checkbox>
</div>
<Button
type="submit"
className="w-full"
disabled={busy || !atolls || needsReverify}
>
{busy ? "Submitting…" : "Register"}
</Button>
</form>
</Shell>
);
}
function Shell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="mx-auto w-full max-w-md space-y-6">
<header className="space-y-1 text-center">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
{title}
</h1>
{subtitle ? (
<p className="text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>
) : null}
</header>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{children}
</div>
</div>
</main>
);
}
function Checkbox({
name,
checked,
onChange,
error,
children,
}: {
name: string;
checked: boolean;
onChange: (value: boolean) => void;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="flex items-start gap-2.5 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
name={name}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 size-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500/30 dark:border-slate-600"
/>
<span>{children}</span>
</label>
{error ? (
<p className="mt-1 pl-6.5 text-sm text-rose-600 dark:text-rose-400">{error}</p>
) : null}
</div>
);
}
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-sky-600 underline hover:no-underline dark:text-sky-400"
>
{children}
</a>
);
}
+30
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+31
View File
@@ -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" },
};
});