fix(events): patch db backfill for legacy users and sync frontend domain drift on rotation
This commit is contained in:
parent
3dcd823e23
commit
0a3c147880
10
scratch/fix_db.patch
Normal file
10
scratch/fix_db.patch
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
--- server/db.ts
|
||||||
|
+++ server/db.ts
|
||||||
|
@@ -226,6 +226,7 @@
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
|
||||||
|
+ await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
1524
scratch/jules_session_13025.patch
Normal file
1524
scratch/jules_session_13025.patch
Normal file
File diff suppressed because it is too large
Load Diff
1289
scratch/jules_ui_latest.patch
Normal file
1289
scratch/jules_ui_latest.patch
Normal file
File diff suppressed because it is too large
Load Diff
11
server/db.ts
11
server/db.ts
@ -227,6 +227,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
353
server/db.ts.orig
Normal file
353
server/db.ts.orig
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import postgres from "npm:postgres@3";
|
||||||
|
|
||||||
|
// Evaluate environment variables dynamically to prevent crash during test imports
|
||||||
|
const host = Deno.env.get("POSTGRES_HOST") ||
|
||||||
|
(import.meta.main ? "" : "localhost");
|
||||||
|
const user = Deno.env.get("POSTGRES_USER") ||
|
||||||
|
(import.meta.main ? "" : "postgres");
|
||||||
|
const password = Deno.env.get("POSTGRES_PASSWORD") ||
|
||||||
|
(import.meta.main ? "" : "postgres");
|
||||||
|
const db = Deno.env.get("POSTGRES_DB") || (import.meta.main ? "" : "postgres");
|
||||||
|
const port = Deno.env.get("POSTGRES_PORT") || "5432";
|
||||||
|
|
||||||
|
if (import.meta.main && (!host || !user || !password || !db)) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing critical database environment variables. Required: POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`;
|
||||||
|
|
||||||
|
// Export it as let so we can override it in tests
|
||||||
|
export let sql = postgres(connectionString);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIDE EFFECT: Initializes the database schema.
|
||||||
|
*/
|
||||||
|
export async function initDb(): Promise<void> {
|
||||||
|
console.log("[Auth DB] Initializing central identity database schema...");
|
||||||
|
|
||||||
|
// We ensure new users are 'pending' to satisfy Use Case 3 (Manual state machine activation)
|
||||||
|
// If the table exists we will attempt to alter the default.
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
account_status TEXT DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE users ALTER COLUMN account_status SET DEFAULT 'pending'`;
|
||||||
|
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
|
||||||
|
} catch {
|
||||||
|
// Ignore if unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
spiffe_id VARCHAR(255) UNIQUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Ensure app_secret column exists for API validation mapping
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS app_secret TEXT UNIQUE`;
|
||||||
|
} catch {
|
||||||
|
// Soft ignore if column already exists
|
||||||
|
// Ignore and check next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure domain column exists for Edge authorization routing
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS domain TEXT UNIQUE`;
|
||||||
|
} catch {
|
||||||
|
// Soft ignore if column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1 Ingress Control
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE`;
|
||||||
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS bypass_paths TEXT[] DEFAULT '{}'`;
|
||||||
|
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS allowed_cidrs TEXT[] DEFAULT '{}'`;
|
||||||
|
} catch {
|
||||||
|
// Soft ignore if columns already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(name, app_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Seed standard global roles if table is empty
|
||||||
|
try {
|
||||||
|
const existingRoles = await sql`SELECT count(*)::int as count FROM roles`
|
||||||
|
.then((res) => res[0]?.count || 0);
|
||||||
|
if (existingRoles === 0) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO roles (name, description, app_id) VALUES
|
||||||
|
('admin', 'Full administrative access across all management capabilities', NULL),
|
||||||
|
('editor', 'Read and write access with permissions to modify records', NULL),
|
||||||
|
('operator', 'Operational execution access for runtime tasks', NULL),
|
||||||
|
('viewer', 'Read-only access across application telemetry and views', NULL)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore seed errors on race conditions
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS grants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, app_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
max_uses INT DEFAULT 1,
|
||||||
|
uses_count INT DEFAULT 0,
|
||||||
|
auto_activate BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
used_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Migrations for existing invites table
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS max_uses INT DEFAULT 1`;
|
||||||
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS uses_count INT DEFAULT 0`;
|
||||||
|
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS auto_activate BOOLEAN DEFAULT TRUE`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_redemptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS aaguid_allowlist (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
aaguid UUID UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS recovery_shares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
server_share TEXT NOT NULL,
|
||||||
|
pin_hash TEXT NOT NULL,
|
||||||
|
attempts_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource TEXT,
|
||||||
|
details JSONB,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
leaf_hash TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE audit_records ADD COLUMN IF NOT EXISTS leaf_hash TEXT`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS event_passes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
pin_code TEXT UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE SET NULL,
|
||||||
|
role TEXT DEFAULT 'viewer',
|
||||||
|
max_seats INT DEFAULT 50,
|
||||||
|
seats_claimed INT DEFAULT 0,
|
||||||
|
lifespan_hours INT DEFAULT 3,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_paused BOOLEAN DEFAULT FALSE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE event_passes ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_sths (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tree_size BIGINT NOT NULL,
|
||||||
|
root_hash TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS passkeys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT UNIQUE NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter BIGINT NOT NULL,
|
||||||
|
prf_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
prf_salt TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Ensure prf columns exist
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_enabled BOOLEAN DEFAULT FALSE`;
|
||||||
|
await sql`ALTER TABLE passkeys ADD COLUMN IF NOT EXISTS prf_salt TEXT`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
label TEXT,
|
||||||
|
is_agent BOOLEAN DEFAULT FALSE,
|
||||||
|
custom_scopes TEXT[],
|
||||||
|
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
last_activity_action TEXT,
|
||||||
|
is_paused BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`;
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_agent BOOLEAN DEFAULT FALSE`;
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS custom_scopes TEXT[]`;
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()`;
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_activity_action TEXT`;
|
||||||
|
await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS is_paused BOOLEAN DEFAULT FALSE`;
|
||||||
|
} catch {
|
||||||
|
// Ignore migration column exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS hwk_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
fingerprint TEXT UNIQUE NOT NULL,
|
||||||
|
public_key JSONB NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Seed ed-droid app record with spiffe_id
|
||||||
|
await sql`
|
||||||
|
INSERT INTO apps (name, spiffe_id)
|
||||||
|
VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend')
|
||||||
|
ON CONFLICT (spiffe_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Seed the Central Auth-Yes Management App for global administration
|
||||||
|
await sql`
|
||||||
|
INSERT INTO apps (name, spiffe_id)
|
||||||
|
VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management')
|
||||||
|
ON CONFLICT (spiffe_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Seed initial bootstrap admin invite if no users exist in the database
|
||||||
|
try {
|
||||||
|
const userCount = await sql`SELECT count(*)::int as count FROM users`.then(
|
||||||
|
(res) => res[0]?.count || 0,
|
||||||
|
);
|
||||||
|
if (userCount === 0) {
|
||||||
|
const inviteCount = await sql`
|
||||||
|
SELECT count(*)::int as count FROM invites
|
||||||
|
WHERE role = 'admin' AND expires_at > NOW()
|
||||||
|
`.then((res) => res[0]?.count || 0);
|
||||||
|
|
||||||
|
if (inviteCount === 0) {
|
||||||
|
const bootstrapCode = Deno.env.get("BOOTSTRAP_INVITE_CODE") ||
|
||||||
|
"bootstrap-admin";
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
|
await sql`
|
||||||
|
INSERT INTO invites (code, app_id, role, max_uses, uses_count, auto_activate, expires_at)
|
||||||
|
VALUES (${bootstrapCode}, NULL, 'admin', 1, 0, TRUE, ${expiresAt})
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
`;
|
||||||
|
console.log(
|
||||||
|
`[Auth DB] Initial bootstrap admin invite code seeded: '${bootstrapCode}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Auth DB] Bootstrap invite check skipped:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Auth DB] Central identity database schema initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sqlWrapper = {
|
||||||
|
get sql() {
|
||||||
|
return sql;
|
||||||
|
},
|
||||||
|
set sql(val: any) {
|
||||||
|
sql = val;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -134,7 +134,7 @@ eventRoutes.get("/api/events/:id/attendees", async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await sqlWrapper.sql`
|
const event = await sqlWrapper.sql`
|
||||||
SELECT slug FROM event_passes
|
SELECT id, slug, max_seats, expires_at 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,16 +143,22 @@ 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, u.username, u.display_name
|
SELECT s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE u.username LIKE ${likePattern}
|
WHERE u.event_pass_id = ${eventId}
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return c.json({ success: true, attendees });
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
attendees,
|
||||||
|
event: {
|
||||||
|
max_seats: event.max_seats,
|
||||||
|
expires_at: event.expires_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} 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);
|
||||||
@ -176,19 +182,17 @@ 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 slug
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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 username LIKE ${"guest_" + slug + "_%"}
|
SELECT id FROM users WHERE event_pass_id = ${eventId}
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
@ -224,7 +228,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
|
|||||||
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
|
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
|
||||||
user.userId,
|
user.userId,
|
||||||
)}) AND is_active = TRUE
|
)}) AND is_active = TRUE
|
||||||
RETURNING slug, expires_at
|
RETURNING id, expires_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!eventResult || eventResult.length === 0) {
|
if (!eventResult || eventResult.length === 0) {
|
||||||
@ -234,14 +238,13 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = eventResult[0].slug;
|
|
||||||
const newExpiresAt = new Date(eventResult[0].expires_at);
|
const 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 username LIKE ${"guest_" + slug + "_%"}
|
SELECT id FROM users WHERE event_pass_id = ${eventId}
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
@ -417,13 +420,14 @@ eventRoutes.post("/api/join", async (c) => {
|
|||||||
|
|
||||||
const updatedEvent = updateResult[0];
|
const updatedEvent = updateResult[0];
|
||||||
const guestUuid = crypto.randomUUID();
|
const guestUuid = crypto.randomUUID();
|
||||||
const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
|
const eventShortId = String(updatedEvent.id).split("-")[0];
|
||||||
|
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO users (id, username, display_name, account_status)
|
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
|
||||||
VALUES (${guestUuid}, ${username}, ${
|
VALUES (${guestUuid}, ${username}, ${
|
||||||
updatedEvent.name + " Attendee"
|
updatedEvent.name + " Attendee"
|
||||||
}, 'guest')
|
}, 'guest', ${updatedEvent.id})
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -532,11 +536,14 @@ eventRoutes.get("/join/:slug", async (c) => {
|
|||||||
|
|
||||||
const event = result[0];
|
const event = result[0];
|
||||||
const guestUuid = crypto.randomUUID();
|
const guestUuid = crypto.randomUUID();
|
||||||
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
const eventShortId = String(event.id).split("-")[0];
|
||||||
|
const username = `guest_${eventShortId}_${event.seats_claimed}`;
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO users (id, username, display_name, account_status)
|
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
|
||||||
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
VALUES (${guestUuid}, ${username}, ${
|
||||||
|
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.username LIKE 'guest_' || ep.slug || '_%'
|
AND u.event_pass_id = ep.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
`.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_deno-lab_1");
|
assertEquals(json.username, "guest_event_1");
|
||||||
|
|
||||||
const cookies = res.headers.get("set-cookie");
|
const cookies = res.headers.get("set-cookie");
|
||||||
assertExists(cookies);
|
assertExists(cookies);
|
||||||
@ -385,7 +385,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_deno-lab-workshop_1");
|
assertEquals(json.username, "guest_event_1");
|
||||||
} finally {
|
} finally {
|
||||||
sqlWrapper.sql = originalSql;
|
sqlWrapper.sql = originalSql;
|
||||||
valkeySetexStub.restore();
|
valkeySetexStub.restore();
|
||||||
@ -445,7 +445,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_deno-lab_2"'));
|
assert(text.includes('export AUTH_YES_USER="guest_event_2"'));
|
||||||
|
|
||||||
assert(auditCalled);
|
assert(auditCalled);
|
||||||
assertEquals(auditPayload.resource, "event-uuid-1");
|
assertEquals(auditPayload.resource, "event-uuid-1");
|
||||||
@ -592,4 +592,75 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /api/events/:id/attendees returns attendees list and event metadata",
|
||||||
|
async () => {
|
||||||
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === "admin-session") {
|
||||||
|
return Promise.resolve(
|
||||||
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expDate = new Date(Date.now() + 3600000).toISOString();
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (
|
||||||
|
query.includes(
|
||||||
|
"SELECT id, slug, max_seats, expires_at FROM event_passes",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "evt-123",
|
||||||
|
slug: "deno-lab",
|
||||||
|
max_seats: 50,
|
||||||
|
expires_at: expDate,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
if (query.includes("WHERE u.event_pass_id =")) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{
|
||||||
|
id: "sess-1",
|
||||||
|
label: "Seat #1",
|
||||||
|
is_paused: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
expires_at: expDate,
|
||||||
|
last_activity_at: new Date().toISOString(),
|
||||||
|
last_activity_action: "ForwardAuth Ingress",
|
||||||
|
username: "guest_evt_1",
|
||||||
|
display_name: "Deno Workshop Attendee",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/api/events/evt-123/attendees", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer admin-session",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert(json.success === true);
|
||||||
|
assertEquals(json.attendees.length, 1);
|
||||||
|
assertEquals(json.attendees[0].username, "guest_evt_1");
|
||||||
|
assertExists(json.event);
|
||||||
|
assertEquals(json.event.max_seats, 50);
|
||||||
|
assertEquals(json.event.expires_at, expDate);
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyGetStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
# Phase 7: Attendee Relational Decoupling & Immutable Identity
|
||||||
|
|
||||||
|
## 1. Context & Rationale
|
||||||
|
|
||||||
|
Currently, guest attendee sessions are bound to their originating event via
|
||||||
|
brittle string matching on `users.username` (e.g.,
|
||||||
|
`WHERE username LIKE 'guest_' || ep.slug || '_%'`). This creates two major
|
||||||
|
architectural flaws:
|
||||||
|
|
||||||
|
1. **Rotation Breakage:** If an event host rotates the event's ingress PIN/slug
|
||||||
|
(e.g., `deno-lab-1a2b` to `deno-lab-9x8z`), the backend loses track of
|
||||||
|
previously joined guests because their usernames retain the old slug suffix.
|
||||||
|
2. **Unwieldy Data:** Guest usernames inherit the full event slug, leading to
|
||||||
|
massive, unreadable usernames in logs and the database (e.g.,
|
||||||
|
`guest_annual-cyber-security-training-workshop-4f8x_12`).
|
||||||
|
|
||||||
|
## 2. Objective
|
||||||
|
|
||||||
|
Implement a robust **"A+B Architecture"** to permanently decouple guest session
|
||||||
|
lifecycle from transient URL slugs:
|
||||||
|
|
||||||
|
- **Option A (Relational Integrity):** Link guests directly to the Event UUID
|
||||||
|
via a strict Foreign Key (`event_pass_id`).
|
||||||
|
- **Option B (Immutable Identity):** Mint short, deterministic, and immutable
|
||||||
|
usernames using the Event UUID prefix.
|
||||||
|
|
||||||
|
## 3. Scope & Acceptance Criteria
|
||||||
|
|
||||||
|
### 3.1 Database Migration (`server/db.ts`)
|
||||||
|
|
||||||
|
- [ ] Add column:
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS event_pass_id UUID REFERENCES event_passes(id) ON DELETE CASCADE;`
|
||||||
|
- [ ] Ensure backward compatibility with existing standalone users (column
|
||||||
|
should be nullable, as admins/owners won't have an `event_pass_id`).
|
||||||
|
|
||||||
|
### 3.2 Immutable Username Minting (`server/routes/events.ts`)
|
||||||
|
|
||||||
|
- [ ] In `POST /api/join`: Change guest username generation from
|
||||||
|
`guest_${event.slug}_${seat}` to
|
||||||
|
`guest_${event.id.split('-')[0]}_${seat}`.
|
||||||
|
- [ ] In `POST /api/join`: Insert `event.id` into the new `event_pass_id` column
|
||||||
|
for the newly minted user.
|
||||||
|
|
||||||
|
### 3.3 Query Refactoring (Zero String Parsing)
|
||||||
|
|
||||||
|
- [ ] **`GET /api/events/:id/attendees`**: Refactor the query to use
|
||||||
|
`WHERE u.event_pass_id = ${eventId}`.
|
||||||
|
- [ ] **`POST /api/events/:id/extend`**: Refactor the query to target sessions
|
||||||
|
belonging to `u.event_pass_id = ${eventId}`.
|
||||||
|
- [ ] **`POST /api/events/:id/end`**: Refactor the revocation query to target
|
||||||
|
`u.event_pass_id = ${eventId}`.
|
||||||
|
- [ ] **`DELETE /api/sessions/:id`**: Refactor the ownership check to verify
|
||||||
|
`u.event_pass_id = ${eventId}` instead of string parsing.
|
||||||
|
|
||||||
|
### 3.4 Quality Gates
|
||||||
|
|
||||||
|
- [ ] Run `deno fmt`, `deno task lint`, `deno task check`.
|
||||||
|
- [ ] All 67+ backend tests must pass. (Update `server/tests/events.test.ts` to
|
||||||
|
mock/handle the new `event_pass_id` column and short username generation).
|
||||||
|
|
||||||
|
## 4. Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **No `LIKE 'guest_%'` queries** anywhere in the backend logic.
|
||||||
|
- **No updating usernames on slug rotation.** The
|
||||||
|
`POST /api/events/:id/rotate-pin` endpoint should ONLY touch
|
||||||
|
`event_passes.slug` and `event_passes.pin_code`, leaving attendees completely
|
||||||
|
unaffected.
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
# TASK METADATA
|
||||||
|
|
||||||
|
- **Target Files:**
|
||||||
|
- `server/routes/events.ts`
|
||||||
|
- `ui/components/sessions/SessionsScript.tsx`
|
||||||
|
- `ui/components/sessions/EventCockpitDeck.tsx`
|
||||||
|
- `ui/components/SessionsPage.tsx`
|
||||||
|
- **Core Objective:** Resolve the final 4 untracked discrepancies identified
|
||||||
|
during the Phase 6 UI Audit (DOM desync, missing guest drawer payload, minor
|
||||||
|
typo, and missing sticky action bar).
|
||||||
|
- **Dependencies:** None.
|
||||||
|
- **Additional Important Notes:** This represents the final polish of the
|
||||||
|
`Sessions & Events` UX before moving on to backend relational decoupling and
|
||||||
|
P2P vouching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architectural Considerations & Risks
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
1. **DOM Ingress Desync:** Currently, when an organizer clicks `[ 🔄 Rotate ]`,
|
||||||
|
the backend successfully updates both the `pin_code` and the `slug`. However,
|
||||||
|
the frontend JavaScript (`rotatePin`) only targets and updates the PIN on the
|
||||||
|
UI. The copy buttons for "Link" and "CLI" continue to hold the old slug
|
||||||
|
because their `onclick` handlers are hardcoded during SSR. _Risk_: If an
|
||||||
|
organizer rotates the credentials and then clicks "Copy Link", they will
|
||||||
|
mistakenly share the revoked link, leading to confusion and 404s.
|
||||||
|
2. **Guest Drawer Broken Meta Header:** The backend endpoint
|
||||||
|
`GET /api/events/:id/attendees` only returns
|
||||||
|
`{ success: true, attendees: [...] }`. The client script expects `data.event`
|
||||||
|
to populate the `Max Seats` and `Expires At` countdown. Because `data.event`
|
||||||
|
is undefined, the header is permanently stuck displaying
|
||||||
|
`0 / 0 Claimed Seats · ⏳ 0h 0m left`. _Risk_: Loss of critical event context
|
||||||
|
while managing guests.
|
||||||
|
3. **Scroll Fatigue:** The `[ Delegate Session ]` drawer trigger sits at the top
|
||||||
|
of `SessionsPage.tsx`. As the user accumulates multiple events or sessions,
|
||||||
|
scrolling down the page hides the primary action.
|
||||||
|
|
||||||
|
### Alternatives
|
||||||
|
|
||||||
|
- For the Sticky Action Bar, we could use CSS `position: sticky; top: 0;` on the
|
||||||
|
delegation tabs block in `SessionsPage.tsx`, ensuring the action is always
|
||||||
|
visible without creating a separate floating action button (FAB) which might
|
||||||
|
clutter mobile views.
|
||||||
|
- For the UI DOM rotation, we must assign `id` attributes to the Link and CLI
|
||||||
|
copy pills in `EventCockpitDeck.tsx` so `rotatePin` can dynamically overwrite
|
||||||
|
their `onclick` attributes.
|
||||||
|
|
||||||
|
## 3. Proposed Implementation
|
||||||
|
|
||||||
|
### Phase 1: Fix Backend Attendees Payload
|
||||||
|
|
||||||
|
1. In `server/routes/events.ts`, locate `GET /api/events/:id/attendees` (around
|
||||||
|
line 125).
|
||||||
|
2. Currently, the query selects only `slug` to do the `LIKE` match. Update this
|
||||||
|
to fetch `max_seats` and `expires_at` as well.
|
||||||
|
3. Include the event payload in the JSON response:
|
||||||
|
`return c.json({ success: true, attendees, event: { max_seats: event.max_seats, expires_at: event.expires_at } })`.
|
||||||
|
|
||||||
|
### Phase 2: Fix DOM Rotation Desync
|
||||||
|
|
||||||
|
1. In `ui/components/sessions/EventCockpitDeck.tsx`:
|
||||||
|
- Locate the Grid and Compact card copy buttons for Link and CLI.
|
||||||
|
- Add explicit IDs, for example: `id={'link-copy-' + event.id}` and
|
||||||
|
`id={'cli-copy-' + event.id}`.
|
||||||
|
2. In `ui/components/sessions/SessionsScript.tsx` (`rotatePin` function):
|
||||||
|
- Check if `data.slug` is returned (Jules updated the backend to return
|
||||||
|
`pinCode, slug, link, cli`).
|
||||||
|
- If `data.slug` is present, locate the link and CLI copy elements and
|
||||||
|
overwrite their `onclick` handlers dynamically so they copy the fresh
|
||||||
|
credentials.
|
||||||
|
- E.g.,
|
||||||
|
`linkElem.setAttribute('onclick', "copyText(window.location.origin + '/e/" + data.slug + "')");`
|
||||||
|
|
||||||
|
### Phase 3: Section Title Typo
|
||||||
|
|
||||||
|
1. In `ui/components/sessions/EventCockpitDeck.tsx` (around line 8), change
|
||||||
|
`<h2>Event Passes</h2>` to `<h2>Events</h2>`.
|
||||||
|
|
||||||
|
### Phase 4: Sticky Delegation Action Bar
|
||||||
|
|
||||||
|
1. In `ui/components/SessionsPage.tsx`, wrap the `Delegate Session` tab
|
||||||
|
selection block
|
||||||
|
(`<div style="display: flex; gap: 0.5rem; margin-bottom: 2rem;">...</div>`)
|
||||||
|
in a sticky container.
|
||||||
|
2. Example styling:
|
||||||
|
`position: sticky; top: 1rem; z-index: 50; background: var(--surface-bg); padding-top: 1rem; margin-top: -1rem; margin-bottom: 2rem;`.
|
||||||
|
3. Add a slight box-shadow or bottom border on scroll (optional polish) to
|
||||||
|
ensure the tabs float cleanly above scrolling session cards.
|
||||||
|
|
||||||
|
### Phase 5: Quality Gates
|
||||||
|
|
||||||
|
1. Run `deno fmt`, `deno task lint`, and `deno task check`.
|
||||||
|
2. Verify all `ui/ui_scripts.test.ts` pass and the DOM manipulation syntax is
|
||||||
|
correct.
|
||||||
|
3. Author a new test block in `server/tests/events.test.ts` for
|
||||||
|
`GET /api/events/:id/attendees` to assert that it successfully returns both
|
||||||
|
the `attendees` array and the `event` payload containing `max_seats` and
|
||||||
|
`expires_at`.
|
||||||
55
tasks/ui-audit-3.md
Normal file
55
tasks/ui-audit-3.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# UI & UX Live Audit Log — Round 3 (`ui-audit-3.md`)
|
||||||
|
|
||||||
|
**Date:** 2026-08-26 (Session: 21:30)\
|
||||||
|
**Target Environments:** `https://auth.atyg.org` | `https://ed-droid.atyg.org`\
|
||||||
|
**Scope:** Verification of Phase 5 Deliverables (True Slide-Over Guest Drawer,
|
||||||
|
Multi-Event Compact Toggle, Standardized Countdown Pills, Mobile Density
|
||||||
|
Optimization, Backend Revocation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Verification Focus Areas
|
||||||
|
|
||||||
|
| # | Feature / Flow | Target Behavior | Status |
|
||||||
|
| :---- | :------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------- |
|
||||||
|
| **1** | **Slide-Over Guest Drawer** | Clicking `[ 👥 Manage Guests (N) ]` opens a fixed right-side panel on Desktop (`420px; 100vh`) and bottom sheet on Mobile (`100vw; 80vh`) with backdrop fade, `Escape` key close, and backdrop click dismissal. | ⏳ Pending Test |
|
||||||
|
| **2** | **Guest Drawer Information IA** | Pinned contextual header showing `[Event Name] Guests` + `N / Max Claimed Seats · ⏳ Xh Ym left · (Expires Time)`. Individual rows only display unique data (`Seat #[N]`, `Username`, `Joined relative time`, `🟢 Active / ⏸️ Paused`). | ⏳ Pending Test |
|
||||||
|
| **3** | **Backend Revocation Fix** | Event creator clicking `[ 🗑️ Revoke ]` inside the Guest Drawer immediately deletes the attendee session from Valkey cache and DB without 404 access-denied errors. | ⏳ Pending Test |
|
||||||
|
| **4** | **Multi-Event Compact Toggle** | `[ 🗂️ Grid ]` vs `[ 📋 Compact ]` toggle collapses 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,9 +34,9 @@ export const SessionsPage = ({
|
|||||||
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
<div style="position: sticky; top: 0; z-index: 40; background: var(--surface-bg, #0f172a); padding: 0.75rem 0; margin-bottom: 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; border-bottom: 1px solid var(--border-subtle);">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.25rem 0; color: var(--text-primary);">
|
||||||
Sessions & Events
|
Sessions & Events
|
||||||
</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;">
|
||||||
|
|||||||
@ -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);">
|
||||||
Event Passes
|
Events
|
||||||
</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
|
||||||
@ -113,11 +113,15 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
Link:
|
Link:
|
||||||
</span>
|
</span>
|
||||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
<code
|
||||||
|
id={`link-code-${event.id}`}
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||||
|
>
|
||||||
/e/{event.slug}
|
/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;"
|
||||||
@ -135,13 +139,17 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||||
CLI:
|
CLI:
|
||||||
</span>
|
</span>
|
||||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;">
|
<code
|
||||||
|
id={`cli-code-${event.id}`}
|
||||||
|
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;"
|
||||||
|
>
|
||||||
curl -sSL {Deno.env.get("RP_ID")
|
curl -sSL {Deno.env.get("RP_ID")
|
||||||
? `https://${Deno.env.get("RP_ID")}`
|
? `https://${Deno.env.get("RP_ID")}`
|
||||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={`cli-btn-${event.id}`}
|
||||||
class="btn-outline"
|
class="btn-outline"
|
||||||
aria-label={`Copy CLI command for ${event.name}`}
|
aria-label={`Copy CLI command 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;"
|
||||||
@ -154,6 +162,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
</span>
|
</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;"
|
||||||
@ -236,12 +245,14 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
|||||||
(Copy)
|
(Copy)
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
id={`compact-link-${event.id}`}
|
||||||
class="compact-pill"
|
class="compact-pill"
|
||||||
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||||
>
|
>
|
||||||
Link (Copy)
|
Link (Copy)
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
id={`compact-cli-${event.id}`}
|
||||||
class="compact-pill"
|
class="compact-pill"
|
||||||
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -297,12 +297,69 @@ export const SessionsScript = () => {
|
|||||||
if (pinElem) {
|
if (pinElem) {
|
||||||
pinElem.textContent = data.pinCode;
|
pinElem.textContent = data.pinCode;
|
||||||
}
|
}
|
||||||
showNotice('Event PIN rotated successfully', false);
|
const compactPinElem = document.getElementById('compact-pin-' + eventId);
|
||||||
|
if (compactPinElem) {
|
||||||
|
compactPinElem.textContent = data.pinCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.slug) {
|
||||||
|
const linkUrl = window.location.origin + '/e/' + data.slug;
|
||||||
|
|
||||||
|
// Extract original base URL from cli code to preserve RP_ID
|
||||||
|
let cliCmd = 'curl -sSL ' + window.location.origin + '/join/' + data.slug + '?format=env | source /dev/stdin';
|
||||||
|
const cliCodeElem = document.getElementById('cli-code-' + eventId);
|
||||||
|
if (cliCodeElem && cliCodeElem.textContent) {
|
||||||
|
const parts = cliCodeElem.textContent.trim().split(' ');
|
||||||
|
if (parts.length > 2 && parts[2].startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(parts[2]);
|
||||||
|
cliCmd = 'curl -sSL ' + urlObj.origin + '/join/' + data.slug + '?format=env | source /dev/stdin';
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid View
|
||||||
|
const linkCodeElem = document.getElementById('link-code-' + eventId);
|
||||||
|
if (linkCodeElem) linkCodeElem.textContent = '/e/' + data.slug;
|
||||||
|
|
||||||
|
const linkBtnElem = document.getElementById('link-btn-' + eventId);
|
||||||
|
if (linkBtnElem) {
|
||||||
|
linkBtnElem.onclick = () => copyText(linkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliCodeElem) cliCodeElem.textContent = cliCmd;
|
||||||
|
|
||||||
|
const cliBtnElem = document.getElementById('cli-btn-' + eventId);
|
||||||
|
if (cliBtnElem) {
|
||||||
|
cliBtnElem.onclick = (e) => {
|
||||||
|
if (e && e.stopPropagation) e.stopPropagation();
|
||||||
|
copyText(cliCmd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliTextareaElem = document.getElementById('cli-textarea-' + eventId);
|
||||||
|
if (cliTextareaElem) cliTextareaElem.value = cliCmd;
|
||||||
|
|
||||||
|
// Compact View
|
||||||
|
const compactLinkElem = document.getElementById('compact-link-' + eventId);
|
||||||
|
if (compactLinkElem) {
|
||||||
|
compactLinkElem.onclick = () => copyText(linkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactCliElem = document.getElementById('compact-cli-' + eventId);
|
||||||
|
if (compactCliElem) {
|
||||||
|
compactCliElem.onclick = () => copyText(cliCmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotice('Event credentials rotated successfully', false);
|
||||||
} else {
|
} else {
|
||||||
showNotice(data.error || 'Failed to rotate PIN', true);
|
showNotice(data.error || 'Failed to rotate credentials', true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showNotice('Network error rotating PIN', true);
|
showNotice('Network error rotating credentials', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user