diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..89d7532 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# SAR Link Portal — Frontend Documentation + +This folder is a **reconstruction spec** for the SAR Link Portal frontend: enough detail to rebuild the app screen-by-screen in any framework. It documents the UI, user flows, API endpoints, design system, and all hardcoded copy. + +## What the app is + +A customer + admin portal for an ISP (SAR Link by OmegaTech Solutions). Customers register (identity-verified against the national registry), add network devices, and pay for device subscriptions by **bank transfer (MIB)** or from a **prepaid wallet**. Admins verify users and manage devices, payments, topups, and wallet credits. + +## Index + +| Doc | What's in it | +|---|---| +| [architecture.md](./architecture.md) | Stack, folder map, current (BFF) vs target (static + nginx + `API_URL`) architecture, auth model, deployment, cleanup list | +| [design-system.md](./design-system.md) | Brand color (`sarLinkOrange #f49b5b`), theme tokens, fonts (Barlow/Bokor), dark mode, status colors, reusable layout patterns, app shell | +| [navigation.md](./navigation.md) | Full sidebar menu tree (labels, icons, routes, permissions), user vs admin visibility, other nav entry points | +| [routes.md](./routes.md) | Every route/page: URL, purpose, components, guards, loading states | +| [components.md](./components.md) | Component catalog by feature area + conventions | +| [user-flows-and-state.md](./user-flows-and-state.md) | End-to-end flows (register, login, buy/pay device, topup, parental control, admin) + Jotai atoms + React Query + auth/token storage | +| [api-endpoints.md](./api-endpoints.md) | Every backend endpoint: method, path, purpose, request/response, auth, + dead-code list | +| [ui-copy.md](./ui-copy.md) | Every hardcoded string (headings, buttons, placeholders, toasts, validation, bank/brand/contacts) for i18n | + +Related, outside this folder: [`../STATIC_MIGRATION_PLAN.md`](../STATIC_MIGRATION_PLAN.md) (in-progress static-export migration), `.build/prod/` (production compose + nginx). + +## How to recreate the app from these docs + +1. **Scaffold** the target stack (recommended: Vite + React + TanStack/React Router + Tailwind v4 + shadcn/ui `new-york` neutral + lucide). See [architecture.md](./architecture.md). +2. **Theme** it from [design-system.md](./design-system.md) — brand color, fonts, tokens, `title-bg`, status colors, dark mode. +3. **Build the shell** — sidebar ([navigation.md](./navigation.md)) + header (wallet / theme / account) + auth-page card layout ([design-system.md](./design-system.md)). +4. **Auth** — implement the login flow and token store from [user-flows-and-state.md](./user-flows-and-state.md) using `API_URL`; guard routes. +5. **Routes** — create every screen in [routes.md](./routes.md); compose from [components.md](./components.md). +6. **Data** — wire each screen to its endpoints in [api-endpoints.md](./api-endpoints.md) via React Query; carry over the response envelope + status conventions. +7. **Copy** — pull all visible text from [ui-copy.md](./ui-copy.md) (fix the noted typos; set up i18n keys). +8. **Flows** — validate the six end-to-end flows in [user-flows-and-state.md](./user-flows-and-state.md). + +## Key facts to carry over + +- **API base is configurable:** `API_URL=http://localhost:8000` (dev) / `https://portal.sarlink.net/api` (prod, nginx → Django). Same build, both environments. +- **Auth:** Knox token from `POST /callback/auth/`, stored in localStorage, sent as `Authorization: Token `; `401` → signin. +- **Two-tier UI:** desktop tables + mobile cards for every list; filters/pagination via URL query params. +- **Payments:** device payments and wallet both verify via the same endpoint with a `method` (`TRANSFER`/`WALLET`); **MIB verification is server-side** — the frontend only sends the method and shows hardcoded bank details. +- **Admin gating:** `user.is_admin` / `user.user_permissions`. +- **Dead code exists** — don't port Omada, Invoice Ninja, next-auth routes, or the unused axios helpers (see [api-endpoints.md](./api-endpoints.md)). + +## Provenance + +Compiled from a full read of `app/`, `components/`, `actions/`, `queries/`, `lib/`, and config. The app is mid-migration from the Node/BFF model to a static build — docs describe **current behavior** and flag migration deltas inline. diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md new file mode 100644 index 0000000..4939f24 --- /dev/null +++ b/docs/api-endpoints.md @@ -0,0 +1,119 @@ +# API Endpoints + +Every backend endpoint the frontend calls. Paths are shown **after** the API base (today `SARLINK_API_BASE_URL`; in the target architecture the base becomes `API_URL` → nginx `/api`). Auth = requires `Authorization: Token `. + +Third-party integrations use different bases (`PERSON_VERIFY_BASE_URL`, `OMADA_BASE_URL`, hardcoded Invoice Ninja) — flagged inline. + +## Summary + +| Method | Path | Purpose | Auth | +|---|---|---|:--:| +| **Auth & onboarding** |||| +| GET | `/api/auth/users/filter/?mobile=&id_card=` | Does a permanent user exist / is verified | No | +| GET | `/api/auth/users/temp/filter/?mobile=&id_card=` | Does a pending (temp) user exist | No | +| POST | `/auth/mobile/` | Send login OTP to a mobile | No | +| POST | `/api/auth/register/` | Register a new (temp) user | No | +| POST | `/api/auth/register/verify/` | Verify registration OTP | No | +| POST | `/callback/auth/` | Exchange OTP PIN → API token + user | No | +| POST | `/auth/logout/` | Invalidate token on sign-out | Yes | +| POST | `/auth/login/` | Username/password login — **DEAD** | No | +| **Billing** |||| +| POST | `/api/billing/payment/` | Create a device payment | Yes | +| GET | `/api/billing/payment/?…&all_payments=` | List payments | Yes | +| GET | `/api/billing/payment/{id}` | Get one payment | Yes | +| PATCH | `/api/billing/payment/{id}/cancel/` | Cancel a payment | Yes | +| PUT | `/api/billing/payment/{id}/verify/` | Verify/complete payment (TRANSFER/WALLET) | Yes | +| POST | `/api/billing/topup/` | Create a wallet top-up | Yes | +| GET | `/api/billing/topup/?…&all_topups=` | List top-ups | Yes | +| GET | `/api/billing/topup/{id}` | Get one top-up | Yes | +| PATCH | `/api/billing/topup/{id}/cancel/` | Cancel a top-up | Yes | +| PUT | `/api/billing/topup/{id}/verify/` | Verify a top-up payment | Yes | +| POST | `/api/billing/admin-topup/` | Admin credits a user's wallet | Yes | +| GET | `/api/billing/wallet-transactions/?…&all_transactions=` | List wallet transactions | Yes | +| **Devices** |||| +| GET | `/api/devices/?…&all_devices=` | List devices | Yes | +| GET | `/api/devices/{id}/` | Get one device | Yes | +| POST | `/api/devices/` | Register/add a device | Yes | +| PUT | `/api/devices/{id}/block/` | Block/unblock a device | Yes | +| **Users / profile / geo** |||| +| GET | `/api/auth/atolls/` | List atolls (+ nested islands) | No | +| GET | `/api/auth/users/?…` | List users (admin) | Yes | +| GET | `/api/auth/users/{id}/` | Get a user profile by id | Yes | +| GET | `/api/auth/profile/` | Logged-in user's own profile | Yes | +| PUT | `/api/auth/users/{id}/verify/` | Admin verify a user | Yes | +| DELETE | `/api/auth/users/{id}/reject/` | Admin reject a user | Yes | +| PUT | `/api/auth/users/{id}/update/` | Update a user's details | Yes | +| PUT | `/api/auth/users/{id}/agreement/` | Upload/update agreement (multipart) | Yes | +| **Third-party** |||| +| GET | `{PERSON_VERIFY}/api/person/{idCard}` | National identity lookup | No | +| — | Omada group/block endpoints | **DEAD** (moving to RADIUS) | X-API-key | +| POST | `{ninja}/api/v1/clients` | Invoice Ninja client — **DEAD** | x-api-token | + +## Auth & onboarding + +- **GET `/api/auth/users/filter/`** — `signin()` (auth-actions) + `checkIdOrPhone()`. Checks a phone/ID exists & is verified before OTP; signup dupe-check. → `{ ok, verified }`. +- **GET `/api/auth/users/temp/filter/`** — `checkTempIdOrPhone()`. Pending-registration lookup. → `{ ok, otp_verified, t_verified }`. +- **POST `/auth/mobile/`** — `signin()`. Sends login OTP. Body `{ mobile }`. → `{ detail }`. +- **POST `/api/auth/register/`** — `backendRegister()` from `signup()`. Body `{ firstname, lastname, username, address, id_card, dob, mobile, island, atoll, acc_no, terms_accepted, policy_accepted }`. → `{ t_username }`. +- **POST `/api/auth/register/verify/`** — `VerifyRegistrationOTP()`. Body `{ mobile, otp }`. → `{ message, verified }`. +- **POST `/callback/auth/`** — the login token exchange (was NextAuth `authorize`, now `verify-otp-form`). Body `{ token: pin }`. → `{ user, token, expiry }`. 400/403/429 return error payloads (`token[0]` / `message`). +- **POST `/auth/logout/`** — `logout()`. Expects `204`. +- **POST `/auth/login/`** — DEAD (`login()` via `axiosInstance`, unused; login goes through `/callback/auth/`). + +## Billing + +`actions/payment.ts`, `queries/wallet.ts`, `actions/user-actions.ts`. + +- **POST `/api/billing/payment/`** `createPayment()` — pay for cart devices. Body `{ device_ids[], number_of_months }` → `Payment`. +- **GET `/api/billing/payment/`** `getPayments()` — list; `all_payments=true` for admin. → `ApiResponse`. +- **GET `/api/billing/payment/{id}`** `getPayment()` → `Payment`. +- **PATCH `…/payment/{id}/cancel/`** `cancelPayment()` → `Payment`. +- **PUT `…/payment/{id}/verify/`** `verifyPayment()` / `verifyDevicePayment()` — Body `{ method: "TRANSFER" | "WALLET" }`. MIB verification is **server-side**; the frontend just sends the method. → `Payment`. +- **POST `/api/billing/topup/`** `createTopup()` — Body `{ amount }` → `Topup`. +- **GET `/api/billing/topup/`** `getTopups()` — `all_topups=true` for admin. → `ApiResponse`. +- **GET `/api/billing/topup/{id}`** `getTopup()` → `Topup`. +- **PATCH `…/topup/{id}/cancel/`** `cancelTopup()` → `Topup`. +- **PUT `…/topup/{id}/verify/`** `verifyTopupPayment()` → `{ status, message, transaction? { ref, sourceBank, trxDate } }`. +- **POST `/api/billing/admin-topup/`** `adminUserTopup()` — Body `{ amount, user_id, description }`. +- **GET `/api/billing/wallet-transactions/`** `getWaleltTransactions()` → `ApiResponse` (`transaction_type: "DEBIT" | "TOPUP"`). + +## Devices + +`queries/devices.ts` (`checkSession()` for token). + +- **GET `/api/devices/`** `getDevices()` — params `name, offset, limit, page, sortBy, status`, `all_devices=true` for admin. → `ApiResponse`. +- **GET `/api/devices/{id}/`** `getDevice()` → `Device`. +- **POST `/api/devices/`** `addDeviceAction()` — Body `{ name, mac, registered: true }` → `Device`. +- **PUT `/api/devices/{id}/block/`** `blockDeviceAction()` — Body `{ blocked, reason_for_blocking, blocked_by: "ADMIN" | "PARENT" }`. Parents forced to `PARENT`. → `Device`. + +## Users / profile / geo + +`queries/users.ts`, `queries/islands.ts`, `actions/user-actions.ts`. + +- **GET `/api/auth/atolls/`** `getAtolls()` — atoll+island dropdowns on signup. → `Atoll[]` (each with nested `islands`). +- **GET `/api/auth/users/`** `getUsers()` — admin user list. → `ApiResponse`. +- **GET `/api/auth/users/{id}/`** `getProfileById()` — profile & admin user pages. → `UserProfile`. +- **GET `/api/auth/profile/`** `getProfile()` — own profile (agreements, payment detail). → `User`. +- **PUT `/api/auth/users/{id}/verify/`** `verifyUser()` — → `{ ok, mismatch_fields, … }`; surfaces field mismatches. +- **DELETE `/api/auth/users/{id}/reject/`** `rejectUser()` — Body `{ rejection_details }`; `204` → revalidate/redirect. +- **PUT `/api/auth/users/{id}/update/`** `updateUser()` — Body = non-empty form fields. → `User` or per-field errors. +- **PUT `/api/auth/users/{id}/agreement/`** `updateUserAgreement()` — multipart FormData (PDF). → `{ agreement }`. + +## Third-party + +- **GET `{PERSON_VERIFY_BASE_URL}/api/person/{idCard}`** `getNationalPerson()` (`lib/person.ts`) — **LIVE**. National identity lookup to cross-check user data during admin verification. ISR `revalidate: 60`. → `TNationalPerson { nic, name(_en), dob, gender, house_name(_en), island_name(_en), atoll(_en), constituency, … }`. *Memory note: planned to route through the backend instead of calling directly.* +- **Omada** (`actions/omada-actions.ts`) — group profiles / add-to-group / block — **DEAD**, none imported by UI. Device blocking now uses backend `/api/devices/{id}/block/`. Consistent with the Omada→RADIUS migration. +- **Invoice Ninja** (`actions/ninja/client.ts`) — hardcoded staging URL, create client — **DEAD**. + +## Frontend's own API routes (`app/api/`) — not backend + +- **`app/api/auth/[...nextauth]/route.ts`** — NextAuth handler. **To be removed** in the static migration. +- **`app/api/check-devices/route.ts`** — stub returning `{ message: "Request received" }`; no backend call. Dead/placeholder. + +## Response envelope + +List endpoints return `ApiResponse = { meta, links, data: T[] }` (pagination). Errors surface as `{ message }` / `{ detail }` (see `handleApiResponse` in `utils/tryCatch.ts`, which is hardened against non-JSON error bodies). + +## Dead code to drop during the port + +`/auth/login/` (#8), `/islands/`, `/inventory/`, all Omada, Invoice Ninja, `backendMobileLogin`, `app/api/check-devices`, and the two axios helpers `utils/axios-client.ts` / `utils/axiosInstance.ts` (only used by dead calls). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0808fd6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,86 @@ +# Architecture + +## What the app is + +SAR Link Portal — a customer + admin portal for an ISP. Users register (verified against the national identity registry), add network devices, and pay for device subscriptions either by bank transfer (MIB) or from a prepaid wallet they top up. Admins verify users, manage devices/payments/topups, and credit wallets. + +## Stack (current) + +- **Next.js 15** (App Router, React 19), TypeScript. +- **Tailwind CSS v4** (CSS-first, no config file) + **shadcn/ui** (`new-york`, neutral) + Radix + **lucide** icons. +- **Jotai** (global UI state) + **React Query** (mounted, not yet used). +- **react-hook-form** + **zod** (forms/validation), **nuqs** (URL query state), **sonner** (toasts), **next-themes**, **motion** (animation). +- **Auth:** mid-migration — old **next-auth** (JWT session) → new **localStorage token** (`lib/auth-store.ts`) + axios **api-client** (`lib/api-client.ts`). + +## Folder map + +``` +app/ + (auth)/auth/{signin,signup,verify-otp,verify-otp-registration}/ # public auth pages + (dashboard)/{devices,payments,top-ups,wallet,users,...}/ # authed app (user + admin) + api/{auth/[...nextauth],check-devices}/ # frontend API routes (being removed) + layout.tsx page.tsx globals.css auth.ts +actions/ # server actions: auth-actions, payment, user-actions, (omada, ninja = dead) +queries/ # server data fetchers: authentication, devices, islands, users, wallet +components/ # feature components + components/ui (shadcn primitives) +lib/ # auth-store, api-client, atoms, backend-types, schemas, person, utils +utils/ # tryCatch, axios-client (dead), axiosInstance (dead) +providers/ # QueryProvider, theme, (AuthProvider = next-auth, being removed) +hooks/ middleware.ts(removed) +docs/ # this documentation +deploy/ # (superseded — real prod is .build/prod/, see below) +``` + +## Current architecture (BFF — what's being replaced) + +``` +Browser ──(RSC + Server Actions)──▶ Next.js server ──(fetch + Token)──▶ Django API +``` + +The Next.js server sits between the browser and Django: +- Server components + server actions read the token via `getServerSession` and call Django server-side over `SARLINK_API_BASE_URL`. +- Public origin `/api/*` = Next.js's own routes (NextAuth); Django's `/api` is reached only internally. +- Deployed as a Node container behind nginx (`.build/prod/`). + +## Target architecture (static + nginx, direct-to-API) + +``` +Browser ──(static HTML/JS from nginx) + ├─ API_URL calls (/api, /callback, /auth/*) ─▶ nginx ─▶ Django + └─ everything else ─▶ nginx serves the static build +``` + +Goals set by the project owner: +- **No Node/Bun in production** — ship a static build served by nginx. +- **Configurable API base:** `API_URL=http://localhost:8000` in dev, `API_URL=https://portal.sarlink.net/api` (nginx → backend) in prod. +- Browser calls the Django API **directly** (same-origin in prod via nginx; cross-origin in dev with CORS). + +Two ways to get there (under discussion): +1. **Next.js static export** (`output: "export"`) — keeps Next; fights the framework (no server actions, `searchParams` needs Suspense, dynamic routes need query-params, middleware gone). Foundation already built: `lib/auth-store.ts`, `lib/api-client.ts`, `components/auth/route-guard.tsx`, config flip, dev `rewrites()` proxy. See `../STATIC_MIGRATION_PLAN.md`. +2. **Vite + React SPA (recommended)** — natural fit for a static client app with a configurable `API_URL` (`import.meta.env.VITE_API_URL`). A **port, not a rewrite**: all components, shadcn, react-query, jotai, zod transfer 1:1; only the shell (routing, `next/link`, `next/font`, `next/image`, layouts) changes. TanStack Router or React Router for routing. + +Either way the **auth/data plumbing already written is reusable**: token in localStorage, `Authorization: Token` interceptor, client route guard, React Query for reads/mutations. + +## Auth model (target) + +1. Login: `POST {API}/callback/auth/ { token: pin }` → `{ token, user, expiry }`. +2. Store token + user in localStorage (`setAuth`). +3. Attach `Authorization: Token ` on every request (axios interceptor). +4. `RouteGuard` gates authed pages; `401` → `clearAuth()` + redirect to signin. +5. Admin gating from `user.is_admin` / `user.user_permissions`. + +## Production deployment (where nginx lives) + +The real prod stack is **`.build/prod/`** (repo root), not `frontend/deploy/`: +- `compose.yml` — postgres + backend (Django/gunicorn) + frontend + **nginx** (single entrypoint, `:8080→80`). +- `nginx.conf` — reverse proxy. **Currently built for the BFF model** (`/` → Next Node server; `/api` NOT proxied to Django). For the static migration this must change to: serve the static build + proxy `/api`, `/callback`, `/auth/{login,logout,mobile}` to Django; the separate Node `frontend` service goes away. +- `frontend.Dockerfile` — currently `output:"standalone"` + `node server.js`; becomes a static build folded into the nginx image. + +See [api-endpoints.md](./api-endpoints.md) for exact paths and the `/auth/*` frontend-vs-backend collision that the nginx config must handle. + +## Known issues / cleanup + +- **Mid-migration auth:** dashboard pages/actions/queries still use `getServerSession`; they must move to the client token store (or be removed in the SPA port). +- **Stale wallet balance:** header balance comes from the login `userAtom` snapshot; not refreshed after topups/payments until re-login. +- **Dead code:** Omada, Invoice Ninja, `/auth/login/`, `/islands/`, `/inventory/`, `backendMobileLogin`, `app/api/check-devices`, `utils/axios-client.ts`, `utils/axiosInstance.ts`. +- **person-verify** is called directly from the frontend; planned to route through the backend. diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..5420650 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,93 @@ +# Components + +Catalog of `components/` by feature area. `components/ui/*` are shadcn/ui + Radix primitives (listed last). "Server" = React Server Component today; most will become client components in the static build. + +## Auth (`components/auth/`) + +- **`login-form.tsx`** (client) — phone-number login entry. `PhoneInput` + Login button; drives the `signin` flow. +- **`signup-form.tsx`** (client) — full registration form: name, ID card, atoll/island (atolls fetched, islands derived from atoll), address, DOB, account no, phone, terms/policy. Two-column (branding + form). Field-level validation. +- **`verify-otp-form.tsx`** (client) — login OTP entry. 6-digit input → `POST /callback/auth/` → `setAuth()` (localStorage) → redirect. Toasts on error. +- **`verify-registration-otp-form.tsx`** (client) — registration OTP entry (server action). +- **`route-guard.tsx`** (client) — auth gate for the dashboard. Renders nothing until `isAuthenticated()` passes; else redirects to signin with `callbackUrl`. Replaces old next-auth middleware. +- **`account-popver.tsx`** (client) — header account menu. Reads `userAtom`; shows name/ID/phone; Logout (backend logout + `clearAuth()`) and View Profile. +- **`application-layout.tsx`** (client) — the authenticated app shell: `SidebarProvider` + `AppSidebar` + sticky header (wallet, theme toggle, account popover) + `WelcomeBanner` + `DeviceCartDrawer` + main content in `NuqsAdapter`. + +## Devices + +- **`devices-table.tsx`** (server) — desktop table + mobile cards of the user's devices; pagination; respects `parentalControl` and admin flags; fetches `getDevices()`. +- **`device-card.tsx`** (client) — mobile device card: name, MAC/vendor badges, active/inactive, expiry, pending-payment indicator, blocked reason. Toggles cart selection. +- **`add-devices-to-cart-button.tsx`** (client) — styled checkbox toggling a device in `deviceCartAtom`; disabled if active/blocked/pending. +- **`device-cart.tsx`** (client) — floating sticky "Pay N device(s)" / Cancel banner; hidden when empty or on payment pages; routes to `/devices-to-pay`. +- **`devices-to-pay.tsx`** (client) — pick number of months → `createPayment` → redirect to payment detail. Also renders bank details + pay-with-wallet / I-have-paid on the payment page. +- **`devices-for-payment.tsx`** (client) — confirm selected devices + months, submit payment. +- **`devices/device-filter.tsx`** (client) — advanced device filter drawer (name/MAC/vendor) with active-filter chips via `nuqs`. +- **`how-to-get-mac.tsx`** — help accordion: how to find a MAC address per device type; support phone. +- **`block-device-dialog.tsx`** (client) — block/unblock. Parental mode = simple block/unblock; admin mode = dialog with reason. Calls `blockDeviceAction`. +- **`device-table-skeleton.tsx`** — loading skeleton. + +## Payments / billing + +- **`payments-table.tsx`** (server) — user subscriptions; desktop table + `MobilePaymentDetails`; status/row color coding; device list per row; `getPayments()`. +- **`topups-table.tsx`** (server) — user top-ups; table + `MobileTopupDetails`. +- **`topup-to-pay.tsx`** (client) — topup detail + bank info + "I have paid" → `verifyTopupPayment`. +- **`account-information.tsx`** (client) — bank account name/number with copy-to-clipboard. +- **`billing/cancel-payment-button.tsx`** (client) — cancel unpaid payment → `cancelPayment`. +- **`billing/cancel-topup-button.tsx`** (client) — cancel unpaid topup → `cancelTopup`. +- **`billing/expiry-time-countdown.tsx`** (client) — 1s countdown + progress bar for unpaid items; redirects on expiry. + +## Wallet + +- **`wallet.tsx`** (client) — header wallet-balance button; opens top-up drawer (`NumberInput`, max 5000) → `createTopup` → redirect to topup detail. Hidden on payment pages. +- **`wallet-transactions-table.tsx`** (server) — transaction history; Total Debit/Credit summary boxes; table + `MobileTransactionDetails`; links to related payment/topup. + +## Admin (`components/admin/`) + +- **`admin-devices-table.tsx`** (server) — all devices; user column; block/unblock; `getDevices(..., true)`. +- **`admin-topup-form.tsx`** (client) — manual wallet credit dialog → `adminUserTopup`. +- **`admin-topup-table.tsx`** (server) — all top-ups. +- **`user-payments-table.tsx`** (server) — all payments; status/method/MIB ref columns. + +## User management (`components/user/`) + +- **`add-device-dialog.tsx`** (client) — add device (name + MAC) with MAC help accordion → `addDeviceAction`. +- **`user-agreement-form.tsx`** (client) — upload/replace agreement PDF → `updateUserAgreement`. +- **`user-update-form.tsx`** (client) — edit user info (ID card, name, address, DOB, mobile) → `updateUser`. +- **`user-verify-dialog.tsx`** (client) — admin verify; warns on `mismatch_fields` → `verifyUser`. +- **`user-reject-dialog.tsx`** (client) — admin reject with reason → `rejectUser`. +- **`user-table.tsx`** (server) — all users; verified/unverified badges; Details link. + +## Layout / navigation + +- **`ui/app-sidebar.tsx`** (client) — the sidebar (see [navigation.md](./navigation.md)). +- **`welcome-banner.tsx`** (client) — animated greeting, auto-hides after 4s (Framer Motion). +- **`theme-toggle.tsx`** (client) — Light/Dark/System (`next-themes`). + +## Shared / utility + +- **`pagination.tsx`** (client) — page controls preserving query params; hidden if ≤1 page. +- **`clickable-row.tsx`** (client) — table row with cart toggle + status. +- **`search.tsx`** (client) — debounced search → URL `query` param. +- **`filter.tsx`** (client) — status select → URL param. +- **`generic-filter.tsx`** (client) — reusable filter drawer + chips. +- **`number-input.tsx`** — React Aria numeric input with +/- and max. +- **`agreement-card.tsx`** — agreement display + View button. +- **`price-calculator.tsx`** (client) — pricing formula tool (Jotai-backed inputs). +- **`full-page-loader.tsx`** — full-screen spinner. +- **`client-error-message.tsx`** — permission/error message with support contact. +- **`input-read-only.tsx`**, **`ui/floating-label.tsx`** — read-only/labeled inputs. + +## UI primitives (`components/ui/`) + +shadcn/ui (`new-york`) + Radix. No product logic. Grouped: +- **Inputs:** input, textarea, label, form, floating-label, phone-input, input-otp, number-field, select, checkbox, radio-group, switch, toggle(+group), slider, dual-range-slider, calendar, datepicker. +- **Layout:** card, separator, scroll-area, sidebar, resizable, aspect-ratio, collapsible. +- **Overlays:** dialog, drawer, sheet, alert-dialog, popover, hover-card, tooltip. +- **Data display:** table, badge, progress, accordion, tabs, breadcrumb, pagination, avatar, carousel, text-shimmer, skeleton. +- **Menus:** dropdown-menu, context-menu, command, navigation-menu, menubar. +- **Feedback:** button, alert, sonner (toasts), search-form. + +## Conventions + +- **Forms:** server-action forms use `useActionState` with a `{ message, success, fieldErrors }` shape; client validation via `react-hook-form` + `zod`. +- **Data:** reads currently go through server components/queries; mutations through server actions. React Query provider is mounted but **no `useQuery`/`useMutation` yet** — the port will move reads/mutations onto React Query + the client `api-client`. +- **State:** global UI state via Jotai atoms (see [user-flows-and-state.md](./user-flows-and-state.md)). diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..2fcedd4 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,90 @@ +# Design System + +Everything needed to reproduce the visual look of the SAR Link Portal. The UI is built on **shadcn/ui** (style: `new-york`, base color: `neutral`, icon set: `lucide`) over **Tailwind CSS v4** (CSS-first config — there is no `tailwind.config.ts`; theming lives in `app/globals.css` via `@theme`). + +## Brand color + +| Token | Value | Use | +|---|---|---| +| `--color-sarLinkOrange` / `text-sarLinkOrange` | `#f49b5b` | Primary brand accent — page headings, logo text, highlights, top loader | +| Pattern orange (dark) | `#e06f10` | Diagonal SVG background pattern (opacity 0.35) | +| Pattern orange (light) | `#f49b5b` | `.title-bg` SVG background pattern (opacity 0.1) | + +The brand orange is used mainly as an **accent on top of a neutral (grayscale) shadcn palette** — it is not the shadcn `--primary`. Primary/secondary/muted etc. remain the default neutral shadcn tokens. + +## Theme tokens + +Colors use the **oklch** color space, neutral base. Defined as CSS variables in `app/globals.css` under `:root` (light) and `.dark` (dark). Standard shadcn token set: + +`--background --foreground --card --card-foreground --popover --popover-foreground --primary --primary-foreground --secondary --secondary-foreground --muted --muted-foreground --accent --accent-foreground --destructive --border --input --ring` plus a sidebar group `--sidebar --sidebar-foreground --sidebar-primary --sidebar-accent --sidebar-border --sidebar-ring`. + +Key values: +- **Light:** `--background: oklch(1 0 0)` (white), `--foreground: oklch(0.145 0 0)` (near-black), `--primary: oklch(0.205 0 0)`. +- **Dark:** `--background: oklch(0.145 0 0)`, `--foreground: oklch(0.985 0 0)`, `--primary: oklch(0.922 0 0)`. +- **Radius:** `--radius: 0.625rem`, with `sm/md/lg/xl` derived (`calc(var(--radius) ± n)`). +- App background (body): `bg-gray-100` light / `bg-black` dark (set in root layout). + +Dark mode is class-based (`@custom-variant dark (&:is(.dark *))`) via `next-themes` (`attribute="class"`, default `system`). + +## Custom utility classes (in `app/globals.css`) + +- **`.title-bg`** — subtle diagonal SVG pattern in brand orange at 0.1 opacity. Used behind page-heading blocks and auth/login cards to give the faint textured background. +- A second diagonal-line SVG pattern using `#e06f10` at 0.35 opacity. + +## Typography + +Two Google fonts loaded in `app/layout.tsx` as CSS variables: + +| Font | Variable | Weights | Use | +|---|---|---|---| +| **Barlow** | `--font-barlow` (also the `font-sans` body font) | 100,300,400,500,600,700,800,900 | Body text, UI, everything by default | +| **Bokor** | `--font-bokor` | 400 | Display / decorative headings (brand) | +| mono | `--font-mono` | — | Monospace bits (e.g. "Profile Status" label) | + +Body element applies `${barlow.variable} ${bokor.variable} antialiased font-sans`. + +## Tailwind plugins in use + +`tailwindcss-animate`, `@pyncz/tailwind-mask-image`, `tailwindcss-motion`. Animations also via `motion` (Framer Motion) — used by the welcome banner and some transitions. `TextShimmer` component for shimmer loading states. + +## Status color conventions + +These recur across badges and table rows (payments, topups, devices, users). Reproduce consistently: + +| State | Color | Typical classes | +|---|---|---| +| Paid / Verified / Credit / success | green / lime | `bg-green-500 text-white`, `bg-lime-*`, green row tint | +| Pending / awaiting | yellow | `bg-yellow-500 text-white`, yellow row tint | +| Failed / Cancelled / Rejected / Debit | red | `bg-red-500 text-white`, `destructive` buttons | +| Expired | gray | gray row tint / muted | +| Unknown | yellow | `bg-yellow-500` | +| Active device (until date) | green accent | brand/green text | +| Inactive / blocked | red / muted | red text, block dialog | + +## Recurring layout patterns + +- **Page heading block** — a flex row with a dashed border, `title-bg` background, rounded, `text-sarLinkOrange text-2xl` heading on the left, optional status/action on the right. (See `profile`, most list pages.) +- **Dual list layout** — every list screen renders an HTML **`` on desktop** and a stack of **cards on mobile** (`MobilePaymentDetails`, `MobileTopupDetails`, `MobileTransactionDetails`, `device-card`), showing the same data. Footer shows `Total N item(s).` and pagination. +- **Read-only field grid** — labeled read-only values in a responsive grid (`grid-cols-1 sm:grid-cols-2 md:grid-cols-3`), used by profile & user details. +- **Drawers** (shadcn `drawer`/`vaul`) — device cart, wallet top-up, and filter panels open as bottom/side drawers. +- **Filter drawer + active-filter chips** — filters applied to URL query params (via `nuqs`), shown as dismissible badges. +- **Skeletons** — `DevicesTableSkeleton` and per-route `loading.tsx` files provide loading UI; `FullPageLoader` for full-screen spins. +- **Countdown** — `ExpiryTimeCountdown` shows `Time left: …` with a progress bar for unpaid payments/topups, ticking every second. + +## App shell (authenticated) + +Rendered by `components/auth/application-layout.tsx`: +- **Sidebar** (`AppSidebar`) on the left inside `SidebarProvider` — see [navigation.md](./navigation.md). +- **Sticky header** (`h-16`, `border-b`, `sticky top-0`, `z-10`): left = sidebar trigger + separator; right = **Wallet balance** button, **theme toggle**, **account popover**. +- **WelcomeBanner** — animated "Welcome, {first} {last}" that auto-hides after 4s. +- **DeviceCartDrawer** — floating cart (hidden on payment pages). +- **Main content** — `p-4`, rounded, `bg-background`, wrapped in `NuqsAdapter` for URL state. + +## Auth-page shell + +`app/(auth)/auth/layout.tsx`: centered full-screen container (`bg-gray-100` light / `bg-black` dark) holding a single card. Login card uses `title-bg` and a `border-2 border-sarLinkOrange/50 rounded-lg shadow`. + +## Global chrome + +- **NextTopLoader** — top progress bar, color `#f49d1b` (orange), no spinner. +- **Toaster** (`sonner`, `richColors`) — all success/error toasts. diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 0000000..54fcb9a --- /dev/null +++ b/docs/navigation.md @@ -0,0 +1,55 @@ +# Navigation + +The sidebar (`components/ui/app-sidebar.tsx`) is the primary navigation. It is a shadcn `Sidebar` with a header (brand) and two collapsible category groups. Menu items are **data-driven** from a `categories` array and **filtered by the user's permissions / admin flag**. + +## Sidebar header + +Brand block at the top: a bordered, centered, uppercase title with `title-bg` background (the "SAR LINK" branding). + +## Menu tree + +### Group: `MENU` (all users) + +| Label | Route | Icon (lucide) | Permission (`perm_identifier`) | +|---|---|---|---| +| Devices | `/devices?page=1` | `Smartphone` | `device` | +| Parental Control | `/parental-control?page=1` | `CreditCard` | `device` | +| Subscriptions | `/payments?page=1` | `CreditCard` | `payment` | +| Top Ups | `/top-ups?page=1` | `BadgePlus` | `topup` | +| Transaction History | `/wallet` | `Wallet2Icon` | `wallet transaction` | +| Agreements | `/agreements` | `Handshake` | `device` | + +### Group: `ADMIN CONTROL` (admin only) + +| Label | Route | Icon (lucide) | Permission (`perm_identifier`) | +|---|---|---|---| +| Users | `/users` | `UsersRound` | `device` | +| User Devices | `/user-devices` | `MonitorSpeaker` | `device` | +| User Payments | `/user-payments` | `Coins` | `payment` | +| User Topups | `/user-topups` | `Coins` | `topup` | +| Price Calculator | `/price-calculator` | `Calculator` | `device` | + +> Note: the `Price Calculator` sits under `ADMIN CONTROL` in the source array, so it is only shown to admins even though the page itself is generic. + +## Visibility logic + +Computed in `app-sidebar.tsx` from the current user (now read from `userAtom`; previously `getServerSession`): + +1. **If `user.is_admin`** → show **all** categories and items. +2. **Else** → + - Drop the entire `ADMIN CONTROL` category. + - For remaining items, keep an item only if the user has a matching permission: the item's `perm_identifier` is compared against the model name parsed from each `user.user_permissions[].name` (permission name split on spaces, model = parts from index 2 onward). + - Drop any category left with zero visible children. + +So a non-admin sees a subset of the `MENU` group based on granted permissions; the whole admin group is hidden. + +## Other navigation entry points + +- **Header account popover** (`account-popver.tsx`): links to **View Profile** (`/profile`) and **Logout**. +- **Header wallet button** (`wallet.tsx`): opens the top-up drawer (not a route). +- **Device cart** (`device-cart.tsx`): "Pay" routes to `/devices-to-pay`. +- **Root `/`**: redirects to `/devices` (authed) or `/auth/signin` (not authed). +- **Admin list rows**: "Details" buttons link into `/users/[userId]/details`, `/devices/[deviceId]`, `/payments/[paymentId]`, `/top-ups/[topupId]`. +- **Table pagination & filters**: mutate URL query params (`?page=`, `?query=`, `?status=`, `?sortBy=` …) via `nuqs`. + +See [routes.md](./routes.md) for the full route list and [navigation gating] mirrors the admin route guards documented there. diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 0000000..9a1a21c --- /dev/null +++ b/docs/routes.md @@ -0,0 +1,84 @@ +# Routes & Pages + +Next.js App Router. Route groups `(auth)` and `(dashboard)` do **not** appear in the URL. Dynamic segments in `[brackets]`. + +## Route table + +| Folder | URL | Page | Audience | +|---|---|---|---| +| `/` | `/` | Home (redirect guard) | any | +| `(auth)/auth/signin` | `/auth/signin` | Sign In (phone) | public | +| `(auth)/auth/signup` | `/auth/signup` | Sign Up | public | +| `(auth)/auth/verify-otp` | `/auth/verify-otp` | Verify OTP (login) | public | +| `(auth)/auth/verify-otp-registration` | `/auth/verify-otp-registration` | Verify OTP (registration) | public | +| `(dashboard)/devices` | `/devices` | My Devices | user | +| `(dashboard)/devices/[deviceId]` | `/devices/{id}` | Device Details | user | +| `(dashboard)/devices-to-pay` | `/devices-to-pay` | Devices to Pay | user | +| `(dashboard)/parental-control` | `/parental-control` | Parental Control | user | +| `(dashboard)/payments` | `/payments` | My Subscriptions | user | +| `(dashboard)/payments/[paymentId]` | `/payments/{id}` | Payment Details | user | +| `(dashboard)/top-ups` | `/top-ups` | My Topups | user | +| `(dashboard)/top-ups/[topupId]` | `/top-ups/{id}` | Topup Details | user | +| `(dashboard)/wallet` | `/wallet` | Transaction History | user | +| `(dashboard)/agreements` | `/agreements` | Agreements | user | +| `(dashboard)/price-calculator` | `/price-calculator` | Price Calculator | user (admin-nav) | +| `(dashboard)/profile` | `/profile` | Profile | user | +| `(dashboard)/user-devices` | `/user-devices` | All User Devices | admin | +| `(dashboard)/user-payments` | `/user-payments` | All User Payments | admin | +| `(dashboard)/user-topups` | `/user-topups` | All User Topups | admin | +| `(dashboard)/users` | `/users` | Users Management | admin | +| `(dashboard)/users/[userId]/details` | `/users/{id}/details` | User Details | admin | +| `(dashboard)/users/[userId]/update` | `/users/{id}/update` | User Update / Verify | admin | +| `(dashboard)/users/[userId]/agreement` | `/users/{id}/agreement` | User Agreement Upload | admin | + +## Layouts & special files + +- **`app/layout.tsx`** (root) — html/body, fonts, providers: Jotai `Provider`, `NextTopLoader`, `Toaster`, `ThemeProvider`, `QueryProvider`. Metadata: "SAR Link Portal". +- **`app/(auth)/auth/layout.tsx`** — centered full-screen card layout for all auth pages. +- **`app/(dashboard)/layout.tsx`** — `RouteGuard` → `ApplicationLayout` (sidebar+header shell) → `QueryProvider` → children. +- **`app/(dashboard)/error.tsx`** — dashboard error boundary ("Something went wrong!" + Try again). +- **`loading.tsx`** files — `devices`, `devices/[deviceId]`, `devices-to-pay`, `payments`, `payments/[paymentId]`, `parental-control` (skeletons / loaders). + +## Auth & onboarding + +- **`/auth/signin`** — `LoginForm`. Enter phone number. `signin()` action checks the number: unknown → redirect `/auth/signup?phone_number=`; known+unverified → "pending verification" message; known+verified → sends OTP (`/auth/mobile/`) → `/auth/verify-otp?phone_number=`. +- **`/auth/signup`** — `SignUpForm`. Full registration (name, ID card, atoll/island, address, DOB, account no, phone, terms/policy). Requires `?phone_number=`. On submit → register → `/auth/verify-otp-registration`. +- **`/auth/verify-otp`** — `VerifyOTPForm` (client, `useSearchParams` in Suspense). Enter 6-digit PIN → `POST /callback/auth/` → stores token+user → `/devices`. Redirects to `/auth/signin` if no `phone_number`. +- **`/auth/verify-otp-registration`** — `VerifyRegistrationOTPForm`. Verifies registration OTP; validates the temp record. On success → `/auth/signin` (account stays pending admin verification; no auto-login). + +## Dashboard — user + +All wrapped by `RouteGuard` (redirects to `/auth/signin` if not authenticated). + +- **`/devices`** — `DevicesTable` + `DynamicFilter` + add-device dialog. List of the user's devices; filter by name/MAC/vendor; paginated. +- **`/devices/{id}`** — single device: name, MAC, status badge, expiry. +- **`/devices-to-pay`** — `DevicesForPayment`: devices selected in the cart, choose number of months, create a payment. +- **`/parental-control`** — `DevicesTable` with `parentalControl` mode, filtered to active devices with no pending payment; block/unblock devices. +- **`/payments`** — `PaymentsTable` + filters (status, method, months). The user's subscriptions. +- **`/payments/{id}`** — payment detail: status, expiry countdown (if pending), covered devices (`DevicesToPay`), cancel button, pay via wallet/transfer. +- **`/top-ups`** — `TopupsTable` + filters (status, expiry, amount). Wallet top-ups. +- **`/top-ups/{id}`** — topup detail: status, countdown, `TopupToPay` (bank details + "I have paid"), cancel. +- **`/wallet`** — `WalletTransactionsTable`: debit/credit history with totals. +- **`/agreements`** — `AgreementCard`: user's service agreement (fetched from profile), Print/View. +- **`/price-calculator`** — `PriceCalculator`: interactive pricing tool. +- **`/profile`** — read-only profile fields + verification status badge. (Recently restyled to non-editable display.) + +## Dashboard — admin + +All additionally check `is_admin`; non-admins are redirected (to `/devices` or the user-equivalent page). + +- **`/user-devices`** — `AdminDevicesTable`: all devices system-wide; filter incl. by device user; block/unblock with reason. +- **`/user-payments`** — `UsersPaymentsTable`: all payments; filters (user, MIB ref, amount, duration, status, method). +- **`/user-topups`** — `AdminTopupsTable`: all topups; filters (user, status, expiry, amount); admin manual top-up. +- **`/users`** — `UsersTable`: all users; filters (name, ID card, house, phone, verified status). +- **`/users/{id}/details`** — DB vs **National registry** comparison (via `getNationalPerson`), photo, verify/reject actions, links to update/agreement. +- **`/users/{id}/update`** — `UserUpdateForm`: edit/verify user info. +- **`/users/{id}/agreement`** — `UserAgreementForm`: upload/replace the user's agreement PDF. + +## Guards summary + +- Unauthenticated → `RouteGuard` / root redirect send to `/auth/signin`. +- Admin pages → `is_admin` check; redirect to the user equivalent. +- `401 UNAUTHORIZED` from the API → redirect `/auth/signin` (via api-client interceptor). + +> Migration note: several dashboard pages still read auth server-side (`getServerSession`); see [architecture.md](./architecture.md) and [user-flows-and-state.md](./user-flows-and-state.md) for the in-progress move to the client token store. diff --git a/docs/ui-copy.md b/docs/ui-copy.md new file mode 100644 index 0000000..d24fd09 --- /dev/null +++ b/docs/ui-copy.md @@ -0,0 +1,183 @@ +# UI Copy Inventory + +Every hardcoded user-facing string in `app/` and `components/` (excludes `components/ui/` primitives). Interpolated parts marked `{like_this}`. Includes messages that originate in `actions/`, `queries/`, `lib/schemas.ts` where they surface in the UI. This feeds i18n and the framework port. + +## 1. Auth & onboarding + +**login-form** — `Enter phone number` (placeholder), `Login` (button). + +**verify-otp-form** — `Login OTP Sent to {phone_number}`, `Enter the OTP` (sr-only), `Enter OTP` (placeholder), `Login`, `Change` / `phone number` (link), toasts: `The token you entered isn't valid.`, `Unable to log in. Please try again or contact support.` + +**verify-registration-otp-form** — `Account verification OTP sent to [{phone_number}]`, `Enter the OTP`, `Enter OTP`, `Request verification`, `Go to` / `login`. + +**signup-form** — `SAR Link by OmegaTech Solutions`, `Register your account`, `Pay for your devices and track your bills.`; labels/placeholders: `Full Name`, `ID Card`, `Atoll`/`Select atoll`/`Atolls`, `Island`/`Select island`/`Islands`, `Address`, `Date of Birth`/`Date of birth`, `Account Number`/`Account no`, `Phone Number`/`Phone number`; `i accept` + `terms and conditions`, `i undertand` (sic) + `the privacy policy`; `Submit`; `Already have an account?` / `login`. + +**auth-actions** (surfaced via forms/toasts) — `Please enter a phone number`, `Your account is on pending verification. Please wait for a response from admin or contact shihaam.` (hardcoded name), `Invalid form data`, `You must be at least 18 years old to register.`, `ID card already exists.`, `Phone number already exists.`, `User created successfully`. + +**queries/authentication** — `OTP is required.`, `Your account has been successfully verified! You may login now.`, `Your account could not be verified. Please wait for you verification to be processed.` (sic). + +## 2. Devices + +**devices page** — `My Devices`; `Device Filter` / `Filter devices by name, MAC address, or vendor.`; `Device Name`/`Enter device name`, `MAC Address`/`Enter MAC address`, `Vendor`/`Enter vendor name`; table: `Device Name`, `Mac Address`, `Vendor`, `#`. + +**device detail** — `Device active until {expiry_date}`, `ACTIVE`. + +**user-devices page** — `User Devices`; filter `Device User`/`User name or id card`; `loading....`. + +**device-card** — `Active until {expiry_date}`, `Device Inactive`, `Payment Pending`, `Blocked by admin` + `{reason}`. + +**device-cart** — `Pay {count} device` / `Pay {count} devices`, `Cancel`. + +**devices-table** — `No active devices` / `No devices.`; `Total {count} device(s).`. + +**devices-to-pay** — `Devices to pay` / `Devices Paid`, `Please send the following amount to the payment address`, bank `Baraveli Dev` / `90101400028321000`, `Payment Verified`, `Processing payment...`, `Pay with wallet`, `I have paid`; rows: `Payment created`, `Total Devices`, `Duration`, `{count} Months`, `Total Due`. + +**devices-for-payment** — `Set No of Months`, `Go to payment`. + +**block-device-dialog** — `Unblock`/`Unblocking`, `Block`/`Blocking`/`Blocking...`, `Block 🚫` (title), `Please provide a reason for blocking this device`, `Reason for blocking`. + +**how-to-get-mac** — `How do I find my MAC Address?` + description; per-device steps (iPhone/Redmi/Samsung/Windows/Other); support phone `9198026`. + +**device-filter** — `Filter`, `Device Filters`, `Select your desired filters here`; placeholders `Device name ...`, `Device Mac address ...`, `Device vendor ...`; `Apply Filters`, `Clear Filters`, `Cancel`; chips `Device Name: {v}` etc., `Remove`. + +**admin-devices-table** — `No devices.`; headers `Device Name`,`User`,`MAC Address`,`Vendor`,`#`; `Comment` (block reason); `Total {count} device(s).`. + +**add-device-dialog** — `Add Device`, `New Device`, `To add a new device, enter the device name and mac address below. Click save when you are done.`, `Device Name`/`eg: iPhone X`, `Mac Address`/`Mac address of your device`, `Save`. + +**queries/devices** — `Device name is required and must be at least 2 characters.`, `Validation failed.`, `Authentication required.`, `Device successfully added!`, `An unexpected error occurred.`, `Reason for blocking is required and must be at least 5 characters.`, `Reason is required and must be at least 5 characters.`, `Device blocked successfully!` / `Device unblocked successfully!`. + +## 3. Payments / billing + +**payments page** — `My Subscriptions`; `Payment` (`All`/`Paid`/`Unpaid`), `Payment Method` (`All`/`Transfer`/`Wallet`), `Number of months`; headers `Details`,`Duration`,`Status`,`Amount`. + +**payment detail** — `Payment`; `Payment Pending` / `Payment Expired` / `Payment Cancelled`. + +**user-payments page** — `User Payments`; filters `User`/`Enter user name`, `MIB Reference`/`Enter MIB Reference`, `Amount Range`, `Duration Range`, `Payment Status`, `Payment Method`; `loading....`. + +**payments-table** — `No Payments.`; `View Details`, `Devices`, `Months`, `Expired`, `MVR`; `Total {n} payment(s).`. + +**account-information** — `Account Information`, `Account Name`, `Account No`, `Account number copied!`, `Copy Account Number`. + +**cancel-payment-button** — `Payment cancelled successfully!`, `Your payment of {amount} MVR has been cancelled.`, `Cancel Payment`. + +**cancel-topup-button** — `Topup cancelled successfully!`, `Your topup of {amount} MVR has been cancelled.`, `Cancel Topup`. + +**expiry-time-countdown** — `Time left: {t}`, `{label} has expired.`. + +**user-payments-table** — `No user payments yet.`; headers `Devices paid`,`User`,`Amount`,`Duration`,`Payment Status`,`Payment Method`,`MIB Reference`,`Paid At`,`Action`; `MVR`, `Months`, `Details`; `Total {n} payment(s).`. + +**actions/payment** — `Payment ID is required`, `Payment method is required`, `Payment completed successfully using wallet!`, `Payment verification successful!`, `Unable to verify payment. Please try again or contact support.`, `Topup ID is required`, `Topup payment verified successfully`, `An error occurred.`. + +## 4. Wallet / topups + +**wallet page** — `Transaction History`; `Type` (`All`/`Debit`/`Credit`), `Topup Amount`; headers `Description`,`Amount`,`Transaction Type`,`View Details`. + +**top-ups page** — `My Topups`; `Status` (`All`/`Pending`/`Cancelled`/`Paid`), `Topup Expiry` (`Expired`/`Not Expired`), `Topup Amount`; headers `Details`,`Status`,`Amount`. + +**topup detail** — `Topup`; `Payment Pending` / `Topup Expired` / `Topup Cancelled`. + +**user-topups page** — `User Topups`; `Filter user topups by status, topup expiry, or amount.`; `loading....`. + +**wallet** — `Wallet`, `Your wallet balance is {balance}`, `Set amount to top up`, `Go to payment`, `Cancel`, `Something went wrong.`. + +**wallet-transactions-table** — `No transactions yet.`, `Total Debit`, `Total Credit`; headers `Description`,`Amount`,`Transaction Type`,`View Details`,`Created at`; `Total {n} transaction(s).`. + +**topups-table** — `No topups.`; headers `Details`,`Status`,`Amount`; `View Details`; `Total {n} topup(s).`. + +**topup-to-pay** — `Topup successful!`, `Your topup payment has been verified successfully using {sourceBank} bank transfer on {trxDate}.`, `Topup Payment Verification Failed`, `Please send the following amount to the payment address`, bank `Baraveli Dev` / `90101400028321000`, `Topup Payment Verified`, `I have paid`, `Processing payment...`; rows `Topup created`, `Payment received`, `MIB Reference`, `Total Due`. + +**admin-topup-form** — `Add cash topup`, `New Manual Topup`, `To add a new manual topup, enter the amount below. Click save when you are done.`, `Topup Amount`, `Topup Description`, `Save`. + +**admin-topup-table** — `No topups yet.`; headers `User`,`Status`,`Amount`,`Action`; `View Details`; `Total {n} topup(s).`. + +## 5. Admin / user management + +**users page** — `Users`; `User Filter` / `Filter users by id card, name, or house name and more.`; filters `User First Name`, `User Last Name`, `ID Card`, `House Name`, `Phone Number`, `User Status` (`All`/`Verified`/`Unverified`); `loading....`. + +**user details** — `User Information`; `Update User`, `Update Agreement`, `View Agreement`, `Verified`; sections `Database Information`, `National Information`; fields `ID Card`,`Name`,`House Name`,`Island`,`Atoll`,`DOB`,`Phone Number`; `id photo` (alt). + +**user update page** — `Verify user`. + +**user agreement page** — `Upload user user agreement` (sic). + +**user-table** — `No Users yet.`; headers `Name`,`ID Card`,`Atoll`,`Island`,`House Name`,`Status`,`Dob`,`Phone Number`,`Action`; `Verified`/`Unverified`, `Details`; `Total {n} user(s).`. + +**user-update-form** — `Go Back`, `Update User Information`; labels `ID Card`,`First Name`,`Last Name`,`House Name`,`DOB`,`Phone Number`; `Update User`; toasts `Success` / `User updated successfully`, `Error in {field}: {error}`. + +**user-agreement-form** — `Go Back`, `Upload User agreement`, `Agreement Document`, `Update Agreement`; toasts `Success` / `User agreement updated successfully`, `Error in {field}: {error}`. + +**user-verify-dialog** — `Verify` / `Verified`, `Verify User`, `Are you sure you want to verify the following user?`, dynamic `Name/ID Card/Address/DOB/Phone Number`, `Verifying...`, `User Verified!`, `The following fields do not match`. + +**user-reject-dialog** — `Reject`, `Are you sure?`, dynamic user lines, `Rejection details`, `User rejected successfully!`. + +**actions/user-actions** — `User verification failed`, `An unexpected error occurred.`, `An error occurred while updating the user.`, `User updated successfully`, `An error occurred while updating the user agreement.`, `User agreement updated successfully`, `Amount is required`, `An error occurred while topping up the user.`, `User topped up successfully`. + +## 6. Profile + +**profile page** — `Profile`, `Profile Status`; fields `Full Name`,`ID Card`,`Island`,`Date of Birth`,`Address`,`Phone Number`,`Account Number`; status `Verified`/`Not Verified`/`Unknown`. + +## 7. Layout / navigation / shared + +**root metadata** — `SAR Link Portal` (title), `Sarlink Portal` (description). + +**dashboard error** — `Something went wrong!`, `Try again`. + +**account-popover** — `{first} {last}`, `Logout`, `View Profile`. + +**welcome-banner** — `Welcome, {firstName} {lastName}`. + +**theme-toggle** — `Toggle theme`, `Light`, `Dark`, `System`. + +**generic-filter** — `Filters`, `Select your desired filters here`, `Filter`, `Applying...`, `Apply Filters`, `Clear Filters`, `Cancel`, `Remove`. + +**search** — `Search...`. **pagination** — `...`. + +**client-error-message** — `You do not have permission to perform this action.`, `Please contact the administrator to give you permissions.`, support phone `919-8026`. + +Generic fallbacks: `An unexpected error occurred.`, `An error occurred.`, `Something went wrong!` / `.`, `Please try again or contact support.`, `loading....`. + +## 8. Price calculator + +`Price Calculator`; labels `Initial Price`,`Number of Devices`,`Number of Days`,`Discount Percentage`,`Total`; `Price for {n} device(s) over {d} day(s): MVR {price}`, `Result`. + +## 9. Agreements + +**agreements page** — `Agreements`, `An error occurred while fetching agreements: {error}`, `No agreement found.`, `Print`. + +**agreement-card** — `Sarlink User Agreement`, `User agreement for Sarlink services.`, `View Agreement`. + +## 10. Parental control + +`Parental Control`; `Device Filter` + name/MAC/vendor filters; table `Device Name`,`Mac Address`,`Vendor`,`#`. + +## 11. Validation messages + +**lib/schemas.ts** (`signUpFormSchema`) — `Name is required.`, `ID Card is required`, `Please enter a valid ID Card number.` (`^[A][0-9]{6}$`), `Atoll is required.`, `Island is required.`, `address is required.` (sic), `Date of birth is required.`, `Phone number is required.`, `Please enter a valid phone number` (`^[79][0-9]{2}[0-9]{4}$`), `You must accept the terms and conditions`, `You must accept the privacy policy`, `Account number is required.`, `Please enter a valid account number` (`^(7\d{12}|9\d{16})$`). + +**auth-actions** — `Please enter a valid phone number` (`^[7|9][0-9]{2}-[0-9]{4}$` — hyphenated variant), `You must be at least 18 years old to register.` + +**verify-otp-form** — `Your one-time password must be 6 characters.` + +## 12. Hardcoded contacts / brand / bank + +| String | Type | Location | +|---|---|---| +| `SAR Link Portal` / `Sarlink Portal` | brand | `app/layout.tsx` | +| `SAR Link by OmegaTech Solutions` | company | signup-form | +| `SAR Link` | brand | how-to-get-mac | +| `Sarlink User Agreement` | brand | agreement-card | +| `Baraveli Dev` | bank account name | devices-to-pay, topup-to-pay | +| `90101400028321000` | bank account number | devices-to-pay, topup-to-pay | +| `9198026` | support phone | how-to-get-mac | +| `919-8026` | support phone | client-error-message | +| `shihaam` | personal name (in pending-verification message) | auth-actions | +| `MVR` | currency | payments/topups/calculator | + +## Cross-cutting notes (for i18n / port) + +- **Pluralization** is inline `count === 1 ? "X." : "Xs."` everywhere — use ICU plurals. +- **Interpolation** via bare template literals — extract as named placeholders. +- **Two phone regexes**: `^[79][0-9]{2}[0-9]{4}$` (schemas) vs hyphenated `^[7|9][0-9]{2}-[0-9]{4}$` (auth-actions) — reconcile. +- **Duplicated label sets** (`Full Name`/`Name`/`First Name`, `DOB`/`Date of Birth`/`Dob`, `House Name`/`Address`) — inconsistent wording/casing for the same concept; unify into shared keys. +- **Source-text defects to fix during extraction:** `i undertand`, `Upload user user agreement`, `for you verification`, lowercase `address is required.`, and the hardcoded name `shihaam` in a user-facing message. +- **Server-owned copy:** many messages come from `actions/*` / `queries/*` or pass through backend `message`/`detail` — the i18n layer must cover both, and backend-originated strings can't be translated on the frontend. diff --git a/docs/user-flows-and-state.md b/docs/user-flows-and-state.md new file mode 100644 index 0000000..44915f6 --- /dev/null +++ b/docs/user-flows-and-state.md @@ -0,0 +1,387 @@ +# 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/`. diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index adc5f9a..0000000 --- a/middleware.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { withAuth } from "next-auth/middleware"; - -export default withAuth( - // `withAuth` augments your `Request` with the user's token. - function middleware(req) {}, -); - -export const config = { - matcher: ["/about/:path*", "/dashboard/:path*", "/devices/:path*"], -};