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} - ) : ( - - )} -
- {name} - - {email} - - -
-
-
- - {onSignOut !== undefined ? ( - - ) : null} -
-
- -
-

- {SETTINGS_STRINGS.accountDetailsHeading} -

-
-
{SETTINGS_STRINGS.accountNameLabel}
-
{name}
-
{SETTINGS_STRINGS.accountEmailLabel}
-
- {email}{" "} + + + {fill.kind === "image" ? ( + {name} + ) : ( + + )} + {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 (

{ onOpenChange(next); - if (!next) reset(); + if (!next) setError(null); }} > @@ -468,61 +419,18 @@ function CreateCredentialDialog({ -
{ - event.preventDefault(); - if (canSubmit) onCreate(name.trim(), type, secret); - }} - > - - - - {error !== null && ( -

- {error} -

- )} -
+ onOpenChange(false)} + /> + {error !== null && ( +

+ {error} +

+ )}
- - - -
); 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 ( +
+
+
+

{title}

+ {description !== undefined ?

{description}

: null} +
+ {action} +
+
{children}
+
+ ); +} + +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…",