register and sign in pages
This commit is contained in:
@@ -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