feat: phase 6 final polish for sessions and events
- implemented universal ingress credential rotation (slug + pin) - fixed event extension logic (`GREATEST(expires_at, NOW())`) - added UI formatter logic for natural dates (`formatNaturalExpiry`, `formatNaturalJoinTime`) - updated event cards to bounded 2-row compact cards - consolidated CLI expanding snippets - overhauled WAI-ARIA support for delegation drawers - removed legacy "Dismiss" mock buttons for cleanly styled "OK" buttons - updated tests and ensured pure zero-dependency SSR JSX compatibility Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
5209534d7a
commit
61789f1d45
@ -31,36 +31,50 @@ eventRoutes.post("/api/events/:id/rotate-pin", async (c) => {
|
||||
|
||||
const eventId = c.req.param("id");
|
||||
|
||||
// Generate new PIN
|
||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||
|
||||
try {
|
||||
const eventResult = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET pin_code = ${newPinCode}
|
||||
const event = await sqlWrapper.sql`
|
||||
SELECT slug, name FROM event_passes
|
||||
WHERE id = ${eventId}
|
||||
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
|
||||
AND is_active = TRUE
|
||||
RETURNING pin_code
|
||||
`;
|
||||
|
||||
if (!eventResult || eventResult.length === 0) {
|
||||
if (!event || event.length === 0) {
|
||||
return c.json(
|
||||
{ error: "Event not found, inactive, or unauthorized" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new PIN
|
||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||
|
||||
// Generate new Slug
|
||||
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
|
||||
const newSuffix = Math.random().toString(36).substring(2, 6);
|
||||
const newSlug = `${baseSlug}-${newSuffix}`;
|
||||
|
||||
const updateResult = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET pin_code = ${newPinCode}, slug = ${newSlug}
|
||||
WHERE id = ${eventId}
|
||||
RETURNING pin_code, slug
|
||||
`;
|
||||
|
||||
auditWrapper.auditLog(
|
||||
user.userId,
|
||||
"event_pin_rotated",
|
||||
"event_ingress_rotated",
|
||||
eventId,
|
||||
{},
|
||||
getClientIp(c),
|
||||
);
|
||||
|
||||
return c.json({ success: true, pinCode: eventResult[0].pin_code });
|
||||
return c.json({
|
||||
success: true,
|
||||
pinCode: updateResult[0].pin_code,
|
||||
slug: updateResult[0].slug,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[Events] Failed to rotate event PIN:", e);
|
||||
return c.json({ error: "Failed to rotate PIN" }, 500);
|
||||
@ -206,7 +220,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
|
||||
try {
|
||||
const eventResult = await sqlWrapper.sql`
|
||||
UPDATE event_passes
|
||||
SET expires_at = expires_at + interval '${extendHours} hours'
|
||||
SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours'
|
||||
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
|
||||
user.userId,
|
||||
)}) AND is_active = TRUE
|
||||
|
||||
@ -232,6 +232,59 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||
},
|
||||
);
|
||||
|
||||
await t.step(
|
||||
"POST /api/events/:id/rotate-pin rotates both pin and slug",
|
||||
async () => {
|
||||
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
||||
if (String(key) === "admin-session") {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const originalSql = sqlWrapper.sql;
|
||||
let updateEventCalled = false;
|
||||
let newSlug = "";
|
||||
|
||||
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||
const query = Array.isArray(strings)
|
||||
? strings.join("?")
|
||||
: String(strings);
|
||||
if (query.includes("SELECT slug, name FROM event_passes")) {
|
||||
return Promise.resolve([{ slug: "deno-lab-1a2b", name: "Deno Lab" }]);
|
||||
}
|
||||
if (query.includes("UPDATE event_passes") && query.includes("slug =")) {
|
||||
updateEventCalled = true;
|
||||
newSlug = _values[1]; // second bound parameter is the slug
|
||||
return Promise.resolve([{ pin_code: _values[0], slug: newSlug }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
const res = await app.request("/api/events/evt-123/rotate-pin", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer admin-session",
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert(json.success === true);
|
||||
assert(updateEventCalled);
|
||||
assert(json.slug.startsWith("deno-lab-"));
|
||||
assert(json.slug !== "deno-lab-1a2b");
|
||||
assertEquals(json.slug, newSlug);
|
||||
} finally {
|
||||
sqlWrapper.sql = originalSql;
|
||||
valkeyGetStub.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await t.step(
|
||||
"POST /api/join enforces 5 failed attempts rate limit per IP",
|
||||
async () => {
|
||||
@ -491,7 +544,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||
: String(strings);
|
||||
if (
|
||||
query.includes("UPDATE event_passes") &&
|
||||
query.includes("expires_at = expires_at + interval")
|
||||
query.includes("expires_at = GREATEST(expires_at, NOW()) + interval")
|
||||
) {
|
||||
updateEventCalled = true;
|
||||
return Promise.resolve([{
|
||||
|
||||
@ -12,28 +12,57 @@
|
||||
- `server/tests/events.test.ts`
|
||||
- `ui/ui_scripts.test.ts`
|
||||
- **Core Objective:** Implement Phase 6 Final Polish & Hardening:
|
||||
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, `Delegate Session`, `Events`, `Sessions`).
|
||||
2. Implement universal ingress credential rotation (rotates both 6-digit PIN and event slug suffix simultaneously).
|
||||
3. Fix expired event extend math bug using `GREATEST(expires_at, NOW()) + interval`.
|
||||
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with inline copy pills and zero text/border collisions.
|
||||
5. Implement standardized natural `ExpiryBadge` format (`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time telemetry in the Guest Drawer.
|
||||
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with high-contrast active states.
|
||||
7. Reset drawer state machine on open and replace verbose/mock buttons with clean `[ OK ]`.
|
||||
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`,
|
||||
`Delegate Session`, `Events`, `Sessions`).
|
||||
2. Implement universal ingress credential rotation (rotates both 6-digit PIN
|
||||
and event slug suffix simultaneously).
|
||||
3. Fix expired event extend math bug using
|
||||
`GREATEST(expires_at, NOW()) + interval`.
|
||||
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with
|
||||
inline copy pills and zero text/border collisions.
|
||||
5. Implement standardized natural `ExpiryBadge` format
|
||||
(`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time
|
||||
telemetry in the Guest Drawer.
|
||||
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with
|
||||
high-contrast active states.
|
||||
7. Reset drawer state machine on open and replace verbose/mock buttons with
|
||||
clean `[ OK ]`.
|
||||
- **Dependencies:** None.
|
||||
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs.
|
||||
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX.
|
||||
All client interactions in `SessionsScript.tsx` must use native vanilla
|
||||
JavaScript DOM APIs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectural Considerations & Risks
|
||||
|
||||
- **Risks:**
|
||||
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating event ingress credentials (`pin_code` and `slug`), existing active guest sessions must remain valid and uninterrupted. Only subsequent unauthenticated join attempts using the old PIN, old Direct Link, or old CLI command must be rejected (404 / Invalid Code).
|
||||
- **Expired Event Extending Edge Cases:** When an organizer extends an event that expired in the past, `expires_at + interval '1 hour'` would leave the expiry in the past. Using `GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees the new expiration time is set relative to the current moment.
|
||||
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements into a single unconstrained horizontal flex line, causing text collisions and input boxes bleeding off the desktop screen. The new 2-Row Compact Card must enforce strict CSS bounding (`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
|
||||
- **State Machine Staleness:** Reopening the delegation drawer must unconditionally reset the form back to State 1 (clean creation form with `Single Session` default tab) rather than leaving the stale success screen visible.
|
||||
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating
|
||||
event ingress credentials (`pin_code` and `slug`), existing active guest
|
||||
sessions must remain valid and uninterrupted. Only subsequent
|
||||
unauthenticated join attempts using the old PIN, old Direct Link, or old CLI
|
||||
command must be rejected (404 / Invalid Code).
|
||||
- **Expired Event Extending Edge Cases:** When an organizer extends an event
|
||||
that expired in the past, `expires_at + interval '1 hour'` would leave the
|
||||
expiry in the past. Using
|
||||
`GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees
|
||||
the new expiration time is set relative to the current moment.
|
||||
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements
|
||||
into a single unconstrained horizontal flex line, causing text collisions
|
||||
and input boxes bleeding off the desktop screen. The new 2-Row Compact Card
|
||||
must enforce strict CSS bounding
|
||||
(`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
|
||||
- **State Machine Staleness:** Reopening the delegation drawer must
|
||||
unconditionally reset the form back to State 1 (clean creation form with
|
||||
`Single Session` default tab) rather than leaving the stale success screen
|
||||
visible.
|
||||
|
||||
- **Alternatives:**
|
||||
- *Single-Line vs. 2-Row Compact Cards:* Single-line cards inevitably drop essential copy actions or clip text on viewports under 1200px. A structured 2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN, Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all layout collisions.
|
||||
- _Single-Line vs. 2-Row Compact Cards:_ Single-line cards inevitably drop
|
||||
essential copy actions or clip text on viewports under 1200px. A structured
|
||||
2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN,
|
||||
Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all
|
||||
layout collisions.
|
||||
|
||||
---
|
||||
|
||||
@ -46,17 +75,20 @@
|
||||
```typescript
|
||||
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
|
||||
|
||||
|
||||
// Extract base slug prefix and generate fresh random 4-char suffix
|
||||
const event = await sqlWrapper.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
|
||||
const event = await sqlWrapper
|
||||
.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
|
||||
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
|
||||
const newSuffix = Math.random().toString(36).substring(2, 6);
|
||||
const newSlug = `${baseSlug}-${newSuffix}`;
|
||||
```
|
||||
- Update database: `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
|
||||
- Update database:
|
||||
`UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
|
||||
- Audit log `event_ingress_rotated`.
|
||||
- Return `{ success: true, pinCode: newPinCode, slug: newSlug }`.
|
||||
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`) authenticate via session cookies/Valkey tokens and are unaffected.
|
||||
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`)
|
||||
authenticate via session cookies/Valkey tokens and are unaffected.
|
||||
|
||||
2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):**
|
||||
- Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`:
|
||||
@ -68,18 +100,24 @@
|
||||
```
|
||||
|
||||
3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):**
|
||||
- Ensure query returns `s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`.
|
||||
- Ensure query returns
|
||||
`s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Page Hierarchy, Nomenclature & Heading Cleanup (`SessionsPage.tsx`)
|
||||
|
||||
1. **Page Title:**
|
||||
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with subtitle *"Manage logins, mint 1:1 delegated tokens, or launch multi-claim workshop events."*
|
||||
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with
|
||||
subtitle _"Manage logins, mint 1:1 delegated tokens, or launch multi-claim
|
||||
workshop events."_
|
||||
2. **Remove Duplicate Headings:**
|
||||
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The section header is exclusively rendered inside `EventCockpitDeck.tsx` as `<h2>Events</h2>` alongside the view toggle.
|
||||
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The
|
||||
section header is exclusively rendered inside `EventCockpitDeck.tsx` as
|
||||
`<h2>Events</h2>` alongside the view toggle.
|
||||
3. **Sessions Section:**
|
||||
- Retain `<h2>Sessions</h2>` heading with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."*
|
||||
- Retain `<h2>Sessions</h2>` heading with subtitle _"Direct device logins,
|
||||
passkey authentications, and delegated agent tokens."_
|
||||
|
||||
---
|
||||
|
||||
@ -88,33 +126,46 @@
|
||||
1. **Section Header & High-Contrast View Toggle:**
|
||||
- Header: `<h2>Events</h2>`.
|
||||
- Toggle buttons (`[ 🗂️ Grid ]` and `[ 📋 Compact ]`):
|
||||
- Active style: `background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
|
||||
- Inactive style: `background: transparent; color: var(--text-muted); opacity: 0.75;`
|
||||
- Active style:
|
||||
`background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
|
||||
- Inactive style:
|
||||
`background: transparent; color: var(--text-muted); opacity: 0.75;`
|
||||
- Include `aria-pressed="true/false"`.
|
||||
|
||||
2. **2-Row Compact Card Layout (`.compact-view .event-card`):**
|
||||
- Container: Strictly bounded flex/grid (~70px height), `box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
|
||||
- Container: Strictly bounded flex/grid (~70px height),
|
||||
`box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
|
||||
- **Row 1 (Metadata Header):**
|
||||
- Left: Event title with ellipsis (`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
|
||||
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot `·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
|
||||
- Left: Event title with ellipsis
|
||||
(`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
|
||||
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot
|
||||
`·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
|
||||
- **Row 2 (1-Click Handoffs & Action Pinned Right):**
|
||||
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`, `[ CLI (Copy) ]` using compact inline pills (`padding: 2px 6px; font-size: 0.75rem;`).
|
||||
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or `[ 🔄 Reopen (+1h) ]` if expired).
|
||||
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its `alert(...)` placeholder.
|
||||
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`,
|
||||
`[ CLI (Copy) ]` using compact inline pills
|
||||
(`padding: 2px 6px; font-size: 0.75rem;`).
|
||||
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or
|
||||
`[ 🔄 Reopen (+1h) ]` if expired).
|
||||
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its
|
||||
`alert(...)` placeholder.
|
||||
|
||||
3. **Grid Card Polish:**
|
||||
- Replace detached CLI `<details>` box with a unified **Integrated Expanding CLI Component**:
|
||||
- Replace detached CLI `<details>` box with a unified **Integrated Expanding
|
||||
CLI Component**:
|
||||
- Single-line snippet when collapsed with `[ Copy ]` and `[ ▾ ]` toggle.
|
||||
- Expands downward into multiline highlighted command block on toggle click.
|
||||
- Expands downward into multiline highlighted command block on toggle
|
||||
click.
|
||||
- Fix double arrow marker bug (`list-style: none;`).
|
||||
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field rotation.
|
||||
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field
|
||||
rotation.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Standardized Natural Expiry & Start-Time Telemetry (`SessionsScript.tsx` & Drawers)
|
||||
|
||||
1. **Natural Expiry Formatter Helper (`formatNaturalExpiry(expiresAt)`):**
|
||||
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D` (if same year), `MMM D, YYYY` (if different year).
|
||||
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D`
|
||||
(if same year), `MMM D, YYYY` (if different year).
|
||||
- Exact Time: `h:mm A` (e.g. `10:39 PM`).
|
||||
- Scaled Urgency Badge:
|
||||
- $<1$h: Amber `[ 45m left ]`
|
||||
@ -126,11 +177,17 @@
|
||||
- Tooltip: `title="${fullISODate}"` on hover/long-press.
|
||||
- Format: `[Date] · [Time] · [ Colored Urgency Badge ]`.
|
||||
|
||||
2. **Inverted Symmetrical Start-Time Formatter (`formatNaturalJoinTime(createdAt)`):**
|
||||
2. **Inverted Symmetrical Start-Time Formatter
|
||||
(`formatNaturalJoinTime(createdAt)`):**
|
||||
- In `EventGuestsDrawer.tsx`, render:
|
||||
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge + `[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
|
||||
- **Line 1:** `Joined Today · 2:15 PM · ` <span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active 7h 54m</span>.
|
||||
- **Line 2:** `Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web` (or `📟 CLI`).
|
||||
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge +
|
||||
`[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
|
||||
- **Line 1:** `Joined Today · 2:15 PM ·`
|
||||
<span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active
|
||||
7h 54m</span>.
|
||||
- **Line 2:**
|
||||
`Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web`
|
||||
(or `📟 CLI`).
|
||||
- Remove redundant `guest_slug_seat` visual text (keep in tooltip only).
|
||||
|
||||
---
|
||||
@ -138,22 +195,27 @@
|
||||
### Phase 5: Delegation Drawer Overhaul (`WorkshopDrawer.tsx` & `SessionsScript.tsx`)
|
||||
|
||||
1. **Drawer Nomenclature & WAI-ARIA High-Contrast Tabs:**
|
||||
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle *"Mint a 1:1 delegated token or launch a multi-seat workshop event."*
|
||||
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle _"Mint a 1:1
|
||||
delegated token or launch a multi-seat workshop event."_
|
||||
- Tabs (`role="tablist"`):
|
||||
- **Tab 1 (`role="tab"`):** `Single Session`
|
||||
- Subtitle hint: *For agents, CI/CD, or 1:1 delegation*
|
||||
- Subtitle hint: _For agents, CI/CD, or 1:1 delegation_
|
||||
- **Tab 2 (`role="tab"`):** `Multi-Claim Event`
|
||||
- Subtitle hint: *For workshops, teams & guest pools*
|
||||
- High-Contrast Active State: Solid `var(--primary)` background with white text (`#ffffff`), `aria-selected="true"`.
|
||||
- Inactive State: Translucent muted background (`opacity: 0.75`), `aria-selected="false"`.
|
||||
- Subtitle hint: _For workshops, teams & guest pools_
|
||||
- High-Contrast Active State: Solid `var(--primary)` background with white
|
||||
text (`#ffffff`), `aria-selected="true"`.
|
||||
- Inactive State: Translucent muted background (`opacity: 0.75`),
|
||||
`aria-selected="false"`.
|
||||
|
||||
2. **Handoff State & Button Minimalist Polish:**
|
||||
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`, `Copy URL`, `Copy 1-Liner`).
|
||||
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`,
|
||||
`Copy URL`, `Copy 1-Liner`).
|
||||
- Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`**.
|
||||
|
||||
3. **State Machine Reset on Open:**
|
||||
- In `SessionsScript.tsx:openDelegateDrawer()`:
|
||||
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to `display: none`.
|
||||
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to
|
||||
`display: none`.
|
||||
- Clear form inputs (`eventName`, `eventSlug`, `eventPinCode`, etc.).
|
||||
- Reset tab selection to `Single Session`.
|
||||
|
||||
@ -162,6 +224,7 @@
|
||||
## 4. Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
|
||||
1. **Universal Credential Rotation Test (`server/tests/events.test.ts`):**
|
||||
- Create event pass -> Call `POST /api/events/:id/rotate-pin`.
|
||||
- Verify response returns new `pinCode` AND new `slug`.
|
||||
@ -173,11 +236,13 @@
|
||||
- Call `POST /api/events/:id/extend` with `extendHours: 1`.
|
||||
- Verify `expires_at > NOW()` (approximately `NOW() + 1 hour`).
|
||||
3. **UI Script & Formatter Tests (`ui/ui_scripts.test.ts`):**
|
||||
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d, 142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
|
||||
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d,
|
||||
142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
|
||||
- Test `formatNaturalJoinTime` inverted duration math.
|
||||
- Test drawer state machine reset logic.
|
||||
|
||||
### Quality Gate Commands
|
||||
|
||||
```bash
|
||||
deno fmt --check
|
||||
deno task lint
|
||||
|
||||
@ -37,11 +37,11 @@ export const SessionsPage = ({
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Sessions & Passes
|
||||
Sessions & Events
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
|
||||
multi-claim workshop event passes.
|
||||
Manage logins, mint 1:1 delegated tokens, or launch multi-claim
|
||||
workshop events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -57,9 +57,6 @@ export const SessionsPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
|
||||
Event Passes
|
||||
</h2>
|
||||
<EventCockpitDeck eventPasses={eventPasses} />
|
||||
|
||||
{/* Delegate Session Drawer */}
|
||||
@ -71,12 +68,12 @@ export const SessionsPage = ({
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
||||
<div style="width: 100%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3
|
||||
<h2
|
||||
id="delegateDrawerTitle"
|
||||
style="margin: 0 0 0.25rem 0; color: var(--text-primary);"
|
||||
style="margin: 0 0 0.25rem 0; color: var(--text-primary); font-size: 1.25rem;"
|
||||
>
|
||||
Create New Pass / Session
|
||||
</h3>
|
||||
Delegate Session
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeDelegateDrawer()"
|
||||
@ -85,23 +82,45 @@ export const SessionsPage = ({
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.9rem;">
|
||||
Mint a 1:1 delegated token or launch a multi-seat workshop event.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<div
|
||||
role="tablist"
|
||||
style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
id="tabBtnDirectPass"
|
||||
class="delegate-tab-btn active"
|
||||
onclick="switchDelegateTab('tabDirectPass')"
|
||||
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
|
||||
>
|
||||
🔑 1:1 Direct Pass
|
||||
<span style="font-size: 0.95rem; font-weight: 700;">
|
||||
Single Session
|
||||
</span>
|
||||
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
|
||||
For agents, CI/CD, or 1:1 delegation
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
id="tabBtnWorkshopPass"
|
||||
class="delegate-tab-btn"
|
||||
onclick="switchDelegateTab('tabWorkshopPass')"
|
||||
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
|
||||
>
|
||||
🎟️ Multi-Claim Workshop Pass
|
||||
<span style="font-size: 0.95rem; font-weight: 700;">
|
||||
Multi-Claim Event
|
||||
</span>
|
||||
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
|
||||
For workshops, teams & guest pools
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -175,11 +194,13 @@ export const SessionsPage = ({
|
||||
transition: all 0.15s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.delegate-tab-btn.active {
|
||||
background: var(--surface-card);
|
||||
color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-xs);
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
@ -14,6 +14,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
class="view-mode-btn active"
|
||||
onclick="setEventViewMode('grid')"
|
||||
aria-label="Grid View"
|
||||
aria-pressed="true"
|
||||
>
|
||||
🗂️ Grid
|
||||
</button>
|
||||
@ -23,6 +24,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
class="view-mode-btn"
|
||||
onclick="setEventViewMode('compact')"
|
||||
aria-label="Compact View"
|
||||
aria-pressed="false"
|
||||
>
|
||||
📋 Compact
|
||||
</button>
|
||||
@ -125,29 +127,31 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
</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);">
|
||||
CLI:
|
||||
</span>
|
||||
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
curl -sSL {Deno.env.get("RP_ID")
|
||||
? `https://${Deno.env.get("RP_ID")}`
|
||||
: ""}/join/{event.slug}?format=env | source /dev/stdin
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Copy CLI command for ${event.name}`}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
|
||||
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
|
||||
▸ Expand full CLI command
|
||||
<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 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"
|
||||
class="btn-outline"
|
||||
aria-label={`Copy CLI command for ${event.name}`}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
|
||||
onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
|
||||
[ ▾ ]
|
||||
</span>
|
||||
</summary>
|
||||
<textarea
|
||||
readonly
|
||||
@ -192,11 +196,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
aria-label={`Rotate PIN code for ${event.name}`}
|
||||
aria-label={`Rotate Credentials for ${event.name}`}
|
||||
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
|
||||
onclick={`rotatePin('${event.id}')`}
|
||||
>
|
||||
🔄 Rotate PIN
|
||||
🔄 Rotate Credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -220,25 +224,50 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
</div>
|
||||
|
||||
{/* Compact Actions (only visible in compact mode) */}
|
||||
<div class="event-card-compact-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||
event.name.replace(/'/g, "\\'")
|
||||
}')`}
|
||||
>
|
||||
👥 Guests ({event.seats_claimed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
|
||||
onclick="alert('Expand functionality for compact view to be implemented if needed')"
|
||||
>
|
||||
▸ Details
|
||||
</button>
|
||||
<div class="event-card-compact-row-2">
|
||||
<div class="compact-pills">
|
||||
<span
|
||||
class="compact-pill"
|
||||
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
|
||||
>
|
||||
PIN:{" "}
|
||||
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
|
||||
{" "}
|
||||
(Copy)
|
||||
</span>
|
||||
<span
|
||||
class="compact-pill"
|
||||
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
|
||||
>
|
||||
Link (Copy)
|
||||
</span>
|
||||
<span
|
||||
class="compact-pill"
|
||||
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
|
||||
>
|
||||
CLI (Copy)
|
||||
</span>
|
||||
</div>
|
||||
<div class="compact-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||
onclick={`openAttendeesDrawer('${event.id}', '${
|
||||
event.name.replace(/'/g, "\\'")
|
||||
}')`}
|
||||
>
|
||||
👥 Guests ({event.seats_claimed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline extend-event-btn"
|
||||
data-event-id={event.id}
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
|
||||
>
|
||||
🔄 +1h
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -246,6 +275,13 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
.cli-details summary {
|
||||
list-style: none;
|
||||
}
|
||||
.cli-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
@ -256,11 +292,15 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
transition: all 0.15s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.view-mode-btn.active {
|
||||
background: var(--surface-card);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
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 */
|
||||
@ -275,7 +315,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.grid-view .event-card-compact-actions {
|
||||
.grid-view .event-card-compact-row-2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -287,10 +327,14 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
}
|
||||
.compact-view .event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 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 */
|
||||
@ -298,34 +342,68 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
|
||||
.compact-view .event-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
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 > div {
|
||||
.compact-view .event-card-header > .status-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.compact-view .event-card-compact-row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.compact-view .event-card-snippets,
|
||||
.compact-view .event-card-actions,
|
||||
.compact-view .status-badge {
|
||||
display: none; /* Hide heavy elements in compact */
|
||||
}
|
||||
.compact-view .event-card-compact-actions {
|
||||
|
||||
.compact-view .compact-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.compact-view .countdown-pill {
|
||||
font-size: 0.75rem;
|
||||
.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 */
|
||||
|
||||
@ -32,8 +32,7 @@ export const EventGuestsDrawer = () => {
|
||||
>
|
||||
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
|
||||
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
|
||||
<span id="guestDrawerCountdown">⏳ 0h 0m left</span> · (Expires{" "}
|
||||
<span id="guestDrawerExpiresAt">Time</span>)
|
||||
<span id="guestDrawerCountdown">⏳ 0h 0m left</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -16,34 +16,47 @@ export const SessionsScript = () => {
|
||||
}
|
||||
|
||||
function openDelegateDrawer() {
|
||||
document.getElementById('delegateDrawer').style.display = 'block';
|
||||
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
const drawer = document.getElementById('delegateDrawer');
|
||||
drawer.style.display = 'block';
|
||||
drawer.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
function closeDelegateDrawer() {
|
||||
document.getElementById('delegateDrawer').style.display = 'none';
|
||||
// Reset WorkshopDrawer 2-State Machine
|
||||
// Reset WorkshopDrawer 2-State Machine on open
|
||||
const eventCreateState = document.getElementById('eventCreateState');
|
||||
if (eventCreateState) eventCreateState.style.display = 'block';
|
||||
const eventHandoffState = document.getElementById('eventHandoffState');
|
||||
if (eventHandoffState) eventHandoffState.style.display = 'none';
|
||||
const eventForm = document.getElementById('eventForm');
|
||||
if (eventForm) eventForm.reset();
|
||||
const drawerTitle = document.getElementById('delegateDrawerTitle');
|
||||
if (drawerTitle) drawerTitle.textContent = 'Create New Pass / Session';
|
||||
|
||||
// Default back to Single Session Tab
|
||||
switchDelegateTab('tabDirectPass');
|
||||
}
|
||||
|
||||
function closeDelegateDrawer() {
|
||||
document.getElementById('delegateDrawer').style.display = 'none';
|
||||
}
|
||||
|
||||
function switchDelegateTab(tabId) {
|
||||
document.getElementById('tabDirectPass').style.display = 'none';
|
||||
document.getElementById('tabWorkshopPass').style.display = 'none';
|
||||
document.getElementById('tabBtnDirectPass').classList.remove('active');
|
||||
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
|
||||
|
||||
const btnDirect = document.getElementById('tabBtnDirectPass');
|
||||
const btnWorkshop = document.getElementById('tabBtnWorkshopPass');
|
||||
|
||||
btnDirect.classList.remove('active');
|
||||
btnDirect.setAttribute('aria-selected', 'false');
|
||||
|
||||
btnWorkshop.classList.remove('active');
|
||||
btnWorkshop.setAttribute('aria-selected', 'false');
|
||||
|
||||
document.getElementById(tabId).style.display = 'block';
|
||||
|
||||
if (tabId === 'tabDirectPass') {
|
||||
document.getElementById('tabBtnDirectPass').classList.add('active');
|
||||
btnDirect.classList.add('active');
|
||||
btnDirect.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
document.getElementById('tabBtnWorkshopPass').classList.add('active');
|
||||
btnWorkshop.classList.add('active');
|
||||
btnWorkshop.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,7 +179,6 @@ export const SessionsScript = () => {
|
||||
|
||||
document.getElementById('eventCreateState').style.display = 'none';
|
||||
document.getElementById('eventHandoffState').style.display = 'block';
|
||||
document.getElementById('delegateDrawerTitle').textContent = 'Event Pass Active';
|
||||
|
||||
showNotice('Workshop pass created: ' + ev.name, false);
|
||||
} else {
|
||||
@ -388,12 +400,18 @@ export const SessionsScript = () => {
|
||||
const usernameParts = att.username.split('_');
|
||||
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
|
||||
|
||||
// Compute relative join time (approximate based on created_at)
|
||||
const createdDate = new Date(att.created_at);
|
||||
const now = new Date();
|
||||
const diffMs = now - createdDate;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
|
||||
const joinText = formatNaturalJoinTime(att.created_at, !isPaused);
|
||||
|
||||
const lastAction = att.last_activity_action || 'ForwardAuth Ingress';
|
||||
let timeAgo = 'just now';
|
||||
if (att.last_activity_at) {
|
||||
const lastActDate = new Date(att.last_activity_at);
|
||||
const now = new Date();
|
||||
const diffMinsAct = Math.floor((now - lastActDate) / 60000);
|
||||
timeAgo = diffMinsAct < 1 ? 'just now' : diffMinsAct + 'm ago';
|
||||
}
|
||||
|
||||
const clientIcon = lastAction.includes('CLI') ? '📟 CLI' : '💻 Web';
|
||||
|
||||
html += \`
|
||||
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
|
||||
@ -405,7 +423,10 @@ export const SessionsScript = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
|
||||
\${joinText} · \${att.username}
|
||||
\${joinText}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem;">
|
||||
Last Action: \${lastAction} · \${timeAgo} · \${clientIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
@ -584,49 +605,117 @@ export const SessionsScript = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Standardized formatters
|
||||
function formatNaturalExpiry(expiresAt) {
|
||||
const expDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expDate.getTime() - now.getTime();
|
||||
|
||||
const timeStr = expDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const fullISO = expDate.toISOString();
|
||||
|
||||
let dateStr = '';
|
||||
const isToday = expDate.getDate() === now.getDate() && expDate.getMonth() === now.getMonth() && expDate.getFullYear() === now.getFullYear();
|
||||
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = expDate.getDate() === tomorrow.getDate() && expDate.getMonth() === tomorrow.getMonth() && expDate.getFullYear() === tomorrow.getFullYear();
|
||||
|
||||
if (isToday) {
|
||||
dateStr = 'Today';
|
||||
} else if (isTomorrow) {
|
||||
dateStr = 'Tomorrow';
|
||||
} else if (expDate.getFullYear() === now.getFullYear()) {
|
||||
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
} else {
|
||||
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
let badge = '';
|
||||
if (diffMs <= 0) {
|
||||
badge = '<span class="status-badge-red" title="' + fullISO + '" style="background:rgba(239,68,68,0.15);color:#dc2626;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Expired</span>';
|
||||
} else {
|
||||
const totalMins = Math.floor(diffMs / 60000);
|
||||
const hours = totalMins / 60;
|
||||
const days = hours / 24;
|
||||
const months = days / 30;
|
||||
const years = days / 365;
|
||||
|
||||
let timeRemainingStr = '';
|
||||
let badgeStyle = 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // green
|
||||
|
||||
if (hours < 1) {
|
||||
timeRemainingStr = totalMins + 'm left';
|
||||
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
|
||||
} else if (hours < 24) {
|
||||
const h = Math.floor(hours);
|
||||
const m = totalMins % 60;
|
||||
timeRemainingStr = h + 'h ' + m + 'm left';
|
||||
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
|
||||
} else if (days <= 60) {
|
||||
timeRemainingStr = Math.floor(days) + 'd left';
|
||||
} else if (months <= 12) {
|
||||
timeRemainingStr = months.toFixed(1) + ' mos left';
|
||||
} else {
|
||||
timeRemainingStr = years.toFixed(1) + ' yrs left';
|
||||
}
|
||||
|
||||
badge = '<span style="' + badgeStyle + '" title="' + fullISO + '">' + timeRemainingStr + '</span>';
|
||||
}
|
||||
|
||||
return dateStr + ' · ' + timeStr + ' · ' + badge;
|
||||
}
|
||||
|
||||
function formatNaturalJoinTime(createdAt, isActive) {
|
||||
const createdDate = new Date(createdAt);
|
||||
const now = new Date();
|
||||
|
||||
const diffMs = now.getTime() - createdDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
const timeStr = createdDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const isToday = createdDate.getDate() === now.getDate() && createdDate.getMonth() === now.getMonth() && createdDate.getFullYear() === now.getFullYear();
|
||||
const dateStr = isToday ? 'Today' : createdDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
|
||||
let durationStr = '';
|
||||
if (diffMins < 60) {
|
||||
durationStr = diffMins + 'm';
|
||||
} else {
|
||||
const h = Math.floor(diffMins / 60);
|
||||
const m = diffMins % 60;
|
||||
durationStr = h + 'h ' + m + 'm';
|
||||
}
|
||||
|
||||
const badgeStyle = isActive ? 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;' : 'background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;';
|
||||
const badgeText = isActive ? 'Active ' + durationStr : 'Paused';
|
||||
|
||||
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText + '</span>';
|
||||
|
||||
return 'Joined ' + dateStr + ' · ' + timeStr + ' · ' + activeBadge;
|
||||
}
|
||||
|
||||
// Dynamic Countdown Updater
|
||||
function updateAllCountdowns() {
|
||||
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
|
||||
const now = new Date();
|
||||
|
||||
pills.forEach(pill => {
|
||||
const expiresAtStr = pill.getAttribute('data-expires-at');
|
||||
if (!expiresAtStr) return;
|
||||
|
||||
const expDate = new Date(expiresAtStr);
|
||||
const diffMs = expDate - now;
|
||||
|
||||
pill.classList.remove('status-green', 'status-amber', 'status-red');
|
||||
|
||||
if (diffMs <= 0) {
|
||||
pill.textContent = '⏳ Expired';
|
||||
pill.classList.add('status-red');
|
||||
if (pill.id === 'guestDrawerCountdown') {
|
||||
const now = new Date();
|
||||
const expDate = new Date(expiresAtStr);
|
||||
const diffMs = expDate - now;
|
||||
if (diffMs <= 0) {
|
||||
pill.textContent = '⏳ Expired';
|
||||
} else {
|
||||
const totalMins = Math.floor(diffMs / 60000);
|
||||
const hours = Math.floor(totalMins / 60);
|
||||
const mins = totalMins % 60;
|
||||
pill.textContent = \`⏳ \${hours}h \${mins}m left\`;
|
||||
}
|
||||
} else {
|
||||
const totalMins = Math.floor(diffMs / 60000);
|
||||
const hours = Math.floor(totalMins / 60);
|
||||
const mins = totalMins % 60;
|
||||
|
||||
let text = '';
|
||||
if (hours > 72) { // more than 3 days
|
||||
const days = Math.floor(hours / 24);
|
||||
text = \`⏳ \${days}d left\`;
|
||||
} else {
|
||||
text = \`⏳ \${hours}h \${mins}m left\`;
|
||||
}
|
||||
|
||||
// Add absolute time suffix if it's a standard pill (not the drawer context header which has it separate)
|
||||
if (pill.id !== 'guestDrawerCountdown') {
|
||||
const timeString = expDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
text += \` · (Expires \${timeString})\`;
|
||||
}
|
||||
|
||||
pill.textContent = text;
|
||||
|
||||
if (hours < 1) {
|
||||
pill.classList.add('status-amber');
|
||||
} else {
|
||||
pill.classList.add('status-green');
|
||||
}
|
||||
pill.innerHTML = formatNaturalExpiry(expiresAtStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -241,7 +241,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
class="btn-outline"
|
||||
aria-label="Copy Universal PIN"
|
||||
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
|
||||
onclick="copyEventHandoff('pin')"
|
||||
@ -304,7 +304,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
|
||||
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
|
||||
onclick="closeEventHandoffModal()"
|
||||
>
|
||||
Dismiss
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
118
ui/components/sessions/formatters.ts
Normal file
118
ui/components/sessions/formatters.ts
Normal file
@ -0,0 +1,118 @@
|
||||
export function formatNaturalExpiry(expiresAt: string | Date): string {
|
||||
const expDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expDate.getTime() - now.getTime();
|
||||
|
||||
const timeStr = expDate.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const fullISO = expDate.toISOString();
|
||||
|
||||
let dateStr = "";
|
||||
const isToday = expDate.getDate() === now.getDate() &&
|
||||
expDate.getMonth() === now.getMonth() &&
|
||||
expDate.getFullYear() === now.getFullYear();
|
||||
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = expDate.getDate() === tomorrow.getDate() &&
|
||||
expDate.getMonth() === tomorrow.getMonth() &&
|
||||
expDate.getFullYear() === tomorrow.getFullYear();
|
||||
|
||||
if (isToday) {
|
||||
dateStr = "Today";
|
||||
} else if (isTomorrow) {
|
||||
dateStr = "Tomorrow";
|
||||
} else if (expDate.getFullYear() === now.getFullYear()) {
|
||||
dateStr = expDate.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
dateStr = expDate.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
let badge = "";
|
||||
|
||||
if (diffMs <= 0) {
|
||||
badge = '<span class="status-badge-red" title="' + fullISO +
|
||||
'">Expired</span>';
|
||||
} else {
|
||||
const totalMins = Math.floor(diffMs / 60000);
|
||||
const hours = totalMins / 60;
|
||||
const days = hours / 24;
|
||||
const months = days / 30;
|
||||
const years = days / 365;
|
||||
|
||||
let timeRemainingStr = "";
|
||||
let badgeClass = "status-badge-green";
|
||||
|
||||
if (hours < 1) {
|
||||
timeRemainingStr = totalMins + "m left";
|
||||
badgeClass = "status-badge-amber";
|
||||
} else if (hours < 24) {
|
||||
const h = Math.floor(hours);
|
||||
const m = totalMins % 60;
|
||||
timeRemainingStr = h + "h " + m + "m left";
|
||||
badgeClass = "status-badge-amber";
|
||||
} else if (days <= 60) {
|
||||
timeRemainingStr = Math.floor(days) + "d left";
|
||||
} else if (months <= 12) {
|
||||
timeRemainingStr = months.toFixed(1) + " mos left";
|
||||
} else {
|
||||
timeRemainingStr = years.toFixed(1) + " yrs left";
|
||||
}
|
||||
|
||||
badge = '<span class="' + badgeClass + '" title="' + fullISO + '">' +
|
||||
timeRemainingStr + "</span>";
|
||||
}
|
||||
|
||||
return dateStr + " · " + timeStr + " · " + badge;
|
||||
}
|
||||
|
||||
export function formatNaturalJoinTime(
|
||||
createdAt: string | Date,
|
||||
isActive: boolean = true,
|
||||
): string {
|
||||
const createdDate = new Date(createdAt);
|
||||
const now = new Date();
|
||||
|
||||
const diffMs = now.getTime() - createdDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
const timeStr = createdDate.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const isToday = createdDate.getDate() === now.getDate() &&
|
||||
createdDate.getMonth() === now.getMonth() &&
|
||||
createdDate.getFullYear() === now.getFullYear();
|
||||
const dateStr = isToday
|
||||
? "Today"
|
||||
: createdDate.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
|
||||
let durationStr = "";
|
||||
if (diffMins < 60) {
|
||||
durationStr = diffMins + "m";
|
||||
} else {
|
||||
const h = Math.floor(diffMins / 60);
|
||||
const m = diffMins % 60;
|
||||
durationStr = h + "h " + m + "m";
|
||||
}
|
||||
|
||||
const badgeStyle = isActive
|
||||
? "background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;"
|
||||
: "background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
|
||||
const badgeText = isActive ? "Active " + durationStr : "Paused";
|
||||
|
||||
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText +
|
||||
"</span>";
|
||||
|
||||
return "Joined " + dateStr + " · " + timeStr + " · " +
|
||||
activeBadge;
|
||||
}
|
||||
@ -1,4 +1,77 @@
|
||||
import { assertEquals } from "jsr:@std/assert@1";
|
||||
import {
|
||||
formatNaturalExpiry,
|
||||
formatNaturalJoinTime,
|
||||
} from "./components/sessions/formatters.ts";
|
||||
|
||||
Deno.test("formatNaturalExpiry - < 1h", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() + 45 * 60000);
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(
|
||||
result.includes("45m left") || result.includes("44m left"),
|
||||
true,
|
||||
);
|
||||
assertEquals(result.includes("status-badge-amber"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalExpiry - < 24h", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() + 165 * 60000); // 2h 45m
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(
|
||||
result.includes("2h 45m left") || result.includes("2h 44m left"),
|
||||
true,
|
||||
);
|
||||
assertEquals(result.includes("status-badge-amber"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalExpiry - 1-60d", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() + 28 * 24 * 60 * 60000);
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(
|
||||
result.includes("28d left") || result.includes("27d left"),
|
||||
true,
|
||||
);
|
||||
assertEquals(result.includes("status-badge-green"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalExpiry - 2-12mos", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() + 142 * 24 * 60 * 60000);
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(result.includes("4.7 mos left"), true);
|
||||
assertEquals(result.includes("status-badge-green"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalExpiry - > 1yr", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() + 420 * 24 * 60 * 60000);
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(
|
||||
result.includes("1.2 yrs left") || result.includes("1.1 yrs left"),
|
||||
true,
|
||||
);
|
||||
assertEquals(result.includes("status-badge-green"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalExpiry - expired", () => {
|
||||
const now = new Date();
|
||||
const expiry = new Date(now.getTime() - 45 * 60000);
|
||||
const result = formatNaturalExpiry(expiry);
|
||||
assertEquals(result.includes("Expired"), true);
|
||||
assertEquals(result.includes("status-badge-red"), true);
|
||||
});
|
||||
|
||||
Deno.test("formatNaturalJoinTime", () => {
|
||||
const now = new Date();
|
||||
const joinTime = new Date(now.getTime() - 474 * 60000); // 7h 54m ago
|
||||
const result = formatNaturalJoinTime(joinTime);
|
||||
assertEquals(result.includes("7h 54m"), true);
|
||||
assertEquals(result.includes("Active"), true);
|
||||
});
|
||||
|
||||
import { SessionsPage } from "./components/SessionsPage.tsx";
|
||||
import { LoginPage } from "./components/LoginPage.tsx";
|
||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user