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