feat(dashboard): GuruConnect v2 Sessions view (pass 2)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m9s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m25s
Build and Test / Build Summary (push) Successful in 10s

Active-sessions table with consent-state badges, viewer-token Join,
and disconnect, built on the v2 session API and existing UI primitives.

- Sessions table: machine, mode (managed/attended), consent badge
  (granted/pending+pulse/denied/not_required), viewers, started,
  duration, status. Sticky header, skeleton load, empty/error states.
- Join action mints a session-scoped viewer token
  (POST /api/sessions/:id/viewer-token) and reveals it with the
  /ws/viewer relay URL and copy buttons. The static viewer.html is
  intentionally not targeted: it sends the raw login JWT, which the v2
  viewer plane rejects. In-dashboard web viewer ships in a later pass.
- Authz split mirrors the server mint gate: admin or control permission
  gets Control; view permission gets View only; neither hides the action.
  Server remains authoritative; the minted token carries the signed
  access claim.
- Disconnect via confirm dialog (DELETE /api/sessions/:id), invalidates
  the sessions query. List polls every 8s so consent transitions surface.

Passed Code Review (no blockers) and local gates (tsc/lint/build green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:12:04 -07:00
parent 43a9432b81
commit 6ecb937eb6
11 changed files with 872 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import { AppShell } from "./components/layout/AppShell";
import { ToastProvider } from "./components/ui/toast";
import { LoginPage } from "./features/auth/LoginPage";
import { MachinesPage } from "./features/machines/MachinesPage";
import { SessionsPage } from "./features/sessions/SessionsPage";
const queryClient = new QueryClient({
defaultOptions: {
@@ -27,7 +28,8 @@ export function App() {
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/machines" element={<MachinesPage />} />
{/* Sessions / Codes / Users land in later passes. */}
<Route path="/sessions" element={<SessionsPage />} />
{/* Codes / Users land in later passes. */}
<Route path="/" element={<Navigate to="/machines" replace />} />
</Route>
</Route>

View File

@@ -0,0 +1,36 @@
import { http } from "./client";
import type { Session, ViewerTokenResponse } from "./types";
/**
* GET /api/sessions — all live sessions known to the relay's in-memory session
* manager (active + offline-persistent). Requires an authenticated dashboard
* JWT; any authenticated user may list.
*/
export function listSessions(signal?: AbortSignal): Promise<Session[]> {
return http.get<Session[]>("/api/sessions", signal);
}
/**
* POST /api/sessions/:id/viewer-token — mint a short-lived, session-scoped
* viewer token. The server decides the access mode from the caller's
* permissions: admin or `control` permission gets a `control` token, otherwise
* a `view_only` token. A caller with neither `control` nor `view` gets 403.
* The access mode is stamped into the signed token; this response only echoes
* it. (See server/src/api/sessions.rs::mint_viewer_token.)
*/
export function mintViewerToken(
sessionId: string,
): Promise<ViewerTokenResponse> {
return http.post<ViewerTokenResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/viewer-token`,
);
}
/**
* DELETE /api/sessions/:id — disconnect/end a live session. The relay sends a
* Disconnect to the agent. Returns 200 on success, 404 if the session is not
* found. Requires an authenticated dashboard JWT (not admin-gated server-side).
*/
export function endSession(sessionId: string): Promise<void> {
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
}

View File

@@ -94,6 +94,69 @@ export interface DeleteMachineResponse {
history: MachineHistory | null;
}
// ---------------------------------------------------------------------------
// Sessions (live relay state)
// ---------------------------------------------------------------------------
/**
* Attended-consent state. Mirrors `connect_sessions.consent_state` and
* `session::ConsentState::as_db_str`. Managed/persistent sessions are
* `not_required`; attended (support-code) sessions move
* `pending` -> `granted` | `denied`. A viewer may only join `not_required` or
* `granted` (the relay refuses the others).
*/
export type ConsentState =
| "not_required"
| "pending"
| "granted"
| "denied";
/** A technician/viewer currently watching a session. Mirrors `ViewerInfoApi`. */
export interface SessionViewer {
id: string;
name: string;
connected_at: string; // RFC3339
}
/**
* Live session as returned by GET /api/sessions. Field names mirror
* `api::SessionInfo` (server/src/api/mod.rs) exactly. This is in-memory relay
* state, not the historical `connect_sessions` row (that is `SessionRecord`).
*/
export interface Session {
id: string;
agent_id: string;
agent_name: string;
started_at: string; // RFC3339
viewer_count: number;
viewers: SessionViewer[];
is_streaming: boolean;
is_online: boolean;
is_persistent: boolean;
last_heartbeat: string; // RFC3339
os_version: string | null;
is_elevated: boolean;
uptime_secs: number;
display_count: number;
agent_version: string | null;
consent_state: ConsentState | string;
}
/** Access mode the relay grants a minted viewer token. */
export type ViewerAccess = "control" | "view_only";
/**
* Response from POST /api/sessions/:id/viewer-token. Mirrors
* `api::sessions::ViewerTokenResponse`. The signed token carries the
* authoritative access claim; `access` here is the echoed mode.
*/
export interface ViewerTokenResponse {
token: string;
session_id: string;
expires_in_secs: number;
access: ViewerAccess | string;
}
// ---------------------------------------------------------------------------
// Per-agent keys (admin plane)
// ---------------------------------------------------------------------------

View File

@@ -17,7 +17,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: false },
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true },
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false },
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
];

View File

@@ -111,3 +111,19 @@ export function CopyIcon(props: IconProps) {
</svg>
);
}
export function JoinIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
);
}
export function StopIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>
);
}

View File

@@ -26,3 +26,23 @@ export function consentTone(state: string): StatusTone {
return "neutral";
}
}
/**
* Human label for an attended-consent state. Kept here next to `consentTone`
* so the color and the words for a given state never drift apart. `pending` is
* phrased as the active wait it represents (a tech is blocked on it).
*/
export function consentLabel(state: string): string {
switch (state) {
case "granted":
return "Granted";
case "pending":
return "Awaiting consent";
case "denied":
return "Denied";
case "not_required":
return "Not required";
default:
return state;
}
}

View File

@@ -0,0 +1,65 @@
import { ApiError } from "../../api/client";
import type { Session } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { useEndSession } from "./hooks";
interface EndSessionDialogProps {
/** The session to end, or null when the dialog is closed. */
session: Session | null;
onClose: () => void;
}
/**
* Confirm + end a live session. The relay sends a Disconnect to the agent and
* tears down the viewer stream; for an attended session this drops the support
* connection entirely. We confirm first because it interrupts whoever is
* connected, then invalidate the list so the row updates.
*/
export function EndSessionDialog({ session, onClose }: EndSessionDialogProps) {
const toast = useToast();
const endSession = useEndSession();
const open = session != null;
function onConfirm() {
if (!session) return;
endSession.mutate(session.id, {
onSuccess: () => {
toast.success(
"Session ended",
`Disconnected ${session.agent_name}.`,
);
onClose();
},
onError: (err) => {
toast.error(
"Could not end session",
err instanceof ApiError ? err.message : "The relay did not respond.",
);
},
});
}
return (
<ConfirmDialog
open={open}
title="End this session?"
danger
busy={endSession.isPending}
confirmLabel="End session"
onConfirm={onConfirm}
onCancel={onClose}
body={
session ? (
<p>
This disconnects{" "}
<strong>{session.agent_name}</strong> and drops any technician
currently viewing it. {session.is_persistent
? "The managed agent stays enrolled and can reconnect."
: "The support session ends and cannot be resumed."}
</p>
) : null
}
/>
);
}

View File

@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import type { Session, ViewerTokenResponse } from "../../api/types";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { Spinner } from "../../components/ui/Spinner";
import { ErrorState } from "../../components/ui/States";
import { CopyIcon } from "../../components/layout/icons";
import { useClipboard } from "../../lib/useClipboard";
import { useMintViewerToken } from "./hooks";
import "./sessions.css";
interface JoinSessionModalProps {
/** The session to join, or null when the modal is closed. */
session: Session | null;
/** Whether the caller may request a control token (admin or `control`). */
canControl: boolean;
onClose: () => void;
}
/**
* Build the exact `/ws/viewer` URL the relay expects for a minted token. The
* relay reads `session_id`, `viewer_name`, and `token` query params
* (server/src/relay/mod.rs::ViewerParams) and binds the token to this session.
* Same-origin in production; the dashboard is served behind the same host as
* the relay.
*/
function buildViewerUrl(token: ViewerTokenResponse, viewerName: string): string {
const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({
session_id: token.session_id,
viewer_name: viewerName,
token: token.token,
});
return `${wsProto}//${window.location.host}/ws/viewer?${qs.toString()}`;
}
/**
* Join action. The in-dashboard web viewer is not built yet (a later pass), and
* the existing static `viewer.html` is incompatible with v2: it reads the token
* from localStorage and sends the raw login JWT, which the v2 relay rejects on
* the viewer plane (it requires a session-scoped viewer token). So instead of
* opening a viewer that would be refused, we mint the token here and reveal it
* with the exact relay URL and copy buttons. This is the honest, working bridge
* until the web viewer ships.
*
* The access mode (Control vs View) is decided SERVER-SIDE from the caller's
* permissions; we only request and reflect it. `canControl` gates the label and
* intent so we never present a control affordance the server would downgrade.
*/
export function JoinSessionModal({
session,
canControl,
onClose,
}: JoinSessionModalProps) {
const open = session != null;
const mint = useMintViewerToken();
const tokenCopy = useClipboard();
const urlCopy = useClipboard();
const [result, setResult] = useState<ViewerTokenResponse | null>(null);
const viewerName = canControl ? "Operator" : "Observer";
// Mint once when the modal opens for a session; reset on close. We mint via an
// effect (not on the click) so the modal can own the loading/error/success
// states cleanly and a re-open always re-mints a fresh short-lived token.
useEffect(() => {
if (!session) {
setResult(null);
mint.reset();
return;
}
let cancelled = false;
mint
.mutateAsync(session.id)
.then((res) => {
if (!cancelled) setResult(res);
})
.catch(() => {
// Error surfaced via mint.isError below; nothing to do here.
});
return () => {
cancelled = true;
};
// Mint is keyed to the session id only; the mutation object is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id]);
const viewerUrl = useMemo(
() => (result ? buildViewerUrl(result, viewerName) : ""),
[result, viewerName],
);
if (!open) return null;
const grantedControl = result?.access === "control";
const ttlMins = result ? Math.round(result.expires_in_secs / 60) : 0;
return (
<Modal
open={open}
title={canControl ? "Control session" : "View session"}
onClose={onClose}
wide
footer={<Button variant="primary" onClick={onClose}>Done</Button>}
>
<p className="joinmodal__lede">
A short-lived viewer token for{" "}
<span className="dt__strong">{session.agent_name}</span> scoped to this
session only. The in-dashboard web viewer ships in a later pass; until
then, use this token with the relay URL below to connect a viewer.
</p>
{mint.isPending && !result ? (
<div className="joinmodal__loading">
<Spinner label="Minting viewer token…" />
</div>
) : mint.isError ? (
<ErrorState
title="Could not mint a viewer token"
message={
mint.error instanceof ApiError
? mint.error.message
: "The relay did not return a token. The session may have just ended."
}
action={
<Button
variant="primary"
onClick={() => {
if (session)
void mint.mutateAsync(session.id).then(setResult).catch(() => {});
}}
>
Retry
</Button>
}
/>
) : result ? (
<>
<div className="joinmodal__accessrow">
<span className="joinmodal__accesslabel">Access granted</span>
{grantedControl ? (
<Badge tone="ok" dot>
Full control
</Badge>
) : (
<Badge tone="neutral" dot>
View only
</Badge>
)}
{canControl && !grantedControl && (
<span className="joinmodal__downgrade">
The relay granted view-only for your account.
</span>
)}
</div>
<FieldReveal
label="Viewer token"
value={result.token}
copied={tokenCopy.copied}
onCopy={() => void tokenCopy.copy(result.token)}
mono
/>
<FieldReveal
label="Relay viewer URL"
value={viewerUrl}
copied={urlCopy.copied}
onCopy={() => void urlCopy.copy(viewerUrl)}
/>
<p className="joinmodal__hint">
Token expires in about {ttlMins} minute{ttlMins === 1 ? "" : "s"} and
works only for this session. It carries the access mode in its signed
claims, so it cannot be upgraded to control after the fact.
</p>
</>
) : null}
</Modal>
);
}
interface FieldRevealProps {
label: string;
value: string;
copied: boolean;
onCopy: () => void;
mono?: boolean;
}
/** A labeled read-only value with a copy button (token / URL reveal). */
function FieldReveal({ label, value, copied, onCopy, mono }: FieldRevealProps) {
return (
<div className="joinmodal__field">
<span className="joinmodal__fieldlabel">{label}</span>
<div className="joinmodal__fieldvalue">
<code className={mono ? "joinmodal__code joinmodal__code--mono" : "joinmodal__code"}>
{value}
</code>
<Button
variant="ghost"
size="sm"
onClick={onCopy}
aria-label={copied ? `${label} copied to clipboard` : `Copy ${label.toLowerCase()}`}
>
<CopyIcon width={14} height={14} />
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import type { Session } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { PageHeader } from "../../components/layout/PageHeader";
import {
JoinIcon,
RefreshIcon,
SearchIcon,
StopIcon,
} from "../../components/layout/icons";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { Input } from "../../components/ui/Input";
import { Panel } from "../../components/ui/Panel";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { StatusDot } from "../../components/ui/StatusDot";
import { consentLabel, consentTone } from "../../components/ui/status";
import { Table, type Column } from "../../components/ui/Table";
import { TableSkeleton } from "../../components/ui/TableSkeleton";
import { absoluteTime, relativeTime } from "../../lib/time";
import { EndSessionDialog } from "./EndSessionDialog";
import { JoinSessionModal } from "./JoinSessionModal";
import { useSessions } from "./hooks";
import "./sessions.css";
/** Live elapsed time since an ISO start, formatted like the duration helper. */
function elapsedSince(iso: string): string {
const start = new Date(iso).getTime();
if (Number.isNaN(start)) return "—";
const secs = Math.max(0, Math.floor((Date.now() - start) / 1000));
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
export function SessionsPage() {
const { isAdmin, hasPermission } = useAuth();
const sessionsQuery = useSessions();
const [filter, setFilter] = useState("");
const [joinFor, setJoinFor] = useState<Session | null>(null);
const [endFor, setEndFor] = useState<Session | null>(null);
// The same authz split the server enforces at mint time: admin OR the
// `control` permission yields a control token; otherwise the floor is `view`.
// We mirror it so we never present a control affordance the server downgrades,
// and so a user with neither permission gets no join action at all (the mint
// would 403).
const canControl = isAdmin || hasPermission("control");
const canView = canControl || hasPermission("view");
const { data } = sessionsQuery;
const sessions = useMemo(() => data ?? [], [data]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter(
(s) =>
s.agent_name.toLowerCase().includes(q) ||
s.agent_id.toLowerCase().includes(q),
);
}, [sessions, filter]);
const onlineCount = useMemo(
() => sessions.filter((s) => s.is_online).length,
[sessions],
);
// A viewer may only join sessions the relay would admit: not_required or
// granted. pending/denied/offline cannot be joined, so the join action is
// disabled with a reason rather than offered and rejected.
function joinBlockedReason(s: Session): string | null {
if (!s.is_online) return "Agent is offline";
if (s.consent_state === "pending") return "Waiting on end-user consent";
if (s.consent_state === "denied") return "Consent was denied";
return null;
}
const columns: Column<Session>[] = [
{
key: "status",
header: "",
cellClass: "dt__status",
render: (s) => (
<StatusDot
tone={s.is_online ? "ok" : "bad"}
label={s.is_online ? "online" : "offline"}
/>
),
},
{
key: "machine",
header: "Machine",
render: (s) => (
<div className="sdt__machine">
<span className="dt__strong">{s.agent_name}</span>
<span className="dt__mono sdt__agentid" title={s.agent_id}>
{s.agent_id}
</span>
</div>
),
},
{
key: "mode",
header: "Mode",
render: (s) =>
s.is_persistent ? (
<Badge tone="accent">Managed</Badge>
) : (
<Badge tone="neutral">Attended</Badge>
),
},
{
key: "consent",
header: "Consent",
render: (s) => (
<Badge tone={consentTone(s.consent_state)} dot>
{consentLabel(s.consent_state)}
</Badge>
),
},
{
key: "viewers",
header: "Viewers",
render: (s) =>
s.viewer_count > 0 ? (
<span className="sdt__viewers" title={s.viewers.map((v) => v.name).join(", ")}>
<span className="dt__strong">{s.viewer_count}</span> watching
</span>
) : (
<span className="dt__muted">None</span>
),
},
{
key: "started",
header: "Started",
render: (s) => (
<span className="dt__mono" title={absoluteTime(s.started_at)}>
{relativeTime(s.started_at)}
</span>
),
},
{
key: "duration",
header: "Duration",
render: (s) => (
<span className="dt__mono">
{s.is_online ? elapsedSince(s.started_at) : "—"}
</span>
),
},
{
key: "actions",
header: "",
cellClass: "dt__actions",
render: (s) => {
const blocked = joinBlockedReason(s);
return (
<span
className="dt__rowactions"
onClick={(e) => e.stopPropagation()}
>
{canView && (
<Button
variant="primary"
size="sm"
onClick={() => setJoinFor(s)}
disabled={blocked != null}
title={blocked ?? undefined}
aria-label={
blocked
? `${canControl ? "Control" : "View"} ${s.agent_name} (unavailable: ${blocked})`
: `${canControl ? "Control" : "View"} ${s.agent_name}`
}
>
<JoinIcon width={14} height={14} />
{canControl ? "Control" : "View"}
</Button>
)}
<Button
variant="danger"
size="sm"
onClick={() => setEndFor(s)}
disabled={!s.is_online}
title={!s.is_online ? "Agent is offline" : undefined}
aria-label={`End session on ${s.agent_name}`}
>
<StopIcon width={14} height={14} />
End
</Button>
</span>
);
},
},
];
return (
<div className="page">
<PageHeader
title="Sessions"
subtitle="Live remote sessions across the relay. Consent transitions update on their own."
actions={
<Button
variant="ghost"
onClick={() => void sessionsQuery.refetch()}
loading={sessionsQuery.isFetching}
>
<RefreshIcon width={15} height={15} />
Refresh
</Button>
}
/>
<Panel flush>
<div style={{ padding: "14px 16px 0" }}>
<div className="toolbar">
<div className="searchbox">
<span className="searchbox__icon">
<SearchIcon width={15} height={15} />
</span>
<Input
placeholder="Filter by machine or agent ID"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter sessions"
/>
</div>
<div className="toolbar__count">
<span className="mono">{onlineCount}</span> live ·{" "}
<span className="mono">{sessions.length}</span> total
</div>
</div>
</div>
{sessionsQuery.isLoading ? (
<>
<span className="visually-hidden" role="status">
Loading sessions
</span>
<TableSkeleton
headers={[
"",
"Machine",
"Mode",
"Consent",
"Viewers",
"Started",
"Duration",
"",
]}
/>
</>
) : sessionsQuery.isError ? (
<ErrorState
title="Could not load sessions"
message={
sessionsQuery.error instanceof ApiError
? sessionsQuery.error.message
: "The GuruConnect relay did not respond. Check the relay status, then retry."
}
action={
<Button variant="primary" onClick={() => void sessionsQuery.refetch()}>
Retry
</Button>
}
/>
) : filtered.length === 0 ? (
filter ? (
<EmptyState
title="No matching sessions"
message={`Nothing matches "${filter}". Clear the filter to see every live session.`}
action={
<Button variant="ghost" onClick={() => setFilter("")}>
Clear filter
</Button>
}
/>
) : (
<EmptyState
title="No active sessions"
message="Sessions appear here when a managed agent connects or a support code is redeemed. Start one from the Machines view or hand a support code to an end user."
/>
)
) : (
<Table
columns={columns}
rows={filtered}
rowKey={(s) => s.id}
/>
)}
</Panel>
<JoinSessionModal
session={joinFor}
canControl={canControl}
onClose={() => setJoinFor(null)}
/>
<EndSessionDialog session={endFor} onClose={() => setEndFor(null)} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import * as sessionsApi from "../../api/sessions";
const SESSIONS_KEY = ["sessions"] as const;
/**
* List all live sessions. Polls on a short interval so consent-state
* transitions (pending -> granted/denied) and viewer counts surface while the
* page is open — a tech watching for an attended user to accept needs this to
* move on its own. Slightly tighter than the machines poll because consent is a
* time-sensitive, human-in-the-loop transition.
*/
export function useSessions() {
return useQuery({
queryKey: SESSIONS_KEY,
queryFn: ({ signal }) => sessionsApi.listSessions(signal),
refetchInterval: 8_000,
staleTime: 4_000,
});
}
/**
* Mint a session-scoped viewer token. The server decides the access mode from
* the caller's permissions; the result is shown in the join modal. No cache
* invalidation — minting does not change session state.
*/
export function useMintViewerToken() {
return useMutation({
mutationFn: (sessionId: string) => sessionsApi.mintViewerToken(sessionId),
});
}
/** End a session, then invalidate the list so it drops (or flips offline). */
export function useEndSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => sessionsApi.endSession(sessionId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SESSIONS_KEY });
},
});
}

View File

@@ -0,0 +1,102 @@
/* ===================================================== Sessions table cells */
/* Machine cell: name over a dimmer agent id, same two-line idiom as the
detail drawer's key/value pairing. */
.sdt__machine {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.sdt__agentid {
font-size: 11px;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
.sdt__viewers {
color: var(--text);
font-size: 13px;
}
/* ========================================================= Join modal ===== */
.joinmodal__lede {
margin: 0 0 16px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
.joinmodal__loading {
display: flex;
justify-content: center;
padding: 28px 0;
}
/* Access-mode summary row: "Access granted" + the granted-mode badge. */
.joinmodal__accessrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 11px 14px;
margin-bottom: 14px;
border-radius: var(--radius-sm);
background: var(--panel-2);
border: 1px solid var(--border);
}
.joinmodal__accesslabel {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.joinmodal__downgrade {
flex-basis: 100%;
font-size: 12px;
color: var(--text-faint);
}
/* Token / URL reveal field: label above a boxed value with a copy button. */
.joinmodal__field {
margin-bottom: 14px;
}
.joinmodal__fieldlabel {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
}
.joinmodal__fieldvalue {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--panel-2);
border: 1px solid var(--border-strong);
}
.joinmodal__code {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text);
word-break: break-all;
user-select: all;
}
.joinmodal__code--mono {
font-family: var(--font-mono);
color: var(--accent);
}
.joinmodal__hint {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}