Adding all the license feature and renewal

This commit is contained in:
Mulu Mehari
2026-08-03 12:49:48 +03:00
parent c62ec59655
commit 77bfb01664
21 changed files with 1256 additions and 59 deletions

88
apps/e2e/README.md Normal file
View 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.

View 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
View 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": "."
}
}
}
}

View 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);
});
});

View 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;
}

View 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 });
}

View 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
View 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"]
}

View File

@@ -32,6 +32,7 @@ import {
IconCreditCard,
IconDownload,
IconFileText,
IconRefresh,
IconShieldCheck,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
@@ -41,11 +42,14 @@ import {
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
extractErrorMessage,
localized,
useCreateApplicationMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
@@ -53,8 +57,13 @@ import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
* The applicant's home screen.
*
* 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 licences they already hold. Every figure is the signed-in user's
* then the records they already have — licences, then applications and only
* 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.
*/
@@ -92,6 +101,8 @@ export function DashboardPage() {
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
const [createApplication, { isLoading: isRenewing }] =
useCreateApplicationMutation();
const items = useMemo(() => applications?.items ?? [], [applications]);
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
@@ -103,6 +114,9 @@ export function DashboardPage() {
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
);
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 days = daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
@@ -113,6 +127,27 @@ export function DashboardPage() {
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) {
return (
<Center h={400}>
@@ -167,50 +202,60 @@ export function DashboardPage() {
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
title="Apply for a licence"
description="Choose the licence that matches the service your company provides."
>
<LicenseCatalogue />
</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>
</Container>
);
@@ -507,14 +552,21 @@ function ApplicationTable({
function LicenseCard({
license,
isDownloading,
isRenewing,
onDownload,
onRenew,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
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 renewable = license.renewable ?? false;
return (
<Card withBorder radius="md" padding="md">
@@ -559,6 +611,50 @@ function LicenseCard({
</ActionIcon>
</Tooltip>
</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>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Box,
Button,
@@ -25,6 +26,7 @@ import {
localized,
useGetLicenseCategoriesQuery,
useGetLicenseTypesQuery,
useGetMyOperatorTypesQuery,
} from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
@@ -52,9 +54,25 @@ export function LicenseCatalogue() {
const navigate = useNavigate();
const { data: types } = useGetLicenseTypesQuery();
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 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 ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder);
@@ -71,7 +89,38 @@ export function LicenseCatalogue() {
// vanish from the page entirely.
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) {
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 (
<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 }) => (
<CategoryGroup
key={category.key}
@@ -100,7 +167,8 @@ export function LicenseCatalogue() {
title={localized(category.name)}
description={localized(category.description)}
licenseTypes={licenseTypes}
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
canApply={canApply}
onSelect={select}
/>
))}
{orphans.length > 0 && (
@@ -109,9 +177,20 @@ export function LicenseCatalogue() {
title="Other licences"
description="Licence types that have not been assigned a category."
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>
);
}
@@ -121,12 +200,14 @@ function CategoryGroup({
title,
description,
licenseTypes,
canApply,
onSelect,
}: {
icon: typeof IconShip;
title: string;
description: string;
licenseTypes: LicenseType[];
canApply: (type: LicenseType) => boolean;
onSelect: (type: LicenseType) => void;
}) {
return (
@@ -144,7 +225,12 @@ function CategoryGroup({
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{licenseTypes.map((type) => (
<LicenseTypeCard key={type.id} type={type} onSelect={onSelect} />
<LicenseTypeCard
key={type.id}
type={type}
canApply={canApply(type)}
onSelect={onSelect}
/>
))}
</SimpleGrid>
</Box>
@@ -153,9 +239,11 @@ function CategoryGroup({
function LicenseTypeCard({
type,
canApply,
onSelect,
}: {
type: LicenseType;
canApply: boolean;
onSelect: (type: LicenseType) => void;
}) {
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
@@ -216,9 +304,10 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
Start application
{canApply ? 'Start application' : 'Add to my operations'}
</Button>
</Box>
</Stack>

View File

@@ -1,7 +1,6 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Box,
Button,
Card,
Center,
@@ -54,7 +53,7 @@ export function MyApplicationsPage() {
color: 'teal',
title: 'Payment bypassed',
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()}.`,
});
} catch (err) {
@@ -118,14 +117,9 @@ export function MyApplicationsPage() {
Licence applications
</Title>
<Text size="sm" c="dimmed" mb="md">
Choose a licence to apply for. Each one asks for its own forms and
supporting documents.
Your licences and applications, and the catalogue to file a new one.
</Text>
<Box mb="xl">
<LicenseCatalogue />
</Box>
{(licences?.items ?? []).length > 0 && (
<>
<Title order={4} mb="sm">
@@ -205,7 +199,7 @@ export function MyApplicationsPage() {
<Stack align="center" gap="xs">
<Text c="dimmed">You have not filed any applications yet.</Text>
<Text size="sm" c="dimmed">
Pick a licence above to get started.
Pick a licence below to get started.
</Text>
</Stack>
</Card>
@@ -314,6 +308,13 @@ export function MyApplicationsPage() {
</Table>
</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>
);
}

View File

@@ -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}</>;
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -32,6 +32,7 @@ import {
IconDeviceFloppy,
IconLock,
IconMail,
IconBuildingWarehouse,
IconMapPin,
IconMoon,
IconPhone,
@@ -63,10 +64,18 @@ import {
addressSchema,
type AddressValues,
} from '../components/AddressFormContent';
import { OperationsFormContent } from '../components/OperationsFormContent';
import classes from './ProfilePage.module.css';
/** 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) {
const source = name?.trim() || fallback?.trim() || '';
@@ -525,6 +534,12 @@ export function ProfilePage() {
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address
</Tabs.Tab>
<Tabs.Tab
value="operations"
leftSection={<IconBuildingWarehouse size={18} />}
>
Operations
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -695,6 +710,13 @@ export function ProfilePage() {
</Paper>
</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 ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -9,6 +9,8 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
// Portal feature pages
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 { SupportPage } from './features/support/pages/SupportPage';
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
@@ -67,13 +69,18 @@ export const router = createBrowserRouter([
element: (
<ProtectedRoute>
<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>
</ProtectedRoute>
),
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
// Config-driven licensing: one set of pages serves every licence type.
{ path: '/licensing/applications', element: <MyApplicationsPage /> },

View File

@@ -14,6 +14,7 @@ import type {
LicenseStatus,
LicenseType,
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
DocumentDecision,
DocumentReview,
@@ -54,6 +55,7 @@ function serialiseQueueFilter(
const TAGS = [
'LicenseType',
'OperatorType',
'LicenseApplication',
'ApplicationQueue',
'Attachment',
@@ -84,6 +86,33 @@ export const licensingApi = baseApi
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
* server, so it is cached under the licence-type list tag.
@@ -758,4 +787,6 @@ export const {
useGetNotificationsQuery,
useGetUnseenNotificationsQuery,
useMarkNotificationReadMutation,
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
} = licensingApi;

View File

@@ -86,6 +86,19 @@ export interface LicenseCategoryDefinition {
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 {
id: string;
key: string;
@@ -442,6 +455,18 @@ export interface IssuedLicense {
issueDate: string;
expiryDate: string;
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;
certificateFileKey: string | null;
}

64
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"@nx/react": "^22.5.4",
"@nx/vite": "^22.5.4",
"@nx/vitest": "^22.5.4",
"@playwright/test": "^1.62.1",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -5153,6 +5154,22 @@
"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": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -16180,6 +16197,53 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",

View File

@@ -48,6 +48,7 @@
"@nx/react": "^22.5.4",
"@nx/vite": "^22.5.4",
"@nx/vitest": "^22.5.4",
"@playwright/test": "^1.62.1",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}