# PRD — ipw-frontend

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/ipw-frontend.git`, `main` at `798bd76` (2025-06-03).

There is no prior PRD in Silicon Ark for this repository. Requirements below are recovered from `README.md`, `package.json`, `nuxt.config.js`, the `api/` client, middleware, and the Vue pages. Provenance is labelled. `.env.template` has no secrets and they are not copied here.

This repository is the browser client only. Server rules (password storage, role enforcement, email delivery) are not in this tree. Where a screen expects a status code or a `body.message`, that expectation is **Implemented** in the client and the server contract is **Unknown** from this repository alone.

Silicon Ark already documents `ipw_new` (API plus a Nuxt client of the same package name and version) and `ipw-php` (CodeIgniter 4 IPW application). This PRD does not copy their requirements. Overlap is **Unknown / Requires Confirmation**.

## Product purpose

**Documented.** `package.json` describes "IPW client application". `README.md` lists public pages and a few member routes for the IPW site.

**Implemented.** A static Nuxt site. Visitors read IPW information and PDFs. Signed-in people maintain contact details, groups, reports, and documents by calling `{NUXT_API_LOCATION}/api`. Staff with administrator role names can manage users, members, and groups. Several other staff screens are present as layout only.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| Public visitor | Pages with no `is-logged-in` middleware: home, cellars, about, manuals, forms, literature, audit, contacts, courses, climate, greenhouse gas, environment, links, contact, login, reminder, registration, confirm, forbidden | **Implemented** as Vue pages. Registration and confirm do not finish their submit handlers |
| Signed-in user | `middleware/isLoggedIn.js` checks the access cookie and may refresh it | **Implemented** |
| IPW staff | `useAuth.js`: refresh-token `user_class_id === 1` | **Implemented** as a client check. The meaning of the id is inferred from the function name `isIpwStaff` |
| IPW member | `useAuth.js`: refresh-token `user_class_id === 2` | **Implemented** as a client check. Inferred from `isIpwMember` |
| Administrator | `middleware/isAdministrator.js` allows role names `System Developer` and `IPW Administrator` | **Implemented** as a route gate. It does not send the visitor to `/forbidden` |
| Document uploader | Sidebar shows `/upload/documents` when `isIpwAdmin`, `isIpwAuditor`, `isIPWLeadAuditor`, `isMemberAdministrator`, or `isDocumentUploads` | **Implemented** in the menu. The page middleware is only `is-logged-in` |
| Operator who builds the site | `package.json` scripts | **Implemented.** Who deploys the generated files is **Unknown** |

No captcha and no second factor appear in the login page.

## Systems the product talks to

1. The IPW HTTP API at `NUXT_API_LOCATION` (template `http://127.0.0.1:9000`; staging build `https://staging.ipw.co.za`; production build `https://ipw.co.za`). Direction: browser to API. Auth: CSRF header `x-csrf-token` from `GET /api/csrf`, plus `Authorization: Bearer` for calls that use `buildFullHeaders` or `buildRefreshHeaders`. Credentials mode is `same-origin` when `ENV` is `staging` or `production`, otherwise `include`.
2. Static files shipped with the client (`public/documents/`, images, favicons).
3. Adobe Reader download and the SAWIS public form URL, as ordinary links.
4. Bitbucket Pipelines, which runs a SonarQube scan on `main`. No deploy step.
5. `localStorage` key `selected_profile` for the member's chosen group.

No database client, queue, or SMTP client exists in this repository. Email, if any, would be sent by the API. That behaviour is **Unknown** here.

## Major workflows

### WF-1 — Public information

**Trigger.** Visitor opens a public route.

**Implemented.** The page renders Vue copy and, on several pages, links to PDFs under `public/documents/`, chosen by locale (`eng` vs `afr` folders). No API call. `components/Adobe.vue` links to the Adobe Reader download.

**Documented** in `README.md`. That list names `member/profile`, `member/logout`, `home`, and `confronting-climate-change`. Those files are not in `pages/`. The home page is `pages/index.vue`. The climate page is `pages/climate-change.vue`. Logout is a sidebar action, not a page.

### WF-2 — Login

**Trigger.** `pages/login.vue` posts username and password through `api/auth.js` `login` to `POST /api/auth/login`.

**Implemented.**

- Middleware `is-not-logged-in` sends an already signed-in visitor to `/member/guidelines`.
- Empty username or password, or a password that fails `owasp-password-strength-test` (`composables/useValidation.js`), disables the button.
- HTTP 200 sets `loggedIn` and navigates to `body.redirect`. The client does not choose the path.
- HTTP 401 with `body.message === 'INVALID_CREDENTIALS'` shows "Username or password is incorrect".
- Any other non-200 shows the generic internal-error string.

The client does not write the cookies. It later reads `__{hostname}-access` and `__{hostname}-refresh` with `js-cookie`. How the API sets those cookies is **Unknown** from this repository.

### WF-3 — Stay signed in

**Trigger.** A guarded page or an API call runs `useJwt.isAccessValid`.

**Implemented.** If the access token is missing or `exp` is in the past, and the refresh token is still valid, the client `POST`s `/api/auth/refresh` with the refresh bearer token and CSRF. Status 200 counts as success. The function does not parse a new token from the body. A non-200 refresh, or an expired refresh token, counts as logged out. `buildFullHeaders` then throws `LOGIN_EXPIRED`, and calling pages navigate to `/login`.

### WF-4 — Logout

**Trigger.** Sidebar logout calls `POST /api/auth/logout`.

**Implemented.** Only HTTP 204 clears `loggedIn`, removes `selected_profile`, and navigates to `/`. Other statuses do nothing visible.

### WF-5 — Password reminder

**Trigger.** `pages/reminder.vue` posts `{ username }` to `POST /api/auth/reset`.

**Implemented, and incomplete.** Username is required and must pass `isValidUsername` (length 1–255). On HTTP 200 the handler assigns `username.value`, which is not defined, so the success path throws before `navigateTo(body.redirect)`. On any other status the toast payload is empty, because the branch that sets a message checks `status === 200` inside the `status !== 200` block.

### WF-6 — Registration and confirmation

**Trigger.** `pages/registration.vue` and `pages/confirm/[type]/[hash]/[token].vue`.

**Implemented as screens. Submit is not implemented.**

- The registration middleware `is-not-logged-in` is commented out.
- The page links to `https://www.sawis.co.za/forms/sawis.php` and collects title, names, telephone, email, and a `human` field.
- `submitForm` calls `auth.login` with that object. The reactive form has no `password`. The success path then reads `form.password` and `email.value`.
- Confirm types `registration`, `email`, and `password` change the heading. `submitFormRegistration`, `submitFormEmail`, and `submitFormPassword` are empty. `hash` and `token` are not sent.

### WF-7 — Member contact details

**Trigger.** Signed-in visitor opens `/member/contact-details`.

**Implemented.** `GET /api/user/contact-details` fills `components/forms/MemberForm.vue`. Save sends `PATCH /api/user/{sub}` with the form values. Handled API messages: `EXISTS_USERNAME`, `EXISTS_EMAIL`, `NO_CHANGE` (HTTP 400), and HTTP 403 `Forbidden`. HTTP 200 shows a success toast and clears the password field when one was entered.

### WF-8 — Groups

**Trigger.** Signed-in visitor opens `/member/groups-and-roles`.

**Implemented.** Loads current groups (`GET /api/user/groups-and-roles`), available groups, and pending requests. Join posts `user_group_id` to `POST /api/user/join-group` after a confirmation modal. Cancel posts `users_has_group_pending_id` to `POST /api/user/cancel-pending-group`.

### WF-9 — Member profile picker

**Trigger.** `loggedIn` becomes true and `user_class_id` is 2.

**Implemented** in `components/Sidebar.vue`. `GET /api/user/available-profiles`. One profile is selected automatically. More than one requires a choice. The choice is stored in `localStorage` as `selected_profile`. Status `20` still renders the option, and includes `statusdesc` in the label when that field is present. What status 20 means is **Unknown**.

### WF-10 — Guidelines and codings

**Trigger.** Member (`user_class_id` 2) opens `/member/guidelines` or `/member/codings`.

**Implemented.** Locale picks an English or Afrikaans PDF under `public/documents/guidelines` or `public/documents/codings`. No API call. The Afrikaans biodiversity link in `guidelines.vue` points at the English PDF (`Biodiversity Chapter 2_Eng_2022.pdf`).

### WF-11 — Reports

**Trigger.** Signed-in visitor opens `/member/reports`.

**Implemented.** Loads years, group names, and control products. Default filter value is `-1` ("all"). Table: `POST /api/report/table-report`. CSV: `POST /api/report/csv-report` with the same filters plus `locale`, downloaded as `IPW_Report_{YYYY-MM-DD}.csv`. The sidebar shows this link to every signed-in user. `useRoles.isMemberReports` (role id 17) is not used on the page.

`pages/member/edit.vue` is the same report screen (title "Reports") and is not linked from `MembershipNav` (contact details and groups only).

### WF-12 — Documents

**Trigger.** Signed-in visitor opens `/upload/documents`.

**Implemented.**

- Document type is the string `Farm` or `Cellar`, sent as `category_type` to `POST /api/upload/categories`.
- Category options show `position` and `category_name_{locale}`.
- Upload is `FormData` (`file`, `user_group_id`, `document_category_id`) to `POST /api/upload/document`. Success is HTTP 204.
- A member sends `user_group_id` from the selected profile (`POST /api/upload/documents`). Staff (`user_class_id` 1) must pick a profile from `GET /api/upload/all-profiles` and then uses `POST /api/upload/all-documents`.
- Download is `GET /api/upload/download-document/{document_upload_id}` and saves `IPW_Document_{YYYY-MM-DD}_{originalname}`.

`pages/management/list-members.vue` repeats this screen and is not linked from the sidebar.

### WF-13 — Staff administration that calls the API

**Trigger.** Visitor passes `is-logged-in` and `is-administrator`.

**Implemented** for these screens:

| Screen | Client call |
|---|---|
| `/admin/ipw-users` | `GET /api/admin/all-users` |
| `/admin/ipw-users/create-user` | `PUT /api/admin/create-user` |
| `/admin/ipw-users/edit-user/:id` | get, patch, role add/remove |
| `/admin/live-users` | `GET /api/admin/live-users` |
| `/admin/member-activity` | `GET /api/admin/member-activity` |
| `/admin/create-member` | `PUT /api/user/member` (expects 201) |
| `/admin/find-member` | `POST /api/admin/all-members` with letter filter `All` or `A`–`Z`, plus search |
| `/admin/find-member/edit-member/:id` | profile, groups, roles |
| `/admin/ipw-groups` | `POST /api/admin/all-groups`, `DELETE /api/admin/group` |

The sidebar shows "IPW Administration" only when `useRoles.isIpwAdmin()` (role ids 2 or 5). The route gate uses role **names**, not those ids. Whether ids 2 and 5 are `System Developer` and `IPW Administrator` is **Unknown** in this repository.

`/admin/member-registrations` calls `admin.newRegistrations()`. That method is not defined on `api/admin.js`. The nav item for it is commented out in `IpwAdministrationNav.vue`.

### WF-14 — Staff screens with no API client

**Trigger.** Same administrator gate. Routes under evaluation forms, member forms, probation, certificates, SAWIS search, SAWIS compliance, add group, and edit group.

**Implemented as layout.** API imports are commented out or absent. Tables contain sample rows (for example a farm evaluation form and the product "1,3 dichloropropene"). Several forms call `submitForm`, which is not defined in the script. Submitting would throw.

## Functional requirements

### FR-001 — Public IPW pages

**Who.** Any visitor.

**Trigger.** Request a public route listed in the sidebar.

**Behaviour.** Render the page in the current locale. Where the page defines PDF helpers, the link follows `locale` (`en` → `eng` folder, `afr` → `afr` folder), except the biodiversity guideline noted in WF-10.

**Output.** HTML and a PDF link. No API call.

**Evidence.** `components/Sidebar.vue`, `pages/index.vue` and the other public pages.

**Implementation.** Implemented.

**Confidence.** High.

### FR-002 — Two languages

**Who.** Any visitor.

**Trigger.** Sidebar language control.

**Behaviour.** `locale` becomes `en` or `afr`. The document title uses `meta.title` for English and `meta.titel` for Afrikaans. Default locale in `i18n.config.ts` is `en`.

**Evidence.** `i18n.config.ts`, `app.vue`, `components/Sidebar.vue`.

**Implementation.** Implemented.

**Confidence.** High.

### FR-003 — Login

**Who.** A person with credentials the API accepts.

**Trigger.** Submit on `/login`.

**Inputs.** `username`, `password`.

**Validation.** Both required. Password must pass the OWASP test configured in `useValidation.js` (minimum length 4, maximum 128, at least 2 optional tests, passphrases allowed).

**Behaviour.** See WF-2.

**Error.** 401 `INVALID_CREDENTIALS` vs any other failure, as in WF-2.

**Evidence.** `pages/login.vue`, `api/auth.js`, `middleware/isNotLoggedIn.js`.

**Implementation.** Implemented.

**Confidence.** High for the client. The API's redirect value is **Unknown** here.

### FR-004 — Route guard for signed-in pages

**Who.** Visitor.

**Trigger.** Navigation to a page whose middleware includes `is-logged-in`.

**Behaviour.** On the client (`document` defined), invalid access and refresh tokens navigate to `/login` with `redirectCode: 301`. A valid session continues. During prerender (`document` undefined) the middleware returns false.

**Evidence.** `middleware/isLoggedIn.js`.

**Implementation.** Implemented.

**Confidence.** High.

### FR-005 — Logout

See WF-4.

**Implementation.** Implemented.

**Confidence.** High.

### FR-006 — Contact details

See WF-7.

**Implementation.** Implemented.

**Confidence.** High for the client contract. Field-level rules inside `MemberForm.vue` were not exhaustively listed; the page-level error codes above were.

### FR-007 — Group join and cancel

See WF-8.

**Implementation.** Implemented.

**Confidence.** High.

### FR-008 — Reports

See WF-11.

**Inputs.** `year`, `user_group_id`, `control_product_id`, each defaulting to `-1`. CSV adds `locale`.

**Implementation.** Implemented.

**Confidence.** High.

### FR-009 — Document upload and download

See WF-12.

**Validation.** Type, category, and file are all required before the button enables. Staff cannot pick a type until a profile is selected (`checkDocumentCategoryTypeDisabled`).

**Implementation.** Implemented.

**Confidence.** High.

### FR-010 — Administrator-only routes

**Who.** A signed-in user whose access-token `roles` include a role named `System Developer` or `IPW Administrator`.

**Trigger.** Navigation to a page that lists both `is-logged-in` and `is-administrator`.

**Behaviour.** The administrator middleware returns the boolean `roles.some(...)`. It logs the roles to the console. It does not navigate to `/forbidden`.

**Evidence.** `middleware/isAdministrator.js`, `pages/admin/**`.

**Implementation.** Implemented.

**Confidence.** High. What Nuxt does with a bare `false` return (stay on the previous page versus a blank navigation) was not run in a browser. **Requires confirmation** if the exact UX matters.

### FR-011 — Create IPW user

**Who.** Administrator.

**Inputs.** `title`, `firstname`, `lastname`, `telephone`, `email`, `username`.

**Validation (this page, not `useValidation.js`).** Email required and must match the inline regex (length 6–254). Username required and at most 25 characters. First name longer than 40 characters is rejected. Last-name length is checked against `form.firstname`, not `form.lastname`. Telephone may be empty; if present it must be a valid number for region `ZA`, then formatted internationally. Title is not required (`errorTitle` is an empty computed).

**Behaviour.** `PUT /api/admin/create-user`.

**Evidence.** `pages/admin/ipw-users/create-user.vue`.

**Implementation.** Implemented, with the last-name check as written.

**Confidence.** High.

### FR-012 — Find members

**Who.** Administrator.

**Inputs.** Filter `All` or a letter `A`–`Z`, and a search string.

**Behaviour.** `POST /api/admin/all-members`. A row with `suspended === 1` shows the minus-shield icon. Any other value shows the tick-shield icon.

**Evidence.** `pages/admin/find-member/index.vue`.

**Implementation.** Implemented.

**Confidence.** High.

### FR-013 — Shell administration screens

**Who.** Administrator, if they can open the route.

**Behaviour.** Render headings, tabs, and sample tables. Do not call the API.

**Evidence.** Evaluation-form, member-form, probation, certificate, SAWIS, and add-group pages. `api/admin.js` has no methods for those areas.

**Implementation.** UI only. Not a working feature.

**Confidence.** High.

### FR-014 — Registration and confirmation

See WF-6.

**Implementation.** Not implemented as a working flow.

**Confidence.** High.

### FR-015 — Password reminder

See WF-5.

**Implementation.** Request is sent. Success and error presentation are broken as written.

**Confidence.** High.

### FR-016 — Generated site per environment

**Who.** A person who runs the npm script.

**Behaviour.** `nuxi generate` with `ENV` and, for staging and production, a fixed `NUXT_API_LOCATION`. Output is the Nuxt generate output (`.output` is gitignored). The README says the files land in `client/.output/public` and should be merged with a server build. There is no `client/` directory in this repository.

**Evidence.** `package.json`, `README.md`, `.gitignore`.

**Implementation.** Scripts exist. The README path does not match this tree.

**Confidence.** High for the scripts. Whether anyone still runs them is **Unknown**.

## Constraints

- Node 18 (`engines`).
- Static client: `ssr: false`, `target: 'static'`, `imports.autoImport: false`.
- API base is baked in at generate time via `runtimeConfig.public.NUXT_API_LOCATION`.
- The client strips the third JWT segment before sending `Authorization` (`useJwt.getRaw`).
- `error.vue` prints `error.statusCode`, `error.message`, and `error.stack` on the page.

## Conflicts inside this repository

| Topic | README or comment | Code |
|---|---|---|
| Dev command | `npm run dev` | Script name is `start` (`nuxi dev`) |
| Build commands | `generate-dev`, `generate`, `generate-production` | `build:dev`, `build:staging`, `build:production` |
| Output path | `client/.output/public` | Repository root; no `client/` |
| Authenticated routes | `member/profile`, `member/logout` | `member/contact-details`, sidebar logout |
| Public routes | `home`, `confronting-climate-change` | `pages/index.vue`, `pages/climate-change.vue` |
| Password length copy | i18n "less than 40" / "less than 25" on some labels | `useValidation.js` allows up to 255; create-user uses 40 and 25 |
| Staff detection | — | `useAuth.isIpwStaff` is `user_class_id === 1`. `useRoles.isIpwStaff` is a different set of role ids and is not what the sidebar calls |
| Admin detection | — | Route middleware uses role names. Menu uses role ids 2 and 5 |
| Member registrations | Page calls `admin.newRegistrations()` | Method is absent from `api/admin.js`. Nav link is commented out |

## Unknown / requires confirmation

- Which service implements the API, and whether production is `ipw-php`, `ipw_new`, or something else.
- Whether this repository is still deployed.
- The meaning of `user_class_id` 1 and 2 beyond the function names, and of profile `status === 20`.
- The display names for role ids 2, 5, 8, 9, 10, 11, 12, 14, 15, 16, 17, and 18.
- Cookie flags the API sets. `useCookies.cookieOptions` mentions `httpOnly` and `signed`, and nothing in the app calls `setItem`.
- Whether `HOST` and `PORT` in `.env.template` affect `nuxi dev`.
