Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,11 @@ jobs:
# also runs the job on any dependency PR; accepted, it is a ~2 minute job.
- "apps/admin/package.json"
- "package-lock.json"
# Stylesheets the accessibility scan's contrast findings come from: the global staff
# stylesheet (defines the check-in classes) and the colour tokens it builds on.
- "apps/admin/src/staff.css"
- "packages/ui/src/styles/tokens/colors.css"
# The accessibility scan (apps/admin/e2e/a11y.spec.ts) covers the check-in page and
# several admin pages, so any change to the admin app or the shared UI package (markup
# and stylesheets alike) can regress it.
- "apps/admin/src/**"
- "packages/ui/src/**"
Comment thread
solarssk marked this conversation as resolved.
- "apps/admin/src/App.tsx"
- "apps/admin/src/main.tsx"
- "apps/admin/src/api/client.ts"
Expand All @@ -218,7 +219,9 @@ jobs:
- "apps/web/src/checkin-gate.ts"
- "apps/web/src/checkin-stream-limit.ts"
- "apps/web/src/auth/**"
- "apps/web/src/admin/checkin-*.ts"
# All admin API handlers: the check-in ones, and those behind the admin pages the
# accessibility scan loads (overview, attendees, settings, communication).
- "apps/web/src/admin/**"
- "packages/auth/**"
- "packages/tickets/**"
- "packages/db/prisma/**"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Adding an attendee to an event that had already reached its capacity limit showed a misleading "This email is already registered for this event" notice instead of a capacity message, even for an email that had never been registered - both failures returned the same HTTP status and the form only checked that status, not which error the server actually reported.
- The Mail report's "Initial vs resend" chart now counts each attendee's first-ever send of a given template as "Initial", even for a location or wallet reminder template, and stays correct if that template is later renamed or deleted - before, only the built-in ticket email could ever be counted as "Initial", so every send of any other template showed up as a "Resend" on this chart regardless of whether that attendee had actually received it before.
- On the check-in page, the "no results yet" message and the checked-in percentage in the stats card are now darker, so they are easier to read for people with low vision or in a bright or dim venue. Before, their contrast against the background was slightly below the accessibility minimum (4.3:1 and 2.7:1 instead of 4.5:1 and 3:1).
- Several pieces of text and buttons in the admin app were slightly too pale to meet the accessibility contrast minimum and are now darker: inactive tab labels (for example on the attendee page and the events list), the green "Live" indicator on an event's Overview and green success buttons, and the "issued" state text in an attendee's items list. The look is otherwise unchanged; they are easier to read for people with low vision and in bright or dim rooms.


## [0.7.2] - 2026-09-20

Expand Down
5 changes: 4 additions & 1 deletion apps/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ Browser-level tests in this repo, both under `apps/admin/e2e/`:

- `checkin.spec.ts`: an operator logs in, looks up a seeded attendee by name, and admits them
through the manual check-in path (not the camera/QR scanner).
- `a11y.spec.ts`: an axe-core accessibility scan of the login page and the operator check-in page.
- `a11y.spec.ts`: an axe-core accessibility scan of the login page, the operator check-in page and
the main admin pages (events list, overview, attendees, attendee detail, event settings,
communication), the latter as a seeded superadmin (`admin-login.ts` walks the forced TOTP
enrollment over the API). iframes (the mail-template previews) are excluded.
Every violation is printed, written to the job summary, and saved as `test-results/*/axe-*.json`;
serious and critical ones fail the test, minor and moderate stay report-only.

Expand Down
126 changes: 120 additions & 6 deletions apps/admin/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { appendFileSync, writeFileSync } from "node:fs";
import AxeBuilder from "@axe-core/playwright";
import { test, expect, type Page, type TestInfo } from "@playwright/test";
import { readSeedData } from "./seed.js";
import { signInAsAdmin } from "./admin-login.js";
import { readSeedData, seedCheckinE2eData } from "./seed.js";

/**
* Accessibility scan (axe-core) over the surfaces this suite can already reach with its one seeded
Expand All @@ -16,8 +17,15 @@ async function scanAndReport(
page: Page,
testInfo: TestInfo,
surface: string,
{ blocking: failOnSerious = true }: { blocking?: boolean } = {},
): Promise<void> {
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
// iframes are excluded: the only ones are the srcdoc mail-template previews on the Communication
// page, whose content is the template's own HTML rather than the app's UI, and axe hangs
// indefinitely trying to scan them.
const results = await new AxeBuilder({ page })
.exclude("iframe")
.withTags(WCAG_TAGS)
.analyze();

// Guards against a silently empty scan (e.g. the page hadn't rendered) counting as "no violations".
expect(results.passes.length + results.violations.length).toBeGreaterThan(0);
Expand Down Expand Up @@ -50,10 +58,15 @@ async function scanAndReport(
);
}

expect(
blocking.map((v) => `${v.id}: ${v.help}`),
`serious/critical accessibility violations on ${surface}`,
).toEqual([]);
if (failOnSerious) {
// Soft, so one page's failure does not stop the remaining pages in the same test from being scanned.
expect
.soft(
blocking.map((v) => `${v.id}: ${v.help}`),
`serious/critical accessibility violations on ${surface}`,
)
.toEqual([]);
}
}

test("login page", async ({ page }, testInfo) => {
Expand All @@ -79,3 +92,104 @@ test("operator check-in page", async ({ page }, testInfo) => {

await scanAndReport(page, testInfo, "operator-checkin");
});

// Admin-side pages, scanned as the seeded superadmin. `blocking: false` marks a page whose current
// findings have not been fixed or triaged yet: it is still scanned and reported, but does not fail.
type Ids = { event: string; attendee: string };

// Each admin page, the API requests that carry its data (a page is only scanned after they have
// completed, so the scan sees real content rather than placeholders), and whether findings fail
// the test (all do today; `blocking: false` would mark a page with unfixed, untriaged findings).
const ADMIN_SURFACES: {
name: string;
path: (ids: Ids) => string;
data: (ids: Ids) => string[];
blocking: boolean;
}[] = [
{
name: "admin-events",
path: () => "/admin",
data: () => ["/api/admin/events"],
blocking: true,
},
{
name: "admin-overview",
path: (i) => `/admin/events/${i.event}/overview`,
data: (i) => [`/api/admin/events/${i.event}/overview`],
blocking: true,
},
{
name: "admin-attendees",
path: (i) => `/admin/events/${i.event}/attendees`,
data: (i) => [`/api/admin/events/${i.event}/attendees`],
blocking: true,
},
{
name: "admin-attendee-detail",
path: (i) => `/admin/events/${i.event}/attendees/${i.attendee}`,
data: (i) => [`/api/admin/events/${i.event}/attendees/${i.attendee}`],
blocking: true,
},
{
name: "admin-event-settings",
path: (i) => `/admin/events/${i.event}/settings`,
data: (i) => [`/api/admin/events/${i.event}/settings`],
blocking: true,
},
{
name: "admin-communication",
path: (i) => `/admin/events/${i.event}/communication`,
data: (i) => [
`/api/admin/events/${i.event}/templates`,
`/api/admin/events/${i.event}/deliveries`,
],
blocking: true,
},
];

test("admin pages", async ({ page, baseURL }, testInfo) => {
// Six pages, each waited for and scanned, plus the sign-in: more than the 30 s default.
test.setTimeout(180_000);
// Re-seed first: it resets the admin's MFA (a retry after a failed attempt could not enrol TOTP
// again otherwise) and puts the attendee back to "not admitted", the state whose "Not yet" item
// labels this test scans.
await seedCheckinE2eData();
const seed = await readSeedData();
await signInAsAdmin(page, baseURL!, seed.adminEmail, seed.adminPassword);

for (const surface of ADMIN_SURFACES) {
const ids = { event: seed.eventId, attendee: seed.attendeeId };
// Listeners are attached before navigating so a fast response cannot be missed.
const loaded = surface
.data(ids)
.map((pathname) =>
page.waitForResponse(
(res) =>
new URL(res.url()).pathname === pathname &&
res.request().method() === "GET" &&
res.ok(),
),
);
await page.goto(surface.path(ids));
await Promise.all(loaded);
await expect(page.getByRole("heading").first()).toBeVisible();
await expect(page.locator(".at-spinner")).toHaveCount(0);
Comment thread
solarssk marked this conversation as resolved.
// Two animation frames let React commit what the responses just delivered, then any finite
// animation (notices fade in) must be over: axe reads colours mid-fade otherwise, and reports
// contrast against a half-transparent text colour that is not what users end up seeing.
await page.evaluate(async () => {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
await Promise.all(
document
.getAnimations()
.filter((a) => a.effect?.getComputedTiming().iterations !== Infinity)
.map((a) => a.finished),
);
});
await scanAndReport(page, testInfo, surface.name, {
blocking: surface.blocking,
});
}
});
79 changes: 79 additions & 0 deletions apps/admin/e2e/admin-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createHmac } from "node:crypto";
import type { Page } from "@playwright/test";

/**
* Signs the seeded superadmin in over the API on the page's own cookie jar, walking the forced
* TOTP enrollment (login -> totp/enroll -> totp/confirm -> backup-codes/complete). Admin roles
* must have MFA, and the enrollment secret only exists in the enroll response, so the code is
* computed here instead of driving the QR screen. Afterwards `page.goto("/admin/...")` is signed in.
*/

function base32Decode(input: string): Buffer {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const ch of input.split("=")[0]!.toUpperCase()) {
const idx = alphabet.indexOf(ch);
if (idx < 0) throw new Error("invalid base32 secret");
bits += idx.toString(2).padStart(5, "0");
}
const bytes: number[] = [];
for (let i = 0; i + 8 <= bits.length; i += 8)
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(bytes);
}

function totp(
secret: string,
digits: number,
period: number,
algorithm: string,
): string {
const counter = Math.floor(Date.now() / 1000 / period);
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const hmac = createHmac(algorithm, base32Decode(secret)).update(buf).digest();
const offset = hmac[hmac.length - 1]! & 0x0f;
const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 10 ** digits;
return String(code).padStart(digits, "0");
}

export async function signInAsAdmin(
page: Page,
baseUrl: string,
email: string,
password: string,
): Promise<void> {
const origin = new URL(baseUrl).origin;
const post = async (path: string, data?: Record<string, unknown>) => {
const res = await page.request.post(path, {
headers: { Origin: origin },
data,
});
if (!res.ok())
throw new Error(`POST ${path} -> ${res.status()} ${await res.text()}`);
return (await res.json()) as Record<string, unknown>;
};

let step = await post("/api/auth/login", { email, password });
if (step["next"] === "enrollment_required") {
const enrolled = await post("/api/auth/mfa/totp/enroll");
const uri = new URL(String(enrolled["otpauth_uri"]));
const secret = uri.searchParams.get("secret");
if (!secret) throw new Error("enroll response had no TOTP secret");
const code = totp(
secret,
Number(uri.searchParams.get("digits") ?? 6),
Number(uri.searchParams.get("period") ?? 30),
(uri.searchParams.get("algorithm") ?? "SHA1").toLowerCase(),
);
step = await post("/api/auth/mfa/totp/confirm", { code });
}
if (step["next"] === "backup_codes_required") {
step = await post("/api/auth/mfa/totp/backup-codes/complete");
}
if (step["next"] !== "complete") {
throw new Error(
`admin login did not reach a full session (next=${String(step["next"])})`,
);
}
}
26 changes: 25 additions & 1 deletion apps/admin/e2e/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { prisma } from "@admitto/db";
import { createUser, findUserByEmail } from "@admitto/auth";
import { bootstrapSuperadmin, createUser, findUserByEmail, resetUserMfa } from "@admitto/auth";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -26,6 +26,9 @@ export const E2E_OPERATOR_EMAIL = "e2e.operator@example.com";
// Local-only fixture password for a synthetic operator account in a disposable E2E database —
// never a real credential, never used outside this seed script and its matching Playwright spec.
export const E2E_OPERATOR_PASSWORD = "E2eCheckinSmoke!2026";
export const E2E_ADMIN_EMAIL = "e2e.admin@example.com";
// Same disposable-database-only fixture-password convention as the operator's above.
export const E2E_ADMIN_PASSWORD = "E2eAdminSmoke!2026";

export interface SeedResult {
organizationId: string;
Expand All @@ -36,6 +39,8 @@ export interface SeedResult {
attendeeEmail: string;
operatorEmail: string;
operatorPassword: string;
adminEmail: string;
adminPassword: string;
}

export async function seedCheckinE2eData(): Promise<SeedResult> {
Expand Down Expand Up @@ -89,6 +94,9 @@ export async function seedCheckinE2eData(): Promise<SeedResult> {

// Clear any check-in history from a previous run so Reports/recent-scans stay clean too.
await prisma.checkIn.deleteMany({ where: { attendee_id: attendee.id } });
// Also drop the per-item state a previous run's admit left behind (the badge is issued on
// admit), so the attendee page always starts from the fresh "Not yet" state a new database has.
await prisma.attendeeItemState.deleteMany({ where: { attendee_id: attendee.id } });

let operator = await findUserByEmail(prisma, E2E_OPERATOR_EMAIL);
if (!operator) {
Expand Down Expand Up @@ -124,6 +132,20 @@ export async function seedCheckinE2eData(): Promise<SeedResult> {
});
}

// Superadmin for the admin-side accessibility scan. Admin roles must enrol TOTP on first login,
// so any MFA left over from a previous run is reset here and the spec enrols it again; the
// bootstrap path leaves onboarding marked complete, so no setup wizard stands in the way.
const admin = await findUserByEmail(prisma, E2E_ADMIN_EMAIL);
if (admin) {
await resetUserMfa(prisma, admin.id);
await prisma.user.update({
where: { id: admin.id },
data: { is_active: true, must_change_password: false, failed_login_streak: 0, failed_mfa_streak: 0 },
});
} else {
await bootstrapSuperadmin(prisma, E2E_ADMIN_EMAIL, E2E_ADMIN_PASSWORD);
}

return {
organizationId: org.id,
eventId: event.id,
Expand All @@ -133,6 +155,8 @@ export async function seedCheckinE2eData(): Promise<SeedResult> {
attendeeEmail: E2E_ATTENDEE_EMAIL,
operatorEmail: E2E_OPERATOR_EMAIL,
operatorPassword: E2E_OPERATOR_PASSWORD,
adminEmail: E2E_ADMIN_EMAIL,
adminPassword: E2E_ADMIN_PASSWORD,
};
}

Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/attendees/attendees.css
Original file line number Diff line number Diff line change
Expand Up @@ -1376,8 +1376,8 @@
flex: none;
}

.attendee-items-row__state--ok { color: var(--status-ok); }
.attendee-items-row__state--muted { color: var(--text-disabled); }
.attendee-items-row__state--ok { color: var(--status-ok-fg); }
.attendee-items-row__state--muted { color: var(--text-muted); }

/* Attendee detail status strip - 5 chips (Pass/Attendance/Ticket delivery/Check-in/
Wallet), matching the design mockup's .att-status-strip. */
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/staff.css
Original file line number Diff line number Diff line change
Expand Up @@ -7300,7 +7300,7 @@ output.mail-field-hint {
padding: 0 10px;
border: none;
border-radius: var(--radius);
background: var(--status-ok);
background: var(--status-ok-fg);
color: #fff;
font-size: var(--fs-sm);
font-weight: 600;
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
.at-btn--danger { background: var(--status-error); color: var(--text-on-primary); }
.at-btn--danger:hover:not(:disabled) { background: var(--status-error-hover); }

.at-btn--success { background: var(--status-ok); color: var(--text-on-primary); }
.at-btn--success:hover:not(:disabled) { background: var(--status-ok-hover); }
.at-btn--success { background: var(--status-ok-fg); color: var(--text-on-primary); }
.at-btn--success:hover:not(:disabled) { background: color-mix(in srgb, var(--status-ok-fg), #000 15%); }

/* Caution tier, between primary and danger — reversible-but-impactful bulk actions (e.g. Revoke
items/check-in). --status-warn-fg (not the raw, much brighter --status-warn) since there's no
Expand Down Expand Up @@ -388,7 +388,7 @@

/* ---------------- Tabs ---------------- */
.at-tabs { display: flex; gap: var(--space-1); border-bottom: var(--border-width) solid var(--border); }
.at-tab { appearance: none; background: none; border: 0; cursor: pointer; font-family: var(--font-sans); font-size: var(--fs-body); font-weight: var(--fw-medium); color: var(--text-muted); padding: var(--space-3) var(--space-3); border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--dur-fast), border-color var(--dur-fast); }
.at-tab { appearance: none; background: none; border: 0; cursor: pointer; font-family: var(--font-sans); font-size: var(--fs-body); font-weight: var(--fw-medium); color: var(--text-secondary); padding: var(--space-3) var(--space-3); border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--dur-fast), border-color var(--dur-fast); }
.at-tab:hover { color: var(--text-primary); }
.at-tab--active { color: var(--primary); border-bottom-color: var(--primary); }

Expand Down
Loading