diff --git a/apps/web/src/settings/account-section.tsx b/apps/web/src/settings/account-section.tsx
index ce56bf520..953df803e 100644
--- a/apps/web/src/settings/account-section.tsx
+++ b/apps/web/src/settings/account-section.tsx
@@ -2,8 +2,7 @@
// The Agent card was removed — nothing there could change until a hub
// preference store exists to write it to.
-import { Avatar, Badge, Button, SettingsPanel, toast } from "@corbits/react-ui";
-import { Select } from "@corbits/react-ui/ui/select";
+import { Avatar, Badge, Button, toast } from "@corbits/react-ui";
import { ChatCircleDots, Copy, SignOut } from "@/lib/icons";
import { useQuery } from "@tanstack/react-query";
@@ -11,6 +10,7 @@ import { QueryView, toAPIQuery } from "@/lib/api-query";
import { resolveAvatarFill } from "@/chat";
import webPackage from "../../package.json";
import { getAccount, type Account } from "./api";
+import { Segmented, SettingsGroup, SettingsRow } from "./rows";
import { SETTINGS_STRINGS } from "./strings";
import { isTheme, setTheme, useTheme } from "../theme-store";
@@ -81,69 +81,64 @@ export function AccountSectionView({
}) {
const fill = resolveAvatarFill(id, image);
return (
-
-
-
- {fill.kind === "image" ? (
-

- ) : (
-
- )}
-
- {name}
-
- {email}
-
-
-
-
-
-
- {onSignOut !== undefined ? (
-
- ) : null}
-
-
-
-
-
- {SETTINGS_STRINGS.accountDetailsHeading}
-
-
- - {SETTINGS_STRINGS.accountNameLabel}
- - {name}
- - {SETTINGS_STRINGS.accountEmailLabel}
- -
- {email}{" "}
+
+
+ {fill.kind === "image" ? (
+
+ ) : (
+
+ )}
+ {name}
+
+ }
+ meta={
+ <>
+ {email}
{emailVerified ? "verified" : "unverified"}
-
-
-
{SETTINGS_STRINGS.accountReadOnlyNote}
-
-
+
+ >
+ }
+ actions={
+ <>
+
+ {onSignOut !== undefined ? (
+
+ ) : null}
+ >
+ }
+ />
+
{SETTINGS_STRINGS.accountReadOnlyNote}
+
);
}
@@ -151,22 +146,24 @@ export function AccountSectionView({
export function AppearanceSection() {
const theme = useTheme();
return (
-
-
-
+
+ {
+ if (isTheme(next)) setTheme(next);
+ }}
+ />
+ }
+ />
+
);
}
diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx
index 266df250c..180610221 100644
--- a/apps/web/src/settings/credentials-section.tsx
+++ b/apps/web/src/settings/credentials-section.tsx
@@ -14,16 +14,8 @@ import {
DialogTitle,
EmptyState,
Input,
- SettingsPanel,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
} from "@corbits/react-ui";
import { reportError } from "@corbits/error-sink";
-import type { CredentialType } from "@intx/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@@ -44,9 +36,6 @@ import {
import { redeployMyraForModelChange } from "@/settings/myra-model-redeploy";
import { tenantKeys } from "@/query-client";
import {
- createCredential,
- createProvider,
- credentialTypes,
deleteCredential,
listCredentials,
listProviders,
@@ -56,6 +45,8 @@ import {
} from "./credentials-api";
import { SETTINGS_STRINGS } from "./strings";
import { ConfirmButton } from "../components/confirm-button";
+import { ProviderConnectStep } from "../onboarding/provider-connect-step";
+import { SettingsGroup, SettingsRow } from "./rows";
type CatalogProvider = typeof ModelProviderResponse.infer;
type CatalogOffering = typeof ModelOfferingResponse.infer;
@@ -136,7 +127,6 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
enabled: tenantId !== null,
});
const query = toAPIQuery(result);
- const providers = result.data?.providers ?? [];
const catalogProviders = result.data?.catalogProviders ?? [];
const catalogOfferings = result.data?.catalogOfferings ?? [];
const catalogModels = result.data?.catalogModels ?? [];
@@ -154,32 +144,6 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
});
}
- const create = useMutation({
- mutationFn: async ({
- name,
- type,
- secret,
- }: {
- readonly name: string;
- readonly type: CredentialType;
- readonly secret: string;
- }) => {
- if (tenantId === null) throw new Error("no workbench selected");
- const existing = providers.find((provider) => provider.name === name);
- const provider = existing !== undefined ? existing : await createProvider(tenantId, name);
- return createCredential(tenantId, {
- providerId: provider.id,
- name,
- type,
- secret,
- });
- },
- onSuccess: () => {
- setCreateOpen(false);
- reload();
- },
- });
-
// Re-signing in files the tokens under the same credential name, so the
// hub replaces the material in place and every offering keeps pointing at it.
const signIn = useMutation({
@@ -311,15 +275,15 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
return (
{({ credentials }) => (
-
-
+ action={
-
+ }
+ >
{del.error === null || del.error === undefined ? null : (
{SETTINGS_STRINGS.credentialsDeleteError}
@@ -337,12 +301,14 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
onDelete={(credential) => del.mutate(credential)}
onSignIn={(credential) => signIn.mutate(credential)}
/>
- create.mutate({ name, type, secret })}
- submitting={create.isPending}
- error={create.error === null ? null : SETTINGS_STRINGS.credentialsCreateError}
+ onConnected={() => {
+ setCreateOpen(false);
+ reload();
+ }}
/>
-
+
)}
);
@@ -382,22 +348,19 @@ function CredentialsTable({
);
}
return (
-
-
-
- {SETTINGS_STRINGS.credentialsNameLabel}
- {SETTINGS_STRINGS.credentialsTypeLabel}
- {SETTINGS_STRINGS.credentialsStatusLabel}
- Actions
-
-
-
- {credentials.map((credential) => (
-
- {credential.name}
- {credential.type}
- {credential.status}
-
+ <>
+ {credentials.map((credential) => (
+
+ {credential.type}
+ {credential.status}
+ >
+ }
+ actions={
+ <>
{credential.type === "oauth_token" && oauthProviderOf(credential) !== null ? (
-
- ))}
-
-
+ >
+ }
+ />
+ ))}
+ >
);
}
-function CreateCredentialDialog({
+function AddProviderDialog({
+ tenantId,
open,
onOpenChange,
- onCreate,
- submitting,
- error = null,
+ onConnected,
}: {
+ readonly tenantId: string;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
- readonly onCreate: (name: string, type: CredentialType, secret: string) => void;
- readonly submitting: boolean;
- readonly error?: string | null;
+ readonly onConnected: () => void;
}) {
- const [name, setName] = useState("");
- const [type, setType] = useState("api_key");
- const [secret, setSecret] = useState("");
- const canSubmit = name.trim().length > 0 && secret.trim().length > 0;
-
- function reset() {
- setName("");
- setType("api_key");
- setSecret("");
- }
-
+ const [error, setError] = useState(null);
return (
);
diff --git a/apps/web/src/settings/people-section.tsx b/apps/web/src/settings/people-section.tsx
index 37f323db4..5c55da154 100644
--- a/apps/web/src/settings/people-section.tsx
+++ b/apps/web/src/settings/people-section.tsx
@@ -13,13 +13,6 @@ import {
DialogTitle,
EmptyState,
Input,
- SettingsPanel,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
} from "@corbits/react-ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@@ -42,6 +35,7 @@ import {
type Role,
} from "./tenancy-api";
import { ConfirmButton } from "../components/confirm-button";
+import { SettingsGroup, SettingsRow } from "./rows";
const STATUS_TONE: Record = {
active: "success",
@@ -180,15 +174,15 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null }
return (
{({ people, roles }) => (
-
-
+ action={
-
+ }
+ >
{rowError !== null && (
{rowError}
@@ -215,7 +209,7 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null }
})
}
/>
-
+
)}
);
@@ -353,90 +347,73 @@ export function PeopleTable({
);
}
return (
-
-
-
-
- Name
- Kind
- Status
- Roles
- Actions
-
-
-
- {people.map((person) => {
- const identity = principalLabel(person.displayName);
- const selectableRoles = [ownerRole, memberRole].filter(
- (r): r is Role => r !== undefined,
- );
- const currentRoleId =
- person.roles.find((r) => selectableRoles.some((role) => role.id === r.id))?.id ??
- memberRole?.id;
+ <>
+ {people.map((person) => {
+ const identity = principalLabel(person.displayName);
+ const selectableRoles = [ownerRole, memberRole].filter((r): r is Role => r !== undefined);
+ const currentRoleId =
+ person.roles.find((r) => selectableRoles.some((role) => role.id === r.id))?.id ??
+ memberRole?.id;
- return (
-
-
- {identity.label}
- {person.email !== undefined ? (
- {person.email}
- ) : null}
-
- {PRINCIPAL_KIND_LABEL[person.kind]}
-
- {person.status}
-
-
- {selectableRoles.length === 2 ? (
-
- ) : person.roles.length === 0 ? (
- SETTINGS_STRINGS.peopleRoleNone
- ) : (
- person.roles.map((role) => (
-
- {role.name}
-
- ))
- )}
-
-
-
- {person.status === "suspended" ? (
-
- ) : (
-
- )}
- onRemove(person)}
- >
- {SETTINGS_STRINGS.peopleRemove}
-
-
-
-
- );
- })}
-
-
-
+ >
+ }
+ actions={
+ <>
+ {selectableRoles.length === 2 ? (
+
+ ) : null}
+ {person.status === "suspended" ? (
+
+ ) : (
+
+ )}
+ onRemove(person)}
+ >
+ {SETTINGS_STRINGS.peopleRemove}
+
+ >
+ }
+ />
+ );
+ })}
+ >
);
}
diff --git a/apps/web/src/settings/rows.css b/apps/web/src/settings/rows.css
new file mode 100644
index 000000000..b22233356
--- /dev/null
+++ b/apps/web/src/settings/rows.css
@@ -0,0 +1,100 @@
+/* Rows-not-cards settings layout; colors come from theme tokens. */
+
+.settings-group {
+ max-width: 44rem;
+ width: 100%;
+}
+
+.settings-group-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-bottom: 0.6rem;
+}
+
+.settings-group-title {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 650;
+}
+
+.settings-group-lede {
+ margin: 0.15rem 0 0;
+ font-size: 0.8125rem;
+ color: var(--muted-foreground);
+}
+
+.settings-rows {
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--border);
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ min-height: 3rem;
+ padding: 0.55rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.settings-row-main {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.settings-row-title {
+ font-size: 0.9375rem;
+ font-weight: 550;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.settings-row-meta {
+ font-size: 0.8125rem;
+ color: var(--muted-foreground);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.settings-row-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.settings-seg {
+ display: inline-flex;
+ padding: 2px;
+ gap: 2px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+}
+
+.settings-seg-option {
+ padding: 0.3rem 0.9rem;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--muted-foreground);
+ font: inherit;
+ font-size: 0.8125rem;
+ cursor: pointer;
+}
+
+.settings-seg-option[aria-checked="true"] {
+ background: var(--primary);
+ color: var(--primary-foreground);
+}
+
+.settings-seg-option:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 1px;
+}
diff --git a/apps/web/src/settings/rows.tsx b/apps/web/src/settings/rows.tsx
new file mode 100644
index 000000000..4d174cf27
--- /dev/null
+++ b/apps/web/src/settings/rows.tsx
@@ -0,0 +1,81 @@
+// Settings sections are quiet rows under a heading, not cards or tables.
+
+import type { ReactNode } from "react";
+
+import "./rows.css";
+
+export function SettingsGroup({
+ title,
+ description,
+ action,
+ children,
+}: {
+ readonly title: string;
+ readonly description?: string;
+ readonly action?: ReactNode;
+ readonly children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function SettingsRow({
+ title,
+ meta,
+ actions,
+}: {
+ readonly title: ReactNode;
+ readonly meta?: ReactNode;
+ readonly actions?: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {meta !== undefined ? {meta} : null}
+
+ {actions !== undefined ?
{actions}
: null}
+
+ );
+}
+
+export type SegmentedOption = { readonly value: T; readonly label: string };
+
+export function Segmented({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ readonly label: string;
+ readonly value: T;
+ readonly options: readonly SegmentedOption[];
+ readonly onChange: (value: T) => void;
+}) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/settings/strings.ts b/apps/web/src/settings/strings.ts
index d6bcc2f47..404de4da4 100644
--- a/apps/web/src/settings/strings.ts
+++ b/apps/web/src/settings/strings.ts
@@ -146,27 +146,21 @@ export const SETTINGS_STRINGS = {
credentialRevokedToast: "Key revoked",
- credentialsSectionTitle: "Credentials",
- credentialsSectionDescription:
- "Keys this bench's agents and workflows can use, stored through the platform's own credential store.",
- credentialsLoadError: "credentials",
- credentialsEmptyTitle: "No credentials yet",
- credentialsEmptyDescription: "Add a key below to give agents something to authenticate with.",
+ credentialsSectionTitle: "Providers",
+ credentialsSectionDescription: "Where this workbench's inference comes from.",
+ credentialsLoadError: "providers",
+ credentialsEmptyTitle: "No providers yet",
+ credentialsEmptyDescription: "Add a provider to give agents a model to run on.",
credentialsNameLabel: "Name",
credentialsTypeLabel: "Type",
- credentialsSecretLabel: "Secret",
- credentialsSecretPlaceholder: "Pasted once, never shown again",
credentialsStatusLabel: "Status",
- credentialsCreateAction: "Add credential",
- credentialsCreateDialogTitle: "Add a credential",
- credentialsCreateDialogDescription:
- "Sealed on save — this secret is never shown again after create.",
- credentialsCreateSubmit: "Add",
- credentialsCreateCancel: "Cancel",
- credentialsCreateError: "Couldn't save that credential — try again.",
- credentialsDeleteAction: "Delete",
- credentialsDeleteConfirm: "Delete this credential for good?",
- credentialsDeleteError: "Couldn't delete that credential — try again.",
+ credentialsCreateAction: "Add provider",
+ credentialsCreateDialogTitle: "Add a provider",
+ credentialsCreateDialogDescription: "Choose where your models come from.",
+ credentialsCreateError: "Couldn't connect that provider — try again.",
+ credentialsDeleteAction: "Remove",
+ credentialsDeleteConfirm: "Remove this provider?",
+ credentialsDeleteError: "Couldn't remove that provider — try again.",
credentialsEditAction: "Edit",
credentialsSignInAgainAction: "Sign in again",
credentialsSignInAgainPending: "Waiting for sign-in…",