From e873521a8f13b90f45ddbf69ac4a369dc6d3e383 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:40:24 -0700 Subject: [PATCH 01/12] web(dashboard): one accounts list, sidebar team switcher, no page team picker The coderouter page showed Claude upstream accounts and hosted subrouter accounts (Codex) as two unrelated sections, each with its own add flow, plus a per-page team switcher and provider logos. Now one Accounts section lists both sources in one table with provider, label, status, and actions, and one add panel offers Anthropic API key, Claude Code OAuth token, Amazon Bedrock, Codex, and OpenCode. The Codex and OpenCode entries are text-only CLI commands. The team scope moves to the bottom-left sidebar as a dashboard-wide switcher that persists the same cookie the server already reads, so the coderouter page no longer renders its own team links. Usage first, accounts second, machines last. Removes the unused aiAccounts and claudeUpstream catalogs and adds coderouterAccounts and teamSwitcher (en, ja). Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS --- .../[locale]/dashboard/coderouter/page.tsx | 214 +----- .../dashboard/components/ai-account-forms.tsx | 186 ----- .../components/claude-upstream-forms.tsx | 398 ---------- .../components/coderouter-accounts.tsx | 679 ++++++++++++++++++ .../[locale]/dashboard/dashboard-shell.tsx | 12 +- .../dashboard/dashboard-team-switcher.tsx | 247 +++++++ web/messages/ar.json | 58 -- web/messages/bs.json | 58 -- web/messages/da.json | 58 -- web/messages/de.json | 58 -- web/messages/en.json | 174 ++--- web/messages/es.json | 58 -- web/messages/fr.json | 58 -- web/messages/it.json | 58 -- web/messages/ja.json | 174 ++--- web/messages/km.json | 58 -- web/messages/ko.json | 58 -- web/messages/no.json | 58 -- web/messages/pl.json | 58 -- web/messages/pt-BR.json | 58 -- web/messages/ru.json | 58 -- web/messages/th.json | 58 -- web/messages/tr.json | 58 -- web/messages/uk.json | 58 -- web/messages/zh-CN.json | 58 -- web/messages/zh-TW.json | 58 -- web/tests/coderouter-accounts.test.tsx | 177 +++++ web/tests/dashboard-coderouter-page.test.tsx | 54 +- web/tests/dashboard-shell.test.tsx | 11 +- web/tests/dashboard-team-switcher.test.tsx | 146 ++++ 30 files changed, 1487 insertions(+), 2029 deletions(-) delete mode 100644 web/app/[locale]/dashboard/components/ai-account-forms.tsx delete mode 100644 web/app/[locale]/dashboard/components/claude-upstream-forms.tsx create mode 100644 web/app/[locale]/dashboard/components/coderouter-accounts.tsx create mode 100644 web/app/[locale]/dashboard/dashboard-team-switcher.tsx create mode 100644 web/tests/coderouter-accounts.test.tsx create mode 100644 web/tests/dashboard-team-switcher.test.tsx diff --git a/web/app/[locale]/dashboard/coderouter/page.tsx b/web/app/[locale]/dashboard/coderouter/page.tsx index 421f927a34f4..d645f3af0fa5 100644 --- a/web/app/[locale]/dashboard/coderouter/page.tsx +++ b/web/app/[locale]/dashboard/coderouter/page.tsx @@ -3,10 +3,8 @@ import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { buildAlternates, openGraphDefaults, seoDescription, twitterSummary } from "@/i18n/seo"; -import { Link } from "@/i18n/navigation"; import { getStackServerApp, isStackConfigured } from "@/app/lib/stack"; import { localizedVaultPath, vaultSignInHref } from "@/app/lib/vault-auth"; -import type { SubrouterAccount } from "@/services/subrouter/types"; import { hostedSubrouterCutoverReadyForTeam } from "@/services/subrouter/cutover"; import { createHostedSubrouterClient } from "@/services/subrouter/hostedClient"; import { @@ -26,15 +24,12 @@ import { loadMachineUsage, MachineUsageSection } from "./machine-usage"; import { coderouterOrganizationFromCookieHeader, } from "@/services/coderouter/organizationScope"; +import { listClaudeAccounts } from "@/services/coderouter/claudeUpstream"; import { - listClaudeAccounts, - type ClaudeAccountDescription, -} from "@/services/coderouter/claudeUpstream"; -import { - AddAiAccountForms, - DeleteAiAccountButton, -} from "../components/ai-account-forms"; -import { ClaudeUpstreamSection } from "../components/claude-upstream-forms"; + CoderouterAccountsSection, + type ClaudeAccountsState, + type SharedAccountsState, +} from "../components/coderouter-accounts"; import { CoderouterPageHeader } from "../components/dashboard-page-headers"; import { withPrioritySpan } from "@/services/telemetry"; import { withStackAuthSpan } from "@/services/auth/stackTelemetry"; @@ -60,12 +55,6 @@ type DashboardTeam = { readonly personal: boolean; }; -type AccountState = - | { readonly kind: "ok"; readonly accounts: readonly SubrouterAccount[] } - | { readonly kind: "migrationPending" } - | { readonly kind: "notConfigured" } - | { readonly kind: "error" }; - export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "dashboard.coderouter" }); @@ -107,7 +96,6 @@ async function ResolvedCoderouterOverviewContent({ params, searchParams }: PageP } type CoderouterAuthorization = { - readonly teams: readonly DashboardTeam[]; readonly selectedTeam: DashboardTeam; readonly accessToken: string; }; @@ -145,15 +133,14 @@ export async function CoderouterOverviewContent({ redirect("/dashboard"); } - const { teams, selectedTeam, accessToken } = authorization.value; - const [tPage, t, accountState, metrics, claudeUpstream, machineUsage] = await Promise.all([ + const { selectedTeam, accessToken } = authorization.value; + const [tPage, sharedAccounts, metrics, claudeAccounts, machineUsage] = await Promise.all([ getTranslations({ locale, namespace: "dashboard.coderouter" }), - getTranslations({ locale, namespace: "dashboard.aiAccounts" }), withPrioritySpan( "cmux-coderouter-dashboard", "cmux.coderouter.accounts", { "cmux.team_scope": "selected" }, - () => loadAccounts(selectedTeam, accessToken), + () => loadSharedAccounts(selectedTeam, accessToken), ), withPrioritySpan( "cmux-coderouter-dashboard", @@ -165,7 +152,7 @@ export async function CoderouterOverviewContent({ "cmux-coderouter-dashboard", "cmux.coderouter.claude_upstream", { "cmux.team_scope": "selected" }, - () => loadClaudeUpstream(selectedTeam.id), + () => loadClaudeAccounts(selectedTeam.id), ), withPrioritySpan( "cmux-coderouter-dashboard", @@ -174,129 +161,28 @@ export async function CoderouterOverviewContent({ () => loadMachineUsage(selectedTeam.id), ), ]); - const dateFormatter = new Intl.DateTimeFormat(locale, { - dateStyle: "medium", - timeStyle: "short", - }); return ( -
-
-
{t("teamSwitcherLabel")}
-
- {teams.map((candidate) => { - const selected = candidate.id === selectedTeam.id; - return ( - - {candidate.name} - - ); - })} -
-
- - - - - - - - {selectedTeam.manageAccounts ? ( -
-
-

{t("addAccountsTitle")}

-
- -
- ) : null} - - {accountState.kind === "notConfigured" ? ( - - ) : accountState.kind === "migrationPending" ? ( - - ) : accountState.kind === "error" ? ( - - ) : ( -
-
-

{t("accountsTitle")}

-

- {t("accountsCount", { count: accountState.accounts.length })} -

-
- - {accountState.accounts.length === 0 ? ( -
-
{t("emptyTitle")}
-

{t("emptyBody")}

-
- ) : ( -
-
-
{t("providerColumn")}
-
{t("labelColumn")}
-
{t("createdColumn")}
- {selectedTeam.manageAccounts ? ( -
{t("actionsColumn")}
- ) :
} -
- {accountState.accounts.map((account) => ( -
-
-
- {t("providerColumn")} -
-
{providerLabel(account.kind, t)}
-
-
-
- {t("labelColumn")} -
- {account.label || t("unlabeledAccount")} -
-
-
- {t("createdColumn")} -
- {formatCreatedAt(account.createdAt, dateFormatter, t("unknownCreatedAt"))} -
- {selectedTeam.manageAccounts ? ( - - ) :
} -
- ))} -
- )} -
- )} -
+ + + + +
); } @@ -369,7 +255,6 @@ async function resolveCoderouterAuthorization( return { kind: "authorized", value: { - teams, selectedTeam, accessToken, }, @@ -381,10 +266,10 @@ async function resolveCoderouterAuthorization( } async function renderCoderouterLoadError(locale: string) { - const t = await getTranslations({ locale, namespace: "dashboard.aiAccounts" }); + const t = await getTranslations({ locale, namespace: "dashboard.coderouterAccounts" }); return ( - + ); } @@ -584,10 +469,10 @@ function selectTeam( return teams[0]; } -async function loadAccounts( +async function loadSharedAccounts( team: DashboardTeam, accessToken: string, -): Promise { +): Promise { try { if (!await hostedSubrouterCutoverReadyForTeam(team.id)) { return { kind: "migrationPending" }; @@ -609,43 +494,10 @@ async function loadAccounts( } } -type ClaudeUpstreamState = - | { readonly kind: "ok"; readonly accounts: readonly ClaudeAccountDescription[] } - | { readonly kind: "error" }; - -async function loadClaudeUpstream(teamId: string): Promise { +async function loadClaudeAccounts(teamId: string): Promise { try { return { kind: "ok", accounts: await listClaudeAccounts(teamId) }; } catch { return { kind: "error" }; } } - -function providerLabel( - kind: string, - t: Awaited>, -): string { - switch (kind) { - case "claude": - return t("providerClaude"); - case "anthropic-apikey": - return t("providerAnthropicApiKey"); - case "codex": - return t("providerCodex"); - case "openai-apikey": - return t("providerOpenAiApiKey"); - default: - return t("providerUnknown"); - } -} - -function formatCreatedAt( - createdAt: string | undefined, - formatter: Intl.DateTimeFormat, - fallback: string, -): string { - if (!createdAt) return fallback; - const date = new Date(createdAt); - if (Number.isNaN(date.getTime())) return fallback; - return formatter.format(date); -} diff --git a/web/app/[locale]/dashboard/components/ai-account-forms.tsx b/web/app/[locale]/dashboard/components/ai-account-forms.tsx deleted file mode 100644 index 1e7644569b97..000000000000 --- a/web/app/[locale]/dashboard/components/ai-account-forms.tsx +++ /dev/null @@ -1,186 +0,0 @@ -"use client"; - -import { Dialog } from "@base-ui-components/react/dialog"; -import { useTranslations } from "next-intl"; -import { useRouter } from "../../../../i18n/navigation"; -import { useState, type ReactNode } from "react"; -import { Modal } from "../../components/modal"; -import { CopyButton } from "../vault/copy-button"; - -type FormStatus = { - readonly state: "idle" | "submitting" | "success" | "error"; - readonly message?: string; -}; - -const idleStatus: FormStatus = { state: "idle" }; - -export function AddAiAccountForms() { - const t = useTranslations("dashboard.aiAccounts"); - return ( -
-

{t("cliAddBody")}

-
- } - /> - } - /> -
-
- ); -} - -function ToolCommandRow({ - name, - command, - label, - copied, - logo, -}: { - name: string; - command: string; - label: string; - copied: string; - logo: ReactNode; -}) { - return ( -
-
- {logo} -
-
{name}
- - {command} - -
-
- -
- ); -} - -function CodexLogo() { - return ( - - ); -} - -function OpenCodeLogo() { - return ( - - ); -} - -export function DeleteAiAccountButton({ - teamId, - accountId, -}: { - teamId: string; - accountId: string; -}) { - const t = useTranslations("dashboard.aiAccounts"); - const router = useRouter(); - const [status, setStatus] = useState(idleStatus); - const [confirmOpen, setConfirmOpen] = useState(false); - - const deleteAccount = async () => { - if (status.state === "submitting") return; - setConfirmOpen(false); - setStatus({ state: "submitting" }); - try { - const response = await fetch( - `/api/subrouter/accounts/${encodeURIComponent(accountId)}?teamId=${encodeURIComponent(teamId)}`, - { method: "DELETE" }, - ); - if (!response.ok) { - setStatus({ - state: "error", - message: errorMessageForStatus(response.status, t, t("deleteError")), - }); - return; - } - setStatus(idleStatus); - router.refresh(); - } catch { - setStatus({ state: "error", message: t("deleteError") }); - } - }; - - return ( -
- - {status.state === "error" && status.message ? ( -
{status.message}
- ) : null} - - - {t("deleteConfirmTitle")} - - - {t("deleteConfirmBody")} - -
- - {t("cancelAction")} - - -
-
-
- ); -} - -function errorMessageForStatus( - status: number, - t: ReturnType>, - fallback: string, -): string { - if (status === 400) return t("validationError"); - if (status === 403) return t("teamAccessError"); - if (status === 503) return t("notConfiguredTitle"); - return fallback; -} diff --git a/web/app/[locale]/dashboard/components/claude-upstream-forms.tsx b/web/app/[locale]/dashboard/components/claude-upstream-forms.tsx deleted file mode 100644 index cf2604533857..000000000000 --- a/web/app/[locale]/dashboard/components/claude-upstream-forms.tsx +++ /dev/null @@ -1,398 +0,0 @@ -"use client"; - -import { Dialog } from "@base-ui-components/react/dialog"; -import { useFormatter, useNow, useTranslations } from "next-intl"; -import { useState, type FormEvent } from "react"; -import { useRouter } from "../../../../i18n/navigation"; -import { Modal } from "../../components/modal"; -import type { - ClaudeAccountDescription, - ClaudeUpstreamKind, -} from "../../../../services/coderouter/claudeUpstream"; - -type FormStatus = { - readonly state: "idle" | "submitting" | "success" | "error"; - readonly message?: string; -}; - -const idleStatus: FormStatus = { state: "idle" }; -const KINDS: readonly ClaudeUpstreamKind[] = ["anthropic_api_key", "anthropic_oauth", "bedrock"]; - -const inputClass = - "w-full border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground"; -const buttonClass = - "border border-border px-3 py-1.5 text-sm transition-colors hover:bg-foreground hover:text-background focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground disabled:cursor-not-allowed disabled:opacity-60"; -const primaryButtonClass = - "border border-foreground bg-foreground px-3 py-1.5 text-sm text-background transition-colors hover:bg-background hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground disabled:cursor-not-allowed disabled:opacity-60"; - -/** - * The team's Claude upstream accounts: every account listed with its health, - * plus the add form. Requests from a Cloud VM are pinned to one account and - * move to another when it cools down, so the list is the whole routing table. - */ -export function ClaudeUpstreamSection({ - teamId, - accounts, - canManage, - loadFailed, -}: { - readonly teamId: string; - readonly accounts: readonly ClaudeAccountDescription[]; - readonly canManage: boolean; - readonly loadFailed: boolean; -}) { - const t = useTranslations("dashboard.claudeUpstream"); - return ( -
-
-

{t("title")}

-

{t("description")}

-
- {loadFailed ? ( -
-
{t("loadErrorTitle")}
-

{t("loadErrorBody")}

-
- ) : ( -
- - {canManage ? : null} -
- )} -
- ); -} - -function AccountList({ - teamId, - accounts, - canManage, -}: { - readonly teamId: string; - readonly accounts: readonly ClaudeAccountDescription[]; - readonly canManage: boolean; -}) { - const t = useTranslations("dashboard.claudeUpstream"); - if (accounts.length === 0) { - return ( -
-
{t("emptyTitle")}
-

{t("emptyBody")}

-
- ); - } - return ( -
-
- {t("accountsLabel", { count: accounts.length })} -
-
    - {accounts.map((account) => ( - - ))} -
-
- ); -} - -function AccountRow({ - teamId, - account, - canManage, -}: { - readonly teamId: string; - readonly account: ClaudeAccountDescription; - readonly canManage: boolean; -}) { - const t = useTranslations("dashboard.claudeUpstream"); - const format = useFormatter(); - const now = useNow(); - const cooling = account.cooldownUntil !== null && new Date(account.cooldownUntil).getTime() > now.getTime(); - const health = account.state === "disabled" - ? t("stateDisabled") - : cooling - ? t("coolingDown", { until: format.dateTime(new Date(account.cooldownUntil!), { timeStyle: "short" }) }) - : t("stateActive"); - return ( -
  • -
    -
    - {kindLabel(account.kind, t)} - {account.label ? · {account.label} : null} -
    -
    - {account.identifier} - {account.region ? ` · ${account.region}` : ""} -
    -
    - {health} - {" · "} - {account.lastUsedAt - ? t("lastUsed", { at: format.relativeTime(new Date(account.lastUsedAt), now) }) - : t("neverUsed")} - {account.lastFailureCode ? ` · ${t("lastFailure", { code: account.lastFailureCode })}` : ""} -
    -
    - {canManage ? : null} -
  • - ); -} - -function AddAccountForm({ teamId }: { readonly teamId: string }) { - const t = useTranslations("dashboard.claudeUpstream"); - const router = useRouter(); - const [kind, setKind] = useState("anthropic_api_key"); - const [status, setStatus] = useState(idleStatus); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (status.state === "submitting") return; - const form = event.currentTarget; - const data = new FormData(form); - const body = bodyForKind(kind, data); - setStatus({ state: "submitting" }); - try { - const response = await fetch( - `/api/coderouter/claude-upstream?teamId=${encodeURIComponent(teamId)}`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!response.ok) { - setStatus({ - state: "error", - message: errorMessageForStatus(response.status, t, t("saveError")), - }); - return; - } - form.reset(); - setStatus({ state: "success", message: t("saveSuccess") }); - router.refresh(); - } catch { - setStatus({ state: "error", message: t("saveError") }); - } - }; - - return ( -
    -
    - {KINDS.map((candidate) => ( - - ))} -
    -
    - - {kind === "anthropic_api_key" ? ( - - ) : null} - {kind === "anthropic_oauth" ? ( - <> - -

    - {t("oauthHint")} claude setup-token -

    - - ) : null} - {kind === "bedrock" ? ( - <> - - - - - - ) : null} -
    - - {status.message ? ( - - {status.message} - - ) : null} -
    - -
    - ); -} - -function Field({ - label, - name, - placeholder, - required = true, - secret = true, - mono = true, -}: { - readonly label: string; - readonly name: string; - readonly placeholder: string; - readonly required?: boolean; - readonly secret?: boolean; - readonly mono?: boolean; -}) { - const id = `claude-upstream-${name}`; - return ( - - ); -} - -function AccountActions({ - teamId, - account, -}: { - readonly teamId: string; - readonly account: ClaudeAccountDescription; -}) { - const t = useTranslations("dashboard.claudeUpstream"); - const router = useRouter(); - const [status, setStatus] = useState(idleStatus); - const [confirmOpen, setConfirmOpen] = useState(false); - const url = `/api/coderouter/claude-upstream/${encodeURIComponent(account.id)}?teamId=${encodeURIComponent(teamId)}`; - - const toggle = async () => { - if (status.state === "submitting") return; - setStatus({ state: "submitting" }); - try { - const response = await fetch(url, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ state: account.state === "disabled" ? "active" : "disabled" }), - }); - if (!response.ok) { - setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("updateError")) }); - return; - } - setStatus(idleStatus); - router.refresh(); - } catch { - setStatus({ state: "error", message: t("updateError") }); - } - }; - - const remove = async () => { - if (status.state === "submitting") return; - setConfirmOpen(false); - setStatus({ state: "submitting" }); - try { - const response = await fetch(url, { method: "DELETE" }); - if (!response.ok && response.status !== 404) { - setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("removeError")) }); - return; - } - setStatus(idleStatus); - router.refresh(); - } catch { - setStatus({ state: "error", message: t("removeError") }); - } - }; - - return ( -
    -
    - - -
    - {status.state === "error" && status.message ? ( -
    {status.message}
    - ) : null} - - - {t("removeConfirmTitle")} - - - {t("removeConfirmBody")} - -
    - {t("cancelAction")} - -
    -
    -
    - ); -} - -function bodyForKind(kind: ClaudeUpstreamKind, data: FormData): Record { - const field = (name: string) => String(data.get(name) ?? "").trim(); - const label = field("label"); - const withLabel = (body: Record) => (label ? { ...body, label } : body); - switch (kind) { - case "anthropic_api_key": - return withLabel({ kind, apiKey: field("apiKey") }); - case "anthropic_oauth": - return withLabel({ kind, token: field("token") }); - case "bedrock": { - const sessionToken = field("sessionToken"); - return withLabel({ - kind, - region: field("region"), - accessKeyId: field("accessKeyId"), - secretAccessKey: field("secretAccessKey"), - ...(sessionToken ? { sessionToken } : {}), - }); - } - } -} - -type Translator = ReturnType>; - -function kindLabel(kind: ClaudeUpstreamKind, t: Translator): string { - switch (kind) { - case "anthropic_api_key": - return t("kindApiKey"); - case "anthropic_oauth": - return t("kindOauth"); - case "bedrock": - return t("kindBedrock"); - } -} - -function errorMessageForStatus(status: number, t: Translator, fallback: string): string { - if (status === 400) return t("validationError"); - if (status === 403) return t("teamAccessError"); - return fallback; -} diff --git a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx new file mode 100644 index 000000000000..c297fcbda005 --- /dev/null +++ b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx @@ -0,0 +1,679 @@ +"use client"; + +import { Dialog } from "@base-ui-components/react/dialog"; +import { useFormatter, useNow, useTranslations } from "next-intl"; +import { useState, type FormEvent, type ReactNode } from "react"; +import { useRouter } from "../../../../i18n/navigation"; +import { Modal } from "../../components/modal"; +import { CopyButton } from "../vault/copy-button"; +import type { + ClaudeAccountDescription, + ClaudeUpstreamKind, +} from "../../../../services/coderouter/claudeUpstream"; +import type { SubrouterAccount } from "../../../../services/subrouter/types"; + +/** + * Every account the team routes through, in one list: the Claude upstream + * accounts stored by the web app (Anthropic API key, Claude Code OAuth token, + * Bedrock) and the shared accounts held by the hosted subrouter (Codex and + * the CLI-added Claude accounts). Both kinds share one row shape and one add + * panel, so a team never has to know which backend holds which credential. + */ +export type ClaudeAccountsState = + | { readonly kind: "ok"; readonly accounts: readonly ClaudeAccountDescription[] } + | { readonly kind: "error" }; + +export type SharedAccountsState = + | { readonly kind: "ok"; readonly accounts: readonly SubrouterAccount[] } + | { readonly kind: "migrationPending" } + | { readonly kind: "notConfigured" } + | { readonly kind: "error" }; + +type FormStatus = { + readonly state: "idle" | "submitting" | "success" | "error"; + readonly message?: string; +}; + +const idleStatus: FormStatus = { state: "idle" }; + +/** Everything the add panel offers, in display order. */ +type AddKind = ClaudeUpstreamKind | "codex" | "opencode"; +const ADD_KINDS: readonly AddKind[] = [ + "anthropic_api_key", + "anthropic_oauth", + "bedrock", + "codex", + "opencode", +]; + +const inputClass = + "w-full border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground"; +const buttonClass = + "border border-border px-3 py-1.5 text-sm transition-colors hover:bg-foreground hover:text-background focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground disabled:cursor-not-allowed disabled:opacity-60"; +const primaryButtonClass = + "border border-foreground bg-foreground px-3 py-1.5 text-sm text-background transition-colors hover:bg-background hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-foreground disabled:cursor-not-allowed disabled:opacity-60"; +const rowGridClass = + "grid gap-2 px-3 py-2 text-sm md:grid-cols-[1.3fr_1fr_1.2fr_auto] md:items-center md:gap-3"; + +type Translator = ReturnType>; + +export function CoderouterAccountsSection({ + teamId, + canManage, + claude, + shared, +}: { + readonly teamId: string; + readonly canManage: boolean; + readonly claude: ClaudeAccountsState; + readonly shared: SharedAccountsState; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const claudeAccounts = claude.kind === "ok" ? claude.accounts : []; + const sharedAccounts = shared.kind === "ok" ? shared.accounts : []; + const total = claudeAccounts.length + sharedAccounts.length; + const partialFailure = claude.kind === "error" || shared.kind === "error"; + + return ( +
    +
    +
    +

    {t("title")}

    +

    {t("description")}

    +
    + + {t("accountsCount", { count: total })} + +
    + + {shared.kind === "notConfigured" ? ( + + ) : null} + {shared.kind === "migrationPending" ? ( + + ) : null} + {partialFailure ? ( + + ) : null} + + {total === 0 ? ( + partialFailure ? null : ( +
    +
    {t("emptyTitle")}
    +

    {t("emptyBody")}

    +
    + ) + ) : ( +
    +
    +
    {t("providerColumn")}
    +
    {t("labelColumn")}
    +
    {t("statusColumn")}
    +
    {canManage ? t("actionsColumn") : ""}
    +
    +
      + {claudeAccounts.map((account) => ( + + ))} + {sharedAccounts.map((account) => ( + + ))} +
    +
    + )} + + {canManage ? : null} +
    + ); +} + +function Notice({ title, body }: { readonly title: string; readonly body: string }) { + return ( +
    +
    {title}
    +

    {body}

    +
    + ); +} + +function ClaudeAccountRow({ + teamId, + account, + canManage, +}: { + readonly teamId: string; + readonly account: ClaudeAccountDescription; + readonly canManage: boolean; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const format = useFormatter(); + const now = useNow(); + const cooling = account.cooldownUntil !== null && + new Date(account.cooldownUntil).getTime() > now.getTime(); + const health = account.state === "disabled" + ? t("stateDisabled") + : cooling + ? t("coolingDown", { + until: format.dateTime(new Date(account.cooldownUntil!), { timeStyle: "short" }), + }) + : t("stateActive"); + const usage = account.lastUsedAt + ? t("lastUsed", { at: format.relativeTime(new Date(account.lastUsedAt), now) }) + : t("neverUsed"); + return ( + : null} + t={t} + /> + ); +} + +function SharedAccountRow({ + teamId, + account, + canManage, +}: { + readonly teamId: string; + readonly account: SubrouterAccount; + readonly canManage: boolean; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const format = useFormatter(); + const created = account.createdAt ? new Date(account.createdAt) : null; + const createdText = created && !Number.isNaN(created.getTime()) + ? t("createdAt", { at: format.dateTime(created, { dateStyle: "medium" }) }) + : null; + const healthy = account.health?.ok !== false; + return ( + : null} + t={t} + /> + ); +} + +function AccountRowFrame({ + provider, + detail, + label, + status, + statusDetail, + dimmed, + actions, + t, +}: { + readonly provider: string; + readonly detail: string | null; + readonly label: string | null; + readonly status: string; + readonly statusDetail: string | null; + readonly dimmed: boolean; + readonly actions: ReactNode; + readonly t: Translator; +}) { + return ( +
  • +
    +
    {t("providerColumn")}
    +
    {provider}
    + {detail ? ( +
    {detail}
    + ) : null} +
    +
    +
    {t("labelColumn")}
    + {label || t("unlabeledAccount")} +
    +
    +
    {t("statusColumn")}
    +
    {status}
    + {statusDetail ?
    {statusDetail}
    : null} +
    +
    {actions ?? }
    +
  • + ); +} + +function ClaudeAccountActions({ + teamId, + account, +}: { + readonly teamId: string; + readonly account: ClaudeAccountDescription; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const router = useRouter(); + const [status, setStatus] = useState(idleStatus); + const [confirmOpen, setConfirmOpen] = useState(false); + const url = `/api/coderouter/claude-upstream/${encodeURIComponent(account.id)}?teamId=${encodeURIComponent(teamId)}`; + + const toggle = async () => { + if (status.state === "submitting") return; + setStatus({ state: "submitting" }); + try { + const response = await fetch(url, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ state: account.state === "disabled" ? "active" : "disabled" }), + }); + if (!response.ok) { + setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("updateError")) }); + return; + } + setStatus(idleStatus); + router.refresh(); + } catch { + setStatus({ state: "error", message: t("updateError") }); + } + }; + + const remove = async () => { + if (status.state === "submitting") return; + setConfirmOpen(false); + setStatus({ state: "submitting" }); + try { + const response = await fetch(url, { method: "DELETE" }); + if (!response.ok && response.status !== 404) { + setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("removeError")) }); + return; + } + setStatus(idleStatus); + router.refresh(); + } catch { + setStatus({ state: "error", message: t("removeError") }); + } + }; + + return ( + + {account.state === "disabled" ? t("enableAction") : t("disableAction")} + + } + /> + ); +} + +function SharedAccountActions({ + teamId, + accountId, +}: { + readonly teamId: string; + readonly accountId: string; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const router = useRouter(); + const [status, setStatus] = useState(idleStatus); + const [confirmOpen, setConfirmOpen] = useState(false); + + const remove = async () => { + if (status.state === "submitting") return; + setConfirmOpen(false); + setStatus({ state: "submitting" }); + try { + const response = await fetch( + `/api/subrouter/accounts/${encodeURIComponent(accountId)}?teamId=${encodeURIComponent(teamId)}`, + { method: "DELETE" }, + ); + if (!response.ok) { + setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("removeError")) }); + return; + } + setStatus(idleStatus); + router.refresh(); + } catch { + setStatus({ state: "error", message: t("removeError") }); + } + }; + + return ( + + ); +} + +function RowActions({ + status, + confirmOpen, + setConfirmOpen, + onConfirm, + confirmTitle, + confirmBody, + leading, + t, +}: { + readonly status: FormStatus; + readonly confirmOpen: boolean; + readonly setConfirmOpen: (open: boolean) => void; + readonly onConfirm: () => void; + readonly confirmTitle: string; + readonly confirmBody: string; + readonly leading?: ReactNode; + readonly t: Translator; +}) { + return ( +
    +
    + {leading} + +
    + {status.state === "error" && status.message ? ( +
    {status.message}
    + ) : null} + + {confirmTitle} + + {confirmBody} + +
    + {t("cancelAction")} + +
    +
    +
    + ); +} + +function AddAccountPanel({ teamId }: { readonly teamId: string }) { + const t = useTranslations("dashboard.coderouterAccounts"); + const [kind, setKind] = useState("anthropic_api_key"); + return ( +
    +

    {t("addTitle")}

    +
    + {ADD_KINDS.map((candidate) => ( + + ))} +
    +
    + {kind === "codex" ? ( + + ) : kind === "opencode" ? ( + + ) : ( + + )} +
    +
    + ); +} + +function CliInstructions({ + body, + command, + t, +}: { + readonly body: string; + readonly command: string; + readonly t: Translator; +}) { + return ( +
    +

    {body}

    +
    + {command} + +
    +
    + ); +} + +function ClaudeUpstreamForm({ + teamId, + kind, +}: { + readonly teamId: string; + readonly kind: ClaudeUpstreamKind; +}) { + const t = useTranslations("dashboard.coderouterAccounts"); + const router = useRouter(); + const [status, setStatus] = useState(idleStatus); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (status.state === "submitting") return; + const form = event.currentTarget; + const body = bodyForKind(kind, new FormData(form)); + setStatus({ state: "submitting" }); + try { + const response = await fetch( + `/api/coderouter/claude-upstream?teamId=${encodeURIComponent(teamId)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + setStatus({ + state: "error", + message: errorMessageForStatus(response.status, t, t("saveError")), + }); + return; + } + form.reset(); + setStatus({ state: "success", message: t("saveSuccess") }); + router.refresh(); + } catch { + setStatus({ state: "error", message: t("saveError") }); + } + }; + + return ( +
    + {kind === "anthropic_oauth" ? ( +

    + {t("oauthHint")} claude setup-token +

    + ) : null} + {kind === "anthropic_api_key" ? ( + + ) : null} + {kind === "anthropic_oauth" ? ( + + ) : null} + {kind === "bedrock" ? ( + <> + + + + + + ) : null} + +
    + + {status.message ? ( + + {status.message} + + ) : null} +
    + + ); +} + +function Field({ + label, + name, + placeholder, + required = true, + secret = true, + mono = true, +}: { + readonly label: string; + readonly name: string; + readonly placeholder: string; + readonly required?: boolean; + readonly secret?: boolean; + readonly mono?: boolean; +}) { + const id = `coderouter-account-${name}`; + return ( + + ); +} + +function bodyForKind(kind: ClaudeUpstreamKind, data: FormData): Record { + const field = (name: string) => String(data.get(name) ?? "").trim(); + const label = field("label"); + const withLabel = (body: Record) => (label ? { ...body, label } : body); + switch (kind) { + case "anthropic_api_key": + return withLabel({ kind, apiKey: field("apiKey") }); + case "anthropic_oauth": + return withLabel({ kind, token: field("token") }); + case "bedrock": { + const sessionToken = field("sessionToken"); + return withLabel({ + kind, + region: field("region"), + accessKeyId: field("accessKeyId"), + secretAccessKey: field("secretAccessKey"), + ...(sessionToken ? { sessionToken } : {}), + }); + } + } +} + +function claudeKindLabel(kind: ClaudeUpstreamKind, t: Translator): string { + switch (kind) { + case "anthropic_api_key": + return t("kindAnthropicApiKey"); + case "anthropic_oauth": + return t("kindClaudeOauth"); + case "bedrock": + return t("kindBedrock"); + } +} + +function addKindLabel(kind: AddKind, t: Translator): string { + switch (kind) { + case "codex": + return t("kindCodex"); + case "opencode": + return t("kindOpencode"); + default: + return claudeKindLabel(kind, t); + } +} + +/** Provider names for accounts held by the hosted subrouter. */ +function sharedKindLabel(kind: string, t: Translator): string { + switch (kind) { + case "claude": + return t("kindClaudeOauth"); + case "anthropic-apikey": + return t("kindAnthropicApiKey"); + case "codex": + return t("kindCodex"); + case "openai-apikey": + return t("kindOpenAiApiKey"); + default: + return t("kindUnknown"); + } +} + +function errorMessageForStatus(status: number, t: Translator, fallback: string): string { + if (status === 400) return t("validationError"); + if (status === 403) return t("teamAccessError"); + if (status === 503) return t("notConfiguredTitle"); + return fallback; +} diff --git a/web/app/[locale]/dashboard/dashboard-shell.tsx b/web/app/[locale]/dashboard/dashboard-shell.tsx index 9814208ac646..33a32244faf1 100644 --- a/web/app/[locale]/dashboard/dashboard-shell.tsx +++ b/web/app/[locale]/dashboard/dashboard-shell.tsx @@ -7,6 +7,7 @@ import { ThemeToggle } from "@/app/[locale]/theme"; import { Link, usePathname } from "@/i18n/navigation"; import { isAdminUser } from "@/services/admin/access"; import { DashboardAccountMenu } from "./dashboard-account-menu"; +import { DashboardTeamSwitcher } from "./dashboard-team-switcher"; type DashboardNavGroup = { label: string; @@ -117,12 +118,17 @@ export function DashboardShell({ } - className="flex-1 overflow-y-auto px-2 py-3 pb-28" + className="flex-1 overflow-y-auto px-2 py-3 pb-40" />
    +
    + }> + + +
    ; } +function DashboardTeamSwitcherFallback() { + return