Compare commits

..

No commits in common. "0a3c147880e843e008d1c1d0425541dd1a41c685" and "5209534d7a320ef834f4bbe789c4fe1b9ea701cf" have entirely different histories.

19 changed files with 223 additions and 4288 deletions

View File

@ -1,10 +0,0 @@
--- 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,17 +227,6 @@ 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
}

View File

@ -1,353 +0,0 @@
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

@ -31,50 +31,36 @@ eventRoutes.post("/api/events/:id/rotate-pin", async (c) => {
const eventId = c.req.param("id");
// Generate new PIN
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
try {
const event = await sqlWrapper.sql`
SELECT slug, name FROM event_passes
const eventResult = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${newPinCode}
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
AND is_active = TRUE
RETURNING pin_code
`;
if (!event || event.length === 0) {
if (!eventResult || eventResult.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
// Generate new PIN
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
// Generate new Slug
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
const newSuffix = Math.random().toString(36).substring(2, 6);
const newSlug = `${baseSlug}-${newSuffix}`;
const updateResult = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${newPinCode}, slug = ${newSlug}
WHERE id = ${eventId}
RETURNING pin_code, slug
`;
auditWrapper.auditLog(
user.userId,
"event_ingress_rotated",
"event_pin_rotated",
eventId,
{},
getClientIp(c),
);
return c.json({
success: true,
pinCode: updateResult[0].pin_code,
slug: updateResult[0].slug,
});
return c.json({ success: true, pinCode: eventResult[0].pin_code });
} catch (e: any) {
console.error("[Events] Failed to rotate event PIN:", e);
return c.json({ error: "Failed to rotate PIN" }, 500);
@ -134,7 +120,7 @@ eventRoutes.get("/api/events/:id/attendees", async (c) => {
try {
const event = await sqlWrapper.sql`
SELECT id, slug, max_seats, expires_at FROM event_passes
SELECT slug FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
`.then((res: any) => res[0]);
@ -143,22 +129,16 @@ 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, s.last_activity_at, s.last_activity_action, u.username, u.display_name
SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, u.username, u.display_name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE u.event_pass_id = ${eventId}
WHERE u.username LIKE ${likePattern}
ORDER BY s.created_at DESC
`;
return c.json({
success: true,
attendees,
event: {
max_seats: event.max_seats,
expires_at: event.expires_at,
},
});
return c.json({ success: true, attendees });
} catch (e: any) {
console.error("[Events] Failed to fetch attendees:", e);
return c.json({ error: "Failed to fetch attendees" }, 500);
@ -182,17 +162,19 @@ eventRoutes.post("/api/events/:id/end", async (c) => {
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)})
RETURNING id
RETURNING slug
`;
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 event_pass_id = ${eventId}
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
)
RETURNING id
`;
@ -224,11 +206,11 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
try {
const eventResult = await sqlWrapper.sql`
UPDATE event_passes
SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours'
SET expires_at = expires_at + interval '${extendHours} hours'
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)}) AND is_active = TRUE
RETURNING id, expires_at
RETURNING slug, expires_at
`;
if (!eventResult || eventResult.length === 0) {
@ -238,13 +220,14 @@ 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 event_pass_id = ${eventId}
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
)
RETURNING id
`;
@ -420,14 +403,13 @@ eventRoutes.post("/api/join", async (c) => {
const updatedEvent = updateResult[0];
const guestUuid = crypto.randomUUID();
const eventShortId = String(updatedEvent.id).split("-")[0];
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${
updatedEvent.name + " Attendee"
}, 'guest', ${updatedEvent.id})
}, 'guest')
ON CONFLICT DO NOTHING
`;
@ -536,14 +518,11 @@ eventRoutes.get("/join/:slug", async (c) => {
const event = result[0];
const guestUuid = crypto.randomUUID();
const eventShortId = String(event.id).split("-")[0];
const username = `guest_${eventShortId}_${event.seats_claimed}`;
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
VALUES (${guestUuid}, ${username}, ${
event.name + " Attendee"
}, 'guest', ${event.id})
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
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.event_pass_id = ep.id
AND u.username LIKE 'guest_' || ep.slug || '_%'
)
)
`.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_event_1");
assertEquals(json.username, "guest_deno-lab_1");
const cookies = res.headers.get("set-cookie");
assertExists(cookies);
@ -232,59 +232,6 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
},
);
await t.step(
"POST /api/events/:id/rotate-pin rotates both pin and slug",
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 originalSql = sqlWrapper.sql;
let updateEventCalled = false;
let newSlug = "";
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
const query = Array.isArray(strings)
? strings.join("?")
: String(strings);
if (query.includes("SELECT slug, name FROM event_passes")) {
return Promise.resolve([{ slug: "deno-lab-1a2b", name: "Deno Lab" }]);
}
if (query.includes("UPDATE event_passes") && query.includes("slug =")) {
updateEventCalled = true;
newSlug = _values[1]; // second bound parameter is the slug
return Promise.resolve([{ pin_code: _values[0], slug: newSlug }]);
}
return Promise.resolve([]);
}) as any;
try {
const res = await app.request("/api/events/evt-123/rotate-pin", {
method: "POST",
headers: {
Authorization: "Bearer admin-session",
},
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assert(updateEventCalled);
assert(json.slug.startsWith("deno-lab-"));
assert(json.slug !== "deno-lab-1a2b");
assertEquals(json.slug, newSlug);
} finally {
sqlWrapper.sql = originalSql;
valkeyGetStub.restore();
}
},
);
await t.step(
"POST /api/join enforces 5 failed attempts rate limit per IP",
async () => {
@ -385,7 +332,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_event_1");
assertEquals(json.username, "guest_deno-lab-workshop_1");
} finally {
sqlWrapper.sql = originalSql;
valkeySetexStub.restore();
@ -445,7 +392,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_event_2"'));
assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"'));
assert(auditCalled);
assertEquals(auditPayload.resource, "event-uuid-1");
@ -544,7 +491,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
: String(strings);
if (
query.includes("UPDATE event_passes") &&
query.includes("expires_at = GREATEST(expires_at, NOW()) + interval")
query.includes("expires_at = expires_at + interval")
) {
updateEventCalled = true;
return Promise.resolve([{
@ -592,75 +539,4 @@ 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

@ -1,67 +0,0 @@
# 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

@ -1,100 +0,0 @@
# 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`.

View File

@ -12,57 +12,28 @@
- `server/tests/events.test.ts`
- `ui/ui_scripts.test.ts`
- **Core Objective:** Implement Phase 6 Final Polish & Hardening:
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`,
`Delegate Session`, `Events`, `Sessions`).
2. Implement universal ingress credential rotation (rotates both 6-digit PIN
and event slug suffix simultaneously).
3. Fix expired event extend math bug using
`GREATEST(expires_at, NOW()) + interval`.
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with
inline copy pills and zero text/border collisions.
5. Implement standardized natural `ExpiryBadge` format
(`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time
telemetry in the Guest Drawer.
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with
high-contrast active states.
7. Reset drawer state machine on open and replace verbose/mock buttons with
clean `[ OK ]`.
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, `Delegate Session`, `Events`, `Sessions`).
2. Implement universal ingress credential rotation (rotates both 6-digit PIN and event slug suffix simultaneously).
3. Fix expired event extend math bug using `GREATEST(expires_at, NOW()) + interval`.
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with inline copy pills and zero text/border collisions.
5. Implement standardized natural `ExpiryBadge` format (`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time telemetry in the Guest Drawer.
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with high-contrast active states.
7. Reset drawer state machine on open and replace verbose/mock buttons with clean `[ OK ]`.
- **Dependencies:** None.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX.
All client interactions in `SessionsScript.tsx` must use native vanilla
JavaScript DOM APIs.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs.
---
## 2. Architectural Considerations & Risks
- **Risks:**
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating
event ingress credentials (`pin_code` and `slug`), existing active guest
sessions must remain valid and uninterrupted. Only subsequent
unauthenticated join attempts using the old PIN, old Direct Link, or old CLI
command must be rejected (404 / Invalid Code).
- **Expired Event Extending Edge Cases:** When an organizer extends an event
that expired in the past, `expires_at + interval '1 hour'` would leave the
expiry in the past. Using
`GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees
the new expiration time is set relative to the current moment.
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements
into a single unconstrained horizontal flex line, causing text collisions
and input boxes bleeding off the desktop screen. The new 2-Row Compact Card
must enforce strict CSS bounding
(`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
- **State Machine Staleness:** Reopening the delegation drawer must
unconditionally reset the form back to State 1 (clean creation form with
`Single Session` default tab) rather than leaving the stale success screen
visible.
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating event ingress credentials (`pin_code` and `slug`), existing active guest sessions must remain valid and uninterrupted. Only subsequent unauthenticated join attempts using the old PIN, old Direct Link, or old CLI command must be rejected (404 / Invalid Code).
- **Expired Event Extending Edge Cases:** When an organizer extends an event that expired in the past, `expires_at + interval '1 hour'` would leave the expiry in the past. Using `GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees the new expiration time is set relative to the current moment.
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements into a single unconstrained horizontal flex line, causing text collisions and input boxes bleeding off the desktop screen. The new 2-Row Compact Card must enforce strict CSS bounding (`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
- **State Machine Staleness:** Reopening the delegation drawer must unconditionally reset the form back to State 1 (clean creation form with `Single Session` default tab) rather than leaving the stale success screen visible.
- **Alternatives:**
- _Single-Line vs. 2-Row Compact Cards:_ Single-line cards inevitably drop
essential copy actions or clip text on viewports under 1200px. A structured
2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN,
Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all
layout collisions.
- *Single-Line vs. 2-Row Compact Cards:* Single-line cards inevitably drop essential copy actions or clip text on viewports under 1200px. A structured 2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN, Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all layout collisions.
---
@ -77,18 +48,15 @@
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
// Extract base slug prefix and generate fresh random 4-char suffix
const event = await sqlWrapper
.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
const event = await sqlWrapper.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
const newSuffix = Math.random().toString(36).substring(2, 6);
const newSlug = `${baseSlug}-${newSuffix}`;
```
- Update database:
`UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
- Update database: `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
- Audit log `event_ingress_rotated`.
- Return `{ success: true, pinCode: newPinCode, slug: newSlug }`.
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`)
authenticate via session cookies/Valkey tokens and are unaffected.
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`) authenticate via session cookies/Valkey tokens and are unaffected.
2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):**
- Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`:
@ -100,24 +68,18 @@
```
3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):**
- Ensure query returns
`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`.
- Ensure query returns `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`.
---
### Phase 2: Page Hierarchy, Nomenclature & Heading Cleanup (`SessionsPage.tsx`)
1. **Page Title:**
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with
subtitle _"Manage logins, mint 1:1 delegated tokens, or launch multi-claim
workshop events."_
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with subtitle *"Manage logins, mint 1:1 delegated tokens, or launch multi-claim workshop events."*
2. **Remove Duplicate Headings:**
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The
section header is exclusively rendered inside `EventCockpitDeck.tsx` as
`<h2>Events</h2>` alongside the view toggle.
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The section header is exclusively rendered inside `EventCockpitDeck.tsx` as `<h2>Events</h2>` alongside the view toggle.
3. **Sessions Section:**
- Retain `<h2>Sessions</h2>` heading with subtitle _"Direct device logins,
passkey authentications, and delegated agent tokens."_
- Retain `<h2>Sessions</h2>` heading with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."*
---
@ -126,46 +88,33 @@
1. **Section Header & High-Contrast View Toggle:**
- Header: `<h2>Events</h2>`.
- Toggle buttons (`[ 🗂️ Grid ]` and `[ 📋 Compact ]`):
- Active style:
`background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
- Inactive style:
`background: transparent; color: var(--text-muted); opacity: 0.75;`
- Active style: `background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
- Inactive style: `background: transparent; color: var(--text-muted); opacity: 0.75;`
- Include `aria-pressed="true/false"`.
2. **2-Row Compact Card Layout (`.compact-view .event-card`):**
- Container: Strictly bounded flex/grid (~70px height),
`box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
- Container: Strictly bounded flex/grid (~70px height), `box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
- **Row 1 (Metadata Header):**
- Left: Event title with ellipsis
(`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot
`·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
- Left: Event title with ellipsis (`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot `·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
- **Row 2 (1-Click Handoffs & Action Pinned Right):**
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`,
`[ CLI (Copy) ]` using compact inline pills
(`padding: 2px 6px; font-size: 0.75rem;`).
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or
`[ 🔄 Reopen (+1h) ]` if expired).
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its
`alert(...)` placeholder.
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`, `[ CLI (Copy) ]` using compact inline pills (`padding: 2px 6px; font-size: 0.75rem;`).
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or `[ 🔄 Reopen (+1h) ]` if expired).
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its `alert(...)` placeholder.
3. **Grid Card Polish:**
- Replace detached CLI `<details>` box with a unified **Integrated Expanding
CLI Component**:
- Replace detached CLI `<details>` box with a unified **Integrated Expanding CLI Component**:
- Single-line snippet when collapsed with `[ Copy ]` and `[ ▾ ]` toggle.
- Expands downward into multiline highlighted command block on toggle
click.
- Expands downward into multiline highlighted command block on toggle click.
- Fix double arrow marker bug (`list-style: none;`).
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field
rotation.
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field rotation.
---
### Phase 4: Standardized Natural Expiry & Start-Time Telemetry (`SessionsScript.tsx` & Drawers)
1. **Natural Expiry Formatter Helper (`formatNaturalExpiry(expiresAt)`):**
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D`
(if same year), `MMM D, YYYY` (if different year).
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D` (if same year), `MMM D, YYYY` (if different year).
- Exact Time: `h:mm A` (e.g. `10:39 PM`).
- Scaled Urgency Badge:
- $<1$h: Amber `[ 45m left ]`
@ -177,17 +126,11 @@
- Tooltip: `title="${fullISODate}"` on hover/long-press.
- Format: `[Date] · [Time] · [ Colored Urgency Badge ]`.
2. **Inverted Symmetrical Start-Time Formatter
(`formatNaturalJoinTime(createdAt)`):**
2. **Inverted Symmetrical Start-Time Formatter (`formatNaturalJoinTime(createdAt)`):**
- In `EventGuestsDrawer.tsx`, render:
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge +
`[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
- **Line 1:** `Joined Today · 2:15 PM ·`
<span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active
7h 54m</span>.
- **Line 2:**
`Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web`
(or `📟 CLI`).
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge + `[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
- **Line 1:** `Joined Today · 2:15 PM · ` <span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active 7h 54m</span>.
- **Line 2:** `Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web` (or `📟 CLI`).
- Remove redundant `guest_slug_seat` visual text (keep in tooltip only).
---
@ -195,27 +138,22 @@
### Phase 5: Delegation Drawer Overhaul (`WorkshopDrawer.tsx` & `SessionsScript.tsx`)
1. **Drawer Nomenclature & WAI-ARIA High-Contrast Tabs:**
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle _"Mint a 1:1
delegated token or launch a multi-seat workshop event."_
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle *"Mint a 1:1 delegated token or launch a multi-seat workshop event."*
- Tabs (`role="tablist"`):
- **Tab 1 (`role="tab"`):** `Single Session`
- Subtitle hint: _For agents, CI/CD, or 1:1 delegation_
- Subtitle hint: *For agents, CI/CD, or 1:1 delegation*
- **Tab 2 (`role="tab"`):** `Multi-Claim Event`
- Subtitle hint: _For workshops, teams & guest pools_
- High-Contrast Active State: Solid `var(--primary)` background with white
text (`#ffffff`), `aria-selected="true"`.
- Inactive State: Translucent muted background (`opacity: 0.75`),
`aria-selected="false"`.
- Subtitle hint: *For workshops, teams & guest pools*
- High-Contrast Active State: Solid `var(--primary)` background with white text (`#ffffff`), `aria-selected="true"`.
- Inactive State: Translucent muted background (`opacity: 0.75`), `aria-selected="false"`.
2. **Handoff State & Button Minimalist Polish:**
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`,
`Copy URL`, `Copy 1-Liner`).
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`, `Copy URL`, `Copy 1-Liner`).
- Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`**.
3. **State Machine Reset on Open:**
- In `SessionsScript.tsx:openDelegateDrawer()`:
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to
`display: none`.
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to `display: none`.
- Clear form inputs (`eventName`, `eventSlug`, `eventPinCode`, etc.).
- Reset tab selection to `Single Session`.
@ -224,7 +162,6 @@
## 4. Verification Plan
### Automated Tests
1. **Universal Credential Rotation Test (`server/tests/events.test.ts`):**
- Create event pass -> Call `POST /api/events/:id/rotate-pin`.
- Verify response returns new `pinCode` AND new `slug`.
@ -236,13 +173,11 @@
- Call `POST /api/events/:id/extend` with `extendHours: 1`.
- Verify `expires_at > NOW()` (approximately `NOW() + 1 hour`).
3. **UI Script & Formatter Tests (`ui/ui_scripts.test.ts`):**
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d,
142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d, 142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
- Test `formatNaturalJoinTime` inverted duration math.
- Test drawer state machine reset logic.
### Quality Gate Commands
```bash
deno fmt --check
deno task lint

View File

@ -1,55 +0,0 @@
# 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,14 +34,14 @@ export const SessionsPage = ({
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<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 style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
Sessions & Events
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Sessions & Passes
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage logins, mint 1:1 delegated tokens, or launch multi-claim
workshop events.
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
multi-claim workshop event passes.
</p>
</div>
@ -57,6 +57,9 @@ export const SessionsPage = ({
</div>
</div>
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
Event Passes
</h2>
<EventCockpitDeck eventPasses={eventPasses} />
{/* Delegate Session Drawer */}
@ -68,12 +71,12 @@ export const SessionsPage = ({
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2
<h3
id="delegateDrawerTitle"
style="margin: 0 0 0.25rem 0; color: var(--text-primary); font-size: 1.25rem;"
style="margin: 0 0 0.25rem 0; color: var(--text-primary);"
>
Delegate Session
</h2>
Create New Pass / Session
</h3>
<button
type="button"
onclick="closeDelegateDrawer()"
@ -82,45 +85,23 @@ export const SessionsPage = ({
&times;
</button>
</div>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.9rem;">
Mint a 1:1 delegated token or launch a multi-seat workshop event.
</p>
<div
role="tablist"
style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;"
>
<div style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;">
<button
type="button"
role="tab"
aria-selected="true"
id="tabBtnDirectPass"
class="delegate-tab-btn active"
onclick="switchDelegateTab('tabDirectPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
<span style="font-size: 0.95rem; font-weight: 700;">
Single Session
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For agents, CI/CD, or 1:1 delegation
</span>
🔑 1:1 Direct Pass
</button>
<button
type="button"
role="tab"
aria-selected="false"
id="tabBtnWorkshopPass"
class="delegate-tab-btn"
onclick="switchDelegateTab('tabWorkshopPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
<span style="font-size: 0.95rem; font-weight: 700;">
Multi-Claim Event
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For workshops, teams & guest pools
</span>
🎟 Multi-Claim Workshop Pass
</button>
</div>
</div>
@ -194,13 +175,11 @@ export const SessionsPage = ({
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
opacity: 0.75;
}
.delegate-tab-btn.active {
background: var(--primary);
color: #ffffff;
background: var(--surface-card);
color: var(--primary);
box-shadow: var(--shadow-xs);
opacity: 1;
}
`}
</style>

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);">
Events
Event Passes
</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
@ -14,7 +14,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
class="view-mode-btn active"
onclick="setEventViewMode('grid')"
aria-label="Grid View"
aria-pressed="true"
>
🗂 Grid
</button>
@ -24,7 +23,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
class="view-mode-btn"
onclick="setEventViewMode('compact')"
aria-label="Compact View"
aria-pressed="false"
>
📋 Compact
</button>
@ -113,15 +111,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
Link:
</span>
<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;"
>
<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;">
/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;"
@ -131,38 +125,31 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</button>
</div>
<details
class="cli-details"
style="margin-top: 0.1rem; width: 100%;"
>
<summary style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
CLI:
</span>
<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;"
onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>
Copy
</button>
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
[ ]
</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<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;">
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"
class="btn-outline"
aria-label={`Copy CLI command for ${event.name}`}
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>
Copy
</button>
</div>
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
Expand full CLI command
</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;"
@ -205,11 +192,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<button
type="button"
class="btn-outline"
aria-label={`Rotate Credentials for ${event.name}`}
aria-label={`Rotate PIN code for ${event.name}`}
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
onclick={`rotatePin('${event.id}')`}
>
🔄 Rotate Credentials
🔄 Rotate PIN
</button>
<button
type="button"
@ -233,52 +220,25 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div>
{/* Compact Actions (only visible in compact mode) */}
<div class="event-card-compact-row-2">
<div class="compact-pills">
<span
class="compact-pill"
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
>
PIN:{" "}
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
{" "}
(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")`}
>
CLI (Copy)
</span>
</div>
<div class="compact-actions-right">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'")
}')`}
>
👥 Guests ({event.seats_claimed})
</button>
<button
type="button"
class="btn-outline extend-event-btn"
data-event-id={event.id}
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
>
🔄 +1h
</button>
</div>
<div class="event-card-compact-actions">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'")
}')`}
>
👥 Guests ({event.seats_claimed})
</button>
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick="alert('Expand functionality for compact view to be implemented if needed')"
>
Details
</button>
</div>
</div>
);
@ -286,13 +246,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div>
<style>
{`
.cli-details summary {
list-style: none;
}
.cli-details summary::-webkit-details-marker {
display: none;
}
.view-mode-btn {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
@ -303,15 +256,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
opacity: 0.75;
}
.view-mode-btn.active {
background: var(--primary);
color: #ffffff;
font-weight: 700;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-radius: var(--radius-sm);
opacity: 1;
background: var(--surface-card);
color: var(--primary);
box-shadow: var(--shadow-xs);
}
/* Grid View Layout */
@ -326,7 +275,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
align-items: flex-start;
margin-bottom: 0.75rem;
}
.grid-view .event-card-compact-row-2 {
.grid-view .event-card-compact-actions {
display: none;
}
@ -338,14 +287,10 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
}
.compact-view .event-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
min-height: 70px;
gap: 1rem;
}
.compact-view .event-card > div {
margin-bottom: 0 !important; /* Reset component margins */
@ -353,68 +298,34 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
.compact-view .event-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
margin-bottom: 0.5rem !important;
}
.compact-view .event-card-header > div {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
flex: 1;
min-width: 0;
}
.compact-view .event-card-header h3 {
margin: 0 !important;
font-size: 1rem !important;
font-weight: 700;
color: var(--text-primary);
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.compact-view .event-card-header > .status-badge {
margin-left: auto;
}
.compact-view .event-card-compact-row-2 {
.compact-view .event-card-header > div {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 1rem;
}
.compact-view .compact-pills {
.compact-view .event-card-snippets,
.compact-view .event-card-actions,
.compact-view .status-badge {
display: none; /* Hide heavy elements in compact */
}
.compact-view .event-card-compact-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.compact-view .compact-pill {
padding: 2px 6px;
font-size: 0.75rem;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-family: monospace;
}
.compact-view .compact-pill:hover {
border-color: var(--primary);
color: var(--primary);
}
.compact-view .compact-actions-right {
display: flex;
gap: 0.5rem;
align-items: center;
}
.compact-view .event-card-snippets,
.compact-view .event-card-actions {
display: none !important; /* Hide heavy elements in compact */
.compact-view .countdown-pill {
font-size: 0.75rem;
}
/* Dynamic Countdown Colors */

View File

@ -32,7 +32,8 @@ export const EventGuestsDrawer = () => {
>
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
<span id="guestDrawerCountdown"> 0h 0m left</span>
<span id="guestDrawerCountdown"> 0h 0m left</span> · (Expires{" "}
<span id="guestDrawerExpiresAt">Time</span>)
</div>
</div>

View File

@ -16,47 +16,34 @@ export const SessionsScript = () => {
}
function openDelegateDrawer() {
const drawer = document.getElementById('delegateDrawer');
drawer.style.display = 'block';
drawer.scrollIntoView({ behavior: 'smooth' });
document.getElementById('delegateDrawer').style.display = 'block';
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
}
// Reset WorkshopDrawer 2-State Machine on open
function closeDelegateDrawer() {
document.getElementById('delegateDrawer').style.display = 'none';
// Reset WorkshopDrawer 2-State Machine
const eventCreateState = document.getElementById('eventCreateState');
if (eventCreateState) eventCreateState.style.display = 'block';
const eventHandoffState = document.getElementById('eventHandoffState');
if (eventHandoffState) eventHandoffState.style.display = 'none';
const eventForm = document.getElementById('eventForm');
if (eventForm) eventForm.reset();
// Default back to Single Session Tab
switchDelegateTab('tabDirectPass');
}
function closeDelegateDrawer() {
document.getElementById('delegateDrawer').style.display = 'none';
const drawerTitle = document.getElementById('delegateDrawerTitle');
if (drawerTitle) drawerTitle.textContent = 'Create New Pass / Session';
}
function switchDelegateTab(tabId) {
document.getElementById('tabDirectPass').style.display = 'none';
document.getElementById('tabWorkshopPass').style.display = 'none';
const btnDirect = document.getElementById('tabBtnDirectPass');
const btnWorkshop = document.getElementById('tabBtnWorkshopPass');
btnDirect.classList.remove('active');
btnDirect.setAttribute('aria-selected', 'false');
btnWorkshop.classList.remove('active');
btnWorkshop.setAttribute('aria-selected', 'false');
document.getElementById('tabBtnDirectPass').classList.remove('active');
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
document.getElementById(tabId).style.display = 'block';
if (tabId === 'tabDirectPass') {
btnDirect.classList.add('active');
btnDirect.setAttribute('aria-selected', 'true');
document.getElementById('tabBtnDirectPass').classList.add('active');
} else {
btnWorkshop.classList.add('active');
btnWorkshop.setAttribute('aria-selected', 'true');
document.getElementById('tabBtnWorkshopPass').classList.add('active');
}
}
@ -179,6 +166,7 @@ export const SessionsScript = () => {
document.getElementById('eventCreateState').style.display = 'none';
document.getElementById('eventHandoffState').style.display = 'block';
document.getElementById('delegateDrawerTitle').textContent = 'Event Pass Active';
showNotice('Workshop pass created: ' + ev.name, false);
} else {
@ -297,69 +285,12 @@ export const SessionsScript = () => {
if (pinElem) {
pinElem.textContent = data.pinCode;
}
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);
showNotice('Event PIN rotated successfully', false);
} else {
showNotice(data.error || 'Failed to rotate credentials', true);
showNotice(data.error || 'Failed to rotate PIN', true);
}
} catch (err) {
showNotice('Network error rotating credentials', true);
showNotice('Network error rotating PIN', true);
}
}
@ -457,18 +388,12 @@ export const SessionsScript = () => {
const usernameParts = att.username.split('_');
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
const joinText = formatNaturalJoinTime(att.created_at, !isPaused);
const lastAction = att.last_activity_action || 'ForwardAuth Ingress';
let timeAgo = 'just now';
if (att.last_activity_at) {
const lastActDate = new Date(att.last_activity_at);
const now = new Date();
const diffMinsAct = Math.floor((now - lastActDate) / 60000);
timeAgo = diffMinsAct < 1 ? 'just now' : diffMinsAct + 'm ago';
}
const clientIcon = lastAction.includes('CLI') ? '📟 CLI' : '💻 Web';
// Compute relative join time (approximate based on created_at)
const createdDate = new Date(att.created_at);
const now = new Date();
const diffMs = now - createdDate;
const diffMins = Math.floor(diffMs / 60000);
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
html += \`
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
@ -480,10 +405,7 @@ export const SessionsScript = () => {
</div>
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
\${joinText}
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem;">
Last Action: \${lastAction} &middot; \${timeAgo} &middot; \${clientIcon}
\${joinText} · \${att.username}
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
@ -662,117 +584,49 @@ export const SessionsScript = () => {
}
}
// Standardized formatters
function formatNaturalExpiry(expiresAt) {
const expDate = new Date(expiresAt);
const now = new Date();
const diffMs = expDate.getTime() - now.getTime();
const timeStr = expDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const fullISO = expDate.toISOString();
let dateStr = '';
const isToday = expDate.getDate() === now.getDate() && expDate.getMonth() === now.getMonth() && expDate.getFullYear() === now.getFullYear();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = expDate.getDate() === tomorrow.getDate() && expDate.getMonth() === tomorrow.getMonth() && expDate.getFullYear() === tomorrow.getFullYear();
if (isToday) {
dateStr = 'Today';
} else if (isTomorrow) {
dateStr = 'Tomorrow';
} else if (expDate.getFullYear() === now.getFullYear()) {
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
} else {
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
let badge = '';
if (diffMs <= 0) {
badge = '<span class="status-badge-red" title="' + fullISO + '" style="background:rgba(239,68,68,0.15);color:#dc2626;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Expired</span>';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = totalMins / 60;
const days = hours / 24;
const months = days / 30;
const years = days / 365;
let timeRemainingStr = '';
let badgeStyle = 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // green
if (hours < 1) {
timeRemainingStr = totalMins + 'm left';
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
} else if (hours < 24) {
const h = Math.floor(hours);
const m = totalMins % 60;
timeRemainingStr = h + 'h ' + m + 'm left';
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
} else if (days <= 60) {
timeRemainingStr = Math.floor(days) + 'd left';
} else if (months <= 12) {
timeRemainingStr = months.toFixed(1) + ' mos left';
} else {
timeRemainingStr = years.toFixed(1) + ' yrs left';
}
badge = '<span style="' + badgeStyle + '" title="' + fullISO + '">' + timeRemainingStr + '</span>';
}
return dateStr + ' &middot; ' + timeStr + ' &middot; ' + badge;
}
function formatNaturalJoinTime(createdAt, isActive) {
const createdDate = new Date(createdAt);
const now = new Date();
const diffMs = now.getTime() - createdDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const timeStr = createdDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const isToday = createdDate.getDate() === now.getDate() && createdDate.getMonth() === now.getMonth() && createdDate.getFullYear() === now.getFullYear();
const dateStr = isToday ? 'Today' : createdDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
let durationStr = '';
if (diffMins < 60) {
durationStr = diffMins + 'm';
} else {
const h = Math.floor(diffMins / 60);
const m = diffMins % 60;
durationStr = h + 'h ' + m + 'm';
}
const badgeStyle = isActive ? 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;' : 'background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;';
const badgeText = isActive ? 'Active ' + durationStr : 'Paused';
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText + '</span>';
return 'Joined ' + dateStr + ' &middot; ' + timeStr + ' &middot; ' + activeBadge;
}
// Dynamic Countdown Updater
function updateAllCountdowns() {
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
const now = new Date();
pills.forEach(pill => {
const expiresAtStr = pill.getAttribute('data-expires-at');
if (!expiresAtStr) return;
if (pill.id === 'guestDrawerCountdown') {
const now = new Date();
const expDate = new Date(expiresAtStr);
const diffMs = expDate - now;
if (diffMs <= 0) {
pill.textContent = '⏳ Expired';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
pill.textContent = \`\${hours}h \${mins}m left\`;
}
const expDate = new Date(expiresAtStr);
const diffMs = expDate - now;
pill.classList.remove('status-green', 'status-amber', 'status-red');
if (diffMs <= 0) {
pill.textContent = '⏳ Expired';
pill.classList.add('status-red');
} else {
pill.innerHTML = formatNaturalExpiry(expiresAtStr);
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
let text = '';
if (hours > 72) { // more than 3 days
const days = Math.floor(hours / 24);
text = \`\${days}d left\`;
} else {
text = \`\${hours}h \${mins}m left\`;
}
// Add absolute time suffix if it's a standard pill (not the drawer context header which has it separate)
if (pill.id !== 'guestDrawerCountdown') {
const timeString = expDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
text += \` · (Expires \${timeString})\`;
}
pill.textContent = text;
if (hours < 1) {
pill.classList.add('status-amber');
} else {
pill.classList.add('status-green');
}
}
});
}

View File

@ -241,7 +241,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
</code>
<button
type="button"
class="btn-outline"
class="btn-primary"
aria-label="Copy Universal PIN"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyEventHandoff('pin')"
@ -304,7 +304,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
onclick="closeEventHandoffModal()"
>
OK
Dismiss
</button>
</div>
</div>

View File

@ -1,118 +0,0 @@
export function formatNaturalExpiry(expiresAt: string | Date): string {
const expDate = new Date(expiresAt);
const now = new Date();
const diffMs = expDate.getTime() - now.getTime();
const timeStr = expDate.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const fullISO = expDate.toISOString();
let dateStr = "";
const isToday = expDate.getDate() === now.getDate() &&
expDate.getMonth() === now.getMonth() &&
expDate.getFullYear() === now.getFullYear();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = expDate.getDate() === tomorrow.getDate() &&
expDate.getMonth() === tomorrow.getMonth() &&
expDate.getFullYear() === tomorrow.getFullYear();
if (isToday) {
dateStr = "Today";
} else if (isTomorrow) {
dateStr = "Tomorrow";
} else if (expDate.getFullYear() === now.getFullYear()) {
dateStr = expDate.toLocaleDateString([], {
month: "short",
day: "numeric",
});
} else {
dateStr = expDate.toLocaleDateString([], {
month: "short",
day: "numeric",
year: "numeric",
});
}
let badge = "";
if (diffMs <= 0) {
badge = '<span class="status-badge-red" title="' + fullISO +
'">Expired</span>';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = totalMins / 60;
const days = hours / 24;
const months = days / 30;
const years = days / 365;
let timeRemainingStr = "";
let badgeClass = "status-badge-green";
if (hours < 1) {
timeRemainingStr = totalMins + "m left";
badgeClass = "status-badge-amber";
} else if (hours < 24) {
const h = Math.floor(hours);
const m = totalMins % 60;
timeRemainingStr = h + "h " + m + "m left";
badgeClass = "status-badge-amber";
} else if (days <= 60) {
timeRemainingStr = Math.floor(days) + "d left";
} else if (months <= 12) {
timeRemainingStr = months.toFixed(1) + " mos left";
} else {
timeRemainingStr = years.toFixed(1) + " yrs left";
}
badge = '<span class="' + badgeClass + '" title="' + fullISO + '">' +
timeRemainingStr + "</span>";
}
return dateStr + " &middot; " + timeStr + " &middot; " + badge;
}
export function formatNaturalJoinTime(
createdAt: string | Date,
isActive: boolean = true,
): string {
const createdDate = new Date(createdAt);
const now = new Date();
const diffMs = now.getTime() - createdDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const timeStr = createdDate.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const isToday = createdDate.getDate() === now.getDate() &&
createdDate.getMonth() === now.getMonth() &&
createdDate.getFullYear() === now.getFullYear();
const dateStr = isToday
? "Today"
: createdDate.toLocaleDateString([], { month: "short", day: "numeric" });
let durationStr = "";
if (diffMins < 60) {
durationStr = diffMins + "m";
} else {
const h = Math.floor(diffMins / 60);
const m = diffMins % 60;
durationStr = h + "h " + m + "m";
}
const badgeStyle = isActive
? "background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;"
: "background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
const badgeText = isActive ? "Active " + durationStr : "Paused";
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText +
"</span>";
return "Joined " + dateStr + " &middot; " + timeStr + " &middot; " +
activeBadge;
}

View File

@ -1,77 +1,4 @@
import { assertEquals } from "jsr:@std/assert@1";
import {
formatNaturalExpiry,
formatNaturalJoinTime,
} from "./components/sessions/formatters.ts";
Deno.test("formatNaturalExpiry - < 1h", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 45 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("45m left") || result.includes("44m left"),
true,
);
assertEquals(result.includes("status-badge-amber"), true);
});
Deno.test("formatNaturalExpiry - < 24h", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 165 * 60000); // 2h 45m
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("2h 45m left") || result.includes("2h 44m left"),
true,
);
assertEquals(result.includes("status-badge-amber"), true);
});
Deno.test("formatNaturalExpiry - 1-60d", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 28 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("28d left") || result.includes("27d left"),
true,
);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - 2-12mos", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 142 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(result.includes("4.7 mos left"), true);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - > 1yr", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 420 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("1.2 yrs left") || result.includes("1.1 yrs left"),
true,
);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - expired", () => {
const now = new Date();
const expiry = new Date(now.getTime() - 45 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(result.includes("Expired"), true);
assertEquals(result.includes("status-badge-red"), true);
});
Deno.test("formatNaturalJoinTime", () => {
const now = new Date();
const joinTime = new Date(now.getTime() - 474 * 60000); // 7h 54m ago
const result = formatNaturalJoinTime(joinTime);
assertEquals(result.includes("7h 54m"), true);
assertEquals(result.includes("Active"), true);
});
import { SessionsPage } from "./components/SessionsPage.tsx";
import { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";