vtc-service 0.7.0

Service for Verifiable Trust Communities
// Sessions plugin — list + revoke active sessions.
//
// Wraps the `/v1/auth/sessions` endpoint family. Lists every active
// session in the daemon's session keyspace, marks the caller's own
// session (so an operator who clicks Revoke on themselves understands
// they're about to be signed out), and offers per-session revoke +
// "revoke all of this DID" buttons.
//
// Purpose: if an operator suspects a cookie has been stolen, they
// open this and revoke the suspect session without having to nuke
// every credential they hold. The backend already enforces that you
// can only revoke your own sessions unless you're admin.

import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowUpDown, Smartphone } from "lucide-react";

import { deleteJson, getJson, WhoamiResponse } from "@/lib/api";
import { useConfirm } from "@/components/ConfirmDialog";
import { formatEpoch, shorten as shortId } from "@/lib/format";
import { useToast } from "@/lib/toast";

type SortKey = "did" | "state" | "createdAt" | "refreshExpiresAt";
type SortDir = "asc" | "desc";

const TRUST_TASK_MANAGE =
  "https://trusttasks.org/openvtc/vtc/auth/legacy/sessions/manage/1.0";
const TRUST_TASK_REVOKE =
  "https://trusttasks.org/openvtc/vtc/auth/legacy/sessions/revoke/1.0";

type SessionState = "Pending" | "Authenticated" | "Revoked";

interface SessionSummary {
  sessionId: string;
  did: string;
  state: SessionState;
  createdAt: number;
  refreshExpiresAt: number | null;
}

async function fetchSessions(): Promise<SessionSummary[]> {
  return getJson<SessionSummary[]>("/v1/auth/sessions", {
    trustTask: TRUST_TASK_MANAGE,
  });
}

async function revokeSession(sessionId: string): Promise<void> {
  await deleteJson<unknown>(
    `/v1/auth/sessions/${encodeURIComponent(sessionId)}`,
    { trustTask: TRUST_TASK_REVOKE },
  );
}

async function revokeAllForDid(did: string): Promise<void> {
  await deleteJson<unknown>(
    `/v1/auth/sessions?did=${encodeURIComponent(did)}`,
    { trustTask: TRUST_TASK_MANAGE },
  );
}

export function Sessions() {
  const qc = useQueryClient();
  const toast = useToast();
  const confirm = useConfirm();
  const [filterText, setFilterText] = useState("");
  // Default sort = newest first by created time. Click a header to
  // toggle direction; clicking a different header switches the sort
  // key with sensible-for-that-column default direction.
  const [sortKey, setSortKey] = useState<SortKey>("createdAt");
  const [sortDir, setSortDir] = useState<SortDir>("desc");

  const sessionsQuery = useQuery({
    queryKey: ["sessions"],
    queryFn: fetchSessions,
  });

  // Whoami is already populated by the App shell via
  // `probeSession`. Reading the cached value via `getQueryData`
  // (rather than declaring our own `useQuery({queryKey:["whoami"]})`
  // with `fetchWhoami` as `queryFn`) avoids two pitfalls:
  //   1. Two `useQuery`s sharing a key but with different `queryFn`s
  //      race each other on cache miss; whichever fires first wins
  //      and the other view gets stale data.
  //   2. `fetchWhoami` throws on 401, but `probeSession` returns
  //      null. A stale-cache refetch here on a logged-out session
  //      would surface a misleading toast.
  // The session-expiry handler in App.tsx invalidates this key, so
  // any change to the live session re-flows through that path
  // before reaching us.
  const whoami = qc.getQueryData<WhoamiResponse | null>(["whoami"]);

  const revokeOne = useMutation({
    mutationFn: revokeSession,
    onSuccess: (_, sessionId) => {
      toast.push("success", `Revoked session ${shortId(sessionId)}`);
      void qc.invalidateQueries({ queryKey: ["sessions"] });
      // If the operator revoked themselves, the whoami probe will
      // flip to null on next refetch and the shell shows Login.
      void qc.invalidateQueries({ queryKey: ["whoami"] });
    },
    onError: (err) => toast.pushFromError(err, "Revoke failed"),
  });

  const revokeMany = useMutation({
    mutationFn: revokeAllForDid,
    onSuccess: (_, did) => {
      toast.push("success", `Revoked every session for ${did}`);
      void qc.invalidateQueries({ queryKey: ["sessions"] });
      void qc.invalidateQueries({ queryKey: ["whoami"] });
    },
    onError: (err) => toast.pushFromError(err, "Bulk revoke failed"),
  });

  const allSessions = sessionsQuery.data ?? [];
  const myDid = whoami?.did;
  const mySessionId = whoami?.sessionId;

  // Filter on substring match against either identifier, then sort.
  // useMemo so revoke-button clicks (which mutate React Query
  // queries that re-render this component) don't re-do this work on
  // every keystroke / button click.
  const sessions = useMemo(() => {
    const needle = filterText.trim().toLowerCase();
    const filtered = needle
      ? allSessions.filter((s) =>
          (s.did + " " + s.sessionId + " " + s.state)
            .toLowerCase()
            .includes(needle),
        )
      : allSessions;
    const sorted = [...filtered].sort((a, b) => {
      const av = a[sortKey];
      const bv = b[sortKey];
      if (av === bv) return 0;
      // Nulls sort last regardless of direction so blanks don't
      // crowd the top.
      if (av === null) return 1;
      if (bv === null) return -1;
      const cmp = av < bv ? -1 : 1;
      return sortDir === "asc" ? cmp : -cmp;
    });
    return sorted;
  }, [allSessions, filterText, sortKey, sortDir]);

  const handleSort = (key: SortKey) => {
    if (sortKey === key) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
      return;
    }
    setSortKey(key);
    // Timestamps default descending (most recent first); strings
    // default ascending (A → Z).
    setSortDir(key === "createdAt" || key === "refreshExpiresAt" ? "desc" : "asc");
  };

  // Group "revoke all for this DID" by DID — only show on the first
  // row of each DID block.
  const seenDids = new Set<string>();

  return (
    <section className="page">
      <h2>Sessions</h2>
      <p className="lead">
        Active server-side sessions in the daemon's session store. If a
        cookie has been compromised, revoke its session here — the
        browser holding it will be signed out on its next request.
      </p>

      {sessionsQuery.isPending && (
        <section className="card">
          <p>Loading sessions…</p>
        </section>
      )}

      {!sessionsQuery.isPending && (
        <section className="card">
          <div className="toolbar">
            <label className="field inline">
              <span className="field-label">Filter</span>
              <input
                type="search"
                placeholder="DID, session id, or state"
                value={filterText}
                onChange={(e) => setFilterText(e.target.value)}
              />
            </label>
            <span className="muted">
              {sessions.length} of {allSessions.length}
              {filterText.trim() && sessions.length !== allSessions.length
                ? " filtered"
                : ""}
            </span>
          </div>
        </section>
      )}

      {sessions.length === 0 && !sessionsQuery.isPending && (
        <section className="card">
          <div className="empty-state">
            <span className="empty-icon" aria-hidden="true">
              <Smartphone />
            </span>
            <h4>
              {allSessions.length === 0
                ? "No active sessions"
                : "No sessions match this filter"}
            </h4>
            <p>
              {allSessions.length === 0
                ? "Sessions appear here when an operator signs in."
                : "Clear the search box to see every active session."}
            </p>
          </div>
        </section>
      )}

      {sessions.length > 0 && (
        <section className="card">
          <table className="data-table">
            <thead>
              <tr>
                <SortableTh
                  label="DID"
                  sortKey="did"
                  active={sortKey}
                  dir={sortDir}
                  onSort={handleSort}
                />
                <th>Session</th>
                <SortableTh
                  label="State"
                  sortKey="state"
                  active={sortKey}
                  dir={sortDir}
                  onSort={handleSort}
                />
                <SortableTh
                  label="Created"
                  sortKey="createdAt"
                  active={sortKey}
                  dir={sortDir}
                  onSort={handleSort}
                />
                <SortableTh
                  label="Refresh expires"
                  sortKey="refreshExpiresAt"
                  active={sortKey}
                  dir={sortDir}
                  onSort={handleSort}
                />
                <th aria-label="Actions"></th>
              </tr>
            </thead>
            <tbody>
              {sessions.map((s) => {
                const isMine = s.sessionId === mySessionId;
                const showBulk = !seenDids.has(s.did);
                seenDids.add(s.did);
                const sameDidCount = sessions.filter(
                  (x) => x.did === s.did,
                ).length;
                return (
                  <tr key={s.sessionId}>
                    <td>
                      <code className="truncate" title={s.did}>
                        {s.did}
                      </code>
                      {s.did === myDid && (
                        <span className="chip accent" title="Your DID">
                          you
                        </span>
                      )}
                    </td>
                    <td>
                      <code className="truncate" title={s.sessionId}>
                        {shortId(s.sessionId)}
                      </code>
                      {isMine && (
                        <span className="chip accent" title="This browser tab">
                          this tab
                        </span>
                      )}
                    </td>
                    <td>
                      <code>{s.state}</code>
                    </td>
                    <td>{formatEpoch(s.createdAt)}</td>
                    <td>
                      {s.refreshExpiresAt ? (
                        formatEpoch(s.refreshExpiresAt)
                      ) : (
                        <span className="muted">—</span>
                      )}
                    </td>
                    <td>
                      <div className="row-actions">
                        <button
                          type="button"
                          className="secondary destructive"
                          disabled={revokeOne.isPending}
                          aria-busy={revokeOne.isPending}
                          onClick={async () => {
                            const ok = await confirm({
                              title: isMine
                                ? "Revoke your own session?"
                                : `Revoke session ${shortId(s.sessionId)}?`,
                              message: isMine
                                ? "You'll be signed out of this tab."
                                : `${s.did} loses this session immediately.`,
                              confirmLabel: "Revoke",
                              destructive: true,
                            });
                            if (ok) revokeOne.mutate(s.sessionId);
                          }}
                        >
                          Revoke
                        </button>
                        {showBulk && sameDidCount > 1 && (
                          <button
                            type="button"
                            className="secondary destructive"
                            disabled={revokeMany.isPending}
                            aria-busy={revokeMany.isPending}
                            title={`Revoke all ${sameDidCount} sessions for ${s.did}`}
                            onClick={async () => {
                              const ok = await confirm({
                                title: `Revoke all sessions for ${s.did}?`,
                                message: `${sameDidCount} active sessions will be terminated immediately.`,
                                confirmLabel: "Revoke all",
                                destructive: true,
                              });
                              if (ok) revokeMany.mutate(s.did);
                            }}
                          >
                            Revoke all for DID
                          </button>
                        )}
                      </div>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </section>
      )}
    </section>
  );
}

function SortableTh({
  label,
  sortKey,
  active,
  dir,
  onSort,
}: {
  label: string;
  sortKey: SortKey;
  active: SortKey;
  dir: SortDir;
  onSort: (key: SortKey) => void;
}) {
  const isActive = active === sortKey;
  const Icon = !isActive ? ArrowUpDown : dir === "asc" ? ArrowUp : ArrowDown;
  return (
    <th>
      <button
        type="button"
        className="sortable-th"
        aria-sort={
          isActive ? (dir === "asc" ? "ascending" : "descending") : "none"
        }
        onClick={() => onSort(sortKey)}
      >
        <span>{label}</span>
        <Icon size={12} aria-hidden="true" />
      </button>
    </th>
  );
}