register and sign in pages

This commit is contained in:
2026-09-22 00:50:30 +05:00
parent f313867653
commit 78d04f75d1
96 changed files with 6383 additions and 109 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.*
!.env.example
+3
View File
@@ -0,0 +1,3 @@
# Where the dev server proxies /api, /admin, /static and /media.
# In compose this is the backend service; on the host it's the mapped port.
VITE_API_PROXY_TARGET=http://localhost:8000
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+13
View File
@@ -0,0 +1,13 @@
# Development image: the vite dev server with HMR.
# Production is a static build served by nginx - see .build/prod/web.Dockerfile.
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+100
View File
@@ -0,0 +1,100 @@
# frontend
Vite + React + TypeScript SPA for the SAR Link portal. No Node at runtime:
production is a static bundle served by nginx.
| | |
|---|---|
| Build | Vite 8 |
| UI | React 19, Tailwind CSS v4 |
| Routing | React Router 7 (SPA, `BrowserRouter`) |
| Auth | knox token in `localStorage`, `AuthProvider` in `src/lib/auth.tsx` |
## Layout
```
src/lib/api.ts fetch wrapper, typed endpoints, ApiError
src/lib/auth.tsx AuthProvider (session bootstrap, sign in/out)
src/lib/auth-context.ts AuthContext + useAuth
src/lib/date.ts local-time YYYY-MM-DD formatting
src/components/ ui primitives, Select, DateField, AppLayout, RequireAuth
src/pages/ Login (two-step), Register (form), Dashboard, NotFound
```
## Run it
From the repo root (starts the API and database too):
```sh
docker compose up --build
```
Or on the host, against a backend on `localhost:8000`:
```sh
npm install
npm run dev
```
Then `http://localhost:5173`.
## Talking to the API
The app always calls a relative `/api/...`. Nothing hardcodes a backend URL:
- **dev** — `vite.config.ts` proxies `/api`, `/admin`, `/static` and `/media` to
`VITE_API_PROXY_TARGET` (`http://backend:8000` in compose).
- **prod** — nginx serves `dist/` and proxies the same prefixes to gunicorn.
So there is no CORS in production, and no API origin to configure at build time.
## Sign-in and registration
`src/pages/Login.tsx` is one form whose second step the API chooses:
1. Mobile number -> `POST /api/auth/start/`
2. The response's `next` decides what appears under it:
- `password` -> password field -> `POST /api/auth/login/password/`
- `otp` -> 6-digit code, with a resend cooldown -> `POST /api/auth/verify/`
3. `verify/` answers with `next`: `dashboard` (token stored, on to the portal)
or `register` (the returned ticket goes to `/register` in router state).
The code step looks the same whether or not the number has an account - the UI
has no idea until the code is confirmed, which is the point.
`src/pages/Register.tsx` requires that ticket (no ticket -> back to `/login`),
shows the verified number read-only, and collects name, ID card/passport/work
permit number, date of birth (`DateField`, a react-day-picker popover with
month and year dropdowns), atoll + island (from `GET /api/locations/atolls/`, island
list filtered by the chosen atoll) and the two agreement checkboxes linking to
sarlink.net/terms and /policy. Submitting shows a "pending approval" panel - it
does not sign the applicant in.
A pending or rejected account that signs in later sees a status banner on the
dashboard instead of services.
On reload `AuthProvider` calls `GET /api/auth/me/` to turn the stored token
back into a user, and clears it if the API rejects it.
## Adding a dependency
`node_modules` lives in a Docker volume that outlives image rebuilds, so a new
entry in `package.json` isn't in the container until it's installed there. The
dev service runs `npm install` on every start, so:
```sh
npm install <pkg> # updates package.json + lock on the host
docker compose restart frontend # installs it in the container
```
If it still can't resolve the import, the volume is stale — recreate it with
`docker compose up -d -V frontend`.
## Scripts
```sh
npm run dev # dev server on :5173
npm run build # tsc -b && vite build -> dist/
npm run preview # serve dist/ locally
npm run lint # oxlint
```
+22
View File
@@ -0,0 +1,22 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
hostname: frontend
# `npm install` runs on every start because the node_modules volume below
# outlives image rebuilds: without this, a dependency added to
# package.json since the volume was created is missing in the container
# ("Failed to resolve import ..."). It's incremental, so it's a no-op once
# everything is installed.
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"
volumes:
- .:/app
# Keep the container's own node_modules: the host's are built for a
# different platform, and the bind mount above would otherwise hide them.
- /app/node_modules
ports:
- "5173:5173"
environment:
# The dev server proxies /api, /admin, /static and /media here.
VITE_API_PROXY_TARGET: http://backend:8000
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>SAR Link Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1965
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+37
View File
@@ -0,0 +1,37 @@
import { Outlet } from "react-router-dom";
import { Button } from "@/components/ui";
import { useAuth } from "@/lib/auth-context";
export default function AppLayout() {
const { user, signOut } = useAuth();
return (
<div className="min-h-dvh bg-slate-50 dark:bg-slate-950">
<header className="border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto flex h-14 max-w-3xl items-center justify-between px-4">
<span className="font-semibold text-slate-900 dark:text-white">
SAR Link
</span>
<div className="flex items-center gap-3">
{user?.is_admin ? (
<a
href="/admin/"
className="text-sm text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
Admin
</a>
) : null}
<Button variant="ghost" onClick={() => void signOut()}>
Sign out
</Button>
</div>
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">
<Outlet />
</main>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/style.css";
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function format(date: Date): string {
return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
}
/**
* Date-of-birth picker: a button that opens a calendar, with the year and
* month selectable so nobody has to click back through 30 years of months.
*/
export default function DateField({
value,
onChange,
id,
}: {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
id?: string;
}) {
const [open, setOpen] = useState(false);
const container = useRef<HTMLDivElement>(null);
const today = new Date();
// Close on an outside click or Escape, like any other popover.
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!container.current?.contains(event.target as Node)) setOpen(false);
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div ref={container} className="relative">
<button
id={id}
type="button"
onClick={() => setOpen((isOpen) => !isOpen)}
aria-haspopup="dialog"
aria-expanded={open}
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-base text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{value ? (
format(value)
) : (
<span className="text-slate-400">Select your date of birth</span>
)}
</button>
{open ? (
<div
role="dialog"
aria-label="Choose a date"
className="absolute left-0 z-20 mt-2 rounded-xl border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-700 dark:bg-slate-900"
>
<DayPicker
mode="single"
required={false}
selected={value}
onSelect={(date) => {
onChange(date);
if (date) setOpen(false);
}}
captionLayout="dropdown"
defaultMonth={value ?? new Date(today.getFullYear() - 25, 0)}
startMonth={new Date(today.getFullYear() - 100, 0)}
endMonth={today}
disabled={{ after: today }}
className="[--rdp-accent-color:var(--color-sky-600)] [--rdp-accent-background-color:var(--color-sky-50)] text-slate-900 dark:text-slate-100 dark:[--rdp-accent-background-color:var(--color-slate-800)]"
/>
</div>
) : null}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Navigate, useLocation } from "react-router-dom";
import type { ReactNode } from "react";
import { Spinner } from "@/components/ui";
import { useAuth } from "@/lib/auth-context";
/** Blocks a route until the stored token has been resolved to a user. */
export default function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Spinner label="Loading your account…" />;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { ComponentPropsWithRef } from "react";
export default function Select({
className = "",
...props
}: ComponentPropsWithRef<"select">) {
return (
<select
className={`h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-base text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 disabled:bg-slate-50 disabled:text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:disabled:bg-slate-800 ${className}`}
{...props}
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ComponentPropsWithRef, ReactNode } from "react";
export function Button({
children,
variant = "primary",
className = "",
...props
}: ComponentPropsWithRef<"button"> & { variant?: "primary" | "ghost" }) {
const base =
"inline-flex h-11 items-center justify-center rounded-lg px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500";
const variants = {
primary: "bg-sky-600 text-white hover:bg-sky-700",
ghost:
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
};
return (
<button className={`${base} ${variants[variant]} ${className}`} {...props}>
{children}
</button>
);
}
export function Field({
label,
hint,
error,
children,
}: {
label: string;
hint?: ReactNode;
error?: string;
children: ReactNode;
}) {
return (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{children}
{error ? (
<span className="block text-sm text-rose-600 dark:text-rose-400">
{error}
</span>
) : hint ? (
<span className="block text-sm text-slate-500 dark:text-slate-400">
{hint}
</span>
) : null}
</label>
);
}
export function TextInput({
className = "",
...props
}: ComponentPropsWithRef<"input">) {
return (
<input
className={`h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-base text-slate-900 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 disabled:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:disabled:bg-slate-800 ${className}`}
{...props}
/>
);
}
export function Alert({ children }: { children: ReactNode }) {
return (
<div
role="alert"
className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-300"
>
{children}
</div>
);
}
export function Spinner({ label = "Loading" }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-slate-500">
<span
aria-hidden
className="size-4 animate-spin rounded-full border-2 border-slate-300 border-t-sky-600"
/>
<span className="text-sm">{label}</span>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Thin client over the Django API.
*
* Every error the backend returns has the same shape
* (`{detail, code, errors?}`), so callers branch on `ApiError.code` rather
* than on status codes or message text.
*/
const TOKEN_KEY = "sarlink.token";
export type AuthMethod = "password" | "otp";
export type AccountStatus = "pending" | "approved" | "rejected";
export interface User {
id: number;
mobile: string;
full_name: string;
email: string | null;
idnumber: string;
date_of_birth: string | null;
atoll: number | null;
atoll_name: string | null;
island: number | null;
island_name: string | null;
auth_method: AuthMethod;
status: AccountStatus;
rejection_reason: string;
mobile_verified: boolean;
is_admin: boolean;
has_password: boolean;
date_joined: string;
}
/** Which second step to render. Identical for every number that gets a code,
* whether or not it has an account. */
export interface AuthStartResult {
next: "password" | "otp";
mobile: string;
expires_at?: string;
resend_available_at?: string;
code_length?: number;
}
export interface Island {
id: number;
name: string;
atoll: number;
}
export interface Atoll {
id: number;
name: string;
code: string;
islands: Island[];
}
/** Proof that a number was verified by SMS, required by the register submit. */
export interface RegistrationTicket {
registration_token: string;
mobile: string;
expires_at: string;
}
export interface RegistrationForm {
registration_token: string;
full_name: string;
idnumber: string;
date_of_birth: string;
atoll: number;
island: number;
terms_accepted: boolean;
policy_accepted: boolean;
}
export interface RegistrationResult {
status: AccountStatus;
mobile: string;
full_name: string;
detail: string;
}
export interface LoginResult {
next: "dashboard";
token: string;
expiry: string | null;
user: User;
}
export interface RegistrationHandoff extends RegistrationTicket {
next: "register";
}
/** A confirmed code either signs the account in or unlocks registration. */
export type VerifyResult = LoginResult | RegistrationHandoff;
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly errors?: Record<string, string[]>;
readonly data: Record<string, unknown>;
constructor(status: number, data: Record<string, unknown>) {
super(
typeof data.detail === "string" ? data.detail : "Something went wrong.",
);
this.name = "ApiError";
this.status = status;
this.code = typeof data.code === "string" ? data.code : "error";
this.errors = data.errors as Record<string, string[]> | undefined;
this.data = data;
}
/** First message for `field`, if the failure was a validation error. */
fieldError(field: string): string | undefined {
return this.errors?.[field]?.[0];
}
}
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* private mode: the session just won't survive a reload */
}
}
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
interface RequestOptions {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: unknown;
/** Send the stored token. Defaults to true when one exists. */
auth?: boolean;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, auth = true } = options;
const token = auth ? getToken() : null;
const headers: Record<string, string> = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (token) headers.Authorization = `Token ${token}`;
let response: Response;
try {
response = await fetch(`/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(0, {
detail: "Can't reach the server. Check your connection.",
code: "network_error",
});
}
if (response.status === 204) return undefined as T;
const text = await response.text();
let data: Record<string, unknown> = {};
if (text) {
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
data = { detail: text };
}
}
if (!response.ok) throw new ApiError(response.status, data);
return data as T;
}
export const api = {
/** Step 1: which second step does this number use? */
authStart: (mobile: string) =>
request<AuthStartResult>("/auth/start/", {
method: "POST",
body: { mobile },
auth: false,
}),
/** Step 2a. */
loginWithPassword: (mobile: string, password: string) =>
request<LoginResult>("/auth/login/password/", {
method: "POST",
body: { mobile, password },
auth: false,
}),
/** Step 2b: the code, whichever purpose it was issued for. */
verifyCode: (mobile: string, code: string) =>
request<VerifyResult>("/auth/verify/", {
method: "POST",
body: { mobile, code },
auth: false,
}),
resendOtp: (mobile: string) =>
request<AuthStartResult>("/auth/otp/resend/", {
method: "POST",
body: { mobile },
auth: false,
}),
/** Submit the registration form. Creates a pending account, no session. */
register: (form: RegistrationForm) =>
request<RegistrationResult>("/auth/register/", {
method: "POST",
body: form,
auth: false,
}),
atolls: () => request<Atoll[]>("/locations/atolls/", { auth: false }),
me: () => request<User>("/auth/me/"),
logout: () => request<void>("/auth/logout/", { method: "POST" }),
health: () =>
request<{ status: string; database: string }>("/health/", { auth: false }),
};
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
import type { LoginResult, User } from "@/lib/api";
export interface AuthState {
user: User | null;
/** True until the stored token has been checked against the API. */
loading: boolean;
signIn: (result: LoginResult) => void;
signOut: () => Promise<void>;
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used inside <AuthProvider>");
return context;
}
+61
View File
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { api, clearToken, getToken, setToken } from "@/lib/api";
import type { LoginResult, User } from "@/lib/api";
import { AuthContext } from "@/lib/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// Nothing to resolve when there's no stored token.
const [loading, setLoading] = useState(() => getToken() !== null);
// A token in localStorage is only a hint - ask the API who it belongs to.
useEffect(() => {
if (!getToken()) return;
let active = true;
api
.me()
.then((me) => {
if (active) setUser(me);
})
.catch(() => {
clearToken();
if (active) setUser(null);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
const signIn = useCallback((result: LoginResult) => {
setToken(result.token);
setUser(result.user);
}, []);
const signOut = useCallback(async () => {
try {
await api.logout();
} catch {
/* the token is going away locally either way */
}
clearToken();
setUser(null);
}, []);
const refresh = useCallback(async () => {
setUser(await api.me());
}, []);
const value = useMemo(
() => ({ user, loading, signIn, signOut, refresh }),
[user, loading, signIn, signOut, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+6
View File
@@ -0,0 +1,6 @@
/** `Date` -> "YYYY-MM-DD" in local time (never UTC-shifted by toISOString). */
export function toIsoDate(date: Date): string {
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
+37
View File
@@ -0,0 +1,37 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import AppLayout from "@/components/AppLayout";
import RequireAuth from "@/components/RequireAuth";
import { AuthProvider } from "@/lib/auth";
import Dashboard from "@/pages/Dashboard";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";
import Register from "@/pages/Register";
import "@/index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="/portal" element={<Navigate to="/" replace />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import { useAuth } from "@/lib/auth-context";
export default function Dashboard() {
const { user } = useAuth();
if (!user) return null;
const rows: [string, ReactNode][] = [
["Mobile", user.mobile],
["Name", user.full_name || "—"],
["ID number", user.idnumber || "—"],
["Date of birth", user.date_of_birth ?? "—"],
[
"Address",
[user.island_name, user.atoll_name].filter(Boolean).join(", ") || "—",
],
["Sign-in method", user.auth_method === "password" ? "Password" : "SMS code"],
];
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Welcome{user.full_name ? `, ${user.full_name.split(" ")[0]}` : ""}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Your account details as the portal has them.
</p>
</div>
<StatusBanner />
<dl className="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 px-4 py-3">
<dt className="text-sm text-slate-500 dark:text-slate-400">{label}</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-slate-100">
{value}
</dd>
</div>
))}
</dl>
</div>
);
}
/** Pending and rejected accounts get no services yet - say so plainly. */
function StatusBanner() {
const { user } = useAuth();
if (!user) return null;
if (user.status === "pending") {
return (
<Banner
tone="amber"
title="Registration pending approval"
body="An admin is reviewing your application. We'll text you as soon as it's approved."
/>
);
}
if (user.status === "rejected") {
return (
<Banner
tone="rose"
title="Registration not approved"
body={
user.rejection_reason ||
"Contact SAR Link support to find out what's needed."
}
/>
);
}
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Devices and billing land here next.
</p>
);
}
function Banner({
tone,
title,
body,
}: {
tone: "amber" | "rose";
title: string;
body: string;
}) {
const tones = {
amber:
"border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200",
rose: "border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200",
};
return (
<div className={`space-y-1 rounded-xl border px-4 py-3 ${tones[tone]}`}>
<p className="text-sm font-medium">{title}</p>
<p className="text-sm opacity-90">{body}</p>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Button, Field, TextInput } from "@/components/ui";
import { api, ApiError } from "@/lib/api";
import type { AuthStartResult } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
type Step = "mobile" | "password" | "otp";
export default function Login() {
const navigate = useNavigate();
const { signIn } = useAuth();
const [step, setStep] = useState<Step>("mobile");
const [mobile, setMobile] = useState("");
const [secret, setSecret] = useState("");
const [start, setStart] = useState<AuthStartResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldError, setFieldError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [cooldown, setCooldown] = useState(0);
const secretRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (step !== "mobile") secretRef.current?.focus();
}, [step]);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
function secondsUntil(timestamp?: string): number {
if (!timestamp) return 0;
return Math.max(0, Math.ceil((Date.parse(timestamp) - Date.now()) / 1000));
}
function handleFailure(err: unknown, field?: string) {
if (err instanceof ApiError) {
const fieldMessage = field ? err.fieldError(field) : undefined;
if (fieldMessage) {
setFieldError(fieldMessage);
return;
}
setError(err.message);
if (err.code === "code_expired" || err.code === "code_exhausted") {
setSecret("");
setCooldown(0);
}
return;
}
setError("Something went wrong. Try again.");
}
async function submitMobile(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldError(null);
try {
const result = await api.authStart(mobile);
setStart(result);
setSecret("");
setStep(result.next);
setCooldown(secondsUntil(result.resend_available_at));
} catch (err) {
handleFailure(err, "mobile");
} finally {
setBusy(false);
}
}
async function submitSecret(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldError(null);
try {
if (step === "password") {
signIn(await api.loginWithPassword(mobile, secret));
navigate("/", { replace: true });
return;
}
const result = await api.verifyCode(mobile, secret);
if (result.next === "register") {
navigate("/register", { replace: true, state: result });
return;
}
signIn(result);
navigate("/", { replace: true });
} catch (err) {
handleFailure(err, step === "password" ? "password" : "code");
} finally {
setBusy(false);
}
}
async function resend() {
setBusy(true);
setError(null);
try {
const result = await api.resendOtp(mobile);
setStart(result);
setSecret("");
setCooldown(secondsUntil(result.resend_available_at) || 60);
} catch (err) {
if (err instanceof ApiError && err.code === "resend_cooldown") {
setCooldown(
secondsUntil(err.data.resend_available_at as string | undefined) || 60,
);
}
handleFailure(err);
} finally {
setBusy(false);
}
}
function restart() {
setStep("mobile");
setSecret("");
setStart(null);
setError(null);
setFieldError(null);
}
const shownMobile = start?.mobile ?? mobile;
return (
<main className="flex min-h-dvh items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="w-full max-w-sm space-y-6">
<header className="space-y-1 text-center">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
SAR Link
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Sign in to the member portal
</p>
</header>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{error ? <Alert>{error}</Alert> : null}
{step === "mobile" ? (
<form onSubmit={submitMobile} className="space-y-4">
<Field label="Mobile number" error={fieldError ?? undefined}>
<TextInput
autoFocus
name="mobile"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="7712345"
value={mobile}
onChange={(event) => setMobile(event.target.value)}
required
/>
</Field>
<Button type="submit" className="w-full" disabled={busy || !mobile}>
{busy ? "Checking…" : "Continue"}
</Button>
</form>
) : (
<form onSubmit={submitSecret} className="space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "password"
? `Signing in as ${shownMobile}`
: `We sent a ${start?.code_length ?? 6}-digit code to ${shownMobile}`}
</p>
{step === "password" ? (
<Field label="Password" error={fieldError ?? undefined}>
<TextInput
ref={secretRef}
name="password"
type="password"
autoComplete="current-password"
value={secret}
onChange={(event) => setSecret(event.target.value)}
required
/>
</Field>
) : (
<Field label="Verification code" error={fieldError ?? undefined}>
<TextInput
ref={secretRef}
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
placeholder="123456"
className="tracking-[0.4em]"
value={secret}
onChange={(event) =>
setSecret(event.target.value.replace(/\D/g, ""))
}
required
/>
</Field>
)}
<Button type="submit" className="w-full" disabled={busy || !secret}>
{busy ? "Verifying…" : "Continue"}
</Button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={restart}
className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
Use another number
</button>
{step === "otp" ? (
<button
type="button"
onClick={resend}
disabled={busy || cooldown > 0}
className="text-sky-600 hover:underline disabled:text-slate-400 disabled:no-underline dark:text-sky-400"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
</button>
) : null}
</div>
</form>
)}
</div>
</div>
</main>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from "react-router-dom";
export default function NotFound() {
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-3 bg-slate-50 px-4 text-center dark:bg-slate-950">
<p className="text-5xl font-semibold text-slate-300 dark:text-slate-700">404</p>
<p className="text-slate-600 dark:text-slate-300">This page doesn't exist.</p>
<Link to="/" className="text-sm text-sky-600 hover:underline dark:text-sky-400">
Go to the portal
</Link>
</main>
);
}
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useState } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
import DateField from "@/components/DateField";
import Select from "@/components/Select";
import { Alert, Button, Field, TextInput } from "@/components/ui";
import { api, ApiError } from "@/lib/api";
import { toIsoDate } from "@/lib/date";
import type { Atoll, RegistrationTicket } from "@/lib/api";
const TERMS_URL = "https://sarlink.net/terms";
const POLICY_URL = "https://sarlink.net/policy";
type Errors = Record<string, string>;
/**
* The registration form, reached only after the number was confirmed by SMS:
* `location.state` carries the registration ticket from /login.
*
* Submitting does not create a usable account - it files an application an
* admin has to approve.
*/
export default function Register() {
const location = useLocation();
const ticket = location.state as RegistrationTicket | null;
const [atolls, setAtolls] = useState<Atoll[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [fullName, setFullName] = useState("");
const [idnumber, setIdnumber] = useState("");
const [dateOfBirth, setDateOfBirth] = useState<Date | undefined>(undefined);
const [atollId, setAtollId] = useState("");
const [islandId, setIslandId] = useState("");
const [terms, setTerms] = useState(false);
const [policy, setPolicy] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [formError, setFormError] = useState<string | null>(null);
// The ticket expired or was already used: the number has to be re-verified.
const [needsReverify, setNeedsReverify] = useState(false);
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState<string | null>(null);
useEffect(() => {
let active = true;
api
.atolls()
.then((result) => {
if (active) setAtolls(result);
})
.catch(() => {
if (active) setLoadError("Couldn't load the island list. Reload to retry.");
});
return () => {
active = false;
};
}, []);
// No ticket means the number was never verified - start over.
if (!ticket?.registration_token && !submitted) {
return <Navigate to="/login" replace />;
}
const islands = atolls?.find((atoll) => `${atoll.id}` === atollId)?.islands ?? [];
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
setFormError(null);
try {
const result = await api.register({
registration_token: ticket!.registration_token,
full_name: fullName,
idnumber: idnumber,
date_of_birth: dateOfBirth ? toIsoDate(dateOfBirth) : "",
atoll: Number(atollId),
island: Number(islandId),
terms_accepted: terms,
policy_accepted: policy,
});
setSubmitted(result.detail);
} catch (err) {
if (err instanceof ApiError) {
const fieldErrors: Errors = {};
for (const [field, messages] of Object.entries(err.errors ?? {})) {
if (Array.isArray(messages)) fieldErrors[field] = messages[0];
}
setErrors(fieldErrors);
const ticketError = fieldErrors.registration_token ?? fieldErrors.mobile;
if (ticketError) {
setNeedsReverify(true);
setFormError(ticketError);
} else {
// Field errors are shown inline; only a general failure needs the banner.
setFormError(Object.keys(fieldErrors).length > 0 ? null : err.message);
}
} else {
setFormError("Something went wrong. Try again.");
}
} finally {
setBusy(false);
}
}
if (submitted) {
return (
<Shell title="Registration received">
<div className="space-y-4 text-center">
<div
aria-hidden
className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-100 text-2xl text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{submitted}</p>
<Link
to="/login"
className="inline-block text-sm text-sky-600 hover:underline dark:text-sky-400"
>
Back to sign in
</Link>
</div>
</Shell>
);
}
return (
<Shell title="Create your account" subtitle="An admin reviews every application.">
{formError ? (
<Alert>
{formError}
{needsReverify ? (
<>
{" "}
<Link to="/login" className="font-medium underline">
Start over
</Link>
</>
) : null}
</Alert>
) : null}
{loadError ? <Alert>{loadError}</Alert> : null}
<form onSubmit={submit} className="space-y-4">
<Field label="Mobile number" hint="Verified by SMS">
<TextInput value={ticket!.mobile} readOnly disabled />
</Field>
<Field label="Full name" error={errors.full_name}>
<TextInput
autoFocus
name="full_name"
autoComplete="name"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
required
/>
</Field>
<Field label="ID Card/Passport/Work Permit Number" error={errors.idnumber}>
<TextInput
name="idnumber"
value={idnumber}
onChange={(event) => setIdnumber(event.target.value)}
required
/>
</Field>
<Field label="Date of birth" error={errors.date_of_birth}>
<DateField value={dateOfBirth} onChange={setDateOfBirth} id="date_of_birth" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Atoll" error={errors.atoll}>
<Select
name="atoll"
value={atollId}
disabled={!atolls}
onChange={(event) => {
setAtollId(event.target.value);
setIslandId("");
}}
required
>
<option value="">{atolls ? "Select" : "Loading…"}</option>
{(atolls ?? []).map((atoll) => (
<option key={atoll.id} value={atoll.id}>
{atoll.name}
</option>
))}
</Select>
</Field>
<Field label="Island" error={errors.island}>
<Select
name="island"
value={islandId}
disabled={!atollId}
onChange={(event) => setIslandId(event.target.value)}
required
>
<option value="">{atollId ? "Select" : "Pick an atoll"}</option>
{islands.map((island) => (
<option key={island.id} value={island.id}>
{island.name}
</option>
))}
</Select>
</Field>
</div>
<div className="space-y-3 rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<Checkbox
name="terms_accepted"
checked={terms}
onChange={setTerms}
error={errors.terms_accepted}
>
I agree to the{" "}
<ExternalLink href={TERMS_URL}>terms and conditions</ExternalLink>.
</Checkbox>
<Checkbox
name="policy_accepted"
checked={policy}
onChange={setPolicy}
error={errors.policy_accepted}
>
I understand the <ExternalLink href={POLICY_URL}>privacy policy</ExternalLink>.
</Checkbox>
</div>
<Button
type="submit"
className="w-full"
disabled={busy || !atolls || needsReverify}
>
{busy ? "Submitting…" : "Register"}
</Button>
</form>
</Shell>
);
}
function Shell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-slate-50 px-4 py-10 dark:bg-slate-950">
<div className="mx-auto w-full max-w-md space-y-6">
<header className="space-y-1 text-center">
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">
{title}
</h1>
{subtitle ? (
<p className="text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>
) : null}
</header>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{children}
</div>
</div>
</main>
);
}
function Checkbox({
name,
checked,
onChange,
error,
children,
}: {
name: string;
checked: boolean;
onChange: (value: boolean) => void;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="flex items-start gap-2.5 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
name={name}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 size-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500/30 dark:border-slate-600"
/>
<span>{children}</span>
</label>
{error ? (
<p className="mt-1 pl-6.5 text-sm text-rose-600 dark:text-rose-400">{error}</p>
) : null}
</div>
);
}
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-sky-600 underline hover:no-underline dark:text-sky-400"
>
{children}
</a>
);
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"strict": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
// The SPA always calls the API at a relative `/api/...`:
// dev -> this proxy forwards to Django
// prod -> nginx serves the built files and proxies /api to gunicorn
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_PROXY_TARGET ?? "http://localhost:8000";
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: {
host: true,
port: 5173,
proxy: {
"/api": { target: apiTarget, changeOrigin: true },
"/admin": { target: apiTarget, changeOrigin: true },
"/static": { target: apiTarget, changeOrigin: true },
"/media": { target: apiTarget, changeOrigin: true },
},
},
build: { outDir: "dist", sourcemap: mode !== "production" },
};
});