mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 07:38:12 +00:00
Merge pull request #7 from Tria-plc/mulufeatures
Adding all the license feature and renewal
This commit is contained in:
88
apps/e2e/README.md
Normal file
88
apps/e2e/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# End-to-end browser tests
|
||||||
|
|
||||||
|
Playwright, driving the portal and the backoffice in a real browser against a
|
||||||
|
real API and a real database.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from emaui/
|
||||||
|
npx nx e2e @ema-platform/e2e # or: npx playwright test --config apps/e2e/playwright.config.ts
|
||||||
|
npx playwright test --config apps/e2e/playwright.config.ts --headed --debug
|
||||||
|
npx playwright show-report ../../dist/e2e-report
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it runs against
|
||||||
|
|
||||||
|
Nothing shared with your own dev stack. The suite starts its own servers, on its
|
||||||
|
own ports, against its own database:
|
||||||
|
|
||||||
|
| | port | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| API | 3011 | `node dist/main.js` with explicit env |
|
||||||
|
| Portal | 4302 | `vite build --mode e2e` then `vite preview` |
|
||||||
|
| Backoffice | 4303 | same |
|
||||||
|
| Database | — | `ema_e2e` |
|
||||||
|
|
||||||
|
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
|
||||||
|
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
|
||||||
|
`.env.local` on every run — a suite that read those files would point at
|
||||||
|
whichever stack was started last. The API is launched with `DATABASE_NAME`,
|
||||||
|
`PORT` and the payment flags passed directly, and the frontends are built with
|
||||||
|
`--mode e2e`, which picks up `apps/*/.env.e2e.local` (higher precedence than
|
||||||
|
`.env.local`, so your own config is left alone).
|
||||||
|
|
||||||
|
`vite preview` on a production build rather than `nx serve`, because Nx
|
||||||
|
serialises `serve` targets per project: with your dev portal already running,
|
||||||
|
a second `nx serve @ema-platform/portal` waits forever on the first.
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
The E2E database has to exist and be bootstrapped once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_NAME=ema_e2e ./dev/start.sh # creates it, migrates, seeds, then starts a stack you can Ctrl-C
|
||||||
|
```
|
||||||
|
|
||||||
|
Afterwards the suite manages its own servers; you do not need `start.sh` again.
|
||||||
|
Note that this run leaves the shared `.env` files pointing at the E2E database
|
||||||
|
and its ports — rerun `./dev/start.sh` plain to put them back.
|
||||||
|
|
||||||
|
## How it gets its data
|
||||||
|
|
||||||
|
- **Accounts are created through the UI.** Every spec signs up its own applicant
|
||||||
|
with a timestamped email, and deletes it in `afterEach` (`deleteApplicant`).
|
||||||
|
Nothing is shared between specs, so specs can run in any order and the second
|
||||||
|
run of the suite behaves exactly like the first.
|
||||||
|
- **The one-time code is scraped from the API log.** There is no local SMS
|
||||||
|
gateway, `iam.user_verifications.verification_code` is argon2-**hashed**, and
|
||||||
|
the notification rows it is delivered through carry an empty body — the log
|
||||||
|
line is the only plaintext. The API is therefore started with its output teed
|
||||||
|
to `/tmp/ema-e2e-api.log`, and `support/api-log.ts` reads codes written after
|
||||||
|
a recorded offset. The test still types the real code into the real screen.
|
||||||
|
Because the log line names no recipient, matching is by position, which is
|
||||||
|
sound only while the suite runs single-worker — hence `workers: 1`.
|
||||||
|
- **Database reads use `psql`**, shelled out from `support/db.ts`, rather than
|
||||||
|
adding a `pg` dependency to the frontend workspace for the sake of a few
|
||||||
|
assertions.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- One worker, serial. The officer queue is global state and the log-offset trick
|
||||||
|
needs ordering; determinism is worth the wall-clock.
|
||||||
|
- Assertions are on what the user sees — headings, badges, buttons — with
|
||||||
|
database checks only where the point is that something was *persisted*.
|
||||||
|
- No `waitForTimeout`. Wait for a condition.
|
||||||
|
- Traces, screenshots and video are retained for failures only.
|
||||||
|
|
||||||
|
## Debugging a failure
|
||||||
|
|
||||||
|
Playwright writes `test-results/<test>/error-context.md` with a full
|
||||||
|
accessibility snapshot of the page at the moment it failed — usually enough on
|
||||||
|
its own. Otherwise `npx playwright show-trace test-results/<test>/trace.zip`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented: signup → OTP → operations gate → dashboard, and the empty-state
|
||||||
|
dashboard. See the parent prompt (`dev/prompts/e2e-browser-tests-prompt.md`) for
|
||||||
|
the flows still to be written: catalogue filtering, the full application
|
||||||
|
lifecycle per licence type, officer review, payment bypass, certificate
|
||||||
|
download, expiry, renewal and reminders.
|
||||||
115
apps/e2e/playwright.config.ts
Normal file
115
apps/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end suite, driving the portal and the backoffice in a real browser.
|
||||||
|
*
|
||||||
|
* Both frontends are in one project on purpose: the flows that matter cross
|
||||||
|
* them — an applicant files, an officer decides, the applicant collects the
|
||||||
|
* certificate — and per-app projects cannot express that.
|
||||||
|
*
|
||||||
|
* Nothing here reads the shared `.env` files. A developer's own stack is
|
||||||
|
* usually already running against `ema_db` on the default ports, and
|
||||||
|
* `dev/start.sh` rewrites those files on every run; a suite that read them
|
||||||
|
* would point at whichever stack was started last. Instead the servers below
|
||||||
|
* are launched with explicit environment, on ports of their own, against a
|
||||||
|
* database of their own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_PORT = Number(process.env.E2E_API_PORT ?? 3011);
|
||||||
|
const PORTAL_PORT = Number(process.env.E2E_PORTAL_PORT ?? 4302);
|
||||||
|
const BACKOFFICE_PORT = Number(process.env.E2E_BACKOFFICE_PORT ?? 4303);
|
||||||
|
|
||||||
|
export const E2E = {
|
||||||
|
apiUrl: `http://localhost:${API_PORT}/api`,
|
||||||
|
portalUrl: `http://localhost:${PORTAL_PORT}`,
|
||||||
|
backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
|
||||||
|
database: process.env.E2E_DB_NAME ?? 'ema_e2e',
|
||||||
|
/**
|
||||||
|
* Where the API's own output is teed.
|
||||||
|
*
|
||||||
|
* The one-time code an applicant types at signup exists in exactly one
|
||||||
|
* readable place: this log. `iam.user_verifications` stores it argon2-hashed,
|
||||||
|
* and the notification rows it is delivered through carry an empty body — so
|
||||||
|
* the log line is not a convenience here, it is the only source.
|
||||||
|
*/
|
||||||
|
apiLog: process.env.E2E_API_LOG ?? '/tmp/ema-e2e-api.log',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emaapi =
|
||||||
|
'../../../emaapi/apps/server/emaapi';
|
||||||
|
|
||||||
|
/** Env the API is started with. Explicit, so no `.env` can redirect it. */
|
||||||
|
const apiEnv = {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(API_PORT),
|
||||||
|
DATABASE_NAME: E2E.database,
|
||||||
|
// The API applies migrations on boot, which is what bootstraps a fresh E2E
|
||||||
|
// database — but it must never be the thing that decides what the schema is.
|
||||||
|
MIGRATIONS_RUN: 'false',
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
FRONTEND_URLS: `${E2E.portalUrl},${E2E.backofficeUrl}`,
|
||||||
|
PUBLIC_VERIFY_URL: `${E2E.portalUrl}/verify`,
|
||||||
|
PORTAL_BASE_URL: E2E.portalUrl,
|
||||||
|
PAYMENT_AUTO_CONFIRM: 'true',
|
||||||
|
ALLOW_PAYMENT_BYPASS: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src',
|
||||||
|
// Flows share one database, and an officer queue is global state. Serial
|
||||||
|
// execution costs wall-clock and buys determinism.
|
||||||
|
workers: 1,
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
reporter: [['list'], ['html', { outputFolder: '../../dist/e2e-report', open: 'never' }]],
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: E2E.portalUrl,
|
||||||
|
// Kept only for failures — a trace per passing test is noise nobody reads.
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
actionTimeout: 15_000,
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
name: 'api',
|
||||||
|
// Teed rather than piped to Playwright's own stdout, because the OTP
|
||||||
|
// helper has to read it back.
|
||||||
|
command: `sh -c 'node dist/main.js 2>&1 | tee ${E2E.apiLog}'`,
|
||||||
|
cwd: emaapi,
|
||||||
|
env: apiEnv,
|
||||||
|
url: `${E2E.apiUrl}/license-types`,
|
||||||
|
// 401 is the healthy answer: the route exists and demands a session.
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'portal',
|
||||||
|
command: `npx vite build --config apps/portal/vite.config.mts --mode e2e && npx vite preview --config apps/portal/vite.config.mts --mode e2e --port ${PORTAL_PORT} --strictPort`,
|
||||||
|
cwd: '../..',
|
||||||
|
url: E2E.portalUrl,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 180_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backoffice',
|
||||||
|
command: `npx vite build --config apps/backoffice/vite.config.mts --mode e2e && npx vite preview --config apps/backoffice/vite.config.mts --mode e2e --port ${BACKOFFICE_PORT} --strictPort`,
|
||||||
|
cwd: '../..',
|
||||||
|
url: E2E.backofficeUrl,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 180_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
15
apps/e2e/project.json
Normal file
15
apps/e2e/project.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@ema-platform/e2e",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"sourceRoot": "apps/e2e/src",
|
||||||
|
"targets": {
|
||||||
|
"e2e": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "npx playwright test --config apps/e2e/playwright.config.ts",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/e2e/src/onboarding.spec.ts
Normal file
102
apps/e2e/src/onboarding.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
Applicant,
|
||||||
|
newApplicant,
|
||||||
|
signUp,
|
||||||
|
verifyOtpIfPrompted,
|
||||||
|
} from './support/applicant';
|
||||||
|
import { deleteApplicant, sql } from './support/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing up, and being asked what you operate as before anything else.
|
||||||
|
*
|
||||||
|
* The gate is the whole point: the catalogue is filtered by the answer and the
|
||||||
|
* API refuses an application for a mode the profile does not hold, so an
|
||||||
|
* applicant who skipped this would reach a dashboard that offers them nothing
|
||||||
|
* and explains nothing.
|
||||||
|
*/
|
||||||
|
test.describe('signup and onboarding', () => {
|
||||||
|
let applicant: Applicant;
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
applicant = newApplicant('onboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
deleteApplicant(applicant.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new applicant is asked for their operations before reaching the dashboard', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const offset = await signUp(page, applicant);
|
||||||
|
await verifyOtpIfPrompted(page, offset);
|
||||||
|
|
||||||
|
// The account exists in IAM ...
|
||||||
|
const rows = sql(
|
||||||
|
`SELECT email FROM iam.users WHERE email = '${applicant.email}'`,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
|
||||||
|
// ... and the first thing behind the session is the question, not the app.
|
||||||
|
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /what do you operate as/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// The dashboard stays out of reach until it is answered.
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page).toHaveURL(/\/onboarding\/operations/);
|
||||||
|
|
||||||
|
// Answer it.
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: /freight forwarder license/i })
|
||||||
|
.first()
|
||||||
|
.check();
|
||||||
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// And it stuck: stored against the profile, keyed by the IAM user id.
|
||||||
|
const declared = sql(`
|
||||||
|
SELECT lt.key
|
||||||
|
FROM profile_operator_types pot
|
||||||
|
JOIN profiles p ON p.id = pot.profile_id
|
||||||
|
JOIN iam.users u ON u.id = p.user_id
|
||||||
|
JOIN license_types lt ON lt.id = pot.license_type_id
|
||||||
|
WHERE u.email = '${applicant.email}' AND pot.deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
expect(declared.map((r) => r[0])).toEqual(['FREIGHT_FORWARDER']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the dashboard leads with a get-started panel when there is nothing to show', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const offset = await signUp(page, applicant);
|
||||||
|
await verifyOtpIfPrompted(page, offset);
|
||||||
|
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: /freight forwarder license/i })
|
||||||
|
.first()
|
||||||
|
.check();
|
||||||
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// Nothing filed and nothing held: one panel, not two empty sections.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'My applications' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: 'My licences' })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The catalogue is directly beneath it, filtered to what they declared.
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /apply for a licence/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText('Freight Forwarder License').first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Shipping Agent License')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/e2e/src/support/api-log.ts
Normal file
58
apps/e2e/src/support/api-log.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { E2E } from '../../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading one-time codes back out of the API's own output.
|
||||||
|
*
|
||||||
|
* There is no local SMS gateway, and the code is not recoverable anywhere else:
|
||||||
|
* `iam.user_verifications.verification_code` holds an argon2 hash, and the
|
||||||
|
* notification rows it is delivered through store an empty body. The log line
|
||||||
|
* the message composer emits is the only plaintext.
|
||||||
|
*
|
||||||
|
* Codes are matched by *position in the log*, not by recipient — the line names
|
||||||
|
* no user. Callers take an offset before the action that triggers the send and
|
||||||
|
* only read what was written after it, which is sound because the suite runs
|
||||||
|
* single-worker and serially.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OTP_PATTERN = /is (\d{4,8})\./g;
|
||||||
|
|
||||||
|
/** Byte offset to read from later. Zero when the log does not exist yet. */
|
||||||
|
export function logOffset(): number {
|
||||||
|
try {
|
||||||
|
return statSync(E2E.apiLog).size;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for a code to appear after `offset`, and returns the last one seen. */
|
||||||
|
export async function waitForOtp(
|
||||||
|
offset: number,
|
||||||
|
timeoutMs = 20_000,
|
||||||
|
): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const found = otpSince(offset);
|
||||||
|
if (found) return found;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No one-time code appeared in ${E2E.apiLog} within ${timeoutMs}ms. ` +
|
||||||
|
`Is the API teeing its output there?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function otpSince(offset: number): string | null {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = [...text.matchAll(OTP_PATTERN)].map((m) => m[1]);
|
||||||
|
return codes.length > 0 ? codes[codes.length - 1] : null;
|
||||||
|
}
|
||||||
99
apps/e2e/src/support/applicant.ts
Normal file
99
apps/e2e/src/support/applicant.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import { logOffset, waitForOtp } from './api-log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new applicant, unique to this run.
|
||||||
|
*
|
||||||
|
* Every spec makes its own. Sharing one account between specs means the second
|
||||||
|
* run of the suite starts from a state the first run left behind — an
|
||||||
|
* applicant who already declared their operations, already holds a licence —
|
||||||
|
* and the assertions quietly stop meaning what they say.
|
||||||
|
*/
|
||||||
|
export interface Applicant {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newApplicant(label: string): Applicant {
|
||||||
|
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
return {
|
||||||
|
email: `e2e.${label}.${stamp}@example.test`,
|
||||||
|
username: `e2e${label}${stamp}`.slice(0, 28),
|
||||||
|
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
||||||
|
phoneNumber: `+2519${stamp.slice(-8)}`,
|
||||||
|
password: 'E2ePassw0rd!',
|
||||||
|
name: `E2E ${label} ${stamp.slice(-4)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills and submits the signup form.
|
||||||
|
*
|
||||||
|
* Returns the log offset taken immediately before submitting, so the caller can
|
||||||
|
* pick up the one-time code that submission triggers without matching a stale
|
||||||
|
* one from an earlier test.
|
||||||
|
*/
|
||||||
|
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await page.getByLabel('Name (English)').fill(applicant.name);
|
||||||
|
await page.getByLabel('Email address').fill(applicant.email);
|
||||||
|
await page.getByLabel('Username').fill(applicant.username);
|
||||||
|
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(applicant.password);
|
||||||
|
await page.getByLabel('Confirm password').fill(applicant.password);
|
||||||
|
|
||||||
|
// The terms checkbox gates the submit button.
|
||||||
|
await page.getByRole('checkbox').check();
|
||||||
|
|
||||||
|
const offset = logOffset();
|
||||||
|
await page.getByRole('button', { name: /create account|sign up/i }).click();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes phone verification by typing the real code.
|
||||||
|
*
|
||||||
|
* No-op when signup did not land on the OTP screen — whether it does depends
|
||||||
|
* on how the account was created, and a test that asserts the flow rather than
|
||||||
|
* the screen should not care.
|
||||||
|
*/
|
||||||
|
export async function verifyOtpIfPrompted(
|
||||||
|
page: Page,
|
||||||
|
sinceOffset: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 30_000 });
|
||||||
|
if (!page.url().includes('/otp-verify')) return false;
|
||||||
|
|
||||||
|
const code = await waitForOtp(sinceOffset);
|
||||||
|
|
||||||
|
// Mantine's PinInput is one input per character and moves focus itself, so
|
||||||
|
// the code is typed rather than filled — filling each box individually
|
||||||
|
// fights the component's own focus handling.
|
||||||
|
const boxes = page.locator('input[inputmode], input[type="text"]');
|
||||||
|
await boxes.first().click();
|
||||||
|
await page.keyboard.type(code, { delay: 30 });
|
||||||
|
|
||||||
|
// `onComplete` submits on the last character; the button is the fallback for
|
||||||
|
// when it does not fire.
|
||||||
|
const submit = page.getByRole('button', { name: /^verify$/i });
|
||||||
|
await Promise.race([
|
||||||
|
page.waitForURL(/\/(onboarding|dashboard)/, { timeout: 10_000 }).catch(() => null),
|
||||||
|
submit.click({ timeout: 5_000 }).catch(() => null),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs in an existing applicant through the login screen. */
|
||||||
|
export async function logIn(page: Page, applicant: Applicant): Promise<void> {
|
||||||
|
await page.goto('/login');
|
||||||
|
// The API takes an email here, not a username — sending a username answers
|
||||||
|
// "email should not be empty".
|
||||||
|
await page.getByLabel(/email/i).fill(applicant.email);
|
||||||
|
await page.getByLabel(/password/i).fill(applicant.password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/(onboarding|dashboard|profile)/, { timeout: 30_000 });
|
||||||
|
}
|
||||||
60
apps/e2e/src/support/db.ts
Normal file
60
apps/e2e/src/support/db.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { E2E } from '../../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct access to the E2E database.
|
||||||
|
*
|
||||||
|
* Shelling out to `psql` rather than adding a `pg` dependency to the frontend
|
||||||
|
* workspace: the suite needs a handful of reads and a cleanup delete, and a
|
||||||
|
* driver in `emaui/package.json` would follow the frontend build around
|
||||||
|
* forever for the sake of them.
|
||||||
|
*/
|
||||||
|
const PSQL_ENV = {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sql(query: string): string[][] {
|
||||||
|
const out = execFileSync(
|
||||||
|
'psql',
|
||||||
|
[
|
||||||
|
'-h', process.env.E2E_DB_HOST ?? 'localhost',
|
||||||
|
'-p', process.env.E2E_DB_PORT ?? '5432',
|
||||||
|
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
||||||
|
'-d', E2E.database,
|
||||||
|
'-tAF', '\t',
|
||||||
|
'-c', query,
|
||||||
|
],
|
||||||
|
{ env: PSQL_ENV, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
return out
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim() !== '')
|
||||||
|
.map((line) => line.split('\t'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First column of the first row, or null when the query found nothing. */
|
||||||
|
export function sqlValue(query: string): string | null {
|
||||||
|
const rows = sql(query);
|
||||||
|
return rows.length > 0 ? rows[0][0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes everything a run created, so the next run starts from the same place. */
|
||||||
|
export function deleteApplicant(email: string): void {
|
||||||
|
const safe = email.replace(/'/g, "''");
|
||||||
|
const userId = sqlValue(`SELECT id FROM iam.users WHERE email = '${safe}'`);
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
sql(`
|
||||||
|
DELETE FROM licenses WHERE holder_user_id = '${userId}';
|
||||||
|
DELETE FROM license_applications WHERE applicant_user_id = '${userId}';
|
||||||
|
DELETE FROM profile_operator_types
|
||||||
|
WHERE profile_id IN (SELECT id FROM profiles WHERE user_id = '${userId}');
|
||||||
|
DELETE FROM profiles WHERE user_id = '${userId}';
|
||||||
|
DELETE FROM iam.notifications WHERE recipient_id = '${userId}';
|
||||||
|
DELETE FROM iam.user_verifications WHERE user_id = '${userId}';
|
||||||
|
DELETE FROM iam.user_credentials WHERE user_id = '${userId}';
|
||||||
|
DELETE FROM iam.user_roles WHERE user_id = '${userId}';
|
||||||
|
DELETE FROM iam.users WHERE id = '${userId}';
|
||||||
|
`);
|
||||||
|
}
|
||||||
11
apps/e2e/tsconfig.json
Normal file
11
apps/e2e/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconRefresh,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||||
@@ -41,11 +42,14 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
STATUS_PROGRESS,
|
STATUS_PROGRESS,
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
|
extractErrorMessage,
|
||||||
localized,
|
localized,
|
||||||
|
useCreateApplicationMutation,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||||
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
||||||
|
|
||||||
@@ -53,8 +57,13 @@ import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
|||||||
* The applicant's home screen.
|
* The applicant's home screen.
|
||||||
*
|
*
|
||||||
* Ordered by what the applicant needs from it: first anything blocked on them,
|
* Ordered by what the applicant needs from it: first anything blocked on them,
|
||||||
* then a read of where their applications stand, then the licence catalogue,
|
* then the records they already have — licences, then applications — and only
|
||||||
* then the licences they already hold. Every figure is the signed-in user's
|
* then the catalogue to file something new. A returning applicant comes here to
|
||||||
|
* check on their own things, not to shop; the catalogue used to sit above both
|
||||||
|
* and pushed them below the fold.
|
||||||
|
*
|
||||||
|
* The exception is an applicant with nothing at all, for whom both sections are
|
||||||
|
* empty and the catalogue *is* the page. Every figure is the signed-in user's
|
||||||
* own data — there are no illustrative numbers on this page.
|
* own data — there are no illustrative numbers on this page.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -92,6 +101,8 @@ export function DashboardPage() {
|
|||||||
const { data: licenses } = useGetMyLicensesQuery();
|
const { data: licenses } = useGetMyLicensesQuery();
|
||||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
const [getCertificateUrl, { isLoading: isDownloading }] =
|
||||||
useGetCertificateUrlMutation();
|
useGetCertificateUrlMutation();
|
||||||
|
const [createApplication, { isLoading: isRenewing }] =
|
||||||
|
useCreateApplicationMutation();
|
||||||
|
|
||||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||||
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
||||||
@@ -103,6 +114,9 @@ export function DashboardPage() {
|
|||||||
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
|
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
|
||||||
);
|
);
|
||||||
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
||||||
|
// Nothing filed and nothing held: the two "my …" sections would both be
|
||||||
|
// empty, so they collapse into one panel and the catalogue carries the page.
|
||||||
|
const hasNoRecords = items.length === 0 && heldLicenses.length === 0;
|
||||||
const expiringSoon = activeLicenses.filter((l) => {
|
const expiringSoon = activeLicenses.filter((l) => {
|
||||||
const days = daysUntil(l.expiryDate);
|
const days = daysUntil(l.expiryDate);
|
||||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
||||||
@@ -113,6 +127,27 @@ export function DashboardPage() {
|
|||||||
window.open(result.url, '_blank', 'noopener');
|
window.open(result.url, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||||
|
* application of kind RENEWAL, asking for that licence type's renewal
|
||||||
|
* document set. `previousLicenseId` is what ties it to the certificate being
|
||||||
|
* replaced, and what the API requires.
|
||||||
|
*/
|
||||||
|
async function renewLicense(license: IssuedLicense) {
|
||||||
|
const typeKey = license.licenseType?.key;
|
||||||
|
if (!typeKey) return;
|
||||||
|
try {
|
||||||
|
const application = await createApplication({
|
||||||
|
licenseType: typeKey,
|
||||||
|
kind: 'RENEWAL',
|
||||||
|
previousLicenseId: license.id,
|
||||||
|
}).unwrap();
|
||||||
|
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center h={400}>
|
<Center h={400}>
|
||||||
@@ -167,50 +202,60 @@ export function DashboardPage() {
|
|||||||
expiringSoon={expiringSoon.length}
|
expiringSoon={expiringSoon.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{hasNoRecords ? (
|
||||||
|
<GetStartedPanel />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Section title="My licences">
|
||||||
|
{heldLicenses.length === 0 ? (
|
||||||
|
<EmptyCard message="No licence has been issued to you yet. One appears here once an application is approved and paid." />
|
||||||
|
) : (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{heldLicenses.map((license) => (
|
||||||
|
<LicenseCard
|
||||||
|
key={license.id}
|
||||||
|
license={license}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
isRenewing={isRenewing}
|
||||||
|
onDownload={() => downloadCertificate(license)}
|
||||||
|
onRenew={() => renewLicense(license)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="My applications"
|
||||||
|
action={
|
||||||
|
items.length > 0 ? (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/licensing/applications')}
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Anchor>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyCard message="You have not filed any applications yet. Pick a licence below to get started." />
|
||||||
|
) : (
|
||||||
|
<ApplicationTable
|
||||||
|
applications={items.slice(0, 6)}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Apply for a licence"
|
title="Apply for a licence"
|
||||||
description="Choose the licence that matches the service your company provides."
|
description="Choose the licence that matches the service your company provides."
|
||||||
>
|
>
|
||||||
<LicenseCatalogue />
|
<LicenseCatalogue />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
|
||||||
title="My applications"
|
|
||||||
action={
|
|
||||||
items.length > 0 ? (
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/licensing/applications')}
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
</Anchor>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<EmptyCard message="You have not filed any applications yet. Pick a licence above to get started." />
|
|
||||||
) : (
|
|
||||||
<ApplicationTable
|
|
||||||
applications={items.slice(0, 6)}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{heldLicenses.length > 0 && (
|
|
||||||
<Section title="My licences">
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
|
||||||
{heldLicenses.map((license) => (
|
|
||||||
<LicenseCard
|
|
||||||
key={license.id}
|
|
||||||
license={license}
|
|
||||||
isDownloading={isDownloading}
|
|
||||||
onDownload={() => downloadCertificate(license)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -507,14 +552,21 @@ function ApplicationTable({
|
|||||||
function LicenseCard({
|
function LicenseCard({
|
||||||
license,
|
license,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
|
isRenewing,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onRenew,
|
||||||
}: {
|
}: {
|
||||||
license: IssuedLicense;
|
license: IssuedLicense;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
|
isRenewing: boolean;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
|
onRenew: () => void;
|
||||||
}) {
|
}) {
|
||||||
const days = daysUntil(license.expiryDate);
|
// The API computes both in the authority's timezone; the local fallbacks are
|
||||||
|
// only for a cached response from before those fields existed.
|
||||||
|
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||||
const expired = license.status === 'EXPIRED' || days < 0;
|
const expired = license.status === 'EXPIRED' || days < 0;
|
||||||
|
const renewable = license.renewable ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" padding="md">
|
<Card withBorder radius="md" padding="md">
|
||||||
@@ -559,6 +611,50 @@ function LicenseCard({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Renewal opens inside the licence type's window and stays open after
|
||||||
|
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
||||||
|
{renewable && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
size="xs"
|
||||||
|
variant={expired ? 'filled' : 'light'}
|
||||||
|
color={expired ? 'orange' : undefined}
|
||||||
|
loading={isRenewing}
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={onRenew}
|
||||||
|
>
|
||||||
|
{expired
|
||||||
|
? 'Renew — this licence has expired'
|
||||||
|
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first-visit panel, in place of two empty sections saying the same thing.
|
||||||
|
* It carries no call to action of its own — the catalogue is directly beneath
|
||||||
|
* it, and a button that only scrolls the page is noise.
|
||||||
|
*/
|
||||||
|
function GetStartedPanel() {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="xl">
|
||||||
|
<Group gap="md" wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" size={44} radius="md">
|
||||||
|
<IconCertificate size={24} stroke={1.5} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Title order={4}>Get started</Title>
|
||||||
|
<Text size="sm" c="dimmed" mt={4} maw={620}>
|
||||||
|
You have not filed an application yet. Choose the licence that
|
||||||
|
matches what your company does — your applications and the licences
|
||||||
|
issued to you will appear here as you go.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
localized,
|
localized,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
|
useGetMyOperatorTypesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
||||||
|
|
||||||
@@ -52,9 +54,25 @@ export function LicenseCatalogue() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: types } = useGetLicenseTypesQuery();
|
const { data: types } = useGetLicenseTypesQuery();
|
||||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||||
|
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||||
|
useGetMyOperatorTypesQuery();
|
||||||
|
// An escape hatch, not a preference: someone who wants to see what else
|
||||||
|
// exists can, without first editing their profile to find out.
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
const declared = useMemo(
|
||||||
|
() => new Set((operatorTypes?.items ?? []).map((o) => o.licenseTypeId)),
|
||||||
|
[operatorTypes],
|
||||||
|
);
|
||||||
|
const hasDeclared = declared.size > 0;
|
||||||
|
|
||||||
const { groups, orphans } = useMemo(() => {
|
const { groups, orphans } = useMemo(() => {
|
||||||
const active = (types?.items ?? []).filter((t) => t.isActive);
|
const active = (types?.items ?? [])
|
||||||
|
.filter((t) => t.isActive)
|
||||||
|
// Only what the applicant operates as. The server enforces the same rule
|
||||||
|
// on create; this is what stops them starting an application they will
|
||||||
|
// be refused at the end of.
|
||||||
|
.filter((t) => showAll || !hasDeclared || declared.has(t.id));
|
||||||
const catalogue = (categories?.items ?? [])
|
const catalogue = (categories?.items ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
@@ -71,7 +89,38 @@ export function LicenseCatalogue() {
|
|||||||
// vanish from the page entirely.
|
// vanish from the page entirely.
|
||||||
orphans: active.filter((t) => !known.has(t.category)),
|
orphans: active.filter((t) => !known.has(t.category)),
|
||||||
};
|
};
|
||||||
}, [types, categories]);
|
}, [types, categories, declared, hasDeclared, showAll]);
|
||||||
|
|
||||||
|
// A profile created before modes existed, or one whose modes were all
|
||||||
|
// removed. Showing an empty catalogue would read as "there is nothing for
|
||||||
|
// you here" when the truth is "tell us what you do".
|
||||||
|
if (!loadingOperatorTypes && !hasDeclared && !showAll) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" padding="xl">
|
||||||
|
<Stack gap="xs" align="center">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" size="lg" radius="xl">
|
||||||
|
<IconBuildingWarehouse size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Tell us what you operate as
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={520}>
|
||||||
|
Licences are offered against your mode of operation — freight
|
||||||
|
forwarder, shipping agent, multimodal transport operator and so on.
|
||||||
|
Choose yours and the licences you can apply for appear here.
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm" mt="xs">
|
||||||
|
<Button size="xs" onClick={() => navigate('/profile#operations')}>
|
||||||
|
Set my operations
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" variant="subtle" onClick={() => setShowAll(true)}>
|
||||||
|
Browse all licences
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (groups.length === 0 && orphans.length === 0) {
|
if (groups.length === 0 && orphans.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -91,8 +140,26 @@ export function LicenseCatalogue() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In browse-all, a card the applicant cannot yet apply for sends them to the
|
||||||
|
// Operations tab rather than into a form the server would refuse to accept.
|
||||||
|
const canApply = (type: LicenseType) => !hasDeclared || declared.has(type.id);
|
||||||
|
const select = (type: LicenseType) =>
|
||||||
|
canApply(type)
|
||||||
|
? navigate(`/licensing/${type.key}/apply`)
|
||||||
|
: navigate('/profile#operations');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{showAll && hasDeclared && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Showing every licence, including ones outside your operations.
|
||||||
|
</Text>
|
||||||
|
<Anchor size="xs" onClick={() => setShowAll(false)}>
|
||||||
|
Show only mine
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
{groups.map(({ category, licenseTypes }) => (
|
{groups.map(({ category, licenseTypes }) => (
|
||||||
<CategoryGroup
|
<CategoryGroup
|
||||||
key={category.key}
|
key={category.key}
|
||||||
@@ -100,7 +167,8 @@ export function LicenseCatalogue() {
|
|||||||
title={localized(category.name)}
|
title={localized(category.name)}
|
||||||
description={localized(category.description)}
|
description={localized(category.description)}
|
||||||
licenseTypes={licenseTypes}
|
licenseTypes={licenseTypes}
|
||||||
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
|
canApply={canApply}
|
||||||
|
onSelect={select}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{orphans.length > 0 && (
|
{orphans.length > 0 && (
|
||||||
@@ -109,9 +177,20 @@ export function LicenseCatalogue() {
|
|||||||
title="Other licences"
|
title="Other licences"
|
||||||
description="Licence types that have not been assigned a category."
|
description="Licence types that have not been assigned a category."
|
||||||
licenseTypes={orphans}
|
licenseTypes={orphans}
|
||||||
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
|
canApply={canApply}
|
||||||
|
onSelect={select}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasDeclared && !showAll && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Only licences matching your declared operations are shown.
|
||||||
|
</Text>
|
||||||
|
<Anchor size="xs" onClick={() => setShowAll(true)}>
|
||||||
|
Browse all licences
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,12 +200,14 @@ function CategoryGroup({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
licenseTypes,
|
licenseTypes,
|
||||||
|
canApply,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof IconShip;
|
icon: typeof IconShip;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
licenseTypes: LicenseType[];
|
licenseTypes: LicenseType[];
|
||||||
|
canApply: (type: LicenseType) => boolean;
|
||||||
onSelect: (type: LicenseType) => void;
|
onSelect: (type: LicenseType) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +225,12 @@ function CategoryGroup({
|
|||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
{licenseTypes.map((type) => (
|
{licenseTypes.map((type) => (
|
||||||
<LicenseTypeCard key={type.id} type={type} onSelect={onSelect} />
|
<LicenseTypeCard
|
||||||
|
key={type.id}
|
||||||
|
type={type}
|
||||||
|
canApply={canApply(type)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -153,9 +239,11 @@ function CategoryGroup({
|
|||||||
|
|
||||||
function LicenseTypeCard({
|
function LicenseTypeCard({
|
||||||
type,
|
type,
|
||||||
|
canApply,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
type: LicenseType;
|
type: LicenseType;
|
||||||
|
canApply: boolean;
|
||||||
onSelect: (type: LicenseType) => void;
|
onSelect: (type: LicenseType) => void;
|
||||||
}) {
|
}) {
|
||||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||||
@@ -216,9 +304,10 @@ function LicenseTypeCard({
|
|||||||
mt="sm"
|
mt="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
color={canApply ? undefined : 'gray'}
|
||||||
rightSection={<IconArrowRight size={14} />}
|
rightSection={<IconArrowRight size={14} />}
|
||||||
>
|
>
|
||||||
Start application
|
{canApply ? 'Start application' : 'Add to my operations'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
@@ -54,7 +53,7 @@ export function MyApplicationsPage() {
|
|||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: 'Payment bypassed',
|
title: 'Payment bypassed',
|
||||||
message: result.certificateIssued
|
message: result.certificateIssued
|
||||||
? 'The licence has been issued — see My licences below.'
|
? 'The licence has been issued — see My licences above.'
|
||||||
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,14 +117,9 @@ export function MyApplicationsPage() {
|
|||||||
Licence applications
|
Licence applications
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
Choose a licence to apply for. Each one asks for its own forms and
|
Your licences and applications, and the catalogue to file a new one.
|
||||||
supporting documents.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box mb="xl">
|
|
||||||
<LicenseCatalogue />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{(licences?.items ?? []).length > 0 && (
|
{(licences?.items ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Title order={4} mb="sm">
|
<Title order={4} mb="sm">
|
||||||
@@ -205,7 +199,7 @@ export function MyApplicationsPage() {
|
|||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<Text c="dimmed">You have not filed any applications yet.</Text>
|
<Text c="dimmed">You have not filed any applications yet.</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Pick a licence above to get started.
|
Pick a licence below to get started.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -314,6 +308,13 @@ export function MyApplicationsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Last, for the same reason as on the dashboard: someone opening this
|
||||||
|
page came to check on what they already filed, not to browse. */}
|
||||||
|
<Title order={4} mt="xl" mb="sm">
|
||||||
|
Apply for a licence
|
||||||
|
</Title>
|
||||||
|
<LicenseCatalogue />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Center, Loader } from '@mantine/core';
|
||||||
|
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an applicant who has not said what they operate as to the step that
|
||||||
|
* asks. Everything else in the portal is keyed off that answer — the catalogue
|
||||||
|
* offers nothing without it, and the server refuses an application for a mode
|
||||||
|
* the profile does not hold — so it is asked once, up front.
|
||||||
|
*
|
||||||
|
* Deliberately not a hard gate on every route: the profile and support pages
|
||||||
|
* stay reachable, because someone who cannot answer the question yet must
|
||||||
|
* still be able to reach their account and ask for help.
|
||||||
|
*/
|
||||||
|
const ALWAYS_ALLOWED = ['/onboarding/operations', '/profile', '/support'];
|
||||||
|
|
||||||
|
export function RequireOperations({ children }: { children: React.ReactNode }) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
|
||||||
|
|
||||||
|
if (ALWAYS_ALLOWED.some((path) => pathname.startsWith(path))) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed lookup must not lock anyone out of the portal — the server still
|
||||||
|
// enforces the rule on create, so the worst case is a catalogue that offers
|
||||||
|
// more than it should for one session.
|
||||||
|
if (isError) return <>{children}</>;
|
||||||
|
|
||||||
|
if ((data?.items ?? []).length === 0) {
|
||||||
|
// An empty set while a request is in flight is not an answer. Saving the
|
||||||
|
// onboarding form invalidates this query and navigates to the dashboard in
|
||||||
|
// the same tick; deciding on the pre-save cache bounced the applicant
|
||||||
|
// straight back to the screen they had just completed.
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Navigate to="/onboarding/operations" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
|
||||||
|
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one thing a new applicant is asked for beyond their credentials.
|
||||||
|
*
|
||||||
|
* It is a step of its own rather than a field on the signup form because the
|
||||||
|
* licence catalogue it offers is not readable without a session — signup
|
||||||
|
* issues the token, and this is the first screen behind it. An applicant who
|
||||||
|
* arrived before modes existed lands here once, for the same reason.
|
||||||
|
*
|
||||||
|
* Saving is what completes it: `RequireOperations` stops redirecting here as
|
||||||
|
* soon as the profile has at least one mode.
|
||||||
|
*/
|
||||||
|
export function OperationsOnboardingPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="sm" py="xl">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Title order={3}>What do you operate as?</Title>
|
||||||
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
|
The Authority licenses by mode of operation. Tell us what your
|
||||||
|
company does and we will show you the licences you can apply for —
|
||||||
|
you can change this later from your profile.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
|
<OperationsFormContent onSaved={() => navigate('/dashboard')} />
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OperationsOnboardingPage;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
extractErrorMessage,
|
||||||
|
localized,
|
||||||
|
useGetLicenseTypesQuery,
|
||||||
|
useGetMyOperatorTypesQuery,
|
||||||
|
useUpdateMyOperatorTypesMutation,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The applicant's modes of operation — what they do, and therefore which
|
||||||
|
* licences the portal offers them.
|
||||||
|
*
|
||||||
|
* The options come from the licence-type catalogue rather than a list in the
|
||||||
|
* code, so a sixth licence type configured by EMA appears here without a
|
||||||
|
* release. Removing a mode is confirmed separately from adding one: adding
|
||||||
|
* only widens what is on offer, while removing changes what the applicant can
|
||||||
|
* still file.
|
||||||
|
*/
|
||||||
|
export function OperationsFormContent({
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
/** Where to go once the set is stored — used by the onboarding step. */
|
||||||
|
onSaved?: () => void;
|
||||||
|
} = {}) {
|
||||||
|
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
|
||||||
|
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
||||||
|
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
||||||
|
|
||||||
|
const declaredIds = useMemo(
|
||||||
|
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
|
||||||
|
[mine],
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
||||||
|
|
||||||
|
// Re-sync whenever the server's answer changes — including after a save, so
|
||||||
|
// the form reflects what was actually stored rather than what was typed.
|
||||||
|
useEffect(() => setSelected(declaredIds), [declaredIds]);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => (catalogue?.items ?? []).filter((t) => t.isActive),
|
||||||
|
[catalogue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = declaredIds.filter((id) => !selected.includes(id));
|
||||||
|
const dirty =
|
||||||
|
removed.length > 0 || selected.some((id) => !declaredIds.includes(id));
|
||||||
|
|
||||||
|
const lastChanged = useMemo(() => {
|
||||||
|
const dates = (mine?.items ?? [])
|
||||||
|
.map((o) => o.declaredAt)
|
||||||
|
.filter((d): d is string => Boolean(d))
|
||||||
|
.sort();
|
||||||
|
return dates.length > 0 ? dates[dates.length - 1] : null;
|
||||||
|
}, [mine]);
|
||||||
|
|
||||||
|
async function persist() {
|
||||||
|
try {
|
||||||
|
await save({ licenseTypeIds: selected }).unwrap();
|
||||||
|
setConfirmingRemoval(false);
|
||||||
|
notify.success(
|
||||||
|
'The licences you can apply for have been updated to match.',
|
||||||
|
'Operations updated',
|
||||||
|
);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err), 'Could not save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingTypes || loadingMine) {
|
||||||
|
return <Loader size="sm" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedNames = options
|
||||||
|
.filter((t) => removed.includes(t.id))
|
||||||
|
.map((t) => localized(t.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<div>
|
||||||
|
<Title order={5}>Mode of operation</Title>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
What your company operates as. This decides which licences you are
|
||||||
|
offered — you can change it whenever your business changes.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Checkbox.Group value={selected} onChange={setSelected}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{options.map((type) => (
|
||||||
|
<Checkbox
|
||||||
|
key={type.id}
|
||||||
|
value={type.id}
|
||||||
|
label={
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="sm">{localized(type.name)}</Text>
|
||||||
|
{declaredIds.includes(type.id) && (
|
||||||
|
<Badge size="xs" variant="light" color="teal">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
type.description ? localized(type.description) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Checkbox.Group>
|
||||||
|
|
||||||
|
{options.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No licence types are configured yet. Contact EMA if you were
|
||||||
|
expecting one.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
icon={<IconAlertTriangle size={18} />}
|
||||||
|
title="No operations selected"
|
||||||
|
>
|
||||||
|
With none selected you will not be offered any licence to apply for.
|
||||||
|
Existing applications and issued licences are unaffected.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{lastChanged
|
||||||
|
? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}`
|
||||||
|
: 'Not set yet'}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
{dirty && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelected(declaredIds)}
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
loading={saving}
|
||||||
|
disabled={!dirty}
|
||||||
|
leftSection={<IconBuildingWarehouse size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
removed.length > 0 ? setConfirmingRemoval(true) : persist()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save operations
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Removal is the one direction that takes something away, so it is
|
||||||
|
spelled out rather than saved on a single click. */}
|
||||||
|
<Modal
|
||||||
|
opened={confirmingRemoval}
|
||||||
|
onClose={() => setConfirmingRemoval(false)}
|
||||||
|
title="Remove from your operations?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
You are removing{' '}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{removedNames.join(', ')}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
You will no longer be offered a new application of that type.
|
||||||
|
Applications already filed carry on as they are, and licences
|
||||||
|
already issued to you stay valid and can still be renewed.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setConfirmingRemoval(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="orange" loading={saving} onClick={persist}>
|
||||||
|
Remove and save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
|
IconBuildingWarehouse,
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPhone,
|
IconPhone,
|
||||||
@@ -63,10 +64,18 @@ import {
|
|||||||
addressSchema,
|
addressSchema,
|
||||||
type AddressValues,
|
type AddressValues,
|
||||||
} from '../components/AddressFormContent';
|
} from '../components/AddressFormContent';
|
||||||
|
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||||
import classes from './ProfilePage.module.css';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
/** Tab keys addressable via the URL hash. */
|
/** Tab keys addressable via the URL hash. */
|
||||||
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
|
const VALID_TABS = [
|
||||||
|
'personal',
|
||||||
|
'profile',
|
||||||
|
'address',
|
||||||
|
'operations',
|
||||||
|
'security',
|
||||||
|
'preferences',
|
||||||
|
];
|
||||||
|
|
||||||
function getInitials(name: string, fallback: string) {
|
function getInitials(name: string, fallback: string) {
|
||||||
const source = name?.trim() || fallback?.trim() || '';
|
const source = name?.trim() || fallback?.trim() || '';
|
||||||
@@ -525,6 +534,12 @@ export function ProfilePage() {
|
|||||||
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
|
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
|
||||||
Address
|
Address
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="operations"
|
||||||
|
leftSection={<IconBuildingWarehouse size={18} />}
|
||||||
|
>
|
||||||
|
Operations
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||||
{t('profile.tabs.security')}
|
{t('profile.tabs.security')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -695,6 +710,13 @@ export function ProfilePage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* ---- Operations (what the applicant may apply for) ---- */}
|
||||||
|
<Tabs.Panel value="operations" pt="md">
|
||||||
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
|
<OperationsFormContent />
|
||||||
|
</Paper>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* ---- Security ---- */}
|
{/* ---- Security ---- */}
|
||||||
<Tabs.Panel value="security" pt="md">
|
<Tabs.Panel value="security" pt="md">
|
||||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
|
|||||||
|
|
||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||||
|
import { RequireOperations } from './features/onboarding/components/RequireOperations';
|
||||||
|
import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage';
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||||
import { SupportPage } from './features/support/pages/SupportPage';
|
import { SupportPage } from './features/support/pages/SupportPage';
|
||||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||||
@@ -67,13 +69,18 @@ export const router = createBrowserRouter([
|
|||||||
element: (
|
element: (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<PortalLayout />
|
{/* Asks what the applicant operates as before the rest of the
|
||||||
|
portal, which is filtered by that answer. */}
|
||||||
|
<RequireOperations>
|
||||||
|
<PortalLayout />
|
||||||
|
</RequireOperations>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
|
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
|
||||||
|
|
||||||
// Config-driven licensing: one set of pages serves every licence type.
|
// Config-driven licensing: one set of pages serves every licence type.
|
||||||
{ path: '/licensing/applications', element: <MyApplicationsPage /> },
|
{ path: '/licensing/applications', element: <MyApplicationsPage /> },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
LicenseType,
|
LicenseType,
|
||||||
LicenseTypeRequirements,
|
LicenseTypeRequirements,
|
||||||
|
OperatorType,
|
||||||
AssignableOfficer,
|
AssignableOfficer,
|
||||||
DocumentDecision,
|
DocumentDecision,
|
||||||
DocumentReview,
|
DocumentReview,
|
||||||
@@ -54,6 +55,7 @@ function serialiseQueueFilter(
|
|||||||
|
|
||||||
const TAGS = [
|
const TAGS = [
|
||||||
'LicenseType',
|
'LicenseType',
|
||||||
|
'OperatorType',
|
||||||
'LicenseApplication',
|
'LicenseApplication',
|
||||||
'ApplicationQueue',
|
'ApplicationQueue',
|
||||||
'Attachment',
|
'Attachment',
|
||||||
@@ -84,6 +86,33 @@ export const licensingApi = baseApi
|
|||||||
providesTags: () => [listTag('LicenseType')],
|
providesTags: () => [listTag('LicenseType')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in applicant's declared modes of operation.
|
||||||
|
*
|
||||||
|
* Separate from the licence-type catalogue on purpose: the catalogue is
|
||||||
|
* the same for everyone and heavily cached, while this is per-user and
|
||||||
|
* changes the moment they edit their profile.
|
||||||
|
*/
|
||||||
|
getMyOperatorTypes: builder.query<{ items: OperatorType[] }, void>({
|
||||||
|
query: () => ({ url: '/profiles/me/operations' }),
|
||||||
|
providesTags: () => [listTag('OperatorType')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Replaces the set — see the Operations tab in the portal profile. */
|
||||||
|
updateMyOperatorTypes: builder.mutation<
|
||||||
|
{ items: OperatorType[] },
|
||||||
|
{ licenseTypeIds: string[] }
|
||||||
|
>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/profiles/me/operations',
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
// The catalogue is filtered by this, so it has to refetch too.
|
||||||
|
invalidatesTags: (_r, error) =>
|
||||||
|
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category catalogue used to group the licence cards. Static on the
|
* Category catalogue used to group the licence cards. Static on the
|
||||||
* server, so it is cached under the licence-type list tag.
|
* server, so it is cached under the licence-type list tag.
|
||||||
@@ -758,4 +787,6 @@ export const {
|
|||||||
useGetNotificationsQuery,
|
useGetNotificationsQuery,
|
||||||
useGetUnseenNotificationsQuery,
|
useGetUnseenNotificationsQuery,
|
||||||
useMarkNotificationReadMutation,
|
useMarkNotificationReadMutation,
|
||||||
|
useGetMyOperatorTypesQuery,
|
||||||
|
useUpdateMyOperatorTypesMutation,
|
||||||
} = licensingApi;
|
} = licensingApi;
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ export interface LicenseCategoryDefinition {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mode of operation the applicant has declared — the licence types they may
|
||||||
|
* file new applications for. `key` and `name` are denormalised from the
|
||||||
|
* licence type so the profile screen can render the set without also loading
|
||||||
|
* the whole catalogue.
|
||||||
|
*/
|
||||||
|
export interface OperatorType {
|
||||||
|
licenseTypeId: string;
|
||||||
|
key: string | null;
|
||||||
|
name: Bilingual | null;
|
||||||
|
declaredAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LicenseType {
|
export interface LicenseType {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -442,6 +455,18 @@ export interface IssuedLicense {
|
|||||||
issueDate: string;
|
issueDate: string;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
|
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
|
||||||
|
/**
|
||||||
|
* Days until the expiry date; negative once it has passed. Computed by the
|
||||||
|
* API in the authority's timezone — the client must not re-derive it, since
|
||||||
|
* a browser in another zone would land on a different day.
|
||||||
|
*/
|
||||||
|
daysUntilExpiry?: number;
|
||||||
|
/**
|
||||||
|
* Whether a renewal can be filed now: inside the licence type's renewal
|
||||||
|
* window, or after expiry. The API decides, because the window is per-type
|
||||||
|
* configuration.
|
||||||
|
*/
|
||||||
|
renewable?: boolean;
|
||||||
verificationCode: string;
|
verificationCode: string;
|
||||||
certificateFileKey: string | null;
|
certificateFileKey: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -43,6 +43,7 @@
|
|||||||
"@nx/react": "^22.5.4",
|
"@nx/react": "^22.5.4",
|
||||||
"@nx/vite": "^22.5.4",
|
"@nx/vite": "^22.5.4",
|
||||||
"@nx/vitest": "^22.5.4",
|
"@nx/vitest": "^22.5.4",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
@@ -5153,6 +5154,22 @@
|
|||||||
"typescript": ">3.0.0"
|
"typescript": ">3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -16180,6 +16197,53 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/png-js": {
|
"node_modules/png-js": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"@nx/react": "^22.5.4",
|
"@nx/react": "^22.5.4",
|
||||||
"@nx/vite": "^22.5.4",
|
"@nx/vite": "^22.5.4",
|
||||||
"@nx/vitest": "^22.5.4",
|
"@nx/vitest": "^22.5.4",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user