chore: sync latest dashboard and role flow updates

This commit is contained in:
Ashwin Kumar 2026-04-05 16:52:01 +02:00
parent c526a376d5
commit 8855b8b378
20 changed files with 701 additions and 42 deletions

View file

@ -0,0 +1,199 @@
# Nxtgauge End-to-End Testing Blueprint
## 1. Scope
- Validate full lifecycle for external users from sign-up to role approval and role-specific module insertion.
- Validate admin-side verification and approval pipelines and state propagation into management modules.
- Validate runtime-config-driven onboarding/dashboard behavior and guard/redirect behavior.
- Validate notification and marketplace flows (customer requirement posting, professional discovery).
- Validate UI parity and visual regressions for management modules using Playwright + pixelmatch + Storybook + VisBug.
- Validate frontend (`vitest`, Playwright) and backend (`cargo test --workspace`) quality gates.
## 2. Business Logic Summary
- External users can exist before any role registration and must appear in Users Management.
- Role activation is gated: verification then approval.
- Approval produces side effects:
- active role created,
- role-specific table insertion,
- notification event.
- Multi-role users are supported and role visibility must remain isolated by role/module.
- Runtime config must not silently mask missing onboarding/dashboard config.
## 3. Environments and Preconditions
- Frontend admin app: `/Users/ashwin/workspace/nxtgauge-admin-solid`
- Rust backend workspace: `/Users/ashwin/workspace/nxtgauge-backend-rust`
- Required ports:
- Admin app: `http://localhost:3000`
- Storybook: `http://localhost:6006`
- Preconditions:
- `npm install` completed in admin repo.
- Rust toolchain + dependencies available for backend repo.
- Seed users/roles configured in backend DB (or fallback data strategy used in UI where applicable).
- Admin preview mode supported with `?_preview=1` for deterministic UI checks.
## 4. Test Data Matrix
- External identities:
- User without role.
- User with pending role onboarding.
- User with verified/pending approval role.
- User with approved active role.
- Multi-role user (2+ roles).
- Roles to cover:
- `COMPANY`
- `JOB_SEEKER`
- `CUSTOMER`
- `DEVELOPER`
- `PHOTOGRAPHER`
- `SOCIAL_MEDIA_MANAGER`
- Marketplace entities:
- 2-3 customer accounts.
- 2 requirements per customer.
- Approved professional accounts for lead/job discovery.
## 5. End-to-End Test Scenarios
1. Auth split:
- external identity blocked from admin login.
- internal identity allowed and redirected to admin.
- external-resolved session redirected to `/login?from=...`.
2. Onboarding:
- role-aware onboarding route loads via runtime config.
- missing config renders explicit error state.
- onboarding submissions persist and move to verification state.
3. Dashboard:
- role-specific sidebar and tabs render correctly.
- guarded routes reject wrong portal/session.
4. Verification:
- submission appears in Verification Management.
- reviewer decisions propagate to Approval Management.
5. Approval:
- approved role appears in Users Management and role-specific module.
- rejected role remains non-active but user remains visible.
6. Users Management:
- users with zero roles are visible.
- role states are visible per user.
7. Role-specific insertion:
- approval inserts into the right module only.
8. Notifications:
- approval emits and surfaces unread notification.
9. Requirements/leads:
- customer creates requirements.
- approved professionals discover and act on leads/jobs.
## 6. Playwright Automation Plan
- Existing specs:
- `tests/e2e/admin-auth.spec.ts`
- `tests/e2e/admin-visual.spec.ts` (pixelmatch)
- Added specs:
- `tests/e2e/management-parity.spec.ts`
- asserts Department-style controls for management modules.
- asserts dashboard management pages are non-empty.
- `tests/e2e/storybook-admin-pages.spec.ts`
- Storybook admin story smoke checks.
- Execution commands:
- `npm run test:e2e`
- `npm run test:management:parity`
- `npm run test:visual`
- `npm run test:storybook:e2e`
## 7. API and Backend Validation Plan
- Validate gateway/admin API responses and status codes for:
- `/api/gateway/api/auth/session`
- `/api/gateway/api/runtime-config`
- `/api/gateway/api/admin/users`
- `/api/gateway/api/admin/external-users`
- `/api/gateway/api/admin/dashboard-config`
- `/api/gateway/api/admin/roles`
- `/api/gateway/api/admin/companies`
- Assertions:
- lifecycle transitions emit expected payload shape/state.
- role key propagation remains consistent across services.
- no silent fallback on missing config.
## 8. Database Validation Plan
- Validate tables/events after each phase:
- user record creation at sign-up.
- onboarding row/state creation.
- verification record creation/update.
- approval decision and role activation state.
- role-specific module table insertion on approval only.
- notification row insertion.
- Cross-check:
- rejected records never inserted into approved role module tables.
- multi-role user keeps separate role states.
## 9. Runtime Config Validation
- Validate:
- role-specific onboarding config retrieval.
- role-specific dashboard config retrieval.
- UI behavior when config exists vs missing.
- no hidden fallback that masks configuration defects.
- Internal/External dashboard management pages must show deterministic list/form states even when backend responses are empty.
## 10. Notification Validation
- Trigger approval and assert:
- notification is created in backend.
- notification module UI renders item.
- unread/read transitions are persisted.
- Negative:
- rejected approvals must not trigger approval-success notifications.
## 11. Lead / Requirement Marketplace Validation
- Customer flow:
- create customer users.
- create 2 requirements per customer.
- assert requirement visibility in marketplace feeds.
- Professional flow:
- approved professionals can access leads/jobs routes.
- wrong-role users cannot access irrelevant lead pools.
- apply/contact actions persist.
## 12. Edge Cases and Negative Cases
- User signs up but never picks role: visible in Users Management.
- Missing runtime config: explicit error/empty guard state.
- Session crossover:
- external session cannot pass admin route guards.
- admin session cannot leak into public workspace.
- Multi-role conflict:
- role A approval should not auto-activate role B.
- Approval before verification must be blocked.
- API `404`/`401` on optional module endpoints should not crash page shell.
## 13. Release Blocking Acceptance Checklist
- Auth split tests pass.
- Verification → approval → insertion path validated for all required roles.
- Users Management shows no-role users.
- Role-specific management insertion validated.
- Notification creation/visibility validated.
- Management pages match Department-style controls and table shell.
- Visual diff (`pixelmatch`) within thresholds.
- `vitest`, existing unit tests, Playwright suites, and `cargo test --workspace` pass.
## 14. Recommended Folder Structure For Tests
- `tests/e2e/admin-auth.spec.ts`
- `tests/e2e/admin-visual.spec.ts`
- `tests/e2e/management-parity.spec.ts`
- `tests/e2e/storybook-admin-pages.spec.ts`
- `tests/vitest/admin-auth.spec.ts`
- `tests/vitest/module-access.spec.ts`
- `tests/visual-artifacts/{actual,diff}`
- `docs/Nxtgauge-End-to-End-Testing-Blueprint.md`
## 15. Suggested Seed Data / Factories
- User factories:
- `external_user_unregistered`
- `external_user_pending_verification`
- `external_user_pending_approval`
- `external_user_approved_role`
- `external_user_multi_role`
- Domain factories:
- `customer_requirement_factory`
- `professional_profile_factory`
- `notification_factory`
- Role fixtures for matrix roles listed in Section 4.
## 16. Risks and Likely Failure Points
- Role-key drift between onboarding, approval, and role-specific insertion.
- Guard and redirect regressions across admin/public portals.
- Runtime config partial failures causing invisible fallback behavior.
- Backend endpoint shape drift (`users`, `external-users`, `dashboard-config`) causing UI empty states.
- Visual regressions in shared table controls/icons across management modules.

View file

@ -13,9 +13,17 @@
"test": "node --test --experimental-strip-types src/lib/**/*.test.ts", "test": "node --test --experimental-strip-types src/lib/**/*.test.ts",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed", "test:e2e:headed": "playwright test --headed",
"test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list", "test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list --workers=1",
"test:management:parity": "playwright test tests/e2e/management-parity.spec.ts --reporter=list --workers=1",
"test:storybook:e2e": "playwright test -c playwright.storybook.config.ts tests/e2e/storybook-admin-pages.spec.ts --reporter=list --workers=1",
"test:external:flow": "playwright test -c playwright.external.config.ts tests/e2e/external-user-flow.spec.ts --reporter=list --workers=1",
"test:external:roles": "playwright test -c playwright.external.config.ts tests/e2e/external-roles-onboarding-dashboard.spec.ts --reporter=list --workers=1",
"test:external:screenshots": "playwright test -c playwright.external.config.ts tests/e2e/external-role-screenshots.spec.ts --reporter=list --workers=1",
"test:vitest": "vitest run",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build",
"qa:visbug": "bash ./scripts/visbug-storybook.sh",
"qa:e2e:blueprint": "bash ./scripts/run-nxtgauge-e2e-suite.sh"
}, },
"dependencies": { "dependencies": {
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",

View file

@ -5,7 +5,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
reporter: [['list']], reporter: [['list']],
webServer: { webServer: {
command: 'npm run build && PORT=3102 npm run start', command: 'npm run dev -- --port 3102 --host 127.0.0.1',
url: 'http://127.0.0.1:3102/', url: 'http://127.0.0.1:3102/',
reuseExistingServer: true, reuseExistingServer: true,
timeout: 300_000, timeout: 300_000,

View file

@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
reporter: [['list']],
use: {
baseURL: process.env.EXTERNAL_BASE_URL || 'http://127.0.0.1:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
viewport: { width: 1440, height: 900 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View file

@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
reporter: [['list']],
webServer: {
command: 'npm run storybook -- --ci --host 127.0.0.1 --port 6006',
url: 'http://127.0.0.1:6006/iframe.html',
reuseExistingServer: true,
timeout: 300_000,
},
use: {
baseURL: 'http://127.0.0.1:6006',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
viewport: { width: 1440, height: 900 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUST_ROOT="/Users/ashwin/workspace/nxtgauge-backend-rust"
echo "[1/7] Node unit tests (existing)"
cd "$ROOT_DIR"
npm run test
echo "[2/7] Vitest suite"
npm run test:vitest
echo "[3/7] Storybook build smoke"
npm run build-storybook
echo "[4/7] Playwright management parity e2e"
npm run test:management:parity
echo "[5/7] Playwright visual pixelmatch e2e"
npm run test:visual
echo "[6/7] Storybook Playwright smoke e2e"
npm run test:storybook:e2e
echo "[7/7] Rust backend cargo test"
if command -v cargo >/dev/null 2>&1 && [ -f "$RUST_ROOT/Cargo.toml" ]; then
(cd "$RUST_ROOT" && cargo test --workspace)
else
echo "Skipping cargo test (cargo or backend workspace not found)."
fi
echo "Nxtgauge E2E suite completed."

10
scripts/visbug-storybook.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
echo "VisBug + Storybook manual visual QA workflow"
echo "1) Start storybook: npm run storybook"
echo "2) Open: http://localhost:6006/?path=/story/admin-pages--dashboard"
echo "3) Install VisBug in browser if not installed:"
echo " https://chrome.google.com/webstore/detail/visbug/cdockenadnadldjbbgcallicgledbeoc"
echo "4) Use VisBug tools on each admin story route to inspect spacing, typography, and alignment."
echo "5) Save screenshots into tests/visual-artifacts/actual for pixelmatch comparison."

View file

@ -1,19 +1,15 @@
import { Title } from "@solidjs/meta"; import { Title } from "@solidjs/meta";
import { HttpStatusCode } from "@solidjs/start"; import { HttpStatusCode } from "@solidjs/start";
import { A } from "@solidjs/router";
export default function NotFound() { export default function NotFound() {
return ( return (
<main> <main style={{ padding: "24px", "font-family": "Inter, system-ui, sans-serif" }}>
<Title>Not Found</Title> <Title>Not Found</Title>
<HttpStatusCode code={404} /> <HttpStatusCode code={404} />
<h1>Page Not Found</h1> <h1>Page Not Found</h1>
<p> <p>This route is not available in the cleaned admin build.</p>
Visit{" "} <A href="/admin">Go to Admin Dashboard</A>
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
</main> </main>
); );
} }

View file

@ -1,10 +1,10 @@
import { Outlet } from '@solidjs/router'; import type { RouteSectionProps } from '@solidjs/router';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
export default function AdminLayout() { export default function AdminLayout(props: RouteSectionProps) {
return ( return (
<AdminShell> <AdminShell>
<Outlet /> {props.children}
</AdminShell> </AdminShell>
); );
} }

View file

@ -57,7 +57,6 @@ const ROLE_BASED_SIDEBAR: Record<'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CU
'Jobs', 'Jobs',
'My Applications', 'My Applications',
'Saved Jobs', 'Saved Jobs',
'Credits',
'Explore Nxtgauge', 'Explore Nxtgauge',
'Verification', 'Verification',
'Help Center', 'Help Center',
@ -127,6 +126,32 @@ function applyPortfolioRule(items: string[], persona: 'PROFESSIONAL' | 'COMPANY'
return items.filter((item) => normalizeToken(item) !== normalizeToken(PORTFOLIO_SIDEBAR_ITEM)); return items.filter((item) => normalizeToken(item) !== normalizeToken(PORTFOLIO_SIDEBAR_ITEM));
} }
function mergeSidebarForPersona(
items: string[],
persona: 'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CUSTOMER' | null,
): string[] {
const base = (persona && ROLE_BASED_SIDEBAR[persona]?.length)
? ROLE_BASED_SIDEBAR[persona]
: ['My Dashboard', 'My Profile', 'Switch Services', 'Logout'];
const merged = new Map<string, string>();
for (const item of [...items, ...base]) {
const label = String(item || '').trim();
if (!label) continue;
const key = normalizeToken(label);
if (!merged.has(key)) merged.set(key, label);
}
let next = Array.from(merged.values());
if (persona === 'JOB_SEEKER') {
next = next.filter((item) => normalizeToken(item) !== 'credits');
}
if (!next.some((item) => normalizeToken(item) === normalizeToken('Explore Nxtgauge'))) {
const verificationIdx = next.findIndex((item) => normalizeToken(item) === normalizeToken('Verification'));
if (verificationIdx >= 0) next.splice(verificationIdx, 0, 'Explore Nxtgauge');
else next.push('Explore Nxtgauge');
}
return applyPortfolioRule(next, persona);
}
function sameSidebarOrder(a: string[], b: string[]): boolean { function sameSidebarOrder(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return a.every((item, idx) => normalizeToken(item) === normalizeToken(b[idx] || '')); return a.every((item, idx) => normalizeToken(item) === normalizeToken(b[idx] || ''));
@ -182,6 +207,18 @@ function normalizeDashboard(item: any): ExternalDashboard {
}; };
} }
function looksLikeGateway404(payload: any): boolean {
const text = String(payload?.message || payload?.error || payload || '').toLowerCase();
return text.includes('route not found in gateway');
}
function normalizeRoleNameFromKey(key: string): string {
return String(key || '')
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function StatusBadge(props: { status: string }) { function StatusBadge(props: { status: string }) {
const active = () => props.status === 'ACTIVE'; const active = () => props.status === 'ACTIVE';
return ( return (
@ -266,12 +303,10 @@ export default function ExternalDashboardManagementPage() {
const persona = personaById ?? personaByKey; const persona = personaById ?? personaByKey;
const baseItems = sidebarItems().length const baseItems = sidebarItems().length
? sidebarItems() ? sidebarItems()
: (persona && ROLE_BASED_SIDEBAR[persona]?.length)
? ROLE_BASED_SIDEBAR[persona]
: sidebarLooksCustomer() : sidebarLooksCustomer()
? ROLE_BASED_SIDEBAR.CUSTOMER ? ROLE_BASED_SIDEBAR.CUSTOMER
: ['My Dashboard', 'My Profile', 'Switch Services', 'Logout']; : ['My Dashboard', 'My Profile', 'Switch Services', 'Logout'];
return applyPortfolioRule(baseItems, persona ?? (sidebarLooksCustomer() ? 'CUSTOMER' : null)); return mergeSidebarForPersona(baseItems, persona ?? (sidebarLooksCustomer() ? 'CUSTOMER' : null));
}); });
const previewTabs = createMemo(() => (tabs().length ? tabs() : ['overview'])); const previewTabs = createMemo(() => (tabs().length ? tabs() : ['overview']));
@ -309,7 +344,7 @@ export default function ExternalDashboardManagementPage() {
const persona = rolePersonaById()[selectedRoleId]; const persona = rolePersonaById()[selectedRoleId];
if (!persona) return; if (!persona) return;
if (!force && sidebarItems().length > 0) return; if (!force && sidebarItems().length > 0) return;
setSidebarItems(ROLE_BASED_SIDEBAR[persona]); setSidebarItems(mergeSidebarForPersona(ROLE_BASED_SIDEBAR[persona], persona));
}; };
const applyPreviewPathForRole = (selectedRoleId: string, force = false) => { const applyPreviewPathForRole = (selectedRoleId: string, force = false) => {
@ -331,17 +366,68 @@ export default function ExternalDashboardManagementPage() {
const dashData = dashRes.ok ? await dashRes.json().catch(() => []) : []; const dashData = dashRes.ok ? await dashRes.json().catch(() => []) : [];
const roleData = rolesRes.ok ? await rolesRes.json().catch(() => []) : []; const roleData = rolesRes.ok ? await rolesRes.json().catch(() => []) : [];
const dashRows = Array.isArray(dashData) ? dashData : (dashData?.items || dashData?.configs || []); const dashNeedsFallback = !dashRes.ok || looksLikeGateway404(dashData);
const roleRows = Array.isArray(roleData) ? roleData : (roleData?.roles || roleData?.items || []); const rolesNeedFallback = !rolesRes.ok || looksLikeGateway404(roleData);
let finalDashData: any = dashData;
let finalRoleData: any = roleData;
if (dashNeedsFallback) {
const fallbackDashRes = await fetch(`${API}/api/config/dashboard`, {
headers: authHeaders(),
credentials: 'include',
});
finalDashData = fallbackDashRes.ok ? await fallbackDashRes.json().catch(() => []) : [];
}
if (rolesNeedFallback) {
// Gateway no longer routes /api/admin/roles in some environments.
// Build role options from dashboard configs so external dashboard table remains usable.
const rawRows = Array.isArray(finalDashData) ? finalDashData : (finalDashData?.items || finalDashData?.configs || []);
const roleMap = new Map<string, RoleOption>();
for (const row of rawRows) {
const audience = String(row?.audience || '').toUpperCase();
if (audience !== 'EXTERNAL') continue;
const roleId = String(row?.role_id || row?.roleId || '').trim();
const roleKey = String(row?.role_key || row?.config_json?.role_key || '').toUpperCase().trim();
if (!roleId) continue;
const name = normalizeRoleNameFromKey(roleKey || 'EXTERNAL_ROLE');
roleMap.set(roleId, { id: roleId, key: roleKey || 'EXTERNAL_ROLE', name });
}
finalRoleData = Array.from(roleMap.values());
}
const dashRows = Array.isArray(finalDashData) ? finalDashData : (finalDashData?.items || finalDashData?.configs || []);
const roleRows = Array.isArray(finalRoleData) ? finalRoleData : (finalRoleData?.roles || finalRoleData?.items || []);
setRoles(roleRows setRoles(roleRows
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL') .filter((r: any) => {
const hasRoleShape = Boolean(r?.id && (r?.key || r?.name));
if (hasRoleShape && r?.audience == null) return true;
return String(r?.audience || '').toUpperCase() === 'EXTERNAL';
})
.map((r: any) => ({ id: String(r?.id || ''), key: String(r?.key || '').toUpperCase(), name: String(r?.name || r?.key || 'External Role') })) .map((r: any) => ({ id: String(r?.id || ''), key: String(r?.key || '').toUpperCase(), name: String(r?.name || r?.key || 'External Role') }))
.filter((r: RoleOption) => r.id)); .filter((r: RoleOption) => r.id));
const roleKeyById = new Map<string, string>();
roleRows.forEach((r: any) => {
const id = String(r?.id || '').trim();
const key = String(r?.key || '').toUpperCase().trim();
if (id) roleKeyById.set(id, key);
});
setRows(dashRows setRows(dashRows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL') .filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.map(normalizeDashboard) .map(normalizeDashboard)
.map((row: ExternalDashboard) => {
const resolvedRoleKey = String(row.roleKey || roleKeyById.get(row.roleId) || '').toUpperCase();
const persona = personaFromKey(resolvedRoleKey);
return {
...row,
roleKey: resolvedRoleKey || row.roleKey,
sidebarItems: mergeSidebarForPersona(row.sidebarItems, persona),
};
})
.sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt))); .sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt)));
} catch (e: any) { } catch (e: any) {
setRows([]); setRows([]);
@ -400,7 +486,8 @@ export default function ExternalDashboardManagementPage() {
setFormRoleKey(row.roleKey); setFormRoleKey(row.roleKey);
setWidgets(row.widgets); setWidgets(row.widgets);
setTabs(row.tabs); setTabs(row.tabs);
setSidebarItems(row.sidebarItems); const persona = rolePersonaById()[row.roleId] ?? personaFromKey(row.roleKey);
setSidebarItems(mergeSidebarForPersona(row.sidebarItems, persona));
setFields(row.fields); setFields(row.fields);
setPreviewPath(row.previewPath || rolePreviewPath(row.roleKey)); setPreviewPath(row.previewPath || rolePreviewPath(row.roleKey));
setIsActive(row.status === 'ACTIVE'); setIsActive(row.status === 'ACTIVE');
@ -425,7 +512,7 @@ export default function ExternalDashboardManagementPage() {
const persona = rolePersonaById()[roleId()] ?? personaFromKey(selectedRoleKey() || formRoleKey()); const persona = rolePersonaById()[roleId()] ?? personaFromKey(selectedRoleKey() || formRoleKey());
if (!persona || !sidebarItems().length) return; if (!persona || !sidebarItems().length) return;
setSidebarItems((prev) => { setSidebarItems((prev) => {
const next = applyPortfolioRule(prev, persona); const next = mergeSidebarForPersona(prev, persona);
return sameSidebarOrder(prev, next) ? prev : next; return sameSidebarOrder(prev, next) ? prev : next;
}); });
}); });

View file

@ -1,17 +1,5 @@
import { A } from '@solidjs/router'; import { Navigate } from '@solidjs/router';
export default function Home() { export default function Home() {
return ( return <Navigate href="/admin" />;
<main class="auth-page">
<div class="auth-bg" />
<section class="auth-card">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="auth-logo" />
<h1 class="auth-title">Admin Access</h1>
<p class="auth-copy">Secure sign-in and runtime control center for NXTGAUGE operations.</p>
<div class="actions">
<A class="btn primary" href="/login">Sign In</A>
</div>
</section>
</main>
);
} }

View file

@ -2,7 +2,7 @@ import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import type { Meta, StoryObj } from 'storybook-solidjs-vite'; import type { Meta, StoryObj } from 'storybook-solidjs-vite';
import '../../app.css'; import '../../app.css';
import AdminHomePage from '../../routes/admin'; import AdminHomePage from '../../routes/admin/index';
import ApprovalPage from '../../routes/admin/approval'; import ApprovalPage from '../../routes/admin/approval';
import VerificationPage from '../../routes/admin/verification'; import VerificationPage from '../../routes/admin/verification';
import DepartmentManagementPage from '../../routes/admin/department-management'; import DepartmentManagementPage from '../../routes/admin/department-management';

View file

@ -0,0 +1,74 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { test, expect } from '@playwright/test';
type RoleScenario = {
roleKey: string;
schemaId: string;
profession?: string;
};
const ROLE_SCENARIOS: RoleScenario[] = [
{ roleKey: 'COMPANY', schemaId: 'company_onboarding_v1' },
{ roleKey: 'JOB_SEEKER', schemaId: 'jobseeker_onboarding_v1' },
{ roleKey: 'CUSTOMER', schemaId: 'customer_onboarding_v1' },
{ roleKey: 'PHOTOGRAPHER', schemaId: 'photographer_onboarding_v1', profession: 'photographer' },
{ roleKey: 'MAKEUP_ARTIST', schemaId: 'makeup_artist_onboarding_v1', profession: 'makeup_artist' },
{ roleKey: 'TUTOR', schemaId: 'tutor_onboarding_v1', profession: 'tutor' },
{ roleKey: 'DEVELOPER', schemaId: 'developer_onboarding_v1', profession: 'developer' },
{ roleKey: 'VIDEO_EDITOR', schemaId: 'video_editor_onboarding_v1', profession: 'video_editor' },
{ roleKey: 'GRAPHIC_DESIGNER', schemaId: 'graphic_designer_onboarding_v1', profession: 'graphic_designer' },
{ roleKey: 'SOCIAL_MEDIA_MANAGER', schemaId: 'social_media_manager_onboarding_v1', profession: 'social_media_manager' },
{ roleKey: 'FITNESS_TRAINER', schemaId: 'fitness_trainer_onboarding_v1', profession: 'fitness_trainer' },
{ roleKey: 'CATERING_SERVICES', schemaId: 'catering_services_onboarding_v1', profession: 'catering_services' },
];
const OUTPUT_ROOT = path.join(process.cwd(), 'tests', 'visual-artifacts', 'external-roles');
async function ensureFolders() {
await fs.mkdir(path.join(OUTPUT_ROOT, 'onboarding'), { recursive: true });
await fs.mkdir(path.join(OUTPUT_ROOT, 'dashboard'), { recursive: true });
}
function roleSlug(roleKey: string) {
return roleKey.toLowerCase();
}
test('capture onboarding + dashboard screenshots for all external roles', async ({ page }) => {
test.setTimeout(240_000);
await ensureFolders();
await page.setViewportSize({ width: 1440, height: 900 });
for (const role of ROLE_SCENARIOS) {
const params = new URLSearchParams({
roleKey: role.roleKey,
schemaId: role.schemaId,
});
if (role.profession) {
params.set('profession', role.profession);
params.set('intent', 'professional');
} else if (role.roleKey === 'COMPANY') {
params.set('intent', 'company');
} else if (role.roleKey === 'CUSTOMER') {
params.set('intent', 'customer');
} else if (role.roleKey === 'JOB_SEEKER') {
params.set('intent', 'job_seeker');
}
await page.goto(`/onboarding?${params.toString()}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1400);
await expect(page.locator('body')).not.toContainText('TypeError');
await page.screenshot({
path: path.join(OUTPUT_ROOT, 'onboarding', `${roleSlug(role.roleKey)}.png`),
fullPage: true,
});
await page.goto(`/dashboard?_preview=${encodeURIComponent(role.roleKey)}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
await expect(page.locator('body')).not.toContainText('TypeError');
await page.screenshot({
path: path.join(OUTPUT_ROOT, 'dashboard', `${roleSlug(role.roleKey)}.png`),
fullPage: true,
});
}
});

View file

@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';
type RoleScenario = {
roleKey: string;
schemaId: string;
profession?: string;
expectedBadge: string;
};
const ROLE_SCENARIOS: RoleScenario[] = [
{ roleKey: 'COMPANY', schemaId: 'company_onboarding_v1', expectedBadge: 'Company' },
{ roleKey: 'JOB_SEEKER', schemaId: 'jobseeker_onboarding_v1', expectedBadge: 'Job Seeker' },
{ roleKey: 'CUSTOMER', schemaId: 'customer_onboarding_v1', expectedBadge: 'Customer' },
{ roleKey: 'PHOTOGRAPHER', schemaId: 'photographer_onboarding_v1', profession: 'photographer', expectedBadge: 'Photographer' },
{ roleKey: 'MAKEUP_ARTIST', schemaId: 'makeup_artist_onboarding_v1', profession: 'makeup_artist', expectedBadge: 'Makeup Artist' },
{ roleKey: 'TUTOR', schemaId: 'tutor_onboarding_v1', profession: 'tutor', expectedBadge: 'Tutor' },
{ roleKey: 'DEVELOPER', schemaId: 'developer_onboarding_v1', profession: 'developer', expectedBadge: 'Developer' },
{ roleKey: 'VIDEO_EDITOR', schemaId: 'video_editor_onboarding_v1', profession: 'video_editor', expectedBadge: 'Video Editor' },
{ roleKey: 'GRAPHIC_DESIGNER', schemaId: 'graphic_designer_onboarding_v1', profession: 'graphic_designer', expectedBadge: 'Graphic Designer' },
{ roleKey: 'SOCIAL_MEDIA_MANAGER', schemaId: 'social_media_manager_onboarding_v1', profession: 'social_media_manager', expectedBadge: 'Social Media Manager' },
{ roleKey: 'FITNESS_TRAINER', schemaId: 'fitness_trainer_onboarding_v1', profession: 'fitness_trainer', expectedBadge: 'Fitness Trainer' },
{ roleKey: 'CATERING_SERVICES', schemaId: 'catering_services_onboarding_v1', profession: 'catering_services', expectedBadge: 'Catering Services' },
];
test.describe('External roles onboarding + dashboard load checks', () => {
for (const role of ROLE_SCENARIOS) {
test(`onboarding page loads for ${role.roleKey}`, async ({ page }) => {
const params = new URLSearchParams({
roleKey: role.roleKey,
schemaId: role.schemaId,
});
if (role.profession) {
params.set('profession', role.profession);
params.set('intent', 'professional');
} else if (role.roleKey === 'COMPANY') {
params.set('intent', 'company');
} else if (role.roleKey === 'CUSTOMER') {
params.set('intent', 'customer');
} else if (role.roleKey === 'JOB_SEEKER') {
params.set('intent', 'job_seeker');
}
await page.goto(`/onboarding?${params.toString()}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await expect(page).toHaveURL(new RegExp(`/onboarding\\?`));
await expect(page.locator('body')).not.toContainText('TypeError');
await expect(page.locator('body')).not.toContainText('Cannot read properties');
await expect(page.locator('body')).not.toContainText('Not Found (role=');
await expect(page.locator('body')).not.toContainText('No onboarding schema configured for role');
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length).toBeGreaterThan(20);
});
test(`dashboard loads for ${role.roleKey} preview`, async ({ page }) => {
await page.goto(`/dashboard?_preview=${encodeURIComponent(role.roleKey)}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(900);
await expect(page.locator('body')).not.toContainText('TypeError');
await expect(page.locator('body')).not.toContainText('Cannot read properties');
await expect(page.locator('.role-badge')).toContainText(role.expectedBadge);
await expect(page.locator('.topbar-user')).toContainText('Preview User');
await expect(page.locator('body')).toContainText('Dashboard');
});
}
});

View file

@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
test.describe('External user public flow smoke', () => {
test('login page renders expected fields', async ({ page }) => {
await page.goto('/auth/login', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
await expect(page.getByPlaceholder('Enter your email')).toBeVisible();
await expect(page.getByPlaceholder('Enter your password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
});
test('choose-role page renders and routes to company onboarding', async ({ page }) => {
await page.goto('/users/choose-role', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'What would you like to do today?' })).toBeVisible();
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Company/i }).first().click();
await expect(page).toHaveURL(/\/onboarding\?roleKey=COMPANY&schemaId=company_onboarding_v1&intent=company/, { timeout: 15000 });
});
test('choose-role page routes professional subtype to professional onboarding', async ({ page }) => {
await page.goto('/users/choose-role', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Photographer/i }).first().click();
await expect(page).toHaveURL(/\/onboarding\?roleKey=PHOTOGRAPHER&schemaId=photographer_onboarding_v1&profession=photographer&intent=professional/, { timeout: 15000 });
});
test('dashboard route does not white-screen for guest session', async ({ page }) => {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await expect(page.locator('body')).not.toContainText('TypeError');
await expect(page.locator('body')).not.toContainText('Cannot read properties');
});
});

View file

@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
const MANAGEMENT_ROUTES = [
'/admin/company?_preview=1',
'/admin/candidate?_preview=1',
'/admin/customer?_preview=1',
'/admin/photographer?_preview=1',
'/admin/makeup-artist?_preview=1',
'/admin/tutors?_preview=1',
'/admin/developers?_preview=1',
'/admin/video-editors?_preview=1',
'/admin/fitness-trainers?_preview=1',
'/admin/catering-services?_preview=1',
'/admin/graphic-designers?_preview=1',
'/admin/social-media-managers?_preview=1',
'/admin/ugc-content-creator?_preview=1',
];
test.describe('Department-style UI parity for management modules', () => {
for (const route of MANAGEMENT_ROUTES) {
test(`controls and table structure: ${route}`, async ({ page }) => {
await page.goto(route, { waitUntil: 'domcontentloaded' });
await expect(page.getByText('Sort').first()).toBeVisible();
await expect(page.getByText('Filters').first()).toBeVisible();
await expect(page.getByText('Export').first()).toBeVisible();
await expect(page.getByText('REGISTERED DATE').first()).toBeVisible();
await expect(page.getByText('ACTIONS').first()).toBeVisible();
await expect(page.getByText('Live legacy module embedded for exact design and functionality parity during migration.')).toHaveCount(0);
});
}
});
test.describe('Dashboard management modules are non-empty', () => {
test('internal dashboard management renders rows or configured table state', async ({ page }) => {
await page.goto('/admin/internal-dashboard-management?_preview=1', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Internal Dashboard Management' })).toBeVisible();
await expect(page.getByText('No internal dashboards found.')).toHaveCount(0);
await expect(page.getByText('NAME').first()).toBeVisible();
});
test('external dashboard management renders rows or configured table state', async ({ page }) => {
await page.goto('/admin/external-dashboard-management?_preview=1', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'External Dashboard Management' })).toBeVisible();
await expect(page.getByText('No external dashboards found.')).toHaveCount(0);
await expect(page.getByText('NAME').first()).toBeVisible();
});
});

View file

@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test';
const STORYBOOK_BASE_URL = process.env.STORYBOOK_BASE_URL || 'http://127.0.0.1:6006';
const STORIES = [
{ id: 'admin-pages--dashboard', text: 'Total Users' },
{ id: 'admin-pages--department-management', text: 'Department Management' },
{ id: 'admin-pages--internal-dashboard-management', text: 'Internal Dashboard Management' },
{ id: 'admin-pages--external-dashboard-management', text: 'External Dashboard Management' },
];
test.describe('Storybook admin stories smoke', () => {
for (const story of STORIES) {
test(`renders ${story.id}`, async ({ page }) => {
const url = `${STORYBOOK_BASE_URL}/iframe.html?id=${story.id}&viewMode=story`;
await page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(page.getByText(story.text).first()).toBeVisible({ timeout: 20_000 });
});
}
});

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { isExternalIdentity, pickManagementLoginError } from '../../src/lib/admin-auth';
describe('admin-auth helpers (vitest)', () => {
it('returns wrong portal message with highest priority', () => {
expect(pickManagementLoginError({ error_code: 'WRONG_PORTAL', message: 'Ignore me' })).toBe(
'This login is only for internal management users. External users must use the public login page.',
);
});
it('detects external identities from multiple payload shapes', () => {
expect(isExternalIdentity({ audience: 'public' })).toBe(true);
expect(isExternalIdentity({ user: { user_type: 'external_user' } })).toBe(true);
expect(isExternalIdentity({ user: { accountType: 'external' } })).toBe(true);
expect(isExternalIdentity({ audience: 'admin', user: { userType: 'employee' } })).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { normalizeAllowedModules } from '../../src/lib/admin/module-access';
describe('module access normalization (vitest)', () => {
it('normalizes explicit enabled modules', () => {
expect(
normalizeAllowedModules({
enabled_modules: ['employee_management', 'approval_management'],
}),
).toEqual(['EMPLOYEE_MANAGEMENT', 'APPROVAL_MANAGEMENT']);
});
it('extracts module keys from permissions object', () => {
expect(
normalizeAllowedModules({
permissions: {
'departments.view': true,
'external_dashboard_management.update': true,
},
}),
).toEqual(['DEPARTMENTS', 'EXTERNAL_DASHBOARD_MANAGEMENT']);
});
});

13
vitest.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/vitest/**/*.spec.ts'],
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
reportsDirectory: './test-results/vitest-coverage',
},
},
});