Compare commits
No commits in common. "0a3c147880e843e008d1c1d0425541dd1a41c685" and "5209534d7a320ef834f4bbe789c4fe1b9ea701cf" have entirely different histories.
0a3c147880
...
5209534d7a
@ -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
11
server/db.ts
11
server/db.ts
@ -227,17 +227,6 @@ export async function initDb(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
|
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 {
|
} catch {
|
||||||
// Ignore migration column exists
|
// Ignore migration column exists
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -31,50 +31,36 @@ eventRoutes.post("/api/events/:id/rotate-pin", async (c) => {
|
|||||||
|
|
||||||
const eventId = c.req.param("id");
|
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 {
|
try {
|
||||||
const event = await sqlWrapper.sql`
|
const eventResult = await sqlWrapper.sql`
|
||||||
SELECT slug, name FROM event_passes
|
UPDATE event_passes
|
||||||
|
SET pin_code = ${newPinCode}
|
||||||
WHERE id = ${eventId}
|
WHERE id = ${eventId}
|
||||||
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
|
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
|
||||||
AND is_active = TRUE
|
AND is_active = TRUE
|
||||||
|
RETURNING pin_code
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!event || event.length === 0) {
|
if (!eventResult || eventResult.length === 0) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Event not found, inactive, or unauthorized" },
|
{ error: "Event not found, inactive, or unauthorized" },
|
||||||
404,
|
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(
|
auditWrapper.auditLog(
|
||||||
user.userId,
|
user.userId,
|
||||||
"event_ingress_rotated",
|
"event_pin_rotated",
|
||||||
eventId,
|
eventId,
|
||||||
{},
|
{},
|
||||||
getClientIp(c),
|
getClientIp(c),
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({ success: true, pinCode: eventResult[0].pin_code });
|
||||||
success: true,
|
|
||||||
pinCode: updateResult[0].pin_code,
|
|
||||||
slug: updateResult[0].slug,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("[Events] Failed to rotate event PIN:", e);
|
console.error("[Events] Failed to rotate event PIN:", e);
|
||||||
return c.json({ error: "Failed to rotate PIN" }, 500);
|
return c.json({ error: "Failed to rotate PIN" }, 500);
|
||||||
@ -134,7 +120,7 @@ eventRoutes.get("/api/events/:id/attendees", async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await sqlWrapper.sql`
|
const event = await sqlWrapper.sql`
|
||||||
SELECT id, slug, max_seats, expires_at FROM event_passes
|
SELECT slug FROM event_passes
|
||||||
WHERE id = ${eventId}
|
WHERE id = ${eventId}
|
||||||
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
|
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
|
||||||
`.then((res: any) => res[0]);
|
`.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);
|
return c.json({ error: "Event not found or unauthorized" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const likePattern = `guest_${event.slug}_%`;
|
||||||
const attendees = await sqlWrapper.sql`
|
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
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
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
|
ORDER BY s.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return c.json({
|
return c.json({ success: true, attendees });
|
||||||
success: true,
|
|
||||||
attendees,
|
|
||||||
event: {
|
|
||||||
max_seats: event.max_seats,
|
|
||||||
expires_at: event.expires_at,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("[Events] Failed to fetch attendees:", e);
|
console.error("[Events] Failed to fetch attendees:", e);
|
||||||
return c.json({ error: "Failed to fetch attendees" }, 500);
|
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(
|
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
|
||||||
user.userId,
|
user.userId,
|
||||||
)})
|
)})
|
||||||
RETURNING id
|
RETURNING slug
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!eventResult || eventResult.length === 0) {
|
if (!eventResult || eventResult.length === 0) {
|
||||||
return c.json({ error: "Event not found or unauthorized" }, 404);
|
return c.json({ error: "Event not found or unauthorized" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slug = eventResult[0].slug;
|
||||||
|
|
||||||
const sessionResult = await sqlWrapper.sql`
|
const sessionResult = await sqlWrapper.sql`
|
||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE user_id IN (
|
WHERE user_id IN (
|
||||||
SELECT id FROM users WHERE event_pass_id = ${eventId}
|
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
@ -224,11 +206,11 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const eventResult = await sqlWrapper.sql`
|
const eventResult = await sqlWrapper.sql`
|
||||||
UPDATE event_passes
|
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(
|
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
|
||||||
user.userId,
|
user.userId,
|
||||||
)}) AND is_active = TRUE
|
)}) AND is_active = TRUE
|
||||||
RETURNING id, expires_at
|
RETURNING slug, expires_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!eventResult || eventResult.length === 0) {
|
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 newExpiresAt = new Date(eventResult[0].expires_at);
|
||||||
|
|
||||||
const sessionResult = await sqlWrapper.sql`
|
const sessionResult = await sqlWrapper.sql`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET expires_at = expires_at + interval '${extendHours} hours'
|
SET expires_at = expires_at + interval '${extendHours} hours'
|
||||||
WHERE user_id IN (
|
WHERE user_id IN (
|
||||||
SELECT id FROM users WHERE event_pass_id = ${eventId}
|
SELECT id FROM users WHERE username LIKE ${"guest_" + slug + "_%"}
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
@ -420,14 +403,13 @@ eventRoutes.post("/api/join", async (c) => {
|
|||||||
|
|
||||||
const updatedEvent = updateResult[0];
|
const updatedEvent = updateResult[0];
|
||||||
const guestUuid = crypto.randomUUID();
|
const guestUuid = crypto.randomUUID();
|
||||||
const eventShortId = String(updatedEvent.id).split("-")[0];
|
const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
|
||||||
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
|
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
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}, ${
|
VALUES (${guestUuid}, ${username}, ${
|
||||||
updatedEvent.name + " Attendee"
|
updatedEvent.name + " Attendee"
|
||||||
}, 'guest', ${updatedEvent.id})
|
}, 'guest')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -536,14 +518,11 @@ eventRoutes.get("/join/:slug", async (c) => {
|
|||||||
|
|
||||||
const event = result[0];
|
const event = result[0];
|
||||||
const guestUuid = crypto.randomUUID();
|
const guestUuid = crypto.randomUUID();
|
||||||
const eventShortId = String(event.id).split("-")[0];
|
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
||||||
const username = `guest_${eventShortId}_${event.seats_claimed}`;
|
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
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}, ${
|
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
||||||
event.name + " Attendee"
|
|
||||||
}, 'guest', ${event.id})
|
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -309,7 +309,7 @@ sessionRoutes.delete("/api/sessions/:id", async (c) => {
|
|||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM event_passes ep
|
SELECT 1 FROM event_passes ep
|
||||||
WHERE ep.created_by = ${auth.userId}
|
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]);
|
`.then((res: any) => res[0]);
|
||||||
|
|||||||
@ -140,7 +140,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
assert(json.success === true);
|
assert(json.success === true);
|
||||||
assert(json.token.startsWith("ay_sess_"));
|
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");
|
const cookies = res.headers.get("set-cookie");
|
||||||
assertExists(cookies);
|
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(
|
await t.step(
|
||||||
"POST /api/join enforces 5 failed attempts rate limit per IP",
|
"POST /api/join enforces 5 failed attempts rate limit per IP",
|
||||||
async () => {
|
async () => {
|
||||||
@ -385,7 +332,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
assertEquals(res.status, 200);
|
assertEquals(res.status, 200);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
assert(json.success === true);
|
assert(json.success === true);
|
||||||
assertEquals(json.username, "guest_event_1");
|
assertEquals(json.username, "guest_deno-lab-workshop_1");
|
||||||
} finally {
|
} finally {
|
||||||
sqlWrapper.sql = originalSql;
|
sqlWrapper.sql = originalSql;
|
||||||
valkeySetexStub.restore();
|
valkeySetexStub.restore();
|
||||||
@ -445,7 +392,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
assertEquals(res.status, 200);
|
assertEquals(res.status, 200);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
assert(text.includes('export AUTH_YES_TOKEN="ay_sess_'));
|
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);
|
assert(auditCalled);
|
||||||
assertEquals(auditPayload.resource, "event-uuid-1");
|
assertEquals(auditPayload.resource, "event-uuid-1");
|
||||||
@ -544,7 +491,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
: String(strings);
|
: String(strings);
|
||||||
if (
|
if (
|
||||||
query.includes("UPDATE event_passes") &&
|
query.includes("UPDATE event_passes") &&
|
||||||
query.includes("expires_at = GREATEST(expires_at, NOW()) + interval")
|
query.includes("expires_at = expires_at + interval")
|
||||||
) {
|
) {
|
||||||
updateEventCalled = true;
|
updateEventCalled = true;
|
||||||
return Promise.resolve([{
|
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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -12,57 +12,28 @@
|
|||||||
- `server/tests/events.test.ts`
|
- `server/tests/events.test.ts`
|
||||||
- `ui/ui_scripts.test.ts`
|
- `ui/ui_scripts.test.ts`
|
||||||
- **Core Objective:** Implement Phase 6 Final Polish & Hardening:
|
- **Core Objective:** Implement Phase 6 Final Polish & Hardening:
|
||||||
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`,
|
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, `Delegate Session`, `Events`, `Sessions`).
|
||||||
`Delegate Session`, `Events`, `Sessions`).
|
2. Implement universal ingress credential rotation (rotates both 6-digit PIN and event slug suffix simultaneously).
|
||||||
2. Implement universal ingress credential rotation (rotates both 6-digit PIN
|
3. Fix expired event extend math bug using `GREATEST(expires_at, NOW()) + interval`.
|
||||||
and event slug suffix simultaneously).
|
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with inline copy pills and zero text/border collisions.
|
||||||
3. Fix expired event extend math bug using
|
5. Implement standardized natural `ExpiryBadge` format (`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time telemetry in the Guest Drawer.
|
||||||
`GREATEST(expires_at, NOW()) + interval`.
|
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with high-contrast active states.
|
||||||
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with
|
7. Reset drawer state machine on open and replace verbose/mock buttons with clean `[ OK ]`.
|
||||||
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.
|
- **Dependencies:** None.
|
||||||
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX.
|
- **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.
|
||||||
All client interactions in `SessionsScript.tsx` must use native vanilla
|
|
||||||
JavaScript DOM APIs.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Architectural Considerations & Risks
|
## 2. Architectural Considerations & Risks
|
||||||
|
|
||||||
- **Risks:**
|
- **Risks:**
|
||||||
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating
|
- **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).
|
||||||
event ingress credentials (`pin_code` and `slug`), existing active guest
|
- **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.
|
||||||
sessions must remain valid and uninterrupted. Only subsequent
|
- **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;`).
|
||||||
unauthenticated join attempts using the old PIN, old Direct Link, or old CLI
|
- **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.
|
||||||
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:**
|
- **Alternatives:**
|
||||||
- _Single-Line vs. 2-Row Compact Cards:_ Single-line cards inevitably drop
|
- *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.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -75,20 +46,17 @@
|
|||||||
```typescript
|
```typescript
|
||||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||||
|
|
||||||
// Extract base slug prefix and generate fresh random 4-char suffix
|
// Extract base slug prefix and generate fresh random 4-char suffix
|
||||||
const event = await sqlWrapper
|
const event = await sqlWrapper.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
|
||||||
.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
|
|
||||||
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
|
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
|
||||||
const newSuffix = Math.random().toString(36).substring(2, 6);
|
const newSuffix = Math.random().toString(36).substring(2, 6);
|
||||||
const newSlug = `${baseSlug}-${newSuffix}`;
|
const newSlug = `${baseSlug}-${newSuffix}`;
|
||||||
```
|
```
|
||||||
- Update database:
|
- Update database: `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
|
||||||
`UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
|
|
||||||
- Audit log `event_ingress_rotated`.
|
- Audit log `event_ingress_rotated`.
|
||||||
- Return `{ success: true, pinCode: newPinCode, slug: newSlug }`.
|
- Return `{ success: true, pinCode: newPinCode, slug: newSlug }`.
|
||||||
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`)
|
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`) authenticate via session cookies/Valkey tokens and are unaffected.
|
||||||
authenticate via session cookies/Valkey tokens and are unaffected.
|
|
||||||
|
|
||||||
2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):**
|
2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):**
|
||||||
- Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`:
|
- Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`:
|
||||||
@ -100,24 +68,18 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):**
|
3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):**
|
||||||
- Ensure query returns
|
- 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`.
|
||||||
`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`)
|
### Phase 2: Page Hierarchy, Nomenclature & Heading Cleanup (`SessionsPage.tsx`)
|
||||||
|
|
||||||
1. **Page Title:**
|
1. **Page Title:**
|
||||||
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with
|
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with subtitle *"Manage logins, mint 1:1 delegated tokens, or launch multi-claim workshop events."*
|
||||||
subtitle _"Manage logins, mint 1:1 delegated tokens, or launch multi-claim
|
|
||||||
workshop events."_
|
|
||||||
2. **Remove Duplicate Headings:**
|
2. **Remove Duplicate Headings:**
|
||||||
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The
|
- 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.
|
||||||
section header is exclusively rendered inside `EventCockpitDeck.tsx` as
|
|
||||||
`<h2>Events</h2>` alongside the view toggle.
|
|
||||||
3. **Sessions Section:**
|
3. **Sessions Section:**
|
||||||
- Retain `<h2>Sessions</h2>` heading with subtitle _"Direct device logins,
|
- Retain `<h2>Sessions</h2>` heading with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."*
|
||||||
passkey authentications, and delegated agent tokens."_
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -126,46 +88,33 @@
|
|||||||
1. **Section Header & High-Contrast View Toggle:**
|
1. **Section Header & High-Contrast View Toggle:**
|
||||||
- Header: `<h2>Events</h2>`.
|
- Header: `<h2>Events</h2>`.
|
||||||
- Toggle buttons (`[ 🗂️ Grid ]` and `[ 📋 Compact ]`):
|
- Toggle buttons (`[ 🗂️ Grid ]` and `[ 📋 Compact ]`):
|
||||||
- Active style:
|
- 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);`
|
||||||
`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;`
|
||||||
- Inactive style:
|
|
||||||
`background: transparent; color: var(--text-muted); opacity: 0.75;`
|
|
||||||
- Include `aria-pressed="true/false"`.
|
- Include `aria-pressed="true/false"`.
|
||||||
|
|
||||||
2. **2-Row Compact Card Layout (`.compact-view .event-card`):**
|
2. **2-Row Compact Card Layout (`.compact-view .event-card`):**
|
||||||
- Container: Strictly bounded flex/grid (~70px height),
|
- Container: Strictly bounded flex/grid (~70px height), `box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
|
||||||
`box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
|
|
||||||
- **Row 1 (Metadata Header):**
|
- **Row 1 (Metadata Header):**
|
||||||
- Left: Event title with ellipsis
|
- Left: Event title with ellipsis (`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
|
||||||
(`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 ]`.
|
||||||
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot
|
|
||||||
`·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
|
|
||||||
- **Row 2 (1-Click Handoffs & Action Pinned Right):**
|
- **Row 2 (1-Click Handoffs & Action Pinned Right):**
|
||||||
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`,
|
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`, `[ CLI (Copy) ]` using compact inline pills (`padding: 2px 6px; font-size: 0.75rem;`).
|
||||||
`[ CLI (Copy) ]` using compact inline pills
|
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or `[ 🔄 Reopen (+1h) ]` if expired).
|
||||||
(`padding: 2px 6px; font-size: 0.75rem;`).
|
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its `alert(...)` placeholder.
|
||||||
- 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:**
|
3. **Grid Card Polish:**
|
||||||
- Replace detached CLI `<details>` box with a unified **Integrated Expanding
|
- Replace detached CLI `<details>` box with a unified **Integrated Expanding CLI Component**:
|
||||||
CLI Component**:
|
|
||||||
- Single-line snippet when collapsed with `[ Copy ]` and `[ ▾ ]` toggle.
|
- Single-line snippet when collapsed with `[ Copy ]` and `[ ▾ ]` toggle.
|
||||||
- Expands downward into multiline highlighted command block on toggle
|
- Expands downward into multiline highlighted command block on toggle click.
|
||||||
click.
|
|
||||||
- Fix double arrow marker bug (`list-style: none;`).
|
- Fix double arrow marker bug (`list-style: none;`).
|
||||||
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field
|
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field rotation.
|
||||||
rotation.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4: Standardized Natural Expiry & Start-Time Telemetry (`SessionsScript.tsx` & Drawers)
|
### Phase 4: Standardized Natural Expiry & Start-Time Telemetry (`SessionsScript.tsx` & Drawers)
|
||||||
|
|
||||||
1. **Natural Expiry Formatter Helper (`formatNaturalExpiry(expiresAt)`):**
|
1. **Natural Expiry Formatter Helper (`formatNaturalExpiry(expiresAt)`):**
|
||||||
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D`
|
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D` (if same year), `MMM D, YYYY` (if different year).
|
||||||
(if same year), `MMM D, YYYY` (if different year).
|
|
||||||
- Exact Time: `h:mm A` (e.g. `10:39 PM`).
|
- Exact Time: `h:mm A` (e.g. `10:39 PM`).
|
||||||
- Scaled Urgency Badge:
|
- Scaled Urgency Badge:
|
||||||
- $<1$h: Amber `[ 45m left ]`
|
- $<1$h: Amber `[ 45m left ]`
|
||||||
@ -177,17 +126,11 @@
|
|||||||
- Tooltip: `title="${fullISODate}"` on hover/long-press.
|
- Tooltip: `title="${fullISODate}"` on hover/long-press.
|
||||||
- Format: `[Date] · [Time] · [ Colored Urgency Badge ]`.
|
- Format: `[Date] · [Time] · [ Colored Urgency Badge ]`.
|
||||||
|
|
||||||
2. **Inverted Symmetrical Start-Time Formatter
|
2. **Inverted Symmetrical Start-Time Formatter (`formatNaturalJoinTime(createdAt)`):**
|
||||||
(`formatNaturalJoinTime(createdAt)`):**
|
|
||||||
- In `EventGuestsDrawer.tsx`, render:
|
- In `EventGuestsDrawer.tsx`, render:
|
||||||
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge +
|
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge + `[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
|
||||||
`[ ⏸️ 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 1:** `Joined Today · 2:15 PM ·`
|
- **Line 2:** `Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web` (or `📟 CLI`).
|
||||||
<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).
|
- Remove redundant `guest_slug_seat` visual text (keep in tooltip only).
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -195,27 +138,22 @@
|
|||||||
### Phase 5: Delegation Drawer Overhaul (`WorkshopDrawer.tsx` & `SessionsScript.tsx`)
|
### Phase 5: Delegation Drawer Overhaul (`WorkshopDrawer.tsx` & `SessionsScript.tsx`)
|
||||||
|
|
||||||
1. **Drawer Nomenclature & WAI-ARIA High-Contrast Tabs:**
|
1. **Drawer Nomenclature & WAI-ARIA High-Contrast Tabs:**
|
||||||
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle _"Mint a 1:1
|
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle *"Mint a 1:1 delegated token or launch a multi-seat workshop event."*
|
||||||
delegated token or launch a multi-seat workshop event."_
|
|
||||||
- Tabs (`role="tablist"`):
|
- Tabs (`role="tablist"`):
|
||||||
- **Tab 1 (`role="tab"`):** `Single Session`
|
- **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`
|
- **Tab 2 (`role="tab"`):** `Multi-Claim Event`
|
||||||
- Subtitle hint: _For workshops, teams & guest pools_
|
- Subtitle hint: *For workshops, teams & guest pools*
|
||||||
- High-Contrast Active State: Solid `var(--primary)` background with white
|
- High-Contrast Active State: Solid `var(--primary)` background with white text (`#ffffff`), `aria-selected="true"`.
|
||||||
text (`#ffffff`), `aria-selected="true"`.
|
- Inactive State: Translucent muted background (`opacity: 0.75`), `aria-selected="false"`.
|
||||||
- Inactive State: Translucent muted background (`opacity: 0.75`),
|
|
||||||
`aria-selected="false"`.
|
|
||||||
|
|
||||||
2. **Handoff State & Button Minimalist Polish:**
|
2. **Handoff State & Button Minimalist Polish:**
|
||||||
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`,
|
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`, `Copy URL`, `Copy 1-Liner`).
|
||||||
`Copy URL`, `Copy 1-Liner`).
|
|
||||||
- Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`**.
|
- Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`**.
|
||||||
|
|
||||||
3. **State Machine Reset on Open:**
|
3. **State Machine Reset on Open:**
|
||||||
- In `SessionsScript.tsx:openDelegateDrawer()`:
|
- In `SessionsScript.tsx:openDelegateDrawer()`:
|
||||||
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to
|
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to `display: none`.
|
||||||
`display: none`.
|
|
||||||
- Clear form inputs (`eventName`, `eventSlug`, `eventPinCode`, etc.).
|
- Clear form inputs (`eventName`, `eventSlug`, `eventPinCode`, etc.).
|
||||||
- Reset tab selection to `Single Session`.
|
- Reset tab selection to `Single Session`.
|
||||||
|
|
||||||
@ -224,7 +162,6 @@
|
|||||||
## 4. Verification Plan
|
## 4. Verification Plan
|
||||||
|
|
||||||
### Automated Tests
|
### Automated Tests
|
||||||
|
|
||||||
1. **Universal Credential Rotation Test (`server/tests/events.test.ts`):**
|
1. **Universal Credential Rotation Test (`server/tests/events.test.ts`):**
|
||||||
- Create event pass -> Call `POST /api/events/:id/rotate-pin`.
|
- Create event pass -> Call `POST /api/events/:id/rotate-pin`.
|
||||||
- Verify response returns new `pinCode` AND new `slug`.
|
- Verify response returns new `pinCode` AND new `slug`.
|
||||||
@ -236,13 +173,11 @@
|
|||||||
- Call `POST /api/events/:id/extend` with `extendHours: 1`.
|
- Call `POST /api/events/:id/extend` with `extendHours: 1`.
|
||||||
- Verify `expires_at > NOW()` (approximately `NOW() + 1 hour`).
|
- Verify `expires_at > NOW()` (approximately `NOW() + 1 hour`).
|
||||||
3. **UI Script & Formatter Tests (`ui/ui_scripts.test.ts`):**
|
3. **UI Script & Formatter Tests (`ui/ui_scripts.test.ts`):**
|
||||||
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d,
|
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d, 142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
|
||||||
142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
|
|
||||||
- Test `formatNaturalJoinTime` inverted duration math.
|
- Test `formatNaturalJoinTime` inverted duration math.
|
||||||
- Test drawer state machine reset logic.
|
- Test drawer state machine reset logic.
|
||||||
|
|
||||||
### Quality Gate Commands
|
### Quality Gate Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deno fmt --check
|
deno fmt --check
|
||||||
deno task lint
|
deno task lint
|
||||||
|
|||||||
@ -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 5–10 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> - $<1$h: Amber `[ 45m left ]`<br> - $<24$h: Amber/Green `[ 2h 45m left ]`<br> - 1–60d: Green `[ 28d left ]`<br> - 2–12 mos: Green `[ 4.5 mos left ]` (no clunky `142d`)<br> - 1+ yrs: Green `[ 1.2 yrs left ]` (no clunky `420d`)<br> - 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.
|
|
||||||
@ -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;"
|
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>
|
<div>
|
||||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||||
Sessions & Events
|
Sessions & Passes
|
||||||
</h1>
|
</h1>
|
||||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||||
Manage logins, mint 1:1 delegated tokens, or launch multi-claim
|
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
||||||
workshop events.
|
multi-claim workshop event passes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -57,6 +57,9 @@ export const SessionsPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
|
||||||
|
Event Passes
|
||||||
|
</h2>
|
||||||
<EventCockpitDeck eventPasses={eventPasses} />
|
<EventCockpitDeck eventPasses={eventPasses} />
|
||||||
|
|
||||||
{/* Delegate Session Drawer */}
|
{/* 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="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||||
<div style="width: 100%;">
|
<div style="width: 100%;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<h2
|
<h3
|
||||||
id="delegateDrawerTitle"
|
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
|
Create New Pass / Session
|
||||||
</h2>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="closeDelegateDrawer()"
|
onclick="closeDelegateDrawer()"
|
||||||
@ -82,45 +85,23 @@ export const SessionsPage = ({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<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;">
|
||||||
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;"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
|
||||||
aria-selected="true"
|
|
||||||
id="tabBtnDirectPass"
|
id="tabBtnDirectPass"
|
||||||
class="delegate-tab-btn active"
|
class="delegate-tab-btn active"
|
||||||
onclick="switchDelegateTab('tabDirectPass')"
|
onclick="switchDelegateTab('tabDirectPass')"
|
||||||
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
|
|
||||||
>
|
>
|
||||||
<span style="font-size: 0.95rem; font-weight: 700;">
|
🔑 1:1 Direct Pass
|
||||||
Single Session
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
|
|
||||||
For agents, CI/CD, or 1:1 delegation
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
id="tabBtnWorkshopPass"
|
id="tabBtnWorkshopPass"
|
||||||
class="delegate-tab-btn"
|
class="delegate-tab-btn"
|
||||||
onclick="switchDelegateTab('tabWorkshopPass')"
|
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 Workshop Pass
|
||||||
Multi-Claim Event
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
|
|
||||||
For workshops, teams & guest pools
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -194,13 +175,11 @@ export const SessionsPage = ({
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.75;
|
|
||||||
}
|
}
|
||||||
.delegate-tab-btn.active {
|
.delegate-tab-btn.active {
|
||||||
background: var(--primary);
|
background: var(--surface-card);
|
||||||
color: #ffffff;
|
color: var(--primary);
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
<div style="margin-bottom: 2rem;">
|
<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;">
|
<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);">
|
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
|
||||||
Events
|
Event Passes
|
||||||
</h2>
|
</h2>
|
||||||
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
||||||
<button
|
<button
|
||||||
@ -14,7 +14,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
class="view-mode-btn active"
|
class="view-mode-btn active"
|
||||||
onclick="setEventViewMode('grid')"
|
onclick="setEventViewMode('grid')"
|
||||||
aria-label="Grid View"
|
aria-label="Grid View"
|
||||||
aria-pressed="true"
|
|
||||||
>
|
>
|
||||||
🗂️ Grid
|
🗂️ Grid
|
||||||
</button>
|
</button>
|
||||||
@ -24,7 +23,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
class="view-mode-btn"
|
class="view-mode-btn"
|
||||||
onclick="setEventViewMode('compact')"
|
onclick="setEventViewMode('compact')"
|
||||||
aria-label="Compact View"
|
aria-label="Compact View"
|
||||||
aria-pressed="false"
|
|
||||||
>
|
>
|
||||||
📋 Compact
|
📋 Compact
|
||||||
</button>
|
</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);">
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
Link:
|
Link:
|
||||||
</span>
|
</span>
|
||||||
<code
|
<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;">
|
||||||
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}
|
/e/{event.slug}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id={`link-btn-${event.id}`}
|
|
||||||
class="btn-outline"
|
class="btn-outline"
|
||||||
aria-label={`Copy direct link for ${event.name}`}
|
aria-label={`Copy direct link for ${event.name}`}
|
||||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
@ -131,38 +125,31 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
class="cli-details"
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
style="margin-top: 0.1rem; width: 100%;"
|
CLI:
|
||||||
>
|
</span>
|
||||||
<summary style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
|
<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;">
|
||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
curl -sSL {Deno.env.get("RP_ID")
|
||||||
CLI:
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
</span>
|
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||||
<code
|
</code>
|
||||||
id={`cli-code-${event.id}`}
|
<button
|
||||||
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;"
|
type="button"
|
||||||
>
|
class="btn-outline"
|
||||||
curl -sSL {Deno.env.get("RP_ID")
|
aria-label={`Copy CLI command for ${event.name}`}
|
||||||
? `https://${Deno.env.get("RP_ID")}`
|
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||||
</code>
|
>
|
||||||
<button
|
Copy
|
||||||
type="button"
|
</button>
|
||||||
id={`cli-btn-${event.id}`}
|
</div>
|
||||||
class="btn-outline"
|
|
||||||
aria-label={`Copy CLI command for ${event.name}`}
|
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
|
||||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
|
||||||
onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
▸ Expand full CLI command
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
|
|
||||||
[ ▾ ]
|
|
||||||
</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<textarea
|
<textarea
|
||||||
id={`cli-textarea-${event.id}`}
|
|
||||||
readonly
|
readonly
|
||||||
rows={2}
|
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;"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-outline"
|
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;"
|
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||||
onclick={`rotatePin('${event.id}')`}
|
onclick={`rotatePin('${event.id}')`}
|
||||||
>
|
>
|
||||||
🔄 Rotate Credentials
|
🔄 Rotate PIN
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -233,52 +220,25 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact Actions (only visible in compact mode) */}
|
{/* Compact Actions (only visible in compact mode) */}
|
||||||
<div class="event-card-compact-row-2">
|
<div class="event-card-compact-actions">
|
||||||
<div class="compact-pills">
|
<button
|
||||||
<span
|
type="button"
|
||||||
class="compact-pill"
|
class="btn-outline"
|
||||||
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
|
||||||
>
|
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||||
PIN:{" "}
|
event.name.replace(/'/g, "\\'")
|
||||||
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
|
}')`}
|
||||||
{" "}
|
>
|
||||||
(Copy)
|
👥 Guests ({event.seats_claimed})
|
||||||
</span>
|
</button>
|
||||||
<span
|
<button
|
||||||
id={`compact-link-${event.id}`}
|
type="button"
|
||||||
class="compact-pill"
|
class="btn-outline"
|
||||||
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
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')"
|
||||||
Link (Copy)
|
>
|
||||||
</span>
|
▸ Details
|
||||||
<span
|
</button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -286,13 +246,6 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
.cli-details summary {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.cli-details summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-mode-btn {
|
.view-mode-btn {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@ -303,15 +256,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.75;
|
|
||||||
}
|
}
|
||||||
.view-mode-btn.active {
|
.view-mode-btn.active {
|
||||||
background: var(--primary);
|
background: var(--surface-card);
|
||||||
color: #ffffff;
|
color: var(--primary);
|
||||||
font-weight: 700;
|
box-shadow: var(--shadow-xs);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid View Layout */
|
/* Grid View Layout */
|
||||||
@ -326,7 +275,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
.grid-view .event-card-compact-row-2 {
|
.grid-view .event-card-compact-actions {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,14 +287,10 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
}
|
}
|
||||||
.compact-view .event-card {
|
.compact-view .event-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
box-sizing: border-box;
|
gap: 1rem;
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-height: 70px;
|
|
||||||
}
|
}
|
||||||
.compact-view .event-card > div {
|
.compact-view .event-card > div {
|
||||||
margin-bottom: 0 !important; /* Reset component margins */
|
margin-bottom: 0 !important; /* Reset component margins */
|
||||||
@ -353,68 +298,34 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
.compact-view .event-card-header {
|
.compact-view .event-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 1rem;
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem !important;
|
|
||||||
}
|
|
||||||
.compact-view .event-card-header > div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.compact-view .event-card-header h3 {
|
.compact-view .event-card-header h3 {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
font-size: 1rem !important;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
max-width: 280px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
.compact-view .event-card-header > .status-badge {
|
.compact-view .event-card-header > div {
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-view .event-card-compact-row-2 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
.compact-view .event-card-snippets,
|
||||||
.compact-view .compact-pills {
|
.compact-view .event-card-actions,
|
||||||
|
.compact-view .status-badge {
|
||||||
|
display: none; /* Hide heavy elements in compact */
|
||||||
|
}
|
||||||
|
.compact-view .event-card-compact-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.compact-view .compact-pill {
|
.compact-view .countdown-pill {
|
||||||
padding: 2px 6px;
|
font-size: 0.75rem;
|
||||||
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 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dynamic Countdown Colors */
|
/* Dynamic Countdown Colors */
|
||||||
|
|||||||
@ -32,7 +32,8 @@ export const EventGuestsDrawer = () => {
|
|||||||
>
|
>
|
||||||
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
||||||
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,47 +16,34 @@ export const SessionsScript = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDelegateDrawer() {
|
function openDelegateDrawer() {
|
||||||
const drawer = document.getElementById('delegateDrawer');
|
document.getElementById('delegateDrawer').style.display = 'block';
|
||||||
drawer.style.display = 'block';
|
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
|
||||||
drawer.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');
|
const eventCreateState = document.getElementById('eventCreateState');
|
||||||
if (eventCreateState) eventCreateState.style.display = 'block';
|
if (eventCreateState) eventCreateState.style.display = 'block';
|
||||||
const eventHandoffState = document.getElementById('eventHandoffState');
|
const eventHandoffState = document.getElementById('eventHandoffState');
|
||||||
if (eventHandoffState) eventHandoffState.style.display = 'none';
|
if (eventHandoffState) eventHandoffState.style.display = 'none';
|
||||||
const eventForm = document.getElementById('eventForm');
|
const eventForm = document.getElementById('eventForm');
|
||||||
if (eventForm) eventForm.reset();
|
if (eventForm) eventForm.reset();
|
||||||
|
const drawerTitle = document.getElementById('delegateDrawerTitle');
|
||||||
// Default back to Single Session Tab
|
if (drawerTitle) drawerTitle.textContent = 'Create New Pass / Session';
|
||||||
switchDelegateTab('tabDirectPass');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDelegateDrawer() {
|
|
||||||
document.getElementById('delegateDrawer').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchDelegateTab(tabId) {
|
function switchDelegateTab(tabId) {
|
||||||
document.getElementById('tabDirectPass').style.display = 'none';
|
document.getElementById('tabDirectPass').style.display = 'none';
|
||||||
document.getElementById('tabWorkshopPass').style.display = 'none';
|
document.getElementById('tabWorkshopPass').style.display = 'none';
|
||||||
|
document.getElementById('tabBtnDirectPass').classList.remove('active');
|
||||||
const btnDirect = document.getElementById('tabBtnDirectPass');
|
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
|
||||||
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(tabId).style.display = 'block';
|
document.getElementById(tabId).style.display = 'block';
|
||||||
|
|
||||||
if (tabId === 'tabDirectPass') {
|
if (tabId === 'tabDirectPass') {
|
||||||
btnDirect.classList.add('active');
|
document.getElementById('tabBtnDirectPass').classList.add('active');
|
||||||
btnDirect.setAttribute('aria-selected', 'true');
|
|
||||||
} else {
|
} else {
|
||||||
btnWorkshop.classList.add('active');
|
document.getElementById('tabBtnWorkshopPass').classList.add('active');
|
||||||
btnWorkshop.setAttribute('aria-selected', 'true');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,6 +166,7 @@ export const SessionsScript = () => {
|
|||||||
|
|
||||||
document.getElementById('eventCreateState').style.display = 'none';
|
document.getElementById('eventCreateState').style.display = 'none';
|
||||||
document.getElementById('eventHandoffState').style.display = 'block';
|
document.getElementById('eventHandoffState').style.display = 'block';
|
||||||
|
document.getElementById('delegateDrawerTitle').textContent = 'Event Pass Active';
|
||||||
|
|
||||||
showNotice('Workshop pass created: ' + ev.name, false);
|
showNotice('Workshop pass created: ' + ev.name, false);
|
||||||
} else {
|
} else {
|
||||||
@ -297,69 +285,12 @@ export const SessionsScript = () => {
|
|||||||
if (pinElem) {
|
if (pinElem) {
|
||||||
pinElem.textContent = data.pinCode;
|
pinElem.textContent = data.pinCode;
|
||||||
}
|
}
|
||||||
const compactPinElem = document.getElementById('compact-pin-' + eventId);
|
showNotice('Event PIN rotated successfully', false);
|
||||||
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 {
|
} else {
|
||||||
showNotice(data.error || 'Failed to rotate credentials', true);
|
showNotice(data.error || 'Failed to rotate PIN', true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 usernameParts = att.username.split('_');
|
||||||
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
|
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
|
||||||
|
|
||||||
const joinText = formatNaturalJoinTime(att.created_at, !isPaused);
|
// Compute relative join time (approximate based on created_at)
|
||||||
|
const createdDate = new Date(att.created_at);
|
||||||
const lastAction = att.last_activity_action || 'ForwardAuth Ingress';
|
const now = new Date();
|
||||||
let timeAgo = 'just now';
|
const diffMs = now - createdDate;
|
||||||
if (att.last_activity_at) {
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
const lastActDate = new Date(att.last_activity_at);
|
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
|
||||||
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';
|
|
||||||
|
|
||||||
html += \`
|
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'};">
|
<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>
|
</div>
|
||||||
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
|
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
|
||||||
\${joinText}
|
\${joinText} · \${att.username}
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem;">
|
|
||||||
Last Action: \${lastAction} · \${timeAgo} · \${clientIcon}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<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 + ' · ' + timeStr + ' · ' + 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 + ' · ' + timeStr + ' · ' + activeBadge;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic Countdown Updater
|
// Dynamic Countdown Updater
|
||||||
function updateAllCountdowns() {
|
function updateAllCountdowns() {
|
||||||
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
|
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
pills.forEach(pill => {
|
pills.forEach(pill => {
|
||||||
const expiresAtStr = pill.getAttribute('data-expires-at');
|
const expiresAtStr = pill.getAttribute('data-expires-at');
|
||||||
if (!expiresAtStr) return;
|
if (!expiresAtStr) return;
|
||||||
|
|
||||||
if (pill.id === 'guestDrawerCountdown') {
|
const expDate = new Date(expiresAtStr);
|
||||||
const now = new Date();
|
const diffMs = expDate - now;
|
||||||
const expDate = new Date(expiresAtStr);
|
|
||||||
const diffMs = expDate - now;
|
pill.classList.remove('status-green', 'status-amber', 'status-red');
|
||||||
if (diffMs <= 0) {
|
|
||||||
pill.textContent = '⏳ Expired';
|
if (diffMs <= 0) {
|
||||||
} else {
|
pill.textContent = '⏳ Expired';
|
||||||
const totalMins = Math.floor(diffMs / 60000);
|
pill.classList.add('status-red');
|
||||||
const hours = Math.floor(totalMins / 60);
|
|
||||||
const mins = totalMins % 60;
|
|
||||||
pill.textContent = \`⏳ \${hours}h \${mins}m left\`;
|
|
||||||
}
|
|
||||||
} else {
|
} 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,7 +241,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
|
|||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-outline"
|
class="btn-primary"
|
||||||
aria-label="Copy Universal PIN"
|
aria-label="Copy Universal PIN"
|
||||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||||
onclick="copyEventHandoff('pin')"
|
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;"
|
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||||
onclick="closeEventHandoffModal()"
|
onclick="closeEventHandoffModal()"
|
||||||
>
|
>
|
||||||
OK
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 + " · " + timeStr + " · " + 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 + " · " + timeStr + " · " +
|
|
||||||
activeBadge;
|
|
||||||
}
|
|
||||||
@ -1,77 +1,4 @@
|
|||||||
import { assertEquals } from "jsr:@std/assert@1";
|
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 { SessionsPage } from "./components/SessionsPage.tsx";
|
||||||
import { LoginPage } from "./components/LoginPage.tsx";
|
import { LoginPage } from "./components/LoginPage.tsx";
|
||||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user