# SAR Link Portal — Frontend User Flows & Client State Developer reference for the Next.js frontend (`frontend/`). Traces each end-to-end user flow across pages, components, and server actions/queries, listing the backend API endpoint(s) hit at each step, then documents client-side state (Jotai atoms, React Query, and auth/session). > **Mid-migration note (read first).** Auth is being moved off `next-auth` onto a > localStorage-based store (`lib/auth-store.ts`). The result is a **split auth model**: > > - **New client login path** (sign-in → OTP) writes the Knox token + user to > `localStorage` and attaches it per-request via an axios interceptor > (`lib/api-client.ts`). No next-auth session cookie is created. > - **Old server actions/queries** (`actions/*.ts`, `queries/*.ts`, and every > dashboard `page.tsx` that gates on admin) still call > `getServerSession(authOptions)` and read `session.apiToken` / > `session.user.is_admin`. > > Because the new login never establishes a next-auth session, `getServerSession` > returns `null` in those server actions/pages unless a legacy next-auth cookie > exists. **This is the central unfinished piece of the migration** — server-side > data fetching and admin gating are not yet wired to the new token. See > [Auth / Session](#auth--session) for detail. --- ## Infrastructure ### API client (new path) — `lib/api-client.ts` - `axios.create({ baseURL: "" })` → all requests are **relative** (`/api/...`, `/callback/auth/`); nginx proxies `/api/` and `/callback/` to Django. No API host is baked into the static build, no CORS. - Request interceptor attaches `Authorization: Token ` from `getToken()` (reads `localStorage`). - `validateStatus: status < 500` → 4xx bodies are returned to the caller (not thrown). - Response interceptor: on **401**, calls `clearAuth()` and redirects to `/auth/signin?callbackUrl=`. ### Server data path (old) — `actions/*.ts`, `queries/*.ts` - Server actions/queries use `fetch(\`${process.env.SARLINK_API_BASE_URL}/...\`)` with `Authorization: Token ${session?.apiToken}` where `session = await getServerSession(authOptions)`. - Some also `revalidatePath(...)` after mutations. --- ## Flow 1 — New user registration / onboarding | # | Page / route | Component | Action / query | Backend endpoint | |---|---|---|---|---| | 1 | `/auth/signin` — `app/(auth)/auth/signin/page.tsx` | `components/auth/login-form.tsx` | `signin()` action (`actions/auth-actions.ts`) | — | | 2 | (action) | — | `signin()` checks the phone | `GET /api/auth/users/filter/?mobile=` | | 3 | redirect → `/auth/signup?phone_number=` | — | — | — | | 4 | `/auth/signup` — `app/(auth)/auth/signup/page.tsx` | `components/auth/signup-form.tsx` | `signup()` action | dup checks (below) | | 5 | (action `signup()`) | — | `checkIdOrPhone`, `checkTempIdOrPhone`, `backendRegister` (`queries/authentication.ts`) | `GET /api/auth/users/filter/?id_card=&mobile=`, `GET /api/auth/users/temp/filter/?...`, `POST /api/auth/register/` | | 6 | redirect → `/auth/verify-otp-registration?phone_number=` | — | — | — | | 7 | `/auth/verify-otp-registration` — `app/(auth)/auth/verify-otp-registration/page.tsx` | `components/auth/verify-registration-otp-form.tsx` | `VerifyRegistrationOTP()` (`queries/authentication.ts`) | `POST /api/auth/register/verify/` | **Steps** 1. User enters phone on `/auth/signin` (`login-form.tsx`, field `phoneNumber`, format `^[7|9][0-9]{2}-[0-9]{4}$`). Submits the `signin()` server action. 2. `signin()` calls `GET /api/auth/users/filter/?mobile=` → `{ ok, verified }`. - **If `!ok` (no such user) → `redirect("/auth/signup?phone_number=")`.** - If `ok && !verified` → returns "account on pending verification" error. - If `ok && verified` → continues to the Login flow (sends OTP; see Flow 2). 3. `/auth/signup` requires `?phone_number=`; the phone field is pre-filled. User fills name, ID card (`A######`), atoll, island, address, DOB (≥18 enforced), account number, terms, policy. Validated by `signUpFormSchema` (`lib/schemas.ts`). 4. `signup()` (`actions/auth-actions.ts`) runs duplicate checks: - `checkIdOrPhone({ id_card })` → `GET /api/auth/users/filter/?id_card=...` - `checkIdOrPhone({ phone_number })` and `checkTempIdOrPhone({ phone_number })` → `GET /api/auth/users/filter/` and `GET /api/auth/users/temp/filter/`. 5. `backendRegister()` → `POST /api/auth/register/` with body `{ firstname, lastname, username, address, id_card, dob, mobile, island, atoll, acc_no, terms_accepted, policy_accepted }`. Response `{ t_username }`. 6. Redirect → `/auth/verify-otp-registration?phone_number=`. 7. User enters the 6-digit OTP. `VerifyRegistrationOTP()` → `POST /api/auth/register/verify/` with `{ mobile, otp }` → `{ verified, message }`. On success the user is told to log in (no auto-login — the commented-out auto-login is disabled in the source). Account remains pending admin verification. --- ## Flow 2 — Login (phone → OTP → token/user → session) | # | Page / route | Component | Action / query | Backend endpoint | |---|---|---|---|---| | 1 | `/auth/signin` | `components/auth/login-form.tsx` | `signin()` action | `GET /api/auth/users/filter/?mobile=` | | 2 | (action) | — | `signin()` sends OTP | `POST /auth/mobile/` (body `{ mobile }`) | | 3 | redirect → `/auth/verify-otp?phone_number=` | — | — | — | | 4 | `/auth/verify-otp` — `app/(auth)/auth/verify-otp/page.tsx` | `components/auth/verify-otp-form.tsx` | inline (`apiClient.post`) | `POST /callback/auth/` (body `{ token: pin }`) | | 5 | client persist + redirect | — | `setAuth()` (`lib/auth-store.ts`) | — | **Steps** 1. User enters phone on `/auth/signin`. `signin()` calls `GET /api/auth/users/filter/?mobile=`. Existing + verified user → proceeds; unknown → redirect to signup (see Flow 1); unverified → error. 2. `signin()` sends the login OTP: `POST /auth/mobile/` with `{ mobile }`. (The same `/auth/mobile/` call is also exposed as `backendMobileLogin()`.) 3. Redirect → `/auth/verify-otp?phone_number=`. 4. **`components/auth/verify-otp-form.tsx`** — the new client login. On submit it calls `apiClient.post("/callback/auth/", { token: pin })`. On HTTP 200 the response is `{ token, user }` (Knox token + user object). Non-200 (validateStatus lets <500 through) surfaces `body.token[0]` / `body.message` via a `sonner` toast. 5. On success: **`setAuth(res.data.token, res.data.user)`** writes to `localStorage` keys `sarlink_token` and `sarlink_user`, then `router.push(callbackUrl || "/devices")`. > **Legacy parallel path (still in the tree, not used by the sign-in UI):** > `app/auth.ts` defines a next-auth `CredentialsProvider` whose `authorize()` posts the > **same** `POST /callback/auth/` with `{ token: pin }` and, on 200, returns > `{ ...user, apiToken, expiry }` into the next-auth JWT/session. The next-auth API > route (`app/api/auth/[...nextauth]/route.ts`) is still mounted, but `AuthProvider` > / `SessionProvider` is **no longer rendered** (see below), so nothing drives this > provider from the UI. Server actions that read `getServerSession` depend on it. --- ## Flow 3 — Buying / registering a device and paying Cart is **purely client-side** Jotai state; nothing is persisted until a payment is created. ### 3a. Register a device | # | Page / route | Component | Action | Endpoint | |---|---|---|---|---| | 1 | `/devices` — `app/(dashboard)/devices/page.tsx` | `components/user/add-device-dialog.tsx` | `addDeviceAction()` (`queries/devices.ts`) | `POST /api/devices/` | - `add-device-dialog.tsx` validates name (≥2 chars) and MAC (`^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$`). `addDeviceAction` posts `{ name, mac, registered: true }`, then `revalidatePath("/devices")`. ### 3b. Add devices to cart | # | Page / route | Component | Atoms | Endpoint | |---|---|---|---|---| | 1 | `/devices`, `/user-devices` | `devices-table.tsx`, `device-card.tsx`, `clickable-row.tsx`, `add-devices-to-cart-button.tsx`, `device-cart.tsx` (`DeviceCartDrawer`) | `deviceCartAtom`, `cartDrawerOpenAtom` | none (client only) | - Selecting a device toggles it in `deviceCartAtom` (`Device[]`). When the cart is non-empty, `DeviceCartDrawer` (rendered by `ApplicationLayout`) shows a "pay N devices" action. No backend call at this stage. ### 3c. Create a payment | # | Page / route | Component | Action | Endpoint | |---|---|---|---|---| | 1 | `/devices-to-pay` — `app/(dashboard)/devices-to-pay/page.tsx` | `components/devices-for-payment.tsx` | `createPayment()` (`actions/payment.ts`) | `POST /api/billing/payment/` | - `devices-for-payment.tsx` reads `deviceCartAtom` and a months value (`numberOfMonths` atom, 1–12). Submitting calls `createPayment({ device_ids, number_of_months })` (type `NewPayment`). On success it clears `deviceCartAtom`, resets `numberOfMonths`, and routes to `/payments/`. `createPayment` also `revalidatePath("/devices")`. ### 3d. Pay a payment — `/payments/[paymentId]` Page `app/(dashboard)/payments/[paymentId]/page.tsx` loads the payment via `getPayment({ id })` → `GET /api/billing/payment/{id}`. The pay UI is `components/devices-to-pay.tsx`; cancel is `components/billing/cancel-payment-button.tsx`. Both pay buttons submit the **same** server action `verifyDevicePayment()` (`actions/payment.ts`) via `useActionState`, differing only by a hidden `method` field → `PUT /api/billing/payment/{id}/verify/` with `{ method }`. - **Bank TRANSFER (MIB):** hidden `method=TRANSFER`. The UI shows a hardcoded beneficiary account (`accountNo="90101400028321000"`, "Baraveli Dev"); the user transfers manually then clicks "I have paid". **MIB transaction verification is done server-side by Django** — the frontend does *not* perform an MIB lookup; it only sends `method: "TRANSFER"`. (`Payment.mib_reference` is displayed read-only.) - **WALLET balance:** hidden `method=WALLET`, shown only when `user.wallet_balance >= amount` (balance read from `userAtom`). Success toast: "Payment completed successfully using wallet!". Backend deducts from the wallet. - **Cancel:** `cancelPayment({ id })` → `PATCH /api/billing/payment/{id}/cancel/` (only when `status === "PENDING"` and not expired) → status `CANCELLED`, redirect to `/devices`. `NewPayment` = `{ device_ids: number[]; number_of_months: number }`. --- ## Flow 4 — Wallet top-up | # | Page / route | Component | Action / query | Endpoint | |---|---|---|---|---| | 1 | any dashboard page (header) | `components/wallet.tsx` (drawer) | — | — | | 2 | (drawer) | `number-input.tsx` | — | — | | 3 | (drawer submit) | `wallet.tsx` | `createTopup()` (`actions/payment.ts`) | `POST /api/billing/topup/` | | 4 | `/top-ups/[topupId]` — `app/(dashboard)/top-ups/[topupId]/page.tsx` | `components/topup-to-pay.tsx` | `getTopup()` | `GET /api/billing/topup/{id}` | | 5 | (topup page) | `topup-to-pay.tsx` | `verifyTopupPayment()` | `PUT /api/billing/topup/{id}/verify/` | | 6 | (topup page) | `components/billing/cancel-topup-button.tsx` | `cancelTopup()` | `PATCH /api/billing/topup/{id}/cancel/` | | 7 | `/wallet` — `app/(dashboard)/wallet/page.tsx` | `components/wallet-transactions-table.tsx` | `getWaleltTransactions()` (`queries/wallet.ts`) | `GET /api/billing/wallet-transactions/` | **Steps** 1. The wallet button in the header (`ApplicationLayout`) opens the `Wallet` drawer (`WalletDrawerOpenAtom`); it displays `walletBalance` passed from `user.wallet_balance` (read from `userAtom`). 2. User sets an amount (`walletTopUpValue` atom; `maxAllowed=5000`, disabled at 0). 3. "Go to payment" → `createTopup({ amount })` → `POST /api/billing/topup/` → `Topup` (`status: "PENDING"`). Routes to `/top-ups/`. 4. Topup page loads it via `getTopup({ id })`. Shows beneficiary account, MIB reference (read-only), expiry countdown, and status badges. 5. After transferring, user clicks "I have paid" → `verifyTopupPayment()` → `PUT /api/billing/topup/{id}/verify/` (no body). Response includes `transaction { sourceBank, trxDate }`. On success the backend credits the wallet; action `revalidatePath("/top-ups/[topupId]")`. 6. Optional cancel (while PENDING & not expired): `cancelTopup({ id })` → `PATCH /api/billing/topup/{id}/cancel/` → status `CANCELLED`. 7. History at `/wallet`: `getWaleltTransactions()` → `GET /api/billing/wallet-transactions/` → `WalletTransaction[]` (`transaction_type: "TOPUP" | "DEBIT"`), with per-row links to the source `/top-ups/{ref}` or `/payments/{ref}`. > Note: the displayed wallet balance comes from `userAtom` (login snapshot), so it can > be **stale** after a topup/payment until the user logs in again — the header does not > re-fetch the profile. --- ## Flow 5 — Parental control Page `app/(dashboard)/parental-control/page.tsx` reuses `components/devices-table.tsx` with `parentalControl={true}` and hard filters `is_active: "true"`, `has_a_pending_payment: "false"`, plus a `DynamicFilter` (name / mac / vendor). | # | Page / route | Component | Action | Endpoint | |---|---|---|---|---| | 1 | `/parental-control` | `devices-table.tsx` | `getDevices()` (`queries/devices.ts`) | `GET /api/devices/?is_active=true&has_a_pending_payment=false&all_devices=false` | | 2 | (row) | `components/block-device-dialog.tsx` | `blockDeviceAction()` | `PUT /api/devices/{id}/block/` | **What it does:** lets a user (parent) block/unblock their own active devices. `blockDeviceAction` (`queries/devices.ts`) branches on the form's `action` (`block` | `simple-block` | `unblock`) and on `session.user.is_superuser`: - **Parent block** (`simple-block`, non-admin): body `{ blocked: true, reason_for_blocking: "Blocked by parent", blocked_by: "PARENT" }`. - **Parent unblock** (`unblock`): `{ blocked: false, reason_for_blocking: "-", blocked_by: "PARENT" }`. - **Admin block** (`block`, `is_superuser`): opens a dialog requiring a reason (≥5 chars, validated in the action); body `{ blocked: true, reason_for_blocking: , blocked_by: "ADMIN" }`. After mutating, `revalidatePath("/devices")` and `revalidatePath("/parental-control")`. --- ## Flow 6 — Admin management **Admin gating (old model):** each admin `page.tsx` runs `const session = await getServerSession(authOptions); if (!session?.user?.is_admin) redirect(...)`. The **sidebar** additionally gates nav items client-side via `userAtom` (`components/ui/app-sidebar.tsx`): if `user.is_admin` all categories show; otherwise the "ADMIN CONTROL" group is dropped and remaining items are filtered by matching `perm_identifier` against `user.user_permissions`. > ⚠️ Consistent with the migration gap: server-side `is_admin` comes from > `getServerSession` (next-auth), while the sidebar's `is_admin`/permissions come from > the localStorage `userAtom`. These are two different sources. **"See everything" flags:** admin list views pass a second boolean to the query that appends an `all_*` query param: | Endpoint | User (default) | Admin | |---|---|---| | `GET /api/devices/` | `all_devices=false` | `all_devices=true` | | `GET /api/billing/payment/` | `all_payments=false` | `all_payments=true` | | `GET /api/billing/topup/` | `all_topups=false` | `all_topups=true` | | `GET /api/billing/wallet-transactions/` | `all_transactions=false` | `all_transactions=true` | ### 6a. Users — `/users`, `/users/[userId]/{details,update,agreement}` | Page | Component | Action / query | Endpoint | |---|---|---|---| | `/users` | `components/user-table.tsx` | `getUsers()` (`queries/users.ts`) | `GET /api/auth/users/?` | | `/users/[userId]/details` | detail view + dialogs | `getProfileById()` | `GET /api/auth/users/{id}/` | | — verify | `components/user/user-verify-dialog.tsx` | `verifyUser()` (`actions/user-actions.ts`) | `PUT /api/auth/users/{id}/verify/` | | — reject | `components/user/user-reject-dialog.tsx` | `rejectUser()` | `DELETE /api/auth/users/{id}/reject/` (body `{ rejection_details }`) | | — add cash | `components/admin/admin-topup-form.tsx` | `adminUserTopup()` | `POST /api/billing/admin-topup/` (body `{ amount, user_id, description }`) | | `/users/[userId]/update` | `components/user/user-update-form.tsx` | `updateUser()` | `PUT /api/auth/users/{id}/update/` | | `/users/[userId]/agreement` | `components/user/user-agreement-form.tsx` | `updateUserAgreement()` | `PUT /api/auth/users/{id}/agreement/` (multipart file) | The details page also compares DB data vs national registry data (`getNationalPerson`, `lib/person.ts` / `lib/types.ts::TNationalPerson`). ### 6b. User devices — `/user-devices` `components/admin/admin-devices-table.tsx` → `getDevices(params, true)` → `GET /api/devices/?...&all_devices=true`. Block/unblock via `block-device-dialog.tsx` in admin mode (`blocked_by: "ADMIN"`, reason required) → `PUT /api/devices/{id}/block/`. ### 6c. User payments — `/user-payments` `components/admin/user-payments-table.tsx` → `getPayments(params, true)` → `GET /api/billing/payment/?...&all_payments=true`. Read-only admin view (status, method, MIB reference, paid-at). ### 6d. User topups — `/user-topups` `components/admin/admin-topup-table.tsx` → `getTopups(params, true)` → `GET /api/billing/topup/?...&all_topups=true`. Manual credit via `admin-topup-form.tsx` → `adminUserTopup()` → `POST /api/billing/admin-topup/`. --- ## Client state ### Jotai atoms **`lib/atoms.ts`** (plain in-memory atoms; a bespoke `store` is exported but the app is wrapped in a default `` in `app/layout.tsx`): | Atom | Default | Purpose | |---|---|---| | `initialPriceAtom` | `100` | Price calculator: base price input | | `discountPercentageAtom` | `75` | Price calculator: per-extra-device increment (used as the multiplier in the formula) | | `numberOfDevicesAtom` | `1` | Price calculator: device count | | `numberOfDaysAtom` | `30` | Price calculator: days (display only) | | `numberOfMonths` | `1` | Months selected on `devices-to-pay` → `createPayment` | | `walletTopUpValue` | `100` | Wallet drawer top-up amount | | `formulaResultAtom` | `""` | Price calculator: computed result string | | `deviceCartAtom` | `[]` (`Device[]`) | Devices selected for payment (the "cart") | | `cartDrawerOpenAtom` | `false` | Device cart drawer open state | | `WalletDrawerOpenAtom` | `false` | Wallet drawer open state | | `loadingDevicesToPayAtom` | `false` | Loading flag during payment creation | Price-calculator atoms are consumed by `components/price-calculator.tsx` and `components/devices-for-payment.tsx`. Cart atoms by the device tables/cards and `device-cart.tsx`. Wallet atoms by `components/wallet.tsx`. **`lib/auth-store.ts`** (`atomWithStorage`, persisted to `localStorage`): | Atom / key | Purpose | |---|---| | `tokenAtom` (key `sarlink_token`) | Knox token, reactive for components | | `userAtom` (key `sarlink_user`) | Logged-in `AuthUser` (id, names, id_card, mobile, `wallet_balance`, `is_admin`, `is_superuser`, `user_permissions`, `expiry`, …) | Plus non-React helpers over the same keys: `getToken()` (used by the axios interceptor), `getStoredUser()`, `setAuth(token, user)`, `clearAuth()`, `isAuthenticated()` (token present and, if `user.expiry` known, not past it). Consumers of `userAtom`: `application-layout.tsx` (wallet balance, welcome banner), `account-popver.tsx` (profile + logout), `app-sidebar.tsx` (admin/permission gating). `isAuthenticated()`: `route-guard.tsx`, `app/page.tsx`. ### React Query — `providers/query-provider.tsx` - A single `new QueryClient()` wrapped in `QueryClientProvider`. Mounted in **both** `app/layout.tsx` (root) and the dashboard layout `app/(dashboard)/layout.tsx`. - No default options are configured (default staleness/caching). - **Currently unused for data fetching:** there are no `useQuery`/`useMutation` calls in the app — server data comes from server actions/queries and mutations from `useActionState`/server actions. The provider is scaffolding for a future migration. `@tanstack/react-query` is a dependency but not yet driving any reads. ### Auth / session (split model, mid-migration) **New (active for the browser UI):** - Login writes `{ sarlink_token, sarlink_user }` to `localStorage` via `setAuth` (from `verify-otp-form.tsx`). - `apiClient` attaches `Authorization: Token ` per request; 401 → `clearAuth` + redirect to sign-in. - **`RouteGuard`** (`components/auth/route-guard.tsx`) wraps the dashboard layout and is the client-side replacement for the old next-auth `middleware.ts`: it renders nothing until `isAuthenticated()`, else redirects to `/auth/signin?callbackUrl=`. - `app/page.tsx` redirects to `/devices` or `/auth/signin` based on `isAuthenticated()`. - `AccountPopover` logout: best-effort `POST /auth/logout/` (token via interceptor), then `clearAuth()` and redirect. - **`AuthProvider` / next-auth `SessionProvider` is NOT rendered anywhere** (defined in `providers/AuthProvider.tsx` but unreferenced); the root layout uses only Jotai ``, `ThemeProvider`, and `QueryProvider`. **Old (still present, used only server-side):** - `app/auth.ts` (`authOptions`) — next-auth `CredentialsProvider` posting `POST /callback/auth/`, JWT strategy (30 min), populating `session.apiToken`, `session.user.is_admin`, `session.user.is_superuser` (types in `app/next-auth.d.ts`). Signs out via `queries/authentication.ts::logout` → `POST /auth/logout/`. - Route still mounted: `app/api/auth/[...nextauth]/route.ts`. - Every `actions/*.ts` and `queries/*.ts` server function and the admin/profile/devices `page.tsx` files call `getServerSession(authOptions)` for the token and `is_admin`. **Consequence / action item:** since the browser login no longer creates a next-auth session cookie, `getServerSession` yields `null` server-side, so those server actions send `Authorization: Token undefined` and admin `page.tsx` gates would redirect. The remaining migration work is to route server-side data fetching through the localStorage token (or otherwise re-establish the session) — until then the two halves of auth are inconsistent. Files using `getServerSession` in the dashboard: `users/page.tsx`, `users/[userId]/update/page.tsx`, `users/[userId]/agreement/page.tsx`, `user-devices/page.tsx`, `user-payments/page.tsx`, `user-topups/page.tsx`, `devices/page.tsx`, `profile/page.tsx` — plus all of `actions/` and `queries/`.