~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
374 lines · 14.1 KB Raw History
import { useQuery } from '@tanstack/react-query';
import {
  ArrowLeftIcon,
  ChevronRightIcon,
  ExternalLinkIcon,
} from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button, buttonVariants } from '#/components/atoms/button.jsx';
import { Heading } from '#/components/atoms/heading.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { PublishedCheck } from '#/components/molecules/published-check.jsx';
import { ScopeList } from '#/components/molecules/scope-list.jsx';
import { SearchField } from '#/components/molecules/search-field.jsx';
import { ShowMore } from '#/components/molecules/show-more.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get, post } from '#/lib/api.js';
import {
  clientHost,
  day,
  groupByApp,
  num,
  spacesForApp,
  timeAgo,
} from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
import { formatUserAgent } from '#/lib/userAgent.js';
import { cn } from '#/lib/utils.js';

const RECORD_TYPES_SHOWN = 8;
const SESSIONS_SHOWN = 4;
const COLLECTION_SEARCH_THRESHOLD = 6;

export function AppPage({ data, domain, collectionHref, onNavigate, onBack }) {
  const { data: appsData, error } = useQuery({
    queryKey: queryKeys.apps,
    queryFn: () => get('/apps'),
  });
  const spacesEnabled = Boolean(data.features?.spaces);
  const { data: spacesData } = useQuery({
    queryKey: queryKeys.spaces,
    queryFn: () => get('/spaces'),
    enabled: spacesEnabled,
  });
  const action = useApiMutation([
    queryKeys.apps,
    queryKeys.sessions,
    queryKeys.overview,
  ]);

  // A row the user folded into another resolves to the row that absorbed it,
  // so a stale link or Home card still lands on the combined entry.
  const aliases = appsData?.aliases || {};
  const shown = aliases[domain] || domain;
  const folded = Object.keys(aliases).filter((from) => aliases[from] === shown);

  const groups = groupByApp(data.collections);
  const own = groups.find((g) => g.domain === shown);
  const foldedGroups = groups.filter((g) => folded.includes(g.domain));
  // Folded domains that brought no records are client hosts (a subdomain
  // sign-in folded into the apex it serves).
  const foldedClients = folded.filter(
    (d) => !foldedGroups.some((g) => g.domain === d),
  );
  const collections = [
    ...(own?.collections || []),
    ...foldedGroups.flatMap((g) => g.collections),
  ].sort((a, b) => b.count - a.count);
  const total =
    (own?.count || 0) + foldedGroups.reduce((sum, g) => sum + g.count, 0);

  // Which of these collections' lexicons are published (resolvable) via the
  // protocol. Loaded separately from the overview so the list renders instantly
  // and the checks fill in as resolution completes.
  const collectionNames = collections.map((c) => c.name);
  const { data: lexiconStatus } = useQuery({
    queryKey: queryKeys.lexiconStatus(collectionNames),
    queryFn: () =>
      get(
        `/lexicon-status?collections=${encodeURIComponent(collectionNames.join(','))}`,
      ),
    enabled: collectionNames.length > 0,
    staleTime: 5 * 60_000,
  });
  const published = lexiconStatus?.published || {};
  const rowDomains = [shown, ...folded];
  const sessions =
    appsData?.apps.filter((app) =>
      rowDomains.includes(clientHost(app.clientId)),
    ) || [];
  const spaces = spacesForApp(spacesData?.spaces, rowDomains);

  const [showAllRecords, setShowAllRecords] = useState(false);
  const [showAllSessions, setShowAllSessions] = useState(false);
  const [collectionQuery, setCollectionQuery] = useState('');
  const needle = collectionQuery.trim().toLowerCase();
  const matchedCollections = needle
    ? collections.filter((collection) =>
        collection.name.toLowerCase().includes(needle),
      )
    : collections;
  const shownCollections = showAllRecords
    ? matchedCollections
    : matchedCollections.slice(0, RECORD_TYPES_SHOWN);
  const shownSessions = showAllSessions
    ? sessions
    : sessions.slice(0, SESSIONS_SHOWN);

  const revoke = (sessionId) =>
    action.run(async () => {
      await post('/apps/revoke', { session: sessionId });
    });

  const revokeAll = () =>
    action.run(async () => {
      for (const session of sessions) {
        if (session.sessionId) {
          await post('/apps/revoke', { session: session.sessionId });
        }
      }
    });

  const separate = () =>
    action.run(async () => {
      for (const from of folded) {
        await post('/apps/alias', { action: 'separate', from });
      }
    });

  return (
    <div className="space-y-5">
      <TextLink onClick={onBack} className="gap-1.5">
        <ArrowLeftIcon className="size-4" />
        Your apps
      </TextLink>

      <Panel className="flex items-center gap-4">
        <AppTile domain={shown} label={shown} size={52} />
        <div className="min-w-0">
          <div className="truncate font-extrabold text-[22px] text-foreground tracking-tight">
            {shown}
          </div>
          <div className="mt-1 text-[13px] text-muted-foreground">
            {num(total)} record{total === 1 ? '' : 's'} across{' '}
            {collections.length} collection{collections.length === 1 ? '' : 's'}
          </div>
          {folded.length > 0 && (
            <div className="mt-1 text-[13px] text-muted-foreground">
              {[
                // A folded record authority reads out as its NSID prefix; a
                // folded client host is named as the sign-in it brought.
                foldedGroups.length > 0 &&
                  `Stores records as ${foldedGroups
                    .map((g) => `${g.domain.split('.').reverse().join('.')}.*`)
                    .join(', ')}`,
                foldedClients.length > 0 &&
                  `Signs in from ${foldedClients.join(', ')}`,
              ]
                .filter(Boolean)
                .join(' · ')}{' '}
              ·{' '}
              <TextLink
                disabled={action.busy}
                onClick={separate}
                className="text-[12.5px]"
              >
                Separate
              </TextLink>
            </div>
          )}
        </div>
        <div className="flex-1" />
        <Link
          href={`https://${shown}`}
          external
          className="flex shrink-0 items-center gap-1.5 font-bold text-[13px] text-key transition-colors hover:text-foreground"
          title={`Open ${shown}`}
        >
          Visit
          <ExternalLinkIcon className="size-4" />
        </Link>
      </Panel>

      <div className="space-y-5">
        <Panel>
          <Heading>Access</Heading>
          <ErrorNotice error={error || action.error} className="mt-3" />
          {sessions.length ? (
            <>
              <div className="mt-3 flex items-baseline gap-3">
                <p className="text-[13px] text-muted-foreground leading-relaxed">
                  Signed in on {sessions.length} session
                  {sessions.length === 1 ? '' : 's'} right now.
                </p>
                <div className="flex-1" />
                {sessions.length > 1 && (
                  <TextLink
                    tone="danger"
                    disabled={action.busy}
                    onClick={revokeAll}
                    className="text-[12.5px]"
                  >
                    Revoke all
                  </TextLink>
                )}
              </div>
              <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
                {shownSessions.map((session, index) => (
                  <div
                    key={session.sessionId || index}
                    className="rounded-2xl border border-border bg-muted p-4"
                  >
                    <ScopeList scope={session.scope} />
                    {formatUserAgent(session.userAgent) && (
                      <div className="mt-2 text-[12.5px] text-foreground">
                        {formatUserAgent(session.userAgent)}
                      </div>
                    )}
                    <div className="mt-3 flex items-center gap-3">
                      <div className="text-[12px] text-faint">
                        {session.authorizedAt
                          ? `Authorized ${day(session.authorizedAt)}`
                          : 'Authorized'}
                        {session.lastAccessedAt && (
                          <span className="block">
                            Last active {timeAgo(session.lastAccessedAt)}
                          </span>
                        )}
                      </div>
                      <div className="flex-1" />
                      {session.sessionId && (
                        <Button
                          variant="outline"
                          size="sm"
                          disabled={action.busy}
                          onClick={() => revoke(session.sessionId)}
                        >
                          Revoke
                        </Button>
                      )}
                    </div>
                  </div>
                ))}
              </div>
              <ShowMore
                total={sessions.length}
                cap={SESSIONS_SHOWN}
                expanded={showAllSessions}
                onToggle={() => setShowAllSessions((v) => !v)}
                noun="sessions"
              />
            </>
          ) : (
            <p className="mt-3 text-[13px] text-muted-foreground leading-relaxed">
              No app is signed in under this name right now. Its records stay in
              your account whether or not it has access.
            </p>
          )}
        </Panel>

        <Panel>
          <Heading className="mb-4">Records</Heading>
          {collections.length ? (
            <>
              {collections.length > COLLECTION_SEARCH_THRESHOLD && (
                <SearchField
                  value={collectionQuery}
                  onChange={setCollectionQuery}
                  placeholder="Search collections"
                  className="mb-3"
                />
              )}
              {matchedCollections.length === 0 ? (
                <p className="py-6 text-center text-[13px] text-muted-foreground">
                  No collections match "{collectionQuery.trim()}".
                </p>
              ) : (
                <>
                  <div className="flex flex-col gap-2">
                    {shownCollections.map((collection) => (
                      <ListRow
                        key={collection.name}
                        href={collectionHref(collection.name)}
                        onNavigate={onNavigate}
                        title={
                          <>
                            <span className="font-semibold text-[14px]">
                              {collection.name}
                            </span>
                            {/* Always a boolean: an undefined prop would make
                                each row resolve its own NSID, stampeding the
                                batch request for the same answers. */}
                            <PublishedCheck
                              nsid={collection.name}
                              published={Boolean(published[collection.name])}
                            />
                          </>
                        }
                        description={`${num(collection.count)} record${collection.count === 1 ? '' : 's'}`}
                        actions={
                          <ChevronRightIcon className="size-4 text-faint" />
                        }
                      />
                    ))}
                  </div>
                  <ShowMore
                    total={matchedCollections.length}
                    cap={RECORD_TYPES_SHOWN}
                    expanded={showAllRecords}
                    onToggle={() => setShowAllRecords((v) => !v)}
                    noun="collections"
                  />
                </>
              )}
            </>
          ) : (
            <p className="text-[13px] text-muted-foreground">
              This app holds access but has not stored anything yet.
            </p>
          )}
        </Panel>
      </div>

      {spaces.length > 0 && (
        <Panel>
          <Heading>Shared spaces</Heading>
          <p className="mt-2 mb-4 text-[13px] text-muted-foreground leading-relaxed">
            Permissioned data this app manages. The counts are what you have
            written. Other members' records live on their own servers.
          </p>
          <div className="space-y-3">
            {spaces.map((space) => (
              <div
                key={space.uri}
                className="rounded-2xl border border-border bg-muted p-4"
              >
                <div className="flex flex-wrap items-center gap-2">
                  <span className="font-mono text-[13px] text-foreground">
                    {space.spaceType}
                  </span>
                  {space.isOwner && <Badge>yours</Badge>}
                  {space.deleted && <Badge>deleted</Badge>}
                </div>
                <div className="mt-1 text-[12px] text-faint">
                  {space.members} member{space.members === 1 ? '' : 's'} ·{' '}
                  {space.policy}
                </div>
                <div className="mt-3 flex flex-wrap gap-2">
                  {space.collections.map((collection) => (
                    <Link
                      key={collection.collection}
                      href={collectionHref(collection.collection, space.uri)}
                      onNavigate={onNavigate}
                      className={cn(
                        buttonVariants({ variant: 'secondary', size: 'sm' }),
                        'h-7 font-mono text-xs',
                      )}
                    >
                      {collection.collection} ({num(collection.count)})
                    </Link>
                  ))}
                </div>
              </div>
            ))}
          </div>
        </Panel>
      )}
    </div>
  );
}