Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
388 lines
22 KiB
Markdown
388 lines
22 KiB
Markdown
# 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/`.
|