Vectrum
Sign In
137 lines · 6.4 KB · TypeScript
import type { Metadata } from "next";
import { Label as MuLabel } from "@unlocalhosted/metalui";
import { disconnectAppAction } from "@/app/actions/oauth";
import { ConfirmButton } from "@/components/confirm-button";
import { SparkleIcon } from "@/components/icons";
import { SettingsSection } from "@/components/metal";
import { CopyIconButton } from "@/components/settings-fields";
import { ListRow, PlateBody, PlateSection, RowGlyph } from "@/components/settings-plate";
import { Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
import { requireViewer } from "@/lib/auth";
import { timeAgo } from "@/lib/format";
import { listConnectedApps, mcpResource } from "@/lib/oauth";
import { serverOrigin } from "@/lib/request-origin";

export const metadata: Metadata = { title: "Connected apps" };

function Command({ children }: { children: string }) {
  return (
    <div className="flex items-center gap-2 rounded-row recipe-well-field py-1.5 pr-1.5 pl-3.5">
      <code className="flex-1 overflow-x-auto font-mono text-[12px] leading-5 whitespace-pre text-ink">{children}</code>
      <CopyIconButton text={children} label="Copy" toast="Copied" />
    </div>
  );
}

export default async function ConnectedAppsPage() {
  const viewer = await requireViewer("/settings/applications");
  const [apps, url, origin] = await Promise.all([listConnectedApps(viewer.id), mcpResource(), serverOrigin()]);

  return (
    <div>
      <PageTitle
        title="Connected apps"
        description="AI agents and other MCP clients you’ve allowed to work with your repositories: read code, commit, manage issues and run workflows."
      />
      <div className="flex flex-col gap-7">
        {apps.length === 0 ? (
          <PlateSection title="Your connected apps">
            <Blankslate icon={<SparkleIcon />} title="No connected apps">
              Connect an agent with the server address below. It will send you here to approve it.
            </Blankslate>
          </PlateSection>
        ) : (
          <SettingsSection title={`Your connected apps · ${apps.length}`}>
            {apps.map((app) => (
              <ListRow
                key={app.id}
                lead={
                  <RowGlyph>
                    <SparkleIcon />
                  </RowGlyph>
                }
                title={
                  <>
                    <span className="truncate">{app.name}</span>
                    <Label tone={app.permission === "write" ? "accent" : "default"}>
                      {app.permission === "write" ? "Read & write" : "Read-only"}
                    </Label>
                  </>
                }
                meta={
                  <>
                    <span className="truncate type-body text-ink2">
                      {app.allRepos ? "All repositories" : app.repos.map((r) => r.fullName).join(", ") || "No repositories (deleted)"}
                    </span>
                    <MuLabel variant="engraved">
                      Connected <b>{timeAgo(app.createdAt)}</b> ·{" "}
                      {app.lastUsedAt ? (
                        <>
                          Last used <b>{timeAgo(app.lastUsedAt)}</b>
                        </>
                      ) : (
                        "Never used"
                      )}
                    </MuLabel>
                  </>
                }
              >
                <ButtonLink href={`/settings/activity?agent=${app.agentKey}`} variant="invisible">
                  Activity
                </ButtonLink>
                <ConfirmButton
                  action={disconnectAppAction.bind(null, app.id)}
                  message={`Disconnect “${app.name}”? It loses access right away and has to ask you again.`}
                  variant="default"
                >
                  Disconnect
                </ConfirmButton>
              </ListRow>
            ))}
          </SettingsSection>
        )}

        <PlateSection title="Connect an agent">
          <PlateBody className="max-w-2xl">
            <div className="flex flex-col gap-1.5">
              <p className="type-title text-ink">MCP server address</p>
              <p className="type-body text-ink2">
                Add it to your agent as a remote MCP server (Streamable HTTP). It signs in with OAuth: you approve it here, and choose what it may
                reach.
              </p>
              <Command>{url}</Command>
            </div>
            <div className="flex flex-col gap-1.5">
              <p className="type-title text-ink">Claude Code</p>
              <Command>{`claude mcp add --transport http vectrum ${url}`}</Command>
              <p className="type-body text-ink2">
                Then run <code className="font-mono text-[12px]">/mcp</code> in Claude Code and choose <i>Authenticate</i>.
              </p>
            </div>
            <div className="flex flex-col gap-1.5">
              <p className="type-title text-ink">Command line</p>
              <p className="type-body text-ink2">
                <code className="font-mono text-[12px]">vectrum</code> works like GitHub&apos;s <code className="font-mono text-[12px]">gh</code>, for agents in a shell and for you:
                pull requests, issues, checks, events. It signs in the same way, and can be git&apos;s credential helper, so pushes count as the agent&apos;s.
              </p>
              <Command>{`curl -fsSLO ${origin}/vectrum.mjs && node vectrum.mjs auth login --host ${origin}`}</Command>
            </div>
            <div className="flex flex-col gap-1.5">
              <p className="type-title text-ink">Reading pages</p>
              <p className="type-body text-ink2">
                Every page is also Markdown: send <code className="font-mono text-[12px]">Accept: text/markdown</code> or add{" "}
                <code className="font-mono text-[12px]">?format=md</code>. <code className="font-mono text-[12px]">{origin}/llms.txt</code> describes this server for agents.
              </p>
            </div>
            <div className="flex flex-col gap-1.5">
              <p className="type-title text-ink">Agents without OAuth</p>
              <p className="type-body text-ink2">
                Send an API token from <i>Settings → API tokens</i> instead, as <code className="font-mono text-[12px]">Authorization: Bearer vec_…</code>.
              </p>
            </div>
          </PlateBody>
        </PlateSection>
      </div>
    </div>
  );
}