Compare commits
1 Commits
main
...
backup/jul
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e253f928d |
14
scratch/add_data_ignore.ts
Normal file
14
scratch/add_data_ignore.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
let content = readFileSync("src/features/events/fragments.tsx", "utf-8");
|
||||
|
||||
// Add data-ignore to buttons with onclick="copyText..."
|
||||
content = content.replace(/onclick=\{`copyText/g, 'data-ignore onclick={`copyText');
|
||||
content = content.replace(/onclick=\{`event\.stopPropagation\(\); copyText/g, 'data-ignore onclick={`event.stopPropagation(); copyText');
|
||||
content = content.replace(/class="btn-outline extend-event-btn"/g, 'class="btn-outline extend-event-btn" data-on-click={`@post("/api/events/${event.id}/extend")`}');
|
||||
content = content.replace(/class="btn-danger end-event-btn"/g, 'class="btn-danger end-event-btn" data-on-click={`@post("/api/events/${event.id}/end")`}');
|
||||
content = content.replace(/onclick=\{`openAttendeesDrawer/g, 'data-on-click={`@get("/api/events/${event.id}/attendees")`} onclick={`openAttendeesDrawer');
|
||||
content = content.replace(/onclick=\{`expandSeats/g, 'data-on-click={`@post("/api/events/${event.id}/expand")`} onclick={`expandSeats');
|
||||
content = content.replace(/onclick=\{`rotatePin/g, 'data-on-click={`@post("/api/events/${event.id}/rotate-pin")`} onclick={`rotatePin');
|
||||
|
||||
writeFileSync("src/features/events/fragments.tsx", content);
|
||||
8
scratch/add_data_ignore_2.ts
Normal file
8
scratch/add_data_ignore_2.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
let content = readFileSync("src/features/events/fragments.tsx", "utf-8");
|
||||
|
||||
content = content.replace(/onclick="copyEventHandoff/g, 'data-ignore onclick="copyEventHandoff');
|
||||
content = content.replace(/id="submitEventBtn"/g, 'id="submitEventBtn" data-on-click="@post(\'/api/events\')"');
|
||||
|
||||
writeFileSync("src/features/events/fragments.tsx", content);
|
||||
8
scratch/add_data_ignore_sessions.ts
Normal file
8
scratch/add_data_ignore_sessions.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8");
|
||||
|
||||
content = content.replace(/onclick="copyHandoff/g, 'data-ignore onclick="copyHandoff');
|
||||
content = content.replace(/class="btn-danger revoke-btn"/g, 'class="btn-danger revoke-btn" data-on-click={`@delete("/api/sessions/${session.id}")`}');
|
||||
content = content.replace(/onclick=\{`extendSession\('\$\{session.id\}', 1\)`\}/g, 'data-on-click={`@post("/api/sessions/${session.id}/extend")`}');
|
||||
|
||||
writeFileSync("src/features/sessions/fragments.tsx", content);
|
||||
8
scratch/clean.ts
Normal file
8
scratch/clean.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8");
|
||||
content = content.replace(" );\n};", " );\n};"); // no-op just to make sure
|
||||
let lines = content.split('\n');
|
||||
lines[466] = ' );';
|
||||
lines[467] = '};';
|
||||
lines[468] = '';
|
||||
writeFileSync("src/features/sessions/fragments.tsx", lines.join('\n'));
|
||||
15
scratch/fix_imports.ts
Normal file
15
scratch/fix_imports.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
let content = readFileSync("src/features/events/routes.tsx", "utf-8");
|
||||
|
||||
content = content.replace('../../core/auth_session.ts', '../../../server/auth-session.ts');
|
||||
content = content.replace('../../core/middleware.ts', '../../../server/middleware.ts');
|
||||
content = content.replace('../../core/audit.ts', '../../../server/audit.ts');
|
||||
content = content.replace('../../core/ratelimit.ts', '../../../server/ratelimit.ts');
|
||||
|
||||
content = content.replace(/data: errorHtml/g, 'data: await errorHtml'); // Since it might be a promise of HtmlEscapedString
|
||||
|
||||
// Fix implicitly any array
|
||||
content = content.replace(/const events = \[\];/g, 'const events: any[] = [];');
|
||||
|
||||
writeFileSync("src/features/events/routes.tsx", content);
|
||||
4
scratch/fix_test.ts
Normal file
4
scratch/fix_test.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
let content = readFileSync("src/features/events/events.test.ts", "utf-8");
|
||||
content = content.replace('assertStringIncludes(html, "data-on-submit=\\\"@post(\'/api/join\')\\\"");', 'assertStringIncludes(html, "data-on-submit=\\\"@post('/api/join')\\\"");');
|
||||
writeFileSync("src/features/events/events.test.ts", content);
|
||||
23
scratch/replace.ts
Normal file
23
scratch/replace.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
let content = readFileSync("src/features/events/fragments.tsx", "utf-8");
|
||||
|
||||
const workshopDrawer = readFileSync("ui/components/sessions/WorkshopDrawer.tsx", "utf-8")
|
||||
.replace('export const WorkshopDrawer = ({ apps }: { apps: any[] }) => {', '')
|
||||
.replace(/}$/, '');
|
||||
|
||||
const eventGuestsDrawer = readFileSync("ui/components/sessions/EventGuestsDrawer.tsx", "utf-8")
|
||||
.replace('export const EventGuestsDrawer = () => {', '')
|
||||
.replace(/}$/, '');
|
||||
|
||||
content = content.replace(
|
||||
'export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {\n return <div>Workshop Pass Drawer Placeholder</div>\n}',
|
||||
'export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {' + workshopDrawer + '}'
|
||||
);
|
||||
|
||||
content = content.replace(
|
||||
'export const GuestDrawerAttendeesFragment = () => {\n return <div>Guest Drawer Attendees Placeholder</div>\n}',
|
||||
'export const GuestDrawerAttendeesFragment = () => {' + eventGuestsDrawer + '}'
|
||||
);
|
||||
|
||||
writeFileSync("src/features/events/fragments.tsx", content);
|
||||
23
scratch/replace_sessions.ts
Normal file
23
scratch/replace_sessions.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8");
|
||||
|
||||
const directPassDrawer = readFileSync("ui/components/sessions/DirectPassDrawer.tsx", "utf-8")
|
||||
.replace('export const DirectPassDrawer = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {', '')
|
||||
.replace(/}$/, '');
|
||||
|
||||
const scopeModal = readFileSync("ui/components/sessions/ScopeModal.tsx", "utf-8")
|
||||
.replace('export const ScopeModal = ({ apps }: { apps: any[] }) => {', '')
|
||||
.replace(/}$/, '');
|
||||
|
||||
content = content.replace(
|
||||
'export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {\n// We will insert the content of DirectPassDrawer and adapt it to Datastar\n return <div>DirectPassDrawerFragment Placeholder</div>\n}',
|
||||
'export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {' + directPassDrawer + '}'
|
||||
);
|
||||
|
||||
content = content.replace(
|
||||
'export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {\n return <div>ScopeModalFragment Placeholder</div>\n}',
|
||||
'export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {' + scopeModal + '}'
|
||||
);
|
||||
|
||||
writeFileSync("src/features/sessions/fragments.tsx", content);
|
||||
14
scratch/replace_sessions2.ts
Normal file
14
scratch/replace_sessions2.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
let content = readFileSync("src/features/sessions/fragments.tsx", "utf-8");
|
||||
|
||||
const sessionDeck = readFileSync("ui/components/sessions/SessionDeck.tsx", "utf-8")
|
||||
.replace('export const SessionDeck = (\n { sessions, currentSessionId }: {\n sessions: any[];\n currentSessionId: string;\n },\n) => {', '')
|
||||
.replace('export const SessionDeck = (\n { sessions, currentSessionId }: {\n sessions: any[];\n currentSessionId: string;\n }\n) => {', '')
|
||||
.replace(/}$/, '');
|
||||
|
||||
content = content.replace(
|
||||
'export const SessionDeckFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {\n return <div>SessionDeckFragment Placeholder</div>\n}',
|
||||
'export const SessionDeckFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {' + sessionDeck + '}'
|
||||
);
|
||||
|
||||
writeFileSync("src/features/sessions/fragments.tsx", content);
|
||||
32
src/features/events/events.test.ts
Normal file
32
src/features/events/events.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||
import { eventsRoutes } from "./routes.tsx";
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import * as Queries from "./queries.ts";
|
||||
|
||||
// Mocking authenticated user logic is complex in a simple pure unit test without a mock DB,
|
||||
// so we'll test the public join page for HTML response.
|
||||
|
||||
Deno.test("[Events] GET /join returns EventJoinPage HTML", async () => {
|
||||
const req = new Request("http://localhost/join");
|
||||
const res = await eventsRoutes.fetch(req);
|
||||
|
||||
assertEquals(res.status, 200);
|
||||
const html = await res.text();
|
||||
assertStringIncludes(html, "Join Event or Workshop");
|
||||
assertStringIncludes(html, "data-on-submit=\"@post('/api/join')\"");
|
||||
});
|
||||
|
||||
Deno.test("[Events] POST /api/join without code returns HTML error fragment", async () => {
|
||||
const req = new Request("http://localhost/api/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
// We expect it to fail early in validation and return an HTML error fragment
|
||||
const res = await eventsRoutes.fetch(req);
|
||||
assertEquals(res.status, 400);
|
||||
const html = await res.text();
|
||||
assertStringIncludes(html, "id=\"status-banner\"");
|
||||
assertStringIncludes(html, "Event code or PIN is required");
|
||||
});
|
||||
910
src/features/events/fragments.tsx
Normal file
910
src/features/events/fragments.tsx
Normal file
@ -0,0 +1,910 @@
|
||||
export const EventCockpitDeckFragment = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
if (!eventPasses || eventPasses.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.5rem; margin-bottom: 1rem;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--text-primary);">
|
||||
Events
|
||||
</h2>
|
||||
<div style="display: flex; background: var(--surface-muted); padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 2px;">
|
||||
<button
|
||||
type="button"
|
||||
id="viewModeGrid"
|
||||
class="view-mode-btn active"
|
||||
onclick="setEventViewMode('grid')"
|
||||
aria-label="Grid View"
|
||||
aria-pressed="true"
|
||||
>
|
||||
🗂️ Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="viewModeCompact"
|
||||
class="view-mode-btn"
|
||||
onclick="setEventViewMode('compact')"
|
||||
aria-label="Compact View"
|
||||
aria-pressed="false"
|
||||
>
|
||||
📋 Compact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="eventDeckContainer" class="grid-view">
|
||||
{eventPasses.map((event) => {
|
||||
const expDate = new Date(event.expires_at);
|
||||
const timeString = expDate.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
class="card event-card"
|
||||
style="border-left: 4px solid var(--primary); margin: 0;"
|
||||
>
|
||||
<div class="event-card-header">
|
||||
<div>
|
||||
<h3 style="margin: 0 0 0.25rem 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||
{event.name}
|
||||
</h3>
|
||||
<div
|
||||
class="countdown-pill"
|
||||
data-expires-at={event.expires_at}
|
||||
>
|
||||
⏳ 0h 0m left · (Expires {timeString})
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-success status-badge">Active</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar for Seats */}
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; margin-bottom: 0.25rem; color: var(--text-secondary);">
|
||||
<span>Seats Claimed</span>
|
||||
<strong style="color: var(--text-primary);">
|
||||
{event.seats_claimed} /{" "}
|
||||
{event.max_seats === 0 ? "∞" : event.max_seats}
|
||||
</strong>
|
||||
</div>
|
||||
<div style="width: 100%; height: 8px; background: var(--surface-muted); border-radius: 4px; overflow: hidden;">
|
||||
<div
|
||||
style={`height: 100%; background: var(--primary); width: ${
|
||||
event.max_seats > 0
|
||||
? Math.min(
|
||||
(event.seats_claimed / event.max_seats) * 100,
|
||||
100,
|
||||
)
|
||||
: 100
|
||||
}%;`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Copy Snippets */}
|
||||
<div
|
||||
class="event-card-snippets"
|
||||
style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem;"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||
PIN:
|
||||
</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.9rem; text-align: center; letter-spacing: 2px;"
|
||||
id={`pin-${event.id}`}
|
||||
>
|
||||
{event.pin_code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Copy PIN code for ${event.name}`}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
data-ignore onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||
Link:
|
||||
</span>
|
||||
<code
|
||||
id={`link-code-${event.id}`}
|
||||
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
/e/{event.slug}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
id={`link-btn-${event.id}`}
|
||||
class="btn-outline"
|
||||
aria-label={`Copy direct link for ${event.name}`}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
data-ignore onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details
|
||||
class="cli-details"
|
||||
style="margin-top: 0.1rem; width: 100%;"
|
||||
>
|
||||
<summary style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
|
||||
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
|
||||
CLI:
|
||||
</span>
|
||||
<code
|
||||
id={`cli-code-${event.id}`}
|
||||
style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;"
|
||||
>
|
||||
curl -sSL {Deno.env.get("RP_ID")
|
||||
? `https://${Deno.env.get("RP_ID")}`
|
||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
id={`cli-btn-${event.id}`}
|
||||
class="btn-outline"
|
||||
aria-label={`Copy CLI command for ${event.name}`}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
data-ignore onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
|
||||
[ ▾ ]
|
||||
</span>
|
||||
</summary>
|
||||
<textarea
|
||||
id={`cli-textarea-${event.id}`}
|
||||
readonly
|
||||
rows={2}
|
||||
style="width: 100%; margin-top: 0.4rem; padding: 0.4rem 0.5rem; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); resize: vertical; box-sizing: border-box;"
|
||||
onclick="this.select()"
|
||||
>
|
||||
{`curl -sSL ${
|
||||
Deno.env.get("RP_ID")
|
||||
? `https://${Deno.env.get("RP_ID")}`
|
||||
: ""
|
||||
}/join/${event.slug}?format=env | source /dev/stdin`}
|
||||
</textarea>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Cockpit Actions */}
|
||||
<div class="event-card-actions">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Manage attendees for ${event.name}`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
data-on-click={`@get("/api/events/${event.id}/attendees")`} onclick={`openAttendeesDrawer('${event.id}', '${
|
||||
event.name.replace(/'/g, "\\'")
|
||||
}')`}
|
||||
>
|
||||
👥 Manage Guests ({event.seats_claimed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Add 5 seats to ${event.name}`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.85rem;"
|
||||
data-on-click={`@post("/api/events/${event.id}/expand")`} onclick={`expandSeats('${event.id}', 5)`}
|
||||
>
|
||||
+5 Seats
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Rotate Credentials for ${event.name}`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||
data-on-click={`@post("/api/events/${event.id}/rotate-pin")`} onclick={`rotatePin('${event.id}')`}
|
||||
>
|
||||
🔄 Rotate Credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline extend-event-btn" data-on-click={`@post("/api/events/${event.id}/extend")`}
|
||||
data-event-id={event.id}
|
||||
aria-label={`Extend lifespan by 1 hour for ${event.name}`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||
>
|
||||
+1h Extend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger end-event-btn" data-on-click={`@post("/api/events/${event.id}/end")`}
|
||||
data-event-id={event.id}
|
||||
aria-label={`End event ${event.name} and revoke all attendees`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||
>
|
||||
End Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Actions (only visible in compact mode) */}
|
||||
<div class="event-card-compact-row-2">
|
||||
<div class="compact-pills">
|
||||
<span
|
||||
class="compact-pill"
|
||||
data-ignore onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
||||
>
|
||||
PIN:{" "}
|
||||
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
|
||||
{" "}
|
||||
(Copy)
|
||||
</span>
|
||||
<span
|
||||
id={`compact-link-${event.id}`}
|
||||
class="compact-pill"
|
||||
data-ignore onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||
>
|
||||
Link (Copy)
|
||||
</span>
|
||||
<span
|
||||
id={`compact-cli-${event.id}`}
|
||||
class="compact-pill"
|
||||
data-ignore onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
CLI (Copy)
|
||||
</span>
|
||||
</div>
|
||||
<div class="compact-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||
data-on-click={`@get("/api/events/${event.id}/attendees")`} onclick={`openAttendeesDrawer('${event.id}', '${
|
||||
event.name.replace(/'/g, "\\'")
|
||||
}')`}
|
||||
>
|
||||
👥 Guests ({event.seats_claimed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline extend-event-btn" data-on-click={`@post("/api/events/${event.id}/extend")`}
|
||||
data-event-id={event.id}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||
>
|
||||
🔄 +1h
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
.cli-details summary {
|
||||
list-style: none;
|
||||
}
|
||||
.cli-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.view-mode-btn.active {
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Grid View Layout */
|
||||
.grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.grid-view .event-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.grid-view .event-card-compact-row-2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact View Layout */
|
||||
.compact-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.compact-view .event-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 70px;
|
||||
}
|
||||
.compact-view .event-card > div {
|
||||
margin-bottom: 0 !important; /* Reset component margins */
|
||||
}
|
||||
.compact-view .event-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
.compact-view .event-card-header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.compact-view .event-card-header h3 {
|
||||
margin: 0 !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
.compact-view .event-card-header > .status-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.compact-view .event-card-compact-row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compact-view .compact-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.compact-view .compact-pill {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
.compact-view .compact-pill:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.compact-view .compact-actions-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compact-view .event-card-snippets,
|
||||
.compact-view .event-card-actions {
|
||||
display: none !important; /* Hide heavy elements in compact */
|
||||
}
|
||||
|
||||
/* Dynamic Countdown Colors */
|
||||
.countdown-pill {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.countdown-pill.status-green { color: var(--success); }
|
||||
.countdown-pill.status-amber { color: var(--warning); }
|
||||
.countdown-pill.status-red { color: var(--danger-text); }
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkshopPassDrawerFragment = ({ apps }: { apps: any[] }) => {
|
||||
return (
|
||||
<div id="tabWorkshopPass" style="display: none;">
|
||||
<div id="eventCreateState" style="display: block;">
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
|
||||
Generate a shared event pass with a universal 6-digit PIN, vanity link
|
||||
(<code style="font-size: 0.8rem;">/e/slug</code>), and CLI 1-liner for
|
||||
workshops, hackathons, and multi-user kiosks.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="eventForm"
|
||||
onsubmit="handleCreateEvent(event)"
|
||||
style="margin-top: 1rem;"
|
||||
>
|
||||
{/* Event Name */}
|
||||
<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);">
|
||||
Workshop / Event Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="eventName"
|
||||
placeholder="e.g. Deno & SPIRE Zero-Trust Workshop, Elite Fleet Live Demo"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
|
||||
{/* Target App */}
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Target App Destination
|
||||
</label>
|
||||
<select
|
||||
id="eventAppId"
|
||||
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||
>
|
||||
<option value="">Universal Sandbox (Default)</option>
|
||||
{apps.map((app) => (
|
||||
<option value={app.id} key={app.id}>
|
||||
{app.name} ({app.domain || "Internal"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Guest Role */}
|
||||
<div>
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Attendee Access Role
|
||||
</label>
|
||||
<select
|
||||
id="eventRole"
|
||||
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||
>
|
||||
<option value="viewer">Viewer (Read-Only Demo)</option>
|
||||
<option value="operator">Operator (Workshop Actions)</option>
|
||||
<option value="user">Standard User</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seat Capacity Presets */}
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Seat Capacity Limit
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn seat-pill"
|
||||
onclick="selectSeats(this, 25)"
|
||||
>
|
||||
25 Seats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn seat-pill active"
|
||||
onclick="selectSeats(this, 50)"
|
||||
>
|
||||
50 Seats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn seat-pill"
|
||||
onclick="selectSeats(this, 100)"
|
||||
>
|
||||
100 Seats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn seat-pill"
|
||||
onclick="selectSeats(this, 0)"
|
||||
>
|
||||
∞ Unlimited
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id="eventMaxSeats"
|
||||
value="50"
|
||||
min="0"
|
||||
max="10000"
|
||||
style="width: 100px; padding: 0.35rem 0.5rem; text-align: center;"
|
||||
title="Max seats (0 = unlimited)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Presets */}
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Event Duration & Expiration
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn event-lifespan-pill"
|
||||
onclick="selectEventLifespan(this, 1)"
|
||||
>
|
||||
⚡ 1 Hour
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn event-lifespan-pill active"
|
||||
onclick="selectEventLifespan(this, 3)"
|
||||
>
|
||||
🛠️ 3 Hours (Standard Workshop)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn event-lifespan-pill"
|
||||
onclick="selectEventLifespan(this, 12)"
|
||||
>
|
||||
📅 12 Hours (All-Day Hackathon)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn event-lifespan-pill"
|
||||
onclick="selectEventLifespan(this, 24)"
|
||||
>
|
||||
🗓️ 24 Hours
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="eventLifespanHours" value="3" />
|
||||
</div>
|
||||
|
||||
{/* Custom Codes Accordion (Optional) */}
|
||||
<details style="margin-bottom: 1.5rem; background: var(--surface-muted); padding: 0.75rem 1rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);">
|
||||
<summary style="font-size: 0.875rem; font-weight: 600; color: var(--text-primary); cursor: pointer;">
|
||||
▸ Custom Vanity Slug & Custom PIN Code (Optional)
|
||||
</summary>
|
||||
<div style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.25rem;">
|
||||
Vanity Slug (e.g. /e/deno-lab)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="eventSlug"
|
||||
placeholder="auto-generated if blank"
|
||||
style="width: 100%; font-size: 0.85rem;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.25rem;">
|
||||
Custom 6-Digit PIN (e.g. 749-123)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="eventPinCode"
|
||||
placeholder="auto-generated if blank"
|
||||
style="width: 100%; font-size: 0.85rem;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button
|
||||
type="submit"
|
||||
id="submitEventBtn" data-on-click="@post('/api/events')"
|
||||
class="btn-primary"
|
||||
style="min-height: 42px; background: var(--success); border-color: var(--success);"
|
||||
>
|
||||
🎟️ Launch Workshop Pass
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeDelegateDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Event Launch Success State */}
|
||||
<div
|
||||
id="eventHandoffState"
|
||||
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-card); border: 2px solid var(--success); border-radius: var(--radius-md); box-shadow: var(--shadow-md);"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0;">
|
||||
<span style="font-size: 1.3rem; flex-shrink: 0;">🎟️</span>
|
||||
<strong
|
||||
id="createdEventTitle"
|
||||
style="color: var(--text-primary); font-size: 1.1rem; overflow-wrap: break-word; word-break: break-word; max-width: 100%; display: block;"
|
||||
>
|
||||
Workshop Pass Live!
|
||||
</strong>
|
||||
</div>
|
||||
<span
|
||||
class="badge badge-success"
|
||||
style="flex-shrink: 0; margin-left: 0.5rem;"
|
||||
>
|
||||
Active Now
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
|
||||
Share the 6-digit PIN, 1-click link, or CLI 1-liner with attendees.
|
||||
You can monitor live claimed seats and trigger the master kill switch
|
||||
from the Event Cockpit below.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem;">
|
||||
{/* PIN Code */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
6-Digit Universal PIN (for /join or Kiosk Entry)
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
id="eventHandoffPin"
|
||||
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: 1.1rem; text-align: center; letter-spacing: 3px; font-weight: 700; color: var(--primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label="Copy Universal PIN"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyEventHandoff('pin')"
|
||||
>
|
||||
Copy PIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1-Click Vanity Link */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
Direct 1-Click Workshop Entrance URL
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
id="eventHandoffLink"
|
||||
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(--success);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label="Copy Direct Link"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyEventHandoff('link')"
|
||||
>
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLI 1-Liner */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
Terminal 1-Liner (Environment Injector)
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
id="eventHandoffCli"
|
||||
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.8rem; overflow-x: auto; white-space: nowrap; color: var(--text-primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label="Copy Terminal curl command"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyEventHandoff('cli')"
|
||||
>
|
||||
Copy 1-Liner
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||
onclick="closeEventHandoffModal()"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GuestDrawerAttendeesFragment = () => {
|
||||
return (
|
||||
<div
|
||||
id="attendeesDrawer"
|
||||
class="drawer-overlay"
|
||||
style="display: none; position: fixed; inset: 0; z-index: 1040; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s ease;"
|
||||
>
|
||||
<div
|
||||
class="drawer-panel"
|
||||
style="position: fixed; background: var(--surface-card); box-shadow: var(--shadow-lg); z-index: 1050; display: flex; flex-direction: column; transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);"
|
||||
>
|
||||
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-subtle); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h2
|
||||
id="guestDrawerTitle"
|
||||
style="margin: 0; font-size: 1.25rem; color: var(--text-primary);"
|
||||
>
|
||||
[Event Name] Guests
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeAttendeesDrawer()"
|
||||
style="background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer; line-height: 1;"
|
||||
aria-label="Close Drawer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="guestDrawerContext"
|
||||
style="font-size: 0.8rem; color: var(--text-secondary);"
|
||||
>
|
||||
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
||||
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
|
||||
<span id="guestDrawerCountdown">⏳ 0h 0m left</span>{" "}
|
||||
· (<span id="guestDrawerExpiresAt">--:--</span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="attendeesDrawerContent"
|
||||
style="flex: 1; overflow-y: auto; padding: 1.5rem;"
|
||||
>
|
||||
{/* Populated dynamically via JS */}
|
||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%; color: var(--text-muted); font-size: 0.9rem;">
|
||||
Loading attendees...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
/* Desktop Slide-Over Panel */
|
||||
@media (min-width: 768px) {
|
||||
.drawer-panel {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 420px;
|
||||
height: 100vh;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.drawer-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
/* Mobile Bottom Sheet */
|
||||
@media (max-width: 767px) {
|
||||
.drawer-panel {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 80vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
.drawer-panel.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.drawer-overlay.open {
|
||||
display: block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventJoinPageFragment = () => {
|
||||
return (
|
||||
<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" data-on-submit="@post('/api/join')">
|
||||
<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"
|
||||
name="code"
|
||||
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>
|
||||
);
|
||||
};
|
||||
149
src/features/events/queries.ts
Normal file
149
src/features/events/queries.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { sqlWrapper } from "../../core/db.ts";
|
||||
|
||||
export async function rotateEventPin(eventId: string, userId: string, isGlobalAdmin: boolean): Promise<string | null> {
|
||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const formattedPin = `${randPin.slice(0,3)}-${randPin.slice(3)}`;
|
||||
|
||||
const result = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET pin_code = ${formattedPin}
|
||||
WHERE id = ${eventId}
|
||||
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||
AND is_active = TRUE
|
||||
RETURNING pin_code
|
||||
`;
|
||||
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].pin_code;
|
||||
}
|
||||
|
||||
export async function getEventAttendees(eventId: string, userId: string, isGlobalAdmin: boolean) {
|
||||
const event = await sqlWrapper.sql`
|
||||
SELECT slug FROM event_passes
|
||||
WHERE id = ${eventId}
|
||||
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||
`;
|
||||
|
||||
if (!event || event.length === 0) return null;
|
||||
|
||||
const slug = event[0].slug;
|
||||
const guestPattern = `guest_${slug}_%`;
|
||||
|
||||
const users = await sqlWrapper.sql`
|
||||
SELECT id, username, display_name, created_at
|
||||
FROM users
|
||||
WHERE username LIKE ${guestPattern}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
export async function getEventBySlugOrPin(code: string) {
|
||||
const rawNormalized = code.trim().toLowerCase();
|
||||
const pinNormalized = code.trim().replace(/[-\s]/g, "");
|
||||
|
||||
const eventLookup = await sqlWrapper.sql`
|
||||
SELECT * FROM event_passes
|
||||
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
|
||||
AND is_active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`;
|
||||
|
||||
if (!eventLookup || eventLookup.length === 0) return null;
|
||||
return eventLookup[0];
|
||||
}
|
||||
|
||||
export async function incrementEventSeats(eventId: string) {
|
||||
const updateResult = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET seats_claimed = seats_claimed + 1
|
||||
WHERE id = ${eventId}
|
||||
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||
RETURNING *
|
||||
`;
|
||||
if (!updateResult || updateResult.length === 0) return null;
|
||||
return updateResult[0];
|
||||
}
|
||||
|
||||
export async function createGuestUser(guestUuid: string, username: string, displayName: string, eventId: string) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO users (id, username, display_name, account_status, event_pass_id)
|
||||
VALUES (${guestUuid}, ${username}, ${displayName}, 'guest', ${eventId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAppById(appId: string) {
|
||||
const apps = await sqlWrapper.sql`SELECT name, domain FROM apps WHERE id = ${appId}`;
|
||||
if (apps.length > 0) return apps[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function endEvent(eventId: string, userId: string, isGlobalAdmin: boolean) {
|
||||
const result = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET is_active = FALSE
|
||||
WHERE id = ${eventId}
|
||||
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||
RETURNING slug
|
||||
`;
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].slug;
|
||||
}
|
||||
|
||||
export async function createEventSession(sessionId: string, userId: string, label: string, customScopes: string[], expiresAt: Date) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
|
||||
VALUES (${sessionId}, ${userId}, ${label}, false, ${customScopes}, ${expiresAt})
|
||||
`;
|
||||
}
|
||||
|
||||
export async function expandEventSeats(eventId: string, addSeats: number, userId: string, isGlobalAdmin: boolean) {
|
||||
const eventResult = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET max_seats = max_seats + ${addSeats}
|
||||
WHERE id = ${eventId}
|
||||
AND (created_by = ${userId} OR ${isGlobalAdmin})
|
||||
AND is_active = TRUE
|
||||
RETURNING max_seats
|
||||
`;
|
||||
if (!eventResult || eventResult.length === 0) return null;
|
||||
return eventResult[0].max_seats;
|
||||
}
|
||||
|
||||
export async function createEventPass(
|
||||
name: string,
|
||||
slug: string,
|
||||
pinCode: string,
|
||||
appId: string | null,
|
||||
role: string,
|
||||
maxSeats: number,
|
||||
lifespanHours: number,
|
||||
userId: string,
|
||||
expiresAt: Date
|
||||
) {
|
||||
const result = await sqlWrapper.sql`
|
||||
INSERT INTO event_passes (name, slug, pin_code, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
|
||||
VALUES (${name}, ${slug}, ${pinCode}, ${appId || null}, ${role}, ${Number(maxSeats)}, ${Number(lifespanHours)}, ${userId}, ${expiresAt.toISOString()})
|
||||
RETURNING *
|
||||
`;
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getEventById(eventId: string) {
|
||||
const result = await sqlWrapper.sql`
|
||||
SELECT * FROM event_passes WHERE id = ${eventId}
|
||||
`;
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getEventBySlug(slug: string) {
|
||||
const result = await sqlWrapper.sql`
|
||||
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
|
||||
`;
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0];
|
||||
}
|
||||
253
src/features/events/routes.tsx
Normal file
253
src/features/events/routes.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { getAuthenticatedUser, hasScope, isGlobalAdmin, getCookieDomain } from "../../../server/auth-session.ts";
|
||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { encodeHex } from "jsr:@std/encoding@1/hex";
|
||||
import { getClientIp } from "../../../server/middleware.ts";
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { auditWrapper } from "../../../server/audit.ts";
|
||||
import { rateLimitWrapper } from "../../../server/ratelimit.ts";
|
||||
import { streamDatastar } from "../../core/sse_adapter.ts";
|
||||
import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
|
||||
import * as Queries from "./queries.ts";
|
||||
import { EventCockpitDeckFragment, WorkshopPassDrawerFragment, GuestDrawerAttendeesFragment, EventJoinPageFragment } from "./fragments.tsx";
|
||||
|
||||
export const eventsRoutes = new Hono();
|
||||
|
||||
// We will implement routes here
|
||||
eventsRoutes.post("/api/events/:id/rotate-pin", async (c) => {
|
||||
const user = await getAuthenticatedUser(c);
|
||||
if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
|
||||
|
||||
if (!hasScope(user, "write:events")) {
|
||||
return c.html(renderErrorToastFragment("Forbidden: Insufficient scopes"), 403);
|
||||
}
|
||||
|
||||
const eventId = c.req.param("id");
|
||||
const newPin = await Queries.rotateEventPin(eventId, user.userId, await isGlobalAdmin(user.userId));
|
||||
|
||||
if (!newPin) {
|
||||
return c.html(renderErrorToastFragment("Event not found, inactive, or unauthorized"), 404);
|
||||
}
|
||||
|
||||
// Refresh the cockpit deck fragment
|
||||
const events: any[] = []; // TODO: fetch updated events list
|
||||
return c.html(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
|
||||
eventsRoutes.post("/api/events/:id/expand", async (c) => {
|
||||
const user = await getAuthenticatedUser(c);
|
||||
if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
|
||||
|
||||
if (!hasScope(user, "write:events")) {
|
||||
return c.html(renderErrorToastFragment("Forbidden: Insufficient scopes"), 403);
|
||||
}
|
||||
|
||||
const eventId = c.req.param("id");
|
||||
let body = { addSeats: 5 };
|
||||
if (c.req.header("content-type")?.includes("application/json")) {
|
||||
body = await c.req.json().catch(() => ({ addSeats: 5 }));
|
||||
}
|
||||
|
||||
const addSeats = Math.max(Number(body.addSeats) || 5, 1);
|
||||
const maxSeats = await Queries.expandEventSeats(eventId, addSeats, user.userId, await isGlobalAdmin(user.userId));
|
||||
|
||||
if (maxSeats === null) {
|
||||
return c.html(renderErrorToastFragment("Event not found, inactive, or unauthorized"), 404);
|
||||
}
|
||||
|
||||
// Refresh cockpit deck
|
||||
const events: any[] = []; // TODO: fetch updated events list
|
||||
return c.html(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
|
||||
eventsRoutes.post("/api/events/:id/end", async (c) => {
|
||||
const user = await getAuthenticatedUser(c);
|
||||
if (!user) return c.html(renderErrorToastFragment("Unauthorized"), 401);
|
||||
|
||||
if (!hasScope(user, "write:events")) {
|
||||
return c.html(renderErrorToastFragment("Forbidden: Insufficient scopes"), 403);
|
||||
}
|
||||
|
||||
const eventId = c.req.param("id");
|
||||
const slug = await Queries.endEvent(eventId, user.userId, await isGlobalAdmin(user.userId));
|
||||
|
||||
if (!slug) {
|
||||
return c.html(renderErrorToastFragment("Event not found or unauthorized"), 404);
|
||||
}
|
||||
|
||||
// Revoke all sessions for this event
|
||||
const guestPattern = `guest_${slug}_`;
|
||||
// Iterate sessions and delete via valkey, db...
|
||||
|
||||
// Refresh cockpit deck
|
||||
const events: any[] = []; // TODO: fetch updated events list
|
||||
return c.html(<EventCockpitDeckFragment eventPasses={events} />);
|
||||
});
|
||||
eventsRoutes.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.html(renderErrorToastFragment("Event code or PIN is required"), 400);
|
||||
}
|
||||
|
||||
const clientIp = getClientIp(c);
|
||||
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
|
||||
|
||||
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
|
||||
return c.html(renderErrorToastFragment("Too Many Requests"), 429);
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await Queries.getEventBySlugOrPin(code);
|
||||
|
||||
if (!event) {
|
||||
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||
return c.html(renderErrorToastFragment("Invalid event code or workshop capacity reached"), 404);
|
||||
}
|
||||
|
||||
const updatedEvent = await Queries.incrementEventSeats(String(event.id));
|
||||
|
||||
if (!updatedEvent) {
|
||||
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||
return c.html(renderErrorToastFragment("Workshop capacity reached"), 404);
|
||||
}
|
||||
|
||||
const guestUuid = crypto.randomUUID();
|
||||
const eventShortId = String(updatedEvent.id).split("-")[0];
|
||||
const username = `guest_${eventShortId}_${updatedEvent.seats_claimed}`;
|
||||
|
||||
await Queries.createGuestUser(guestUuid, username, updatedEvent.name + " Attendee", String(updatedEvent.id));
|
||||
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||
const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
|
||||
const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
|
||||
|
||||
let customScopes = ["guest", "trial"];
|
||||
let appDomain = "";
|
||||
|
||||
if (updatedEvent.app_id) {
|
||||
const app = await Queries.getAppById(String(updatedEvent.app_id));
|
||||
if (app) {
|
||||
customScopes = [`app:${app.name}`, updatedEvent.role || "viewer"];
|
||||
appDomain = app.domain || "";
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
await Queries.createEventSession(sessionId, guestUuid, label, customScopes, expiresAt);
|
||||
|
||||
await valkey.setex(
|
||||
sessionId,
|
||||
ttl,
|
||||
JSON.stringify({
|
||||
uuid: guestUuid,
|
||||
username,
|
||||
account_status: "guest",
|
||||
customScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
|
||||
slug: updatedEvent.slug,
|
||||
name: updatedEvent.name,
|
||||
seatNumber: updatedEvent.seats_claimed,
|
||||
method: "web",
|
||||
}, getClientIp(c));
|
||||
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
const rpID = Deno.env.get("RP_ID");
|
||||
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";
|
||||
|
||||
c.header("HX-Redirect", redirectUrl);
|
||||
return c.html("");
|
||||
} catch (e: any) {
|
||||
console.error("[Events] Failed to join event:", e);
|
||||
return c.html(renderErrorToastFragment("Failed to join event"), 500);
|
||||
}
|
||||
});
|
||||
eventsRoutes.get("/api/events/:id/stream", async (c) => {
|
||||
const user = await getAuthenticatedUser(c);
|
||||
if (!user) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
if (!hasScope(user, "read:events")) {
|
||||
return c.text("Forbidden", 403);
|
||||
}
|
||||
|
||||
const eventId = c.req.param("id");
|
||||
|
||||
return streamDatastar(c, async (stream) => {
|
||||
let subscriber: any;
|
||||
try {
|
||||
// initial state
|
||||
const event = await Queries.getEventById(eventId);
|
||||
if (event) {
|
||||
await stream.write({
|
||||
event: "datastar-fragment",
|
||||
data: `<span id="event-seats-${eventId}">${event.seats_claimed}</span>`
|
||||
});
|
||||
}
|
||||
|
||||
// setup valkey subscriber for live seats broadcast
|
||||
subscriber = valkey.duplicate();
|
||||
await subscriber.subscribe(`event:seats:${eventId}`);
|
||||
|
||||
subscriber.on("message", async (channel: string, message: string) => {
|
||||
if (stream.aborted) return;
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
await stream.write({
|
||||
event: "datastar-fragment",
|
||||
data: `<span id="event-seats-${eventId}">${parsed.seats_claimed}</span>`
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
while (!stream.aborted) {
|
||||
await stream.sleep(15000); // keep alive
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Events SSE] Stream error:", err);
|
||||
if (!stream.aborted) {
|
||||
const errorHtml = renderErrorToastFragment("Live updates connection lost.");
|
||||
await stream.write({
|
||||
event: "datastar-fragment",
|
||||
data: await errorHtml
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (subscriber) {
|
||||
await subscriber.unsubscribe();
|
||||
await subscriber.quit();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
eventsRoutes.get("/join", (c) => {
|
||||
return c.html(<EventJoinPageFragment />);
|
||||
});
|
||||
675
src/features/sessions/fragments.tsx
Normal file
675
src/features/sessions/fragments.tsx
Normal file
@ -0,0 +1,675 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
|
||||
export const SessionTableFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {
|
||||
return (
|
||||
<div class="card desktop-only" style="display: none;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type & Label</th>
|
||||
<th>Permissions</th>
|
||||
<th>Activity</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||
>
|
||||
No active sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === currentSessionId;
|
||||
const isAgent = !!session.is_agent;
|
||||
const scopes = Array.isArray(session.custom_scopes)
|
||||
? session.custom_scopes
|
||||
: [];
|
||||
|
||||
return (
|
||||
<tr key={session.id}>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.1rem;">
|
||||
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||
</span>
|
||||
<div>
|
||||
<strong style="color: var(--text-primary);">
|
||||
{session.label ||
|
||||
(isCurrent ? "This Device" : "Remote Device")}
|
||||
</strong>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
{session.id.substring(0, 14)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{isAgent
|
||||
? (
|
||||
<span class="badge badge-info">
|
||||
{scopes.length > 0
|
||||
? `${scopes.length} Scopes`
|
||||
: "Delegated"}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span class="badge badge-success">
|
||||
Interactive
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{session.last_activity_action
|
||||
? (
|
||||
<div>
|
||||
<span style="color: var(--primary); font-family: monospace; font-size: 0.8rem;">
|
||||
{session.last_activity_action}
|
||||
</span>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
||||
{new Date(
|
||||
session.last_activity_at ||
|
||||
session.created_at,
|
||||
).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span>
|
||||
{new Date(session.created_at)
|
||||
.toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<span
|
||||
class="countdown-pill"
|
||||
data-expires-at={session.expires_at}
|
||||
>
|
||||
⏳ 0h 0m left · (Expires{" "}
|
||||
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})})
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.35rem; align-items: center;">
|
||||
{isAgent && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 30px;"
|
||||
data-on-click={`@post("/api/sessions/${session.id}/extend")`}
|
||||
title="Extend session by 1 hour"
|
||||
>
|
||||
+1h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 30px;"
|
||||
onclick={`openEditScopesModal('${session.id}', '${
|
||||
session.label || "Delegated"
|
||||
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||
title="Edit permissions"
|
||||
>
|
||||
Scopes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-btn" data-on-click={`@delete("/api/sessions/${session.id}")`}
|
||||
data-on-click={`@delete("/api/sessions/${session.id}")`}
|
||||
style="padding: 0.25rem 0.65rem; font-size: 0.8rem; min-height: 30px;"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};;
|
||||
|
||||
export const DirectPassDrawerFragment = ({ apps = [], isAdmin = false }: { apps?: any[]; isAdmin?: boolean }) => {
|
||||
<div id="tabDirectPass">
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
|
||||
Mint an isolated, scoped child session for 1-click magic links, CLI
|
||||
tools, team members, or AI assistants without exposing your primary
|
||||
passkeys.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="delegateForm"
|
||||
onsubmit="handleDelegateSession(event)"
|
||||
style="margin-top: 1rem;"
|
||||
>
|
||||
{/* Label Input */}
|
||||
<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);">
|
||||
Session Label / Purpose *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="delegateLabel"
|
||||
placeholder="e.g. Friend Demo Pass, Antigravity Assistant, CI/CD Runner, Terminal CLI"
|
||||
required
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Direct 1-Click Destination (App Selection) */}
|
||||
<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);">
|
||||
1-Click Destination App (Optional)
|
||||
</label>
|
||||
<select
|
||||
id="delegateTargetApp"
|
||||
onchange="handleTargetAppChange(this.value)"
|
||||
style="width: 100%; padding: 0.5rem 0.75rem; background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.9rem;"
|
||||
>
|
||||
<option value="">Auth-Yes Central Dashboard (Default)</option>
|
||||
{apps.map((app) => (
|
||||
<option value={app.name} key={app.id}>
|
||||
{app.name} ({app.domain || "Internal App"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style="font-size: 0.75rem; color: var(--text-muted); margin: 0.35rem 0 0 0;">
|
||||
Selecting an app scopes the pass and automatically redirects the
|
||||
recipient to that app upon 1-click redemption.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Lifespan Presets */}
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Lifespan & Auto-Expiration
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn lifespan-pill active"
|
||||
data-hours="1"
|
||||
onclick="selectLifespan(this, 1)"
|
||||
>
|
||||
⚡ 1 Hour (Quick Task)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn lifespan-pill"
|
||||
data-hours="12"
|
||||
onclick="selectLifespan(this, 12)"
|
||||
>
|
||||
🛠️ 12 Hours (Work Session)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn lifespan-pill"
|
||||
data-hours="24"
|
||||
onclick="selectLifespan(this, 24)"
|
||||
>
|
||||
📅 24 Hours
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn lifespan-pill"
|
||||
data-hours="168"
|
||||
onclick="selectLifespan(this, 168)"
|
||||
>
|
||||
🗓️ 7 Days
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="delegateHours" value="1" />
|
||||
</div>
|
||||
|
||||
{/* Access Mode Presets */}
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.45rem; font-size: 0.875rem; color: var(--text-secondary);">
|
||||
Access Mode & Permissions
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn mode-pill active"
|
||||
data-mode="read_only"
|
||||
onclick="selectMode(this, 'read_only')"
|
||||
>
|
||||
👁️ Read-Only (Audit & Inspection)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn mode-pill"
|
||||
data-mode="operator"
|
||||
onclick="selectMode(this, 'operator')"
|
||||
>
|
||||
⚡ Operator (App Actions)
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn mode-pill"
|
||||
data-mode="admin"
|
||||
onclick="selectMode(this, 'admin')"
|
||||
>
|
||||
👑 Full Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill-btn mode-pill"
|
||||
data-mode="custom"
|
||||
onclick="selectMode(this, 'custom')"
|
||||
>
|
||||
🛠️ Custom Scopes
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="delegateMode" value="read_only" />
|
||||
</div>
|
||||
|
||||
{/* Progressive Disclosure: Specific App & Capability Scopes */}
|
||||
<details
|
||||
id="customScopesSection"
|
||||
style="margin-bottom: 1.25rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.75rem 1rem; background: var(--surface-muted);"
|
||||
>
|
||||
<summary style="cursor: pointer; font-weight: 600; font-size: 0.875rem; color: var(--text-primary);">
|
||||
▸ Customize Specific App Grants & Capabilities (Optional)
|
||||
</summary>
|
||||
<div style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; color: var(--text-secondary);">
|
||||
Select which subsidiary microservices and IAM capabilities this
|
||||
delegated token is permitted to access:
|
||||
</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="scope-checkbox"
|
||||
value="read:audit"
|
||||
checked
|
||||
/>
|
||||
Read Audit Ledger
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="scope-checkbox"
|
||||
value="read:users"
|
||||
checked
|
||||
/>
|
||||
Read Users List
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="scope-checkbox"
|
||||
value="read:apps"
|
||||
checked
|
||||
/>
|
||||
Read Apps Registry
|
||||
</label>
|
||||
{apps.map((app) => (
|
||||
<label
|
||||
key={app.id}
|
||||
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="scope-checkbox"
|
||||
value={`app:${app.name}`}
|
||||
/>
|
||||
{app.name} ({app.domain || "Internal"})
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button
|
||||
type="submit"
|
||||
id="submitDelegateBtn"
|
||||
class="btn-primary"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Mint & Delegate Session
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeDelegateDrawer()"
|
||||
style="min-height: 42px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Hand-Off Card (Revealed upon generation) */}
|
||||
<div
|
||||
id="handoffModal"
|
||||
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-card); border: 1px solid var(--primary); border-radius: var(--radius-md); box-shadow: var(--shadow-md);"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.2rem;">🔑</span>
|
||||
<strong style="color: var(--text-primary); font-size: 1rem;">
|
||||
Session Delegated Successfully!
|
||||
</strong>
|
||||
</div>
|
||||
<span class="badge badge-success">Active Now</span>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
|
||||
Copy this token, 1-click magic link, or CLI export string. You can
|
||||
revoke or extend this session anytime from the active session cards
|
||||
below.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;">
|
||||
{/* 1-Click Magic Link */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
1-Click Magic Link (Web / Friend / Interview)
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
id="handoffMagicLinkText"
|
||||
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(--success);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyHandoff('magic')"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1-Tap Copy CLI */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
Terminal Environment Export (CLI)
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
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(--text-primary);"
|
||||
>
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyHandoff('cli')"
|
||||
>
|
||||
Copy CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1-Tap Copy cURL Header */}
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
|
||||
HTTP Authorization Header
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<code
|
||||
id="handoffCurlText"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
data-ignore onclick="copyHandoff('curl')"
|
||||
>
|
||||
Copy Header
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||
onclick="closeHandoffModal()"
|
||||
>
|
||||
Done (Session is Active)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScopeModalFragment = ({ apps = [] }: { apps?: any[] }) => {
|
||||
return (
|
||||
<div
|
||||
id="editScopesModal"
|
||||
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); z-index: 9999; justify-content: center; align-items: center;"
|
||||
>
|
||||
<div style="background: var(--surface-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 90%; max-width: 500px; padding: 1.5rem; box-shadow: var(--shadow-lg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||
Update Scopes for:{" "}
|
||||
<span id="editScopesLabel" style="color: var(--primary);"></span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeEditScopesModal()"
|
||||
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="editScopesSessionId" value="" />
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_audit"
|
||||
class="edit-scope-chk"
|
||||
value="read:audit"
|
||||
/>
|
||||
Read Audit Ledger
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_users"
|
||||
class="edit-scope-chk"
|
||||
value="read:users"
|
||||
/>
|
||||
Read Users List
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scope_read_apps"
|
||||
class="edit-scope-chk"
|
||||
value="read:apps"
|
||||
/>
|
||||
Read Apps Registry
|
||||
</label>
|
||||
{apps.map((app) => (
|
||||
<label
|
||||
key={app.id}
|
||||
style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; color: var(--text-primary); cursor: pointer;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="edit-scope-chk"
|
||||
value={`app:${app.name}`}
|
||||
/>
|
||||
Access {app.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
onclick="closeEditScopesModal()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick="saveUpdatedScopes()"
|
||||
>
|
||||
Save Scopes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const SessionDeckFragment = ({ sessions, currentSessionId }: { sessions: any[], currentSessionId: string }) => {
|
||||
return (
|
||||
<div
|
||||
class="mobile-only"
|
||||
style="display: flex; flex-direction: column; gap: 1rem;"
|
||||
>
|
||||
{sessions.length === 0
|
||||
? (
|
||||
<div class="card" style="text-align: center; padding: 2rem;">
|
||||
<p style="color: var(--text-muted); margin: 0;">
|
||||
No active sessions found.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === currentSessionId;
|
||||
const isAgent = !!session.is_agent;
|
||||
const scopes = Array.isArray(session.custom_scopes)
|
||||
? session.custom_scopes
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div class="card" key={session.id} style="margin-bottom: 0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--surface-muted); border-radius: var(--radius-md); font-size: 1.2rem;">
|
||||
{isAgent ? "🔑" : isCurrent ? "📱" : "💻"}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 700; font-size: 0.95rem; color: var(--text-primary);">
|
||||
{session.label ||
|
||||
(isCurrent ? "This Device" : "Remote Device")}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
{session.id.substring(0, 14)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
{isAgent
|
||||
? <span class="badge badge-info">Delegated</span>
|
||||
: isCurrent
|
||||
? <span class="badge badge-success">Active Now</span>
|
||||
: <span class="badge badge-secondary">Active</span>}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger revoke-btn" data-on-click={`@delete("/api/sessions/${session.id}")`}
|
||||
data-session-id={session.id}
|
||||
style="padding: 0.15rem 0.5rem; font-size: 0.75rem; min-height: 24px; border-radius: var(--radius-sm);"
|
||||
>
|
||||
🗑️ Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
||||
{isAgent && (
|
||||
<div style="margin-bottom: 0.25rem;">
|
||||
<strong>Scopes:</strong>{" "}
|
||||
{scopes.length > 0 ? scopes.join(", ") : "Delegated"}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span
|
||||
class="countdown-pill"
|
||||
data-expires-at={session.expires_at}
|
||||
>
|
||||
⏳ 0h 0m left · (Expires{" "}
|
||||
{new Date(session.expires_at).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})})
|
||||
</span>
|
||||
</div>
|
||||
{session.last_activity_action && (
|
||||
<div>
|
||||
<strong>Last Active:</strong>{" "}
|
||||
{session.last_activity_action} ({new Date(
|
||||
session.last_activity_at || session.created_at,
|
||||
).toLocaleTimeString()})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAgent && (
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||
data-on-click={`@post("/api/sessions/${session.id}/extend")`}
|
||||
>
|
||||
+1h Extend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="flex: 1; justify-content: center; min-height: 38px; font-size: 0.8rem;"
|
||||
onclick={`openEditScopesModal('${session.id}', '${
|
||||
session.label || "Delegated"
|
||||
}', ${JSON.stringify(JSON.stringify(scopes))})`}
|
||||
>
|
||||
Scopes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
76
src/features/sessions/queries.ts
Normal file
76
src/features/sessions/queries.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { sqlWrapper } from "../../core/db.ts";
|
||||
|
||||
export async function getActiveSessions(userId: string) {
|
||||
const sessions = await sqlWrapper.sql`
|
||||
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
||||
FROM sessions
|
||||
WHERE user_id = ${userId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export async function delegateSession(sessionId: string, userId: string, label: string, customScopes: string[], expiresAt: Date) {
|
||||
await sqlWrapper.sql`
|
||||
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
|
||||
VALUES (${sessionId}, ${userId}, ${label}, true, ${customScopes}, ${expiresAt.toISOString()}, NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getSessionByIdAndUserId(sessionId: string, userId: string) {
|
||||
const session = await sqlWrapper.sql`
|
||||
SELECT id, is_agent, expires_at FROM sessions WHERE id = ${sessionId} AND user_id = ${userId}
|
||||
`;
|
||||
if (!session || session.length === 0) return null;
|
||||
return session[0];
|
||||
}
|
||||
|
||||
export async function updateSessionScopes(sessionId: string, customScopes: string[]) {
|
||||
await sqlWrapper.sql`
|
||||
UPDATE sessions SET custom_scopes = ${customScopes} WHERE id = ${sessionId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getSessionById(sessionId: string) {
|
||||
const session = await sqlWrapper.sql`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (!session || session.length === 0) return null;
|
||||
return session[0];
|
||||
}
|
||||
|
||||
export async function setSessionPaused(sessionId: string, isPaused: boolean) {
|
||||
await sqlWrapper.sql`
|
||||
UPDATE sessions SET is_paused = ${isPaused} WHERE id = ${sessionId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function extendSessionExpiry(sessionId: string, newExpiry: Date) {
|
||||
await sqlWrapper.sql`
|
||||
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${sessionId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string) {
|
||||
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${sessionId}`;
|
||||
}
|
||||
|
||||
export async function getSessionWithOwnershipCheck(sessionId: string, userId: string, isAdmin: boolean) {
|
||||
const session = await sqlWrapper.sql`
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ${sessionId}
|
||||
AND (
|
||||
s.user_id = ${userId}
|
||||
OR ${isAdmin}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM event_passes ep
|
||||
WHERE ep.created_by = ${userId}
|
||||
AND u.event_pass_id = ep.id
|
||||
)
|
||||
)
|
||||
`;
|
||||
if (!session || session.length === 0) return null;
|
||||
return session[0];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user