Files
pn-new-crm/tests/e2e/smoke/20-critical-path-client-to-invoice.spec.ts
Matt Ciaccio 475b051e29
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
feat(portal): replace magic-link with email/password + admin-initiated activation
The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.

Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):

- `portal_users` — one per client account, separate from the CRM
  `users` table (better-auth) so the auth realms stay isolated. Email
  is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
  only the SHA-256 hash so a DB compromise never leaks live tokens.

Services:

- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
  uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
  resendActivation, activateAccount, signIn (timing-safe),
  requestPasswordReset, resetPassword. Auth failures throw the new
  UnauthorizedError (401); enumeration-safe behaviour everywhere.

Routes:

- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).

UI:

- /portal/login — replaced email-only magic-link form with email +
  password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.

Email send:

- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
  set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
  pinning it to `noreply@${SMTP_HOST}`.

Tests:

- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
  match the post-refactor client + invoice forms (drop companyName,
  use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.

Drops the dead `requestMagicLink` from portal.service.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00

278 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test';
import { login, navigateTo, waitForSheet, PORT_SLUG } from './helpers';
const TEST_CLIENT_NAME = `Critical Path Client ${Date.now()}`;
const TEST_CLIENT_EMAIL = 'criticalpath@e2etest.com';
test.describe('Critical Path: Client → Interest → Invoice', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
test('create a new client and land on detail page', async ({ page }) => {
await navigateTo(page, '/clients');
const newBtn = page.getByRole('button', { name: /new client/i }).first();
await expect(newBtn).toBeVisible({ timeout: 10_000 });
await newBtn.click();
await waitForSheet(page);
const sheet = page.locator('[role="dialog"]');
await sheet.locator('input[name="fullName"]').fill(TEST_CLIENT_NAME);
await sheet.locator('input[name="contacts.0.value"]').fill(TEST_CLIENT_EMAIL);
await sheet.getByRole('button', { name: /create client/i }).click();
// Sheet should close on success
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2_000);
// Verify we remain in the port context (may redirect to client detail)
const url = page.url();
expect(url.includes(PORT_SLUG)).toBeTruthy();
});
test('new client appears in client list', async ({ page }) => {
await navigateTo(page, '/clients');
await expect(page.locator('table').first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
// Retry if data hasn't loaded yet
const hasClient = await page
.getByText(TEST_CLIENT_NAME)
.isVisible({ timeout: 5_000 })
.catch(() => false);
if (!hasClient) {
await page.waitForTimeout(3_000);
}
await expect(page.getByText(TEST_CLIENT_NAME).first()).toBeVisible({ timeout: 10_000 });
});
test('navigate to client detail from list', async ({ page }) => {
await navigateTo(page, '/clients');
await expect(page.locator('table').first()).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
// Prefer clicking an anchor link, fall back to any clickable element
const clientLink = page.locator('a').filter({ hasText: TEST_CLIENT_NAME }).first();
const isLink = await clientLink.isVisible({ timeout: 5_000 }).catch(() => false);
if (isLink) {
await clientLink.click();
} else {
await page.getByText(TEST_CLIENT_NAME).first().click();
}
await page.waitForTimeout(3_000);
const url = page.url();
const isDetailPage = url.includes('/clients/') && !url.endsWith('/clients');
expect(isDetailPage || url.includes(PORT_SLUG)).toBeTruthy();
});
test('create an interest linked to the new client', async ({ page }) => {
await navigateTo(page, '/interests');
await page.waitForTimeout(2_000);
const newBtn = page.getByRole('button', { name: /new interest/i }).first();
await expect(newBtn).toBeVisible({ timeout: 10_000 });
await newBtn.click();
await waitForSheet(page);
const interestSheet = page.locator('[role="dialog"]');
// Open the client combobox
const clientTrigger = interestSheet.getByRole('combobox').first();
await clientTrigger.click();
await page.waitForTimeout(2_000);
// Wait for combobox options to populate
const cmdItems = page.locator('[cmdk-item]');
await expect(cmdItems.first()).toBeVisible({ timeout: 10_000 });
// Try to find and select the client we created
let selected = false;
for (let attempt = 0; attempt < 5; attempt++) {
const count = await cmdItems.count();
for (let i = 0; i < count; i++) {
const text = await cmdItems
.nth(i)
.textContent()
.catch(() => '');
if (text && text.includes('Critical Path')) {
await cmdItems.nth(i).click();
selected = true;
break;
}
}
if (selected) break;
await page.waitForTimeout(1_000);
}
if (!selected) {
// If our specific client isn't found, pick any available client so the
// test can continue to verify the overall form submission flow
const anyItem = cmdItems.first();
const anyItemVisible = await anyItem.isVisible({ timeout: 3_000 }).catch(() => false);
if (anyItemVisible) {
await anyItem.click();
selected = true;
} else {
console.log(' ⚠️ No clients available in combobox. Skipping interest creation.');
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
return;
}
}
await page.waitForTimeout(500);
await interestSheet.getByRole('button', { name: /create interest/i }).click();
// Sheet heading should disappear on success
const sheetHeading = page.getByRole('heading', { name: /new interest/i });
await expect(sheetHeading).not.toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2_000);
});
test('advance interest pipeline stage', async ({ page }) => {
await navigateTo(page, '/interests');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3_000);
// Attempt to click into the first interest row
const rows = page.locator('table tbody tr');
const rowCount = await rows.count();
if (rowCount === 0) {
console.log(' No interests found — skipping stage advancement');
return;
}
const firstRow = rows.first();
const link = firstRow.locator('a').first();
if (await link.isVisible({ timeout: 3_000 }).catch(() => false)) {
await link.click();
} else {
await firstRow.click();
}
await page.waitForTimeout(3_000);
// On the interest detail page, look for a stage selector or dropdown
const stageSelector = page
.locator('[data-testid*="stage"], [class*="stage"]')
.first()
.or(page.getByRole('combobox').first())
.or(page.getByRole('button', { name: /stage|pipeline/i }).first());
const stageSelectorVisible = await stageSelector
.isVisible({ timeout: 5_000 })
.catch(() => false);
if (stageSelectorVisible) {
await stageSelector.click();
await page.waitForTimeout(1_000);
// Pick the next option available in the dropdown
const option = page.getByRole('option').nth(1);
if (await option.isVisible({ timeout: 3_000 }).catch(() => false)) {
await option.click();
await page.waitForTimeout(2_000);
console.log(' ✓ Pipeline stage advanced');
} else {
console.log(' No selectable pipeline stage options found');
}
} else {
console.log(' Stage selector not found on detail page');
}
// Either way, we should still be in the port context
const url = page.url();
expect(url.includes(PORT_SLUG)).toBeTruthy();
});
test('create a new invoice with client info and line items', async ({ page }) => {
await navigateTo(page, '/invoices');
await page.waitForLoadState('networkidle');
const newBtn = page
.getByRole('link', { name: /new invoice/i })
.first()
.or(page.getByRole('button', { name: /new invoice/i }).first());
await newBtn.first().click();
await page.waitForURL(`**/${PORT_SLUG}/invoices/new**`, { timeout: 10_000 });
// Step 1: pick the previously-created client via the OwnerPicker.
const ownerTrigger = page.locator('button[role="combobox"]:has-text("Select owner")').first();
await expect(ownerTrigger).toBeVisible({ timeout: 5_000 });
await ownerTrigger.click();
const searchInput = page.getByPlaceholder(/search clients/i);
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(TEST_CLIENT_NAME);
await page.waitForTimeout(500);
await page.getByRole('option', { name: TEST_CLIENT_NAME }).first().click();
await page.fill('#billingEmail', TEST_CLIENT_EMAIL);
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 30);
await page.fill('#dueDate', dueDate.toISOString().split('T')[0]!);
await page.getByRole('button', { name: /next/i }).click();
await page.waitForTimeout(1_000);
// Step 2: Line Items
for (let i = 0; i < 2; i++) {
await page.getByRole('button', { name: /add line item/i }).click();
await page.waitForTimeout(300);
}
await page.locator('input[name="lineItems.0.description"]').fill('Berth Rental - Annual');
await page.locator('input[name="lineItems.0.quantity"]').fill('1');
await page.locator('input[name="lineItems.0.unitPrice"]').fill('50000');
await page.locator('input[name="lineItems.1.description"]').fill('Maintenance Fee');
await page.locator('input[name="lineItems.1.quantity"]').fill('4');
await page.locator('input[name="lineItems.1.unitPrice"]').fill('500');
// Subtotal should be 50000 + (4 * 500) = 52000
await expect(page.getByText(/52[,.]?000/).first()).toBeVisible({ timeout: 5_000 });
// Step 3: Review
await page.getByRole('button', { name: /^next$/i }).click();
const createBtn = page.getByRole('button', { name: /create invoice/i });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/52[,.]?000/).first()).toBeVisible({ timeout: 5_000 });
// Submit
await createBtn.click();
// Should redirect away from /invoices/new on success
await page.waitForURL(
(url) => url.pathname.includes('/invoices') && !url.pathname.includes('/new'),
{ timeout: 15_000 },
);
const finalUrl = page.url();
expect(finalUrl.includes('/invoices')).toBeTruthy();
});
test('created invoice appears in invoice list', async ({ page }) => {
await navigateTo(page, '/invoices');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2_000);
// Should see at least one invoice (the one created above)
const invoiceTable = page.locator('table').first();
await expect(invoiceTable).toBeVisible({ timeout: 10_000 });
const rows = invoiceTable.locator('tbody tr');
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
});