register and sign in pages
This commit is contained in:
+38
-47
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
+27
-18
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
**/.git
|
||||
**/.venv
|
||||
**/venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/__pycache__
|
||||
**/.ruff_cache
|
||||
**/staticfiles
|
||||
**/media
|
||||
**/.env
|
||||
!**/.env.example
|
||||
+18
-1
@@ -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/
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
db.sqlite3
|
||||
/staticfiles/
|
||||
/media/
|
||||
.ruff_cache/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Procrastinate app - postgres-backed background tasks, no broker."""
|
||||
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class DefaultPagination(PageNumberPagination):
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import healthcheck
|
||||
|
||||
urlpatterns = [
|
||||
path("health/", healthcheck, name="healthcheck"),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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 .
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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)]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .api import AtollListView
|
||||
|
||||
urlpatterns = [
|
||||
path("atolls/", AtollListView.as_view(), name="atoll-list"),
|
||||
]
|
||||
Executable
+22
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "users"
|
||||
@@ -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))
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,3 @@
|
||||
# Where the dev server proxies /api, /admin, /static and /media.
|
||||
# In compose this is the backend service; on the host it's the mapped port.
|
||||
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||
@@ -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
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+1965
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user