fix(events): patch db backfill for legacy users and sync frontend domain drift on rotation

This commit is contained in:
Tyler Gillispie 2026-08-27 00:21:34 -07:00
parent 3dcd823e23
commit 0a3c147880
14 changed files with 3585 additions and 30 deletions

10
scratch/fix_db.patch Normal file
View File

@ -0,0 +1,10 @@
--- server/db.ts
+++ server/db.ts
@@ -226,6 +226,7 @@
try {
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
+ await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE`;
} catch {
// Ignore migration column exists
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -227,6 +227,17 @@ export async function initDb(): Promise<void> {
try {
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE`;
// Backfill legacy event passes to ensure older guests can still be revoked/extended
await sql`
UPDATE users u
SET event_pass_id = ep.id
FROM event_passes ep
WHERE u.account_status = 'guest'
AND u.event_pass_id IS NULL
AND u.username LIKE 'guest_' || ep.slug || '\\_%'
`;
} catch {
// Ignore migration column exists
}

353
server/db.ts.orig Normal file
View File

@ -0,0 +1,353 @@
import postgres from "npm:postgres@3";
// Evaluate environment variables dynamically to prevent crash during test imports
const host = Deno.env.get("POSTGRES_HOST") ||
(import.meta.main ? "" : "localhost");
const user = Deno.env.get("POSTGRES_USER") ||
(import.meta.main ? "" : "postgres");
const password = Deno.env.get("POSTGRES_PASSWORD") ||
(import.meta.main ? "" : "postgres");
const db = Deno.env.get("POSTGRES_DB") || (import.meta.main ? "" : "postgres");
const port = Deno.env.get("POSTGRES_PORT") || "5432";
if (import.meta.main && (!host || !user || !password || !db)) {
throw new Error(
"Missing critical database environment variables. Required: POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.",
);
}
const connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`;
// Export it as let so we can override it in tests
export let sql = postgres(connectionString);
/**
* SIDE EFFECT: Initializes the database schema.
*/
export async function initDb(): Promise<void> {
console.log("[Auth DB] Initializing central identity database schema...");
// We ensure new users are 'pending' to satisfy Use Case 3 (Manual state machine activation)
// If the table exists we will attempt to alter the default.
await sql`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
account_status TEXT DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
try {
await sql`ALTER TABLE users ALTER COLUMN account_status SET DEFAULT 'pending'`;
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
} catch {
// Ignore if unsupported
}
await sql`
CREATE TABLE IF NOT EXISTS apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
spiffe_id VARCHAR(255) UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
// Ensure app_secret column exists for API validation mapping
try {
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS app_secret TEXT UNIQUE`;
} catch {
// Soft ignore if column already exists
// Ignore and check next
}
// Ensure domain column exists for Edge authorization routing
try {
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS domain TEXT UNIQUE`;
} catch {
// Soft ignore if column already exists
}
// Tier 1 Ingress Control
try {
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE`;
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS bypass_paths TEXT[] DEFAULT '{}'`;
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS allowed_cidrs TEXT[] DEFAULT '{}'`;
} catch {
// Soft ignore if columns already exist
}
await sql`
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(name, app_id)
);
`;
// Seed standard global roles if table is empty
try {
const existingRoles = await sql`SELECT count(*)::int as count FROM roles`
.then((res) => res[0]?.count || 0);
if (existingRoles === 0) {
await sql`
INSERT INTO roles (name, description, app_id) VALUES
('admin', 'Full administrative access across all management capabilities', NULL),
('editor', 'Read and write access with permissions to modify records', NULL),
('operator', 'Operational execution access for runtime tasks', NULL),
('viewer', 'Read-only access across application telemetry and views', NULL)
ON CONFLICT DO NOTHING
`;
}
} catch {
// Ignore seed errors on race conditions
}
await sql`
CREATE TABLE IF NOT EXISTS grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, app_id)
);
`;
await sql`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
max_uses INT DEFAULT 1,
uses_count INT DEFAULT 0,
auto_activate BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
used_by UUID REFERENCES users(id) ON DELETE SET NULL
);
`;
// Migrations for existing invites table
try {
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS max_uses INT DEFAULT 1`;
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS uses_count INT DEFAULT 0`;
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS auto_activate BOOLEAN DEFAULT TRUE`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS invite_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS aaguid_allowlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aaguid UUID UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS recovery_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE
);
`;
await sql`
CREATE TABLE IF NOT EXISTS recovery_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
server_share TEXT NOT NULL,
pin_hash TEXT NOT NULL,
attempts_count INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS audit_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details JSONB,
ip_address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
leaf_hash TEXT
);
`;
try {
await sql`ALTER TABLE audit_records ADD COLUMN IF NOT EXISTS leaf_hash TEXT`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS event_passes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
pin_code TEXT UNIQUE,
name TEXT NOT NULL,
app_id UUID REFERENCES apps(id) ON DELETE SET NULL,
role TEXT DEFAULT 'viewer',
max_seats INT DEFAULT 50,
seats_claimed INT DEFAULT 0,
lifespan_hours INT DEFAULT 3,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT TRUE,
is_paused BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
try {
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS audit_sths (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tree_size BIGINT NOT NULL,
root_hash TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL,
prf_enabled BOOLEAN DEFAULT FALSE,
prf_salt TEXT
);
`;
// Ensure prf columns exist
try {
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`;
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT,
is_agent BOOLEAN DEFAULT FALSE,
custom_scopes TEXT[],
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_activity_action TEXT,
is_paused BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
`;
try {
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_agent BOOLEAN DEFAULT FALSE`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_scopes TEXT[]`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_action TEXT`;
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS hwk_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fingerprint TEXT UNIQUE NOT NULL,
public_key JSONB NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
// Seed ed-droid app record with spiffe_id
await sql`
INSERT INTO apps (name, spiffe_id)
VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend')
ON CONFLICT (spiffe_id) DO NOTHING
`;
// Seed the Central Auth-Yes Management App for global administration
await sql`
INSERT INTO apps (name, spiffe_id)
VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management')
ON CONFLICT (spiffe_id) DO NOTHING
`;
// Seed initial bootstrap admin invite if no users exist in the database
try {
const userCount = await sql`SELECT count(*)::int as count FROM users`.then(
(res) => res[0]?.count || 0,
);
if (userCount === 0) {
const inviteCount = await sql`
SELECT count(*)::int as count FROM invites
WHERE role = 'admin' AND expires_at > NOW()
`.then((res) => res[0]?.count || 0);
if (inviteCount === 0) {
const bootstrapCode = Deno.env.get("BOOTSTRAP_INVITE_CODE") ||
"bootstrap-admin";
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await sql`
INSERT INTO invites (code, app_id, role, max_uses, uses_count, auto_activate, expires_at)
VALUES (${bootstrapCode}, NULL, 'admin', 1, 0, TRUE, ${expiresAt})
ON CONFLICT (code) DO NOTHING
`;
console.log(
`[Auth DB] Initial bootstrap admin invite code seeded: '${bootstrapCode}'`,
);
}
}
} catch (err) {
console.warn("[Auth DB] Bootstrap invite check skipped:", err);
}
console.log("[Auth DB] Central identity database schema initialized.");
}
export const sqlWrapper = {
get sql() {
return sql;
},
set sql(val: any) {
sql = val;
},
};

View File

@ -134,7 +134,7 @@ eventRoutes.get("/api/events/:id/attendees", async (c) => {
try {
const event = await sqlWrapper.sql`
SELECT slug FROM event_passes
SELECT id, slug, max_seats, expires_at FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
`.then((res: any) => res[0]);
@ -143,16 +143,22 @@ eventRoutes.get("/api/events/:id/attendees", async (c) => {
return c.json({ error: "Event not found or unauthorized" }, 404);
}
const likePattern = `guest_${event.slug}_%`;
const attendees = await sqlWrapper.sql`
SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, u.username, u.display_name
SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE u.username LIKE ${likePattern}
WHERE u.event_pass_id = ${eventId}
ORDER BY s.created_at DESC
`;
return c.json({ success: true, attendees });
return c.json({
success: true,
attendees,
event: {
max_seats: event.max_seats,
expires_at: event.expires_at,
},
});
} catch (e: any) {
console.error("[Events] Failed to fetch attendees:", e);
return c.json({ error: "Failed to fetch attendees" }, 500);
@ -176,19 +182,17 @@ eventRoutes.post("/api/events/:id/end", async (c) => {
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)})
RETURNING slug
RETURNING id
`;
if (!eventResult || eventResult.length === 0) {
return c.json({ error: "Event not found or unauthorized" }, 404);
}
const slug = eventResult[0].slug;
const sessionResult = await sqlWrapper.sql`
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
SELECT id FROM users WHERE event_pass_id = ${eventId}
)
RETURNING id
`;
@ -224,7 +228,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)}) AND is_active = TRUE
RETURNING slug, expires_at
RETURNING id, expires_at
`;
if (!eventResult || eventResult.length === 0) {
@ -234,14 +238,13 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
);
}
const slug = eventResult[0].slug;
const newExpiresAt = new Date(eventResult[0].expires_at);
const sessionResult = await sqlWrapper.sql`
UPDATE sessions
SET expires_at = expires_at + interval '${extendHours} hours'
WHERE user_id IN (
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
SELECT id FROM users WHERE event_pass_id = ${eventId}
)
RETURNING id
`;
@ -417,13 +420,14 @@ eventRoutes.post("/api/join", async (c) => {
const updatedEvent = updateResult[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
const eventShortId = String(updatedEvent.id).split("-")[0];
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
VALUES (${guestUuid}, ${username}, ${
updatedEvent.name + " Attendee"
}, 'guest')
}, 'guest', ${updatedEvent.id})
ON CONFLICT DO NOTHING
`;
@ -532,11 +536,14 @@ eventRoutes.get("/join/:slug", async (c) => {
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
const eventShortId = String(event.id).split("-")[0];
const username = `guest_${eventShortId}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
VALUES (${guestUuid}, ${username}, ${
event.name + " Attendee"
}, 'guest', ${event.id})
ON CONFLICT DO NOTHING
`;

View File

@ -309,7 +309,7 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => {
OR EXISTS (
SELECT 1 FROM event_passes ep
WHERE ep.created_by = ${auth.userId}
AND u.username LIKE 'guest_' || ep.slug || '_%'
AND u.event_pass_id = ep.id
)
)
`.then((res: any) => res[0]);

View File

@ -140,7 +140,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
const json = await res.json();
assert(json.success === true);
assert(json.token.startsWith("ay_sess_"));
assertEquals(json.username, "guest_deno-lab_1");
assertEquals(json.username, "guest_event_1");
const cookies = res.headers.get("set-cookie");
assertExists(cookies);
@ -385,7 +385,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assertEquals(json.username, "guest_deno-lab-workshop_1");
assertEquals(json.username, "guest_event_1");
} finally {
sqlWrapper.sql = originalSql;
valkeySetexStub.restore();
@ -445,7 +445,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
assertEquals(res.status, 200);
const text = await res.text();
assert(text.includes('export AUTH_YES_TOKEN="ay_sess_'));
assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"'));
assert(text.includes('export AUTH_YES_USER="guest_event_2"'));
assert(auditCalled);
assertEquals(auditPayload.resource, "event-uuid-1");
@ -592,4 +592,75 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
}
},
);
await t.step(
"GET /api/events/:id/attendees returns attendees list and event metadata",
async () => {
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === "admin-session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const expDate = new Date(Date.now() + 3600000).toISOString();
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
const query = Array.isArray(strings)
? strings.join("?")
: String(strings);
if (
query.includes(
"SELECT id, slug, max_seats, expires_at FROM event_passes",
)
) {
return Promise.resolve([{
id: "evt-123",
slug: "deno-lab",
max_seats: 50,
expires_at: expDate,
}]);
}
if (query.includes("WHERE u.event_pass_id =")) {
return Promise.resolve([
{
id: "sess-1",
label: "Seat #1",
is_paused: false,
created_at: new Date().toISOString(),
expires_at: expDate,
last_activity_at: new Date().toISOString(),
last_activity_action: "ForwardAuth Ingress",
username: "guest_evt_1",
display_name: "Deno Workshop Attendee",
},
]);
}
return Promise.resolve([]);
}) as any;
try {
const res = await app.request("/api/events/evt-123/attendees", {
method: "GET",
headers: {
Authorization: "Bearer admin-session",
},
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assertEquals(json.attendees.length, 1);
assertEquals(json.attendees[0].username, "guest_evt_1");
assertExists(json.event);
assertEquals(json.event.max_seats, 50);
assertEquals(json.event.expires_at, expDate);
} finally {
sqlWrapper.sql = originalSql;
valkeyGetStub.restore();
}
},
);
});

View File

@ -0,0 +1,67 @@
# Phase 7: Attendee Relational Decoupling & Immutable Identity
## 1. Context & Rationale
Currently, guest attendee sessions are bound to their originating event via
brittle string matching on `users.username` (e.g.,
`WHERE username LIKE 'guest_' || ep.slug || '_%'`). This creates two major
architectural flaws:
1. **Rotation Breakage:** If an event host rotates the event's ingress PIN/slug
(e.g., `deno-lab-1a2b` to `deno-lab-9x8z`), the backend loses track of
previously joined guests because their usernames retain the old slug suffix.
2. **Unwieldy Data:** Guest usernames inherit the full event slug, leading to
massive, unreadable usernames in logs and the database (e.g.,
`guest_annual-cyber-security-training-workshop-4f8x_12`).
## 2. Objective
Implement a robust **"A+B Architecture"** to permanently decouple guest session
lifecycle from transient URL slugs:
- **Option A (Relational Integrity):** Link guests directly to the Event UUID
via a strict Foreign Key (`event_pass_id`).
- **Option B (Immutable Identity):** Mint short, deterministic, and immutable
usernames using the Event UUID prefix.
## 3. Scope & Acceptance Criteria
### 3.1 Database Migration (`server/db.ts`)
- [ ] Add column:
`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE;`
- [ ] Ensure backward compatibility with existing standalone users (column
should be nullable, as admins/owners won't have an `event_pass_id`).
### 3.2 Immutable Username Minting (`server/routes/events.ts`)
- [ ] In `POST /api/join`: Change guest username generation from
`guest_${event.slug}_${seat}` to
`guest_${event.id.split('-')[0]}_${seat}`.
- [ ] In `POST /api/join`: Insert `event.id` into the new `event_pass_id` column
for the newly minted user.
### 3.3 Query Refactoring (Zero String Parsing)
- [ ] **`GET /api/events/:id/attendees`**: Refactor the query to use
`WHERE u.event_pass_id = ${eventId}`.
- [ ] **`POST /api/events/:id/extend`**: Refactor the query to target sessions
belonging to `u.event_pass_id = ${eventId}`.
- [ ] **`POST /api/events/:id/end`**: Refactor the revocation query to target
`u.event_pass_id = ${eventId}`.
- [ ] **`DELETE /api/sessions/:id`**: Refactor the ownership check to verify
`u.event_pass_id = ${eventId}` instead of string parsing.
### 3.4 Quality Gates
- [ ] Run `deno fmt`, `deno task lint`, `deno task check`.
- [ ] All 67+ backend tests must pass. (Update `server/tests/events.test.ts` to
mock/handle the new `event_pass_id` column and short username generation).
## 4. Anti-Patterns to Avoid
- **No `LIKE 'guest_%'` queries** anywhere in the backend logic.
- **No updating usernames on slug rotation.** The
`POST /api/events/:id/rotate-pin` endpoint should ONLY touch
`event_passes.slug` and `event_passes.pin_code`, leaving attendees completely
unaffected.

View File

@ -0,0 +1,100 @@
# TASK METADATA
- **Target Files:**
- `server/routes/events.ts`
- `ui/components/sessions/SessionsScript.tsx`
- `ui/components/sessions/EventCockpitDeck.tsx`
- `ui/components/SessionsPage.tsx`
- **Core Objective:** Resolve the final 4 untracked discrepancies identified
during the Phase 6 UI Audit (DOM desync, missing guest drawer payload, minor
typo, and missing sticky action bar).
- **Dependencies:** None.
- **Additional Important Notes:** This represents the final polish of the
`Sessions & Events` UX before moving on to backend relational decoupling and
P2P vouching.
---
## 2. Architectural Considerations & Risks
### Risks
1. **DOM Ingress Desync:** Currently, when an organizer clicks `[ 🔄 Rotate ]`,
the backend successfully updates both the `pin_code` and the `slug`. However,
the frontend JavaScript (`rotatePin`) only targets and updates the PIN on the
UI. The copy buttons for "Link" and "CLI" continue to hold the old slug
because their `onclick` handlers are hardcoded during SSR. _Risk_: If an
organizer rotates the credentials and then clicks "Copy Link", they will
mistakenly share the revoked link, leading to confusion and 404s.
2. **Guest Drawer Broken Meta Header:** The backend endpoint
`GET /api/events/:id/attendees` only returns
`{ success: true, attendees: [...] }`. The client script expects `data.event`
to populate the `Max Seats` and `Expires At` countdown. Because `data.event`
is undefined, the header is permanently stuck displaying
`0 / 0 Claimed Seats · ⏳ 0h 0m left`. _Risk_: Loss of critical event context
while managing guests.
3. **Scroll Fatigue:** The `[ Delegate Session ]` drawer trigger sits at the top
of `SessionsPage.tsx`. As the user accumulates multiple events or sessions,
scrolling down the page hides the primary action.
### Alternatives
- For the Sticky Action Bar, we could use CSS `position: sticky; top: 0;` on the
delegation tabs block in `SessionsPage.tsx`, ensuring the action is always
visible without creating a separate floating action button (FAB) which might
clutter mobile views.
- For the UI DOM rotation, we must assign `id` attributes to the Link and CLI
copy pills in `EventCockpitDeck.tsx` so `rotatePin` can dynamically overwrite
their `onclick` attributes.
## 3. Proposed Implementation
### Phase 1: Fix Backend Attendees Payload
1. In `server/routes/events.ts`, locate `GET /api/events/:id/attendees` (around
line 125).
2. Currently, the query selects only `slug` to do the `LIKE` match. Update this
to fetch `max_seats` and `expires_at` as well.
3. Include the event payload in the JSON response:
`return c.json({ success: true, attendees, event: { max_seats: event.max_seats, expires_at: event.expires_at } })`.
### Phase 2: Fix DOM Rotation Desync
1. In `ui/components/sessions/EventCockpitDeck.tsx`:
- Locate the Grid and Compact card copy buttons for Link and CLI.
- Add explicit IDs, for example: `id={'link-copy-' + event.id}` and
`id={'cli-copy-' + event.id}`.
2. In `ui/components/sessions/SessionsScript.tsx` (`rotatePin` function):
- Check if `data.slug` is returned (Jules updated the backend to return
`pinCode, slug, link, cli`).
- If `data.slug` is present, locate the link and CLI copy elements and
overwrite their `onclick` handlers dynamically so they copy the fresh
credentials.
- E.g.,
`linkElem.setAttribute('onclick', "copyText(window.location.origin + '/e/" + data.slug + "')");`
### Phase 3: Section Title Typo
1. In `ui/components/sessions/EventCockpitDeck.tsx` (around line 8), change
`<h2>Event Passes</h2>` to `<h2>Events</h2>`.
### Phase 4: Sticky Delegation Action Bar
1. In `ui/components/SessionsPage.tsx`, wrap the `Delegate Session` tab
selection block
(`<div style="display: flex; gap: 0.5rem; margin-bottom: 2rem;">...</div>`)
in a sticky container.
2. Example styling:
`position: sticky; top: 1rem; z-index: 50; background: var(--surface-bg); padding-top: 1rem; margin-top: -1rem; margin-bottom: 2rem;`.
3. Add a slight box-shadow or bottom border on scroll (optional polish) to
ensure the tabs float cleanly above scrolling session cards.
### Phase 5: Quality Gates
1. Run `deno fmt`, `deno task lint`, and `deno task check`.
2. Verify all `ui/ui_scripts.test.ts` pass and the DOM manipulation syntax is
correct.
3. Author a new test block in `server/tests/events.test.ts` for
`GET /api/events/:id/attendees` to assert that it successfully returns both
the `attendees` array and the `event` payload containing `max_seats` and
`expires_at`.

55
tasks/ui-audit-3.md Normal file
View File

@ -0,0 +1,55 @@
# UI & UX Live Audit Log — Round 3 (`ui-audit-3.md`)
**Date:** 2026-08-26 (Session: 21:30)\
**Target Environments:** `https://auth.atyg.org` | `https://ed-droid.atyg.org`\
**Scope:** Verification of Phase 5 Deliverables (True Slide-Over Guest Drawer,
Multi-Event Compact Toggle, Standardized Countdown Pills, Mobile Density
Optimization, Backend Revocation).
---
## 1. Core Verification Focus Areas
| # | Feature / Flow | Target Behavior | Status |
| :---- | :------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------- |
| **1** | **Slide-Over Guest Drawer** | Clicking `[ 👥 Manage Guests (N) ]` opens a fixed right-side panel on Desktop (`420px; 100vh`) and bottom sheet on Mobile (`100vw; 80vh`) with backdrop fade, `Escape` key close, and backdrop click dismissal. | ⏳ Pending Test |
| **2** | **Guest Drawer Information IA** | Pinned contextual header showing `[Event Name] Guests` + `N / Max Claimed Seats · ⏳ Xh Ym left · (Expires Time)`. Individual rows only display unique data (`Seat #[N]`, `Username`, `Joined relative time`, `🟢 Active / ⏸️ Paused`). | ⏳ Pending Test |
| **3** | **Backend Revocation Fix** | Event creator clicking `[ 🗑️ Revoke ]` inside the Guest Drawer immediately deletes the attendee session from Valkey cache and DB without 404 access-denied errors. | ⏳ Pending Test |
| **4** | **Multi-Event Compact Toggle** | `[ 🗂️ Grid ]` vs `[ 📋 Compact ]` toggle collapses 510 active event cards into sleek 1-row strips (`Title · 🟢 Active · ⏳ 2h 45m left · 12/50 Seats · PIN · [ 👥 Guests ]`). View mode persists in `localStorage`. | ⏳ Pending Test |
| **5** | **Dynamic Countdown Pills** | Countdown pills across all Event Pass cards, Guest Drawer, and Sessions list display `⏳ Xh Ym left · (Expires Time)` with dynamic colors (green $\rightarrow$ amber $<1$h $\rightarrow$ red expired) and tick down live every 30s. | Pending Test |
| **6** | **Mobile Session Density** | Remote personal device cards in `SessionDeck.tsx` have no dedicated full-width bottom row; compact `[ 🗑️ Revoke ]` sits inline in the header row next to the status badge. | ⏳ Pending Test |
---
## 2. Live Observation & Findings Log
_Record observations, visual feedback, quirks, and confirmations here as we
execute._
| Timestamp | Scenario | Component / Flow | Observation / Finding | Resolution / Action Item |
| :-------- | :------------------------------ | :------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `21:30` | Setup | System | Opened `ui-audit-3.md` tracking log for Phase 5 live deployment testing. | Ready for live observation inputs. |
| `21:31` | State Consistency | `EventCockpitDeck.tsx` & Queries | **Contradictory Status (`⏳ Expired` + `Active` Badge):** An expired workshop pass shows the countdown pill as `⏳ Expired` (red) but right next to it the status badge remains hardcoded as green `Active` (`<span class="badge badge-success status-badge">Active</span>`). Furthermore, expired passes remain in the active deck rather than being filtered or marked as expired. | **Action Item:**<br>1. Derive badge dynamically: if `is_paused` $\rightarrow$ `badge-warning Paused`; if `isExpired` $\rightarrow$ `badge-secondary Expired`; else `badge-success Active`.<br>2. Filter out or separate expired passes in the database query (`WHERE expires_at > NOW()`) so the active cockpit deck only shows currently actionable passes. |
| `21:38` | Information Architecture | `ExpiryBadge` Component | **Finalized Natural Expiry & Lifespan Format (Option A+):** Standardize all session and pass expiration indicators to a clean, highly readable pattern:<br>`[Date] · [Time] · [ Colored Urgency Badge ]`<br><br>**Natural Date String:** `Today` (if $<24$h), `Tomorrow` (if $<48$h), `Sep 23` (same year), `Jan 15, 2027` (future year).<br>**Exact Time:** `10:39 PM` (local time).<br>**Floating Dot Separator:** Styled subtle `·` with muted opacity to preserve visual rhythm.<br>**Multi-Tier Urgency Badge:**<br>&nbsp;&nbsp;- $<1$h: Amber `[ 45m left ]`<br>&nbsp;&nbsp;- $<24$h: Amber/Green `[ 2h 45m left ]`<br>&nbsp;&nbsp;- 160d: Green `[ 28d left ]`<br>&nbsp;&nbsp;- 212 mos: Green `[ 4.5 mos left ]` (no clunky `142d`)<br>&nbsp;&nbsp;- 1+ yrs: Green `[ 1.2 yrs left ]` (no clunky `420d`)<br>&nbsp;&nbsp;- Expired: Red `[ Expired ]`<br>**Optional Prefix:** `showPrefix?: boolean` (defaults to `false` since headers/cards establish context).<br>**Full Precision Tooltip:** `title="Wednesday, August 26, 2026, 10:39:01 PM EDT"` on element for hover/long-press. | |
| `21:59` | Lifecycle & Business Logic | `server/routes/events.ts` & Deck | **Extending Expired Events (Re-activation vs Kill Switch):**<br>1. **Not an Anti-Pattern:** Allowing an event host to add time to a naturally expired event (where `is_active = TRUE`) is high-value for real workshops running overtime, saving the host from creating a new event and forcing 30 participants to re-join.<br>2. **Backend Math Bug:** Currently `SET expires_at = expires_at + interval '1 hour'` fails if the event expired hours ago. It must be `SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours'`.<br>3. **Distinction from Hard Kill Switch:** `[ End Event ]` sets `is_active = FALSE` (hard revocation, permanent archive). A naturally expired event remains `is_active = TRUE` and can be re-opened with `[ 🔄 Reopen (+1h) ]`. | **Action Item:**<br>• Update SQL in `events.ts:extend` to use `GREATEST(expires_at, NOW())`.<br>• In `EventCockpitDeck.tsx`, render expired passes in a collapsed `<details>` / `[ 📁 Expired Passes (N) ]` section or with a distinct `[ 🔄 Reopen (+1h) ]` button rather than deleting them immediately. |
| `22:13` | IA & Telemetry Design | `EventGuestsDrawer.tsx` & Script | **Clunky "Joined 474m ago" & Redundant Slug Overwhelm:**<br>1. Displaying raw minutes (`Joined 474m ago`) is unreadable math clutter.<br>2. Printing full redundant slugs (`guest_universal-sandbox-read-only_1`) on every seat creates massive visual noise.<br>3. Missing actionable guest telemetry (last action, ingress route, activity level). | **Action Item (Inverted Start-Time & Telemetry Pattern):**<br>**Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge.<br>**Line 1 (Inverted Start-Time):** `Joined Today · 2:15 PM · [ Active 7h 54m ]` (or `Joined Aug 25 · [ Active 2d ]`) using the symmetrical time-since formatter.<br>**Line 2 (Action Telemetry):** `Last Action: ForwardAuth Ingress (ed-droid) · 2m ago · 💻 Web` (pulling `last_activity_action` and `last_activity_at` from `sessions`).<br>• Drop the redundant full slug from the visual text (keep in hover tooltip only). |
| `22:18` | Visual Layout & Redundancy | `SessionsPage.tsx` vs `EventCockpitDeck.tsx` | **Duplicate "Event Passes" Heading:** `SessionsPage.tsx:60` rendered `<h2>Event Passes</h2>`, while `EventCockpitDeck.tsx:7` ALSO rendered `<h2>Event Passes</h2>` alongside the view toggle, creating two stacked identical titles. | **Action Item:** Remove the redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx` so `EventCockpitDeck.tsx` exclusively owns its section header and attached view mode toggle. |
| `22:25` | Layout Density & Visual Bug | `EventCockpitDeck.tsx` (Compact View) | **Critical Compact Mode Breakdown (Verified via Screenshot):**<br>1. **Severe Text Collision:** The event title (`testing workshop event:...`) collides and overlaps directly on top of `Seats Claimed 3/40`, `⏳ Expired`, and the PIN input.<br>2. **Border Bleed:** Input boxes and Copy buttons blow past the right card border boundary.<br>3. **Buttons Pushed Outside Card:** `[ 👥 Guests (3) ]` and `[ ▸ Details ]` are pushed completely outside the blue card border onto the empty page background.<br>4. **Mock Code Regression:** The `[ ▸ Details ]` button executes an inline browser alert (`alert('Expand functionality for compact view to be implemented if needed')`).<br>5. **Double Heading & Arrow:** Double `Event Passes` headings and double `▶ ▸` disclosure arrows. | **Action Item (2-Row Compact Card Architecture):** Scrap the broken single-line CSS hack. Build a dedicated, strictly bounded **2-Row Compact Card** (~70px height):<br>**Row 1:** `[▸ Event Title (ellipsis, max-w 260px)]` · `[Date · Time · Urgency Badge]` · `[3/40 Seats]` · `[🟢 Status Badge]`.<br>**Row 2:** `[ PIN: 241-881 (Copy) ]` `[ Link (Copy) ]` `[ CLI (Copy) ]` on the left, and `[ 👥 Guests (3) ]` + `[ 🔄 +1h ]` docked on the right.<br>• Remove mock `[ ▸ Details ]` alert completely since all handoffs are 1 click away on Row 2.<br>• Remove duplicate `<h2>Event Passes</h2>` from `SessionsPage.tsx`. |
| `22:30` | Visual Affordance & Contrast | `EventCockpitDeck.tsx` (View Toggle) | **Ambiguous Active State on Grid/Compact Toggle:** The `[ 🗂️ Grid ]` vs `[ 📋 Compact ]` toggle lacks clear visual distinction between active and inactive states (both look almost flat with faint border outlines), making it hard to discern at a glance which mode is currently selected. | **Action Item (High-Contrast Segmented Pill Control):**<br>**Active Option:** High-contrast solid brand pill (`background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3);`).<br>**Inactive Option:** Muted translucent background (`color: var(--text-muted); opacity: 0.75;`).<br>• Include `aria-pressed="true/false"` for complete screen reader accessibility. |
| `22:54` | UX Polish & Action Clarity | `WorkshopDrawer.tsx` (Handoff State) | **Mismatched Blue Copy Button & Ambiguous "Dismiss":**<br>1. In the created event handoff screen, `Copy PIN` is styled as `btn-primary` (solid blue) while `Copy URL` and `Copy 1-Liner` are `btn-outline`, creating inconsistent button hierarchy.<br>2. The bottom button says `"Dismiss"`, which sounds negative and confusing. | **Action Item:**<br>• Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`, `Copy URL`, `Copy 1-Liner`).<br>• Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`** that closes the drawer and ensures the newly created event appears in the deck. |
| `22:58` | State Machine Reset | `SessionsScript.tsx` (`openDelegateDrawer`) | **Stale Drawer State on Re-open:** When clicking `[ Delegate Session ]` after previously creating an event or session, the drawer opens directly to the stale handoff success screen rather than resetting back to the clean creation form. | **Action Item:** In `openDelegateDrawer()`, always reset the state machine:<br>• Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to `display: none`.<br>• Clear form input values.<br>• Default back to `Single Session` tab. |
| `23:01` | Security & Ingress Invalidation | `server/routes/events.ts` (`rotate-pin`) | **Incomplete Ingress Rotation (PIN rotated but Link & CLI stayed open):** Currently, clicking rotate only generates a new 6-digit PIN, leaving the static slug active. If the direct link or CLI command was leaked to bad actors, rotating the PIN fails to lock them out! | **Action Item (Universal Ingress Rotation):**<br>• Update `POST /api/events/:id/rotate-pin` to rotate **BOTH** the numeric PIN (`pin_code`) and the event slug suffix (`slug`).<br>• Return `{ success: true, pinCode, slug, link, cli }` and update all 3 fields in the UI simultaneously.<br>• Existing active attendees remain completely unaffected, while any new join attempt using the old PIN, old Link, or old CLI command is immediately blocked. |
---
## 3. Retained Security & Architecture Roadmap
- [ ] **🛡️ Peer-to-Peer Vouching (High-Security Web of Trust):** Attendee PIN
join $\rightarrow$ `quarantined: true` $\rightarrow$ Local peer/host
1-time QR code/emoji approval $\rightarrow$ Activated (with hierarchical
branch revocation).
- [ ] **⚡ Transparent Client Proof-of-Work (PoW):** Background ~50ms
`crypto.subtle` SHA-256 hash collision on PIN submission to defeat
distributed botnets.
- [ ] **📌 Sticky / Persistent Delegation Action Bar:** Floating/sticky trigger
for `[ Delegate Session ]` on deep scrolling pages.

View File

@ -34,9 +34,9 @@ export const SessionsPage = ({
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div style="position: sticky; top: 0; z-index: 40; background: var(--surface-bg, #0f172a); padding: 0.75rem 0; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle);">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
Sessions & Events
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">

View File

@ -5,7 +5,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<div style="margin-bottom: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem; margin-bottom: 1rem;">
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
Event Passes
Events
</h2>
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
<button
@ -113,11 +113,15 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
Link:
</span>
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<code
id={`link-code-${event.id}`}
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
>
/e/{event.slug}
</code>
<button
type="button"
id={`link-btn-${event.id}`}
class="btn-outline"
aria-label={`Copy direct link for ${event.name}`}
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
@ -135,13 +139,17 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
CLI:
</span>
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;">
<code
id={`cli-code-${event.id}`}
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;"
>
curl -sSL {Deno.env.get("RP_ID")
? `https://${Deno.env.get("RP_ID")}`
: ""}/join/{event.slug}?format=env | source /dev/stdin
</code>
<button
type="button"
id={`cli-btn-${event.id}`}
class="btn-outline"
aria-label={`Copy CLI command for ${event.name}`}
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
@ -154,6 +162,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</span>
</summary>
<textarea
id={`cli-textarea-${event.id}`}
readonly
rows={2}
style="width: 100%; margin-top: 0.4rem; padding: 0.4rem 0.5rem; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); resize: vertical; box-sizing: border-box;"
@ -236,12 +245,14 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
(Copy)
</span>
<span
id={`compact-link-${event.id}`}
class="compact-pill"
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
>
Link (Copy)
</span>
<span
id={`compact-cli-${event.id}`}
class="compact-pill"
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>

View File

@ -297,12 +297,69 @@ export const SessionsScript = () => {
if (pinElem) {
pinElem.textContent = data.pinCode;
}
showNotice('Event PIN rotated successfully', false);
const compactPinElem = document.getElementById('compact-pin-' + eventId);
if (compactPinElem) {
compactPinElem.textContent = data.pinCode;
}
if (data.slug) {
const linkUrl = window.location.origin + '/e/' + data.slug;
// Extract original base URL from cli code to preserve RP_ID
let cliCmd = 'curl -sSL ' + window.location.origin + '/join/' + data.slug + '?format=env | source /dev/stdin';
const cliCodeElem = document.getElementById('cli-code-' + eventId);
if (cliCodeElem && cliCodeElem.textContent) {
const parts = cliCodeElem.textContent.trim().split(' ');
if (parts.length > 2 && parts[2].startsWith('http')) {
try {
const urlObj = new URL(parts[2]);
cliCmd = 'curl -sSL ' + urlObj.origin + '/join/' + data.slug + '?format=env | source /dev/stdin';
} catch (e) {
// ignore
}
}
}
// Grid View
const linkCodeElem = document.getElementById('link-code-' + eventId);
if (linkCodeElem) linkCodeElem.textContent = '/e/' + data.slug;
const linkBtnElem = document.getElementById('link-btn-' + eventId);
if (linkBtnElem) {
linkBtnElem.onclick = () => copyText(linkUrl);
}
if (cliCodeElem) cliCodeElem.textContent = cliCmd;
const cliBtnElem = document.getElementById('cli-btn-' + eventId);
if (cliBtnElem) {
cliBtnElem.onclick = (e) => {
if (e && e.stopPropagation) e.stopPropagation();
copyText(cliCmd);
};
}
const cliTextareaElem = document.getElementById('cli-textarea-' + eventId);
if (cliTextareaElem) cliTextareaElem.value = cliCmd;
// Compact View
const compactLinkElem = document.getElementById('compact-link-' + eventId);
if (compactLinkElem) {
compactLinkElem.onclick = () => copyText(linkUrl);
}
const compactCliElem = document.getElementById('compact-cli-' + eventId);
if (compactCliElem) {
compactCliElem.onclick = () => copyText(cliCmd);
}
}
showNotice('Event credentials rotated successfully', false);
} else {
showNotice(data.error || 'Failed to rotate PIN', true);
showNotice(data.error || 'Failed to rotate credentials', true);
}
} catch (err) {
showNotice('Network error rotating PIN', true);
showNotice('Network error rotating credentials', true);
}
}