feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner
This commit is contained in:
parent
368150c2c0
commit
40804fd813
18
server/db.ts
18
server/db.ts
@ -206,6 +206,24 @@ export async function initDb(): Promise<void> {
|
|||||||
// Ignore migration column exists
|
// 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,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS audit_sths (
|
CREATE TABLE IF NOT EXISTS audit_sths (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@ -939,3 +939,260 @@ Deno.test("Agent Session Delegation & Scoped Permissions", async (t) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => {
|
||||||
|
await t.step("GET /pass with missing token redirects to login", async () => {
|
||||||
|
const res = await app.request("/pass", { method: "GET" });
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("location"),
|
||||||
|
"/login?error=invalid_or_expired_pass",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step("GET /pass with invalid token redirects to login", async () => {
|
||||||
|
const valkeyStub = stub(valkey, "get", () => Promise.resolve(null));
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = (() => Promise.resolve([])) as any;
|
||||||
|
try {
|
||||||
|
const res = await app.request("/pass?token=invalid", { method: "GET" });
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(
|
||||||
|
res.headers.get("location"),
|
||||||
|
"/login?error=invalid_or_expired_pass",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /pass with valid token sets cookies and redirects to app domain",
|
||||||
|
async () => {
|
||||||
|
const sessionToken = "ay_sess_valid_app";
|
||||||
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === sessionToken) {
|
||||||
|
return Promise.resolve(JSON.stringify({
|
||||||
|
uuid: "user-uuid",
|
||||||
|
username: "testuser",
|
||||||
|
customScopes: ["app:ed-droid"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
|
||||||
|
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("SELECT domain FROM apps WHERE name =")) {
|
||||||
|
return Promise.resolve([{ domain: "ed-droid.atyg.org" }]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request(`/pass?token=${sessionToken}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(res.headers.get("location"), "https://ed-droid.atyg.org");
|
||||||
|
|
||||||
|
const cookies = res.headers.get("set-cookie");
|
||||||
|
assertExists(cookies);
|
||||||
|
assert(cookies.includes(`session_id=${sessionToken};`));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyGetStub.restore();
|
||||||
|
valkeyTtlStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /pass with valid token defaults to /dashboard if no app scope",
|
||||||
|
async () => {
|
||||||
|
const sessionToken = "ay_sess_valid_dashboard";
|
||||||
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||||
|
if (String(key) === sessionToken) {
|
||||||
|
return Promise.resolve(JSON.stringify({
|
||||||
|
uuid: "user-uuid",
|
||||||
|
username: "testuser",
|
||||||
|
customScopes: ["read:audit"],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request(`/pass?token=${sessionToken}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
assertEquals(res.status, 302);
|
||||||
|
assertEquals(res.headers.get("location"), "/dashboard");
|
||||||
|
} finally {
|
||||||
|
valkeyGetStub.restore();
|
||||||
|
valkeyTtlStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||||
|
await t.step("POST /api/events creates an event pass", async () => {
|
||||||
|
const valkeyStub = 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;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("INSERT INTO event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 0,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/api/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer admin-session",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Deno Workshop",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pinCode: "749-123",
|
||||||
|
maxSeats: 50,
|
||||||
|
lifespanHours: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert(json.success === true);
|
||||||
|
assertEquals(json.event.slug, "deno-lab");
|
||||||
|
assertEquals(json.event.pin_code, "749-123");
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeyStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"POST /api/join redeems PIN / slug and mints guest session",
|
||||||
|
async () => {
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("UPDATE event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 1,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
app_id: null,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const valkeySetexStub = stub(
|
||||||
|
valkey,
|
||||||
|
"setex",
|
||||||
|
() => Promise.resolve("OK" as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/api/join", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "749-123" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert(json.success === true);
|
||||||
|
assert(json.token.startsWith("ay_sess_"));
|
||||||
|
assertEquals(json.username, "guest_deno-lab_1");
|
||||||
|
|
||||||
|
const cookies = res.headers.get("set-cookie");
|
||||||
|
assertExists(cookies);
|
||||||
|
assert(cookies.includes(`session_id=${json.token};`));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeySetexStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.step(
|
||||||
|
"GET /join/:slug?format=env returns CLI export string",
|
||||||
|
async () => {
|
||||||
|
const originalSql = sqlWrapper.sql;
|
||||||
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||||
|
const query = Array.isArray(strings)
|
||||||
|
? strings.join("?")
|
||||||
|
: String(strings);
|
||||||
|
if (query.includes("UPDATE event_passes")) {
|
||||||
|
return Promise.resolve([{
|
||||||
|
id: "event-uuid-1",
|
||||||
|
slug: "deno-lab",
|
||||||
|
pin_code: "749-123",
|
||||||
|
name: "Deno Workshop",
|
||||||
|
max_seats: 50,
|
||||||
|
seats_claimed: 2,
|
||||||
|
lifespan_hours: 3,
|
||||||
|
app_id: null,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const valkeySetexStub = stub(
|
||||||
|
valkey,
|
||||||
|
"setex",
|
||||||
|
() => Promise.resolve("OK" as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.request("/join/deno-lab?format=env", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
const text = await res.text();
|
||||||
|
assert(text.includes('export AUTH_YES_TOKEN="ay_sess_'));
|
||||||
|
assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"'));
|
||||||
|
} finally {
|
||||||
|
sqlWrapper.sql = originalSql;
|
||||||
|
valkeySetexStub.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
294
server/main.ts
294
server/main.ts
@ -12,11 +12,16 @@ import type {
|
|||||||
RegistrationResponseJSON,
|
RegistrationResponseJSON,
|
||||||
} from "jsr:@simplewebauthn/server@13";
|
} from "jsr:@simplewebauthn/server@13";
|
||||||
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||||
import { extractAllSessionIds } from "./auth-session.ts";
|
import {
|
||||||
|
extractAllSessionIds,
|
||||||
|
getAuthenticatedUser,
|
||||||
|
isGlobalAdmin,
|
||||||
|
} from "./auth-session.ts";
|
||||||
import {
|
import {
|
||||||
decodeBase64Url,
|
decodeBase64Url,
|
||||||
encodeBase64Url,
|
encodeBase64Url,
|
||||||
} from "jsr:@std/encoding@1/base64url";
|
} from "jsr:@std/encoding@1/base64url";
|
||||||
|
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||||
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
||||||
import { initDb, sqlWrapper } from "./db.ts";
|
import { initDb, sqlWrapper } from "./db.ts";
|
||||||
import { pingValkey, valkey } from "./valkey.ts";
|
import { pingValkey, valkey } from "./valkey.ts";
|
||||||
@ -30,6 +35,8 @@ import {
|
|||||||
} from "npm:@connectrpc/connect@^1.4.0/protocol";
|
} from "npm:@connectrpc/connect@^1.4.0/protocol";
|
||||||
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
||||||
import { uiApp } from "../ui/mod.ts";
|
import { uiApp } from "../ui/mod.ts";
|
||||||
|
import { EventJoinPage } from "../ui/components/EventJoinPage.tsx";
|
||||||
|
import { EventSplashPage } from "../ui/components/EventSplashPage.tsx";
|
||||||
|
|
||||||
type Variables = {
|
type Variables = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -193,7 +200,7 @@ app.get("/pass", async (c) => {
|
|||||||
sessionDataStr = await valkey.get(token);
|
sessionDataStr = await valkey.get(token);
|
||||||
} catch (_err) {}
|
} catch (_err) {}
|
||||||
|
|
||||||
let sessionInfo = null;
|
let sessionInfo: any = null;
|
||||||
if (sessionDataStr) {
|
if (sessionDataStr) {
|
||||||
try {
|
try {
|
||||||
sessionInfo = JSON.parse(sessionDataStr);
|
sessionInfo = JSON.parse(sessionDataStr);
|
||||||
@ -244,7 +251,7 @@ app.get("/pass", async (c) => {
|
|||||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||||
}
|
}
|
||||||
expiresAtDate = new Date(Date.now() + ttl * 1000);
|
expiresAtDate = new Date(Date.now() + ttl * 1000);
|
||||||
customScopes = sessionInfo.customScopes || [];
|
customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes || [];
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||||
}
|
}
|
||||||
@ -274,9 +281,11 @@ app.get("/pass", async (c) => {
|
|||||||
// 3. Redirect URL Resolution
|
// 3. Redirect URL Resolution
|
||||||
let targetDomain = null;
|
let targetDomain = null;
|
||||||
if (Array.isArray(customScopes)) {
|
if (Array.isArray(customScopes)) {
|
||||||
const appScope = customScopes.find((s: string) => s.startsWith("app:"));
|
const appScope = customScopes.find((s: string) =>
|
||||||
|
typeof s === "string" && s.startsWith("app:")
|
||||||
|
);
|
||||||
if (appScope) {
|
if (appScope) {
|
||||||
const appName = appScope.substring(4); // remove 'app:'
|
const appName = appScope.substring(4);
|
||||||
try {
|
try {
|
||||||
const appRecord = await sqlWrapper.sql`
|
const appRecord = await sqlWrapper.sql`
|
||||||
SELECT domain FROM apps WHERE name = ${appName}
|
SELECT domain FROM apps WHERE name = ${appName}
|
||||||
@ -296,6 +305,279 @@ app.get("/pass", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
// Multi-Claim Event Passes & Short Code Join Portal
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
app.post("/api/events", async (c) => {
|
||||||
|
const user = await getAuthenticatedUser(c);
|
||||||
|
if (!user) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } =
|
||||||
|
body;
|
||||||
|
let { slug, pinCode } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== "string") {
|
||||||
|
return c.json({ error: "Event name is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
slug =
|
||||||
|
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") +
|
||||||
|
"-" + Math.random().toString(36).substring(2, 6);
|
||||||
|
}
|
||||||
|
if (!pinCode) {
|
||||||
|
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + Number(lifespanHours) * 3600 * 1000,
|
||||||
|
);
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
|
||||||
|
VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${
|
||||||
|
Number(maxSeats)
|
||||||
|
}, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return c.json({ success: true, event: result[0] });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Events] Failed to create event pass:", e);
|
||||||
|
return c.json({ error: "Failed to create event pass" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/join", async (c) => {
|
||||||
|
let code = "";
|
||||||
|
if (
|
||||||
|
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
|
||||||
|
) {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
code = (fd.get("code") as string) || "";
|
||||||
|
} else {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
code = body.code || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== "string") {
|
||||||
|
return c.json({ error: "Event code or PIN is required" }, 400);
|
||||||
|
}
|
||||||
|
code = code.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET seats_claimed = seats_claimed + 1
|
||||||
|
WHERE (slug = ${code} OR pin_code = ${code})
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
error: "Invalid event code or workshop capacity reached",
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = result[0];
|
||||||
|
const guestUuid = crypto.randomUUID();
|
||||||
|
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO users (id, username, display_name, account_status)
|
||||||
|
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||||
|
const label = `${event.name} Seat #${event.seats_claimed}`;
|
||||||
|
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
||||||
|
|
||||||
|
let customScopes = ["guest", "trial"];
|
||||||
|
let appDomain = "";
|
||||||
|
|
||||||
|
if (event.app_id) {
|
||||||
|
const apps = await sqlWrapper
|
||||||
|
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
|
||||||
|
if (apps.length > 0) {
|
||||||
|
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
|
||||||
|
appDomain = apps[0].domain || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||||
|
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||||
|
`;
|
||||||
|
|
||||||
|
await valkey.setex(
|
||||||
|
sessionId,
|
||||||
|
ttl,
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: guestUuid,
|
||||||
|
username,
|
||||||
|
account_status: "guest",
|
||||||
|
customScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
const cookieDomain = getCookieDomain(rpID);
|
||||||
|
|
||||||
|
setCookie(c, "session_id", sessionId, {
|
||||||
|
domain: cookieDomain,
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: ttl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
|
||||||
|
|
||||||
|
if (
|
||||||
|
c.req.header("accept")?.includes("text/html") &&
|
||||||
|
!c.req.header("accept")?.includes("application/json")
|
||||||
|
) {
|
||||||
|
return c.redirect(redirectUrl, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
token: sessionId,
|
||||||
|
guestUuid,
|
||||||
|
username,
|
||||||
|
redirectUrl,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Events] Failed to join event:", e);
|
||||||
|
return c.json({ error: "Failed to join event" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/join/:slug", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
const format = c.req.query("format") || "html";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET seats_claimed = seats_claimed + 1
|
||||||
|
WHERE slug = ${slug}
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
if (format === "env" || format === "json") {
|
||||||
|
return c.text("Invalid slug or workshop capacity reached", 404);
|
||||||
|
}
|
||||||
|
return c.redirect("/join?error=not_found", 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = result[0];
|
||||||
|
const guestUuid = crypto.randomUUID();
|
||||||
|
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO users (id, username, display_name, account_status)
|
||||||
|
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||||
|
const label = `${event.name} Seat #${event.seats_claimed}`;
|
||||||
|
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
||||||
|
|
||||||
|
let customScopes = ["guest", "trial"];
|
||||||
|
if (event.app_id) {
|
||||||
|
const apps = await sqlWrapper
|
||||||
|
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
|
||||||
|
if (apps.length > 0) {
|
||||||
|
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||||
|
|
||||||
|
await sqlWrapper.sql`
|
||||||
|
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||||
|
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||||
|
`;
|
||||||
|
|
||||||
|
await valkey.setex(
|
||||||
|
sessionId,
|
||||||
|
ttl,
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: guestUuid,
|
||||||
|
username,
|
||||||
|
account_status: "guest",
|
||||||
|
customScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (format === "env") {
|
||||||
|
return c.text(
|
||||||
|
`export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`,
|
||||||
|
);
|
||||||
|
} else if (format === "json") {
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
token: sessionId,
|
||||||
|
username,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
setCookie(c, "session_id", sessionId, {
|
||||||
|
domain: getCookieDomain(rpID),
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: ttl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.redirect("/dashboard", 302);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Events] Failed to execute CLI join:", e);
|
||||||
|
return c.text("Internal Server Error", 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/join", (c) => {
|
||||||
|
return c.html(EventJoinPage());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/e/:slug", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
try {
|
||||||
|
const result = await sqlWrapper.sql`
|
||||||
|
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
|
||||||
|
`;
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return c.redirect("/join?error=event_not_found", 302);
|
||||||
|
}
|
||||||
|
return c.html(EventSplashPage({ event: result[0] }));
|
||||||
|
} catch (_e) {
|
||||||
|
return c.redirect("/join?error=db_error", 302);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
>>>>>>> 0f4b72e (feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner)
|
||||||
// Provisioning & Registration (Use Cases 1, 2, 3)
|
// Provisioning & Registration (Use Cases 1, 2, 3)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
@ -1143,8 +1425,6 @@ app.all("/auth.v1.AuthService/*", async (c) => {
|
|||||||
// Session & Credential Management (Authenticated APIs)
|
// Session & Credential Management (Authenticated APIs)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
import { getAuthenticatedUser, isGlobalAdmin } from "./auth-session.ts";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
// Global Admin APIs (For the Management Console)
|
// Global Admin APIs (For the Management Console)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
123
ui/components/EventJoinPage.tsx
Normal file
123
ui/components/EventJoinPage.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Layout } from "./Layout.tsx";
|
||||||
|
|
||||||
|
export const EventJoinPage = () => {
|
||||||
|
return (
|
||||||
|
<Layout title="Join Event & Workshop">
|
||||||
|
<div>
|
||||||
|
<div class="brand-header">
|
||||||
|
<div
|
||||||
|
class="brand-logo"
|
||||||
|
style="background: var(--primary-light); color: var(--primary);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
|
||||||
|
</path>
|
||||||
|
<path d="M13 5v2"></path>
|
||||||
|
<path d="M13 17v2"></path>
|
||||||
|
<path d="M13 11v2"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Join Event or Workshop</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Enter your event PIN code or slug to claim an instant sandbox seat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="joinForm" onsubmit="handleJoin(event)">
|
||||||
|
<div style="margin-bottom: 1.25rem;">
|
||||||
|
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||||
|
Event PIN or Slug Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="eventCode"
|
||||||
|
placeholder="e.g. 749-123 or deno-lab"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
style="width: 100%; font-size: 1.1rem; text-align: center; letter-spacing: 0.05em; font-weight: 600; min-height: 48px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="joinNotice"
|
||||||
|
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="joinBtn"
|
||||||
|
class="btn-primary"
|
||||||
|
style="width: 100%; min-height: 48px; font-size: 1rem;"
|
||||||
|
>
|
||||||
|
⚡ Enter Workshop
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
Looking for standard sign in?{" "}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
style="color: var(--primary); text-decoration: none; font-weight: 600;"
|
||||||
|
>
|
||||||
|
Sign in with Passkey
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
async function handleJoin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const code = document.getElementById('eventCode').value.trim();
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('joinBtn');
|
||||||
|
const notice = document.getElementById('joinNotice');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Claiming Seat...';
|
||||||
|
notice.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = data.redirectUrl || '/dashboard';
|
||||||
|
} else {
|
||||||
|
notice.textContent = data.error || 'Invalid event code or workshop is full';
|
||||||
|
notice.style.display = 'block';
|
||||||
|
notice.style.background = 'var(--danger-bg)';
|
||||||
|
notice.style.color = 'var(--danger-text)';
|
||||||
|
notice.style.border = '1px solid var(--danger-border)';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⚡ Enter Workshop';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notice.textContent = 'Network error connecting to event';
|
||||||
|
notice.style.display = 'block';
|
||||||
|
notice.style.background = 'var(--danger-bg)';
|
||||||
|
notice.style.color = 'var(--danger-text)';
|
||||||
|
notice.style.border = '1px solid var(--danger-border)';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⚡ Enter Workshop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
ui/components/EventSplashPage.tsx
Normal file
161
ui/components/EventSplashPage.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { Layout } from "./Layout.tsx";
|
||||||
|
|
||||||
|
export const EventSplashPage = ({ event }: { event: any }) => {
|
||||||
|
const maxSeats = Number(event.max_seats) || 0;
|
||||||
|
const seatsClaimed = Number(event.seats_claimed) || 0;
|
||||||
|
const isFull = maxSeats > 0 && seatsClaimed >= maxSeats;
|
||||||
|
const seatsRemaining = maxSeats > 0
|
||||||
|
? Math.max(0, maxSeats - seatsClaimed)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={`Join ${event.name}`}>
|
||||||
|
<div>
|
||||||
|
<div class="brand-header">
|
||||||
|
<div
|
||||||
|
class="brand-logo"
|
||||||
|
style="background: var(--primary-light); color: var(--primary);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z">
|
||||||
|
</path>
|
||||||
|
<path d="M13 5v2"></path>
|
||||||
|
<path d="M13 17v2"></path>
|
||||||
|
<path d="M13 11v2"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>{event.name}</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
You've been invited to join this event sandbox session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
style="margin-bottom: 1.5rem; text-align: center; border: 1px solid var(--border-subtle); background: var(--surface-muted);"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap;">
|
||||||
|
{isFull
|
||||||
|
? (
|
||||||
|
<span class="badge badge-danger">
|
||||||
|
Workshop Full ({seatsClaimed}/{maxSeats})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: seatsRemaining !== null
|
||||||
|
? (
|
||||||
|
<span class="badge badge-success">
|
||||||
|
{seatsRemaining} seats remaining ({seatsClaimed}/{maxSeats})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span class="badge badge-success">
|
||||||
|
{seatsClaimed} attendees active (Open Access)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span class="badge badge-info">
|
||||||
|
⏱️ {event.lifespan_hours || 3}h Session
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.pin_code && (
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.5rem;">
|
||||||
|
Event PIN:{" "}
|
||||||
|
<code style="font-weight: 700; color: var(--primary); font-size: 0.95rem;">
|
||||||
|
{event.pin_code}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="splashNotice"
|
||||||
|
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isFull
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="joinSplashBtn"
|
||||||
|
class="btn-primary"
|
||||||
|
style="width: 100%; min-height: 52px; font-size: 1.05rem; box-shadow: var(--shadow-sm);"
|
||||||
|
onclick={`handleSplashJoin('${event.slug}')`}
|
||||||
|
>
|
||||||
|
⚡ Enter Workshop & Claim Seat
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-outline"
|
||||||
|
disabled
|
||||||
|
style="width: 100%; min-height: 52px; opacity: 0.6; cursor: not-allowed;"
|
||||||
|
>
|
||||||
|
Workshop at Capacity
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
Standard account login?{" "}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
style="color: var(--primary); text-decoration: none; font-weight: 600;"
|
||||||
|
>
|
||||||
|
Sign in with Passkey
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
async function handleSplashJoin(slug) {
|
||||||
|
const btn = document.getElementById('joinSplashBtn');
|
||||||
|
const notice = document.getElementById('splashNotice');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Claiming Seat...';
|
||||||
|
notice.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: slug }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = data.redirectUrl || '/dashboard';
|
||||||
|
} else {
|
||||||
|
notice.textContent = data.error || 'Failed to join event';
|
||||||
|
notice.style.display = 'block';
|
||||||
|
notice.style.background = 'var(--danger-bg)';
|
||||||
|
notice.style.color = 'var(--danger-text)';
|
||||||
|
notice.style.border = '1px solid var(--danger-border)';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⚡ Enter Workshop & Claim Seat';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notice.textContent = 'Network error connecting to event';
|
||||||
|
notice.style.display = 'block';
|
||||||
|
notice.style.background = 'var(--danger-bg)';
|
||||||
|
notice.style.color = 'var(--danger-text)';
|
||||||
|
notice.style.border = '1px solid var(--danger-border)';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⚡ Enter Workshop & Claim Seat';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -297,7 +297,7 @@ export const SessionsPage = ({
|
|||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-outline"
|
class="btn-primary"
|
||||||
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="copyHandoff('magic')"
|
onclick="copyHandoff('magic')"
|
||||||
>
|
>
|
||||||
@ -314,12 +314,12 @@ export const SessionsPage = ({
|
|||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<code
|
<code
|
||||||
id="handoffCliText"
|
id="handoffCliText"
|
||||||
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--primary);"
|
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
|
||||||
>
|
>
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-outline"
|
||||||
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="copyHandoff('cli')"
|
onclick="copyHandoff('cli')"
|
||||||
>
|
>
|
||||||
@ -811,6 +811,7 @@ export const SessionsPage = ({
|
|||||||
|
|
||||||
function copyHandoff(type) {
|
function copyHandoff(type) {
|
||||||
let text = '';
|
let text = '';
|
||||||
|
if (type === 'link') text = document.getElementById('handoffLinkText').textContent;
|
||||||
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
|
if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
|
||||||
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
|
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
|
||||||
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
|
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user