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

22 KiB
Raw Permalink Blame History

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 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}/...`)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/signinapp/(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/signupapp/(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-registrationapp/(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-otpapp/(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 /devicesapp/(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-payapp/(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 /walletapp/(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.tsxgetDevices(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.tsxgetPayments(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.tsxgetTopups(params, true)GET /api/billing/topup/?...&all_topups=true. Manual credit via admin-topup-form.tsxadminUserTopup()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-paycreatePayment
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::logoutPOST /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/.