22 KiB
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-authonto 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
localStorageand 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 dashboardpage.tsxthat gates on admin) still callgetServerSession(authOptions)and readsession.apiToken/session.user.is_admin.Because the new login never establishes a next-auth session,
getServerSessionreturnsnullin 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 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>fromgetToken()(readslocalStorage). 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}/...`)withAuthorization: Token ${session?.apiToken}wheresession = 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
- User enters phone on
/auth/signin(login-form.tsx, fieldphoneNumber, format^[7|9][0-9]{2}-[0-9]{4}$). Submits thesignin()server action. signin()callsGET /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).
- If
/auth/signuprequires?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 bysignUpFormSchema(lib/schemas.ts).signup()(actions/auth-actions.ts) runs duplicate checks:checkIdOrPhone({ id_card })→GET /api/auth/users/filter/?id_card=...checkIdOrPhone({ phone_number })andcheckTempIdOrPhone({ phone_number })→GET /api/auth/users/filter/andGET /api/auth/users/temp/filter/.
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 }.- Redirect →
/auth/verify-otp-registration?phone_number=<t_username>. - 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
- User enters phone on
/auth/signin.signin()callsGET /api/auth/users/filter/?mobile=<mobile>. Existing + verified user → proceeds; unknown → redirect to signup (see Flow 1); unverified → error. signin()sends the login OTP:POST /auth/mobile/with{ mobile }. (The same/auth/mobile/call is also exposed asbackendMobileLogin().)- Redirect →
/auth/verify-otp?phone_number=<mobile>. components/auth/verify-otp-form.tsx— the new client login. On submit it callsapiClient.post("/callback/auth/", { token: pin }). On HTTP 200 the response is{ token, user }(Knox token + user object). Non-200 (validateStatus lets <500 through) surfacesbody.token[0]/body.messagevia asonnertoast.- On success:
setAuth(res.data.token, res.data.user)writes tolocalStoragekeyssarlink_tokenandsarlink_user, thenrouter.push(callbackUrl || "/devices").
Legacy parallel path (still in the tree, not used by the sign-in UI):
app/auth.tsdefines a next-authCredentialsProviderwhoseauthorize()posts the samePOST /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, butAuthProvider/SessionProvideris no longer rendered (see below), so nothing drives this provider from the UI. Server actions that readgetServerSessiondepend 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.tsxvalidates name (≥2 chars) and MAC (^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$).addDeviceActionposts{ name, mac, registered: true }, thenrevalidatePath("/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 byApplicationLayout) 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.tsxreadsdeviceCartAtomand a months value (numberOfMonthsatom, 1–12). Submitting callscreatePayment({ device_ids, number_of_months })(typeNewPayment). On success it clearsdeviceCartAtom, resetsnumberOfMonths, and routes to/payments/<paymentId>.createPaymentalsorevalidatePath("/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 sendsmethod: "TRANSFER". (Payment.mib_referenceis displayed read-only.) - WALLET balance: hidden
method=WALLET, shown only whenuser.wallet_balance >= amount(balance read fromuserAtom). Success toast: "Payment completed successfully using wallet!". Backend deducts from the wallet. - Cancel:
cancelPayment({ id })→PATCH /api/billing/payment/{id}/cancel/(only whenstatus === "PENDING"and not expired) → statusCANCELLED, 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
- The wallet button in the header (
ApplicationLayout) opens theWalletdrawer (WalletDrawerOpenAtom); it displayswalletBalancepassed fromuser.wallet_balance(read fromuserAtom). - User sets an amount (
walletTopUpValueatom;maxAllowed=5000, disabled at 0). - "Go to payment" →
createTopup({ amount })→POST /api/billing/topup/→Topup(status: "PENDING"). Routes to/top-ups/<topup.id>. - Topup page loads it via
getTopup({ id }). Shows beneficiary account, MIB reference (read-only), expiry countdown, and status badges. - After transferring, user clicks "I have paid" →
verifyTopupPayment()→PUT /api/billing/topup/{id}/verify/(no body). Response includestransaction { sourceBank, trxDate }. On success the backend credits the wallet; actionrevalidatePath("/top-ups/[topupId]"). - Optional cancel (while PENDING & not expired):
cancelTopup({ id })→PATCH /api/billing/topup/{id}/cancel/→ statusCANCELLED. - 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_admincomes fromgetServerSession(next-auth), while the sidebar'sis_admin/permissions come from the localStorageuserAtom. 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 inQueryClientProvider. Mounted in bothapp/layout.tsx(root) and the dashboard layoutapp/(dashboard)/layout.tsx. - No default options are configured (default staleness/caching).
- Currently unused for data fetching: there are no
useQuery/useMutationcalls in the app — server data comes from server actions/queries and mutations fromuseActionState/server actions. The provider is scaffolding for a future migration.@tanstack/react-queryis 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 }tolocalStorageviasetAuth(fromverify-otp-form.tsx). apiClientattachesAuthorization: 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-authmiddleware.ts: it renders nothing untilisAuthenticated(), else redirects to/auth/signin?callbackUrl=<path>.app/page.tsxredirects to/devicesor/auth/signinbased onisAuthenticated().AccountPopoverlogout: best-effortPOST /auth/logout/(token via interceptor), thenclearAuth()and redirect.AuthProvider/ next-authSessionProvideris NOT rendered anywhere (defined inproviders/AuthProvider.tsxbut unreferenced); the root layout uses only Jotai<Provider>,ThemeProvider, andQueryProvider.
Old (still present, used only server-side):
app/auth.ts(authOptions) — next-authCredentialsProviderpostingPOST /callback/auth/, JWT strategy (30 min), populatingsession.apiToken,session.user.is_admin,session.user.is_superuser(types inapp/next-auth.d.ts). Signs out viaqueries/authentication.ts::logout→POST /auth/logout/.- Route still mounted:
app/api/auth/[...nextauth]/route.ts. - Every
actions/*.tsandqueries/*.tsserver function and the admin/profile/devicespage.tsxfiles callgetServerSession(authOptions)for the token andis_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/.