Files
sarlinkportal-webui/docs/user-flows-and-state.md
shihaam 2f5659f3ff
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
document the ui
2026-08-02 18:44:46 +05:00

388 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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=<current path>`.
### 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=<mobile>` |
| 3 | redirect → `/auth/signup?phone_number=<phone>` | — | — | — |
| 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=<t_username>` | — | — | — |
| 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=<digits>``{ ok, verified }`.
- **If `!ok` (no such user) → `redirect("/auth/signup?phone_number=<phone>")`.**
- 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=<t_username>`.
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=<mobile>` |
| 2 | (action) | — | `signin()` sends OTP | `POST /auth/mobile/` (body `{ mobile }`) |
| 3 | redirect → `/auth/verify-otp?phone_number=<mobile>` | — | — | — |
| 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=<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=<mobile>`.
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, 112). Submitting calls `createPayment({ device_ids,
number_of_months })` (type `NewPayment`). On success it clears `deviceCartAtom`,
resets `numberOfMonths`, and routes to `/payments/<paymentId>`.
`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/<topup.id>`.
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: <reason>, 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/?<filters>` |
| `/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 `<Provider>` 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 <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=<path>`.
- `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
`<Provider>`, `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/`.