diff --git a/apps/web/src/settings/models-section.tsx b/apps/web/src/settings/models-section.tsx new file mode 100644 index 000000000..c165b7872 --- /dev/null +++ b/apps/web/src/settings/models-section.tsx @@ -0,0 +1,141 @@ +// The "Models" settings section: a read-only view of this workbench's +// credential providers (`GET /api/tenants/:t/providers`, the same stock +// route `credentials-section.tsx` reads) and its resolved model catalog +// (`GET /api/tenants/:t/models`, `createModelDiscoveryRoutes` — the same +// read `resolveModelSources` would act on at launch). No write path here; +// changing an offering's priority or restricting it belongs to the +// catalog-management routes this section deliberately doesn't touch. + +import { + Badge, + EmptyState, + SettingsPanel, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; +import { useQuery } from "@tanstack/react-query"; + +import { QueryView, toAPIQuery } from "@/lib/api-query"; +import { listProviders, type Provider } from "./credentials-api"; +import { getResolvedCatalog, type ModelInfo } from "./inference"; +import { SETTINGS_STRINGS } from "./strings"; + +type ModelsData = { + readonly providers: readonly Provider[]; + readonly models: readonly ModelInfo[]; +}; + +export function ModelsSection({ tenantId }: { readonly tenantId: string | null }) { + const query = toAPIQuery( + useQuery({ + queryKey: ["tenant", tenantId ?? "none", "settings-models"] as const, + queryFn: async (): Promise => { + if (tenantId === null) return { providers: [], models: [] }; + const [providers, models] = await Promise.all([ + listProviders(tenantId), + getResolvedCatalog(tenantId), + ]); + return { providers, models }; + }, + enabled: tenantId !== null, + }), + ); + + if (tenantId === null) { + return ( + + ); + } + + return ( + + {({ providers, models }) => ( + +

{SETTINGS_STRINGS.modelsProvidersHeading}

+ +

{SETTINGS_STRINGS.modelsCatalogHeading}

+ +
+ )} +
+ ); +} + +function ProvidersTable({ providers }: { readonly providers: readonly Provider[] }) { + if (providers.length === 0) { + return ( + + ); + } + return ( +
+ + + + {SETTINGS_STRINGS.modelsProviderColumn} + Plugin + + + + {providers.map((provider) => ( + + {provider.name} + {provider.plugin} + + ))} + +
+
+ ); +} + +function ModelsTable({ models }: { readonly models: readonly ModelInfo[] }) { + if (models.length === 0) { + return ( + + ); + } + return ( +
+ + + + {SETTINGS_STRINGS.modelsModelColumn} + {SETTINGS_STRINGS.modelsOfferingsColumn} + + + + {models.map((model) => ( + + {model.displayName ?? model.canonicalName} + + {model.offerings.map((offering, index) => ( + + {offering.providerName} + {index === 0 ? ` · ${SETTINGS_STRINGS.modelsDefaultBadge}` : ""} + + ))} + + + ))} + +
+
+ ); +} diff --git a/apps/web/src/settings/people-section.tsx b/apps/web/src/settings/people-section.tsx index 1b05c869e..93882f286 100644 --- a/apps/web/src/settings/people-section.tsx +++ b/apps/web/src/settings/people-section.tsx @@ -11,7 +11,15 @@ import { Badge, Button, ConfirmButton, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, EmptyState, + Input, SettingsPanel, Table, TableBody, @@ -20,7 +28,7 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { QueryView, toAPIQuery } from "@/lib/api-query"; @@ -31,6 +39,7 @@ import { PRINCIPAL_KIND_LABEL, principalLabel } from "./identity"; import { SETTINGS_STRINGS } from "./strings"; import { assignRole, + inviteMember, listPrincipals, listRoles, removePrincipal, @@ -62,6 +71,7 @@ type PeopleData = { export function PeopleSection({ tenantId }: { readonly tenantId: string | null }) { const queryClient = useQueryClient(); const [rowError, setRowError] = useState(null); + const [inviteOpen, setInviteOpen] = useState(false); // People are the user-kind principals; the role picker needs this tenant's // roles alongside them, so both read under one key every write invalidates. @@ -85,6 +95,20 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null } void queryClient.invalidateQueries({ queryKey: tenantKeys.principals(tenantId) }); } + const inviteMutation = useMutation({ + mutationFn: (input: { email: string; roleId?: string }) => { + if (tenantId === null) return Promise.reject(new Error("No workbench selected")); + return inviteMember(tenantId, input); + }, + onSuccess: () => { + setInviteOpen(false); + reload(); + }, + onError: (cause: unknown) => { + reportError(cause, { operation: "settings.people.invite", tenantId: tenantId ?? "none" }); + }, + }); + if (tenantId === null) { return ( +
+ +
{rowError !== null && (

{rowError} @@ -174,12 +203,123 @@ export function PeopleSection({ tenantId }: { readonly tenantId: string | null } onRemove={handleRemove} onRoleChange={(p, roleId) => handleRoleChange(p, roleId, people, roles)} /> + + inviteMutation.mutate({ email, ...(roleId !== undefined ? { roleId } : {}) }) + } + /> )} ); } +function InviteDialog({ + open, + onOpenChange, + roles, + submitting, + error, + onInvite, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly roles: readonly Role[]; + readonly submitting: boolean; + readonly error: string | null; + readonly onInvite: (email: string, roleId: string | undefined) => void; +}) { + const [email, setEmail] = useState(""); + const memberRole = findSystemRole(roles, "member"); + const ownerRole = findSystemRole(roles, "owner"); + const selectableRoles = [ownerRole, memberRole].filter((r): r is Role => r !== undefined); + const [roleId, setRoleId] = useState(undefined); + const canSubmit = email.trim().length > 0; + + function reset() { + setEmail(""); + setRoleId(undefined); + } + + return ( +

{ + onOpenChange(next); + if (!next) reset(); + }} + > + + + {SETTINGS_STRINGS.peopleInviteDialogTitle} + {SETTINGS_STRINGS.peopleInviteDialogDescription} + + +
{ + event.preventDefault(); + if (canSubmit) onInvite(email.trim(), roleId ?? memberRole?.id); + }} + > + + {selectableRoles.length > 0 && ( + + )} + {error !== null && ( +

+ {error} +

+ )} +
+
+ + + + +
+
+ ); +} + function countHoldingRole(people: readonly Principal[], roleId: string): number { return people.filter((p) => p.roles.some((r) => r.id === roleId)).length; } @@ -211,87 +351,91 @@ 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; +
+
+ + + 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; - 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" ? ( - + 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} + + )) )} - onRemove(person)} - > - {SETTINGS_STRINGS.peopleRemove} - -
-
-
- ); - })} -
-
+ + +
+ {person.status === "suspended" ? ( + + ) : ( + + )} + onRemove(person)} + > + {SETTINGS_STRINGS.peopleRemove} + +
+
+ + ); + })} + + + ); } diff --git a/apps/web/src/settings/section-registry.tsx b/apps/web/src/settings/section-registry.tsx index 3ec1c3659..55cbfac67 100644 --- a/apps/web/src/settings/section-registry.tsx +++ b/apps/web/src/settings/section-registry.tsx @@ -18,13 +18,14 @@ // `resolveSettingsSectionGroups` — the domain model of "what settings // exist and who can see them" lives here, not in an app. -import { Key, ListBullets, Shield, Star, User, Users } from "@/lib/icons"; +import { Cpu, Key, ListBullets, Shield, Star, User, Users } from "@/lib/icons"; import { AccountSection } from "./account-section"; import type { TenancyAccess } from "./access"; import { AuditSection } from "./audit-section"; import { CredentialsSection } from "./credentials-section"; import { GrantsSection } from "./grants-section"; +import { ModelsSection } from "./models-section"; import { PeopleSection } from "./people-section"; import { RolesSection } from "./roles-section"; import type { SettingsSection, SettingsSectionGroup } from "./shell"; @@ -101,6 +102,16 @@ const SETTINGS_SECTION_GROUPS: readonly SettingsSectionGroupDef[] = [ advanced: true, render: (ctx) => , }, + { + // Read-only: this workbench's providers and resolved model + // catalog, over the same stock routes credentials/inference + // already read. No gate — model:*/provider:* read is the same + // grant every member needs to chat at all. + id: "models", + title: SETTINGS_STRINGS.modelsSectionTitle, + icon: Cpu, + render: (ctx) => , + }, { id: "audit", title: SETTINGS_STRINGS.auditSectionTitle, diff --git a/apps/web/src/settings/strings.ts b/apps/web/src/settings/strings.ts index 5e3ec37e1..ba2d0ff09 100644 --- a/apps/web/src/settings/strings.ts +++ b/apps/web/src/settings/strings.ts @@ -58,6 +58,13 @@ export const SETTINGS_STRINGS = { peopleInviteRoleLabel: "Role", peopleInviteRoleOwner: "Owner", peopleInviteRoleMember: "Member", + peopleInviteAction: "Invite", + peopleInviteDialogTitle: "Invite someone", + peopleInviteDialogDescription: "They need an existing account on this platform to accept.", + peopleInviteEmailLabel: "Email", + peopleInviteSubmit: "Send invite", + peopleInviteCancel: "Cancel", + peopleInviteError: "Couldn't send that invite — try again.", peopleSuspend: "Suspend", peopleReactivate: "Reactivate", peopleRemove: "Remove", @@ -174,4 +181,18 @@ export const SETTINGS_STRINGS = { auditEmpty: "No recent changes to show yet.", auditHonestyNote: "Current-state view, not an append-only audit log — treat as orientation, not evidence.", + + modelsSectionTitle: "Models", + modelsSectionDescription: "Providers and model offerings resolved for this workbench.", + modelsLoadError: "this workbench's model catalog", + modelsEmptyTitle: "No models available", + modelsEmptyDescription: "No provider has offered a model to this workbench yet.", + modelsProvidersEmptyTitle: "No providers yet", + modelsProvidersEmptyDescription: "No credential providers are configured on this workbench.", + modelsProvidersHeading: "Providers", + modelsCatalogHeading: "Model offerings", + modelsProviderColumn: "Provider", + modelsModelColumn: "Model", + modelsOfferingsColumn: "Offered by", + modelsDefaultBadge: "Default", } as const; diff --git a/apps/web/src/settings/styles.css b/apps/web/src/settings/styles.css index e42d3f8d0..28f2018aa 100644 --- a/apps/web/src/settings/styles.css +++ b/apps/web/src/settings/styles.css @@ -134,6 +134,20 @@ min-width: min-content; } +/* Settings tables (People, Credentials, Models): scroll their own + overflow container rather than letting the panel widen or clipping the + Actions column at desktop width — the table itself keeps its natural + width and only this wrapper scrolls horizontally. */ +.settings-table-scroll { + overflow-x: auto; + width: 100%; +} + +.settings-table-scroll table { + width: 100%; + min-width: 40rem; +} + .settings-inline-error { margin: 0; color: var(--destructive); diff --git a/apps/web/src/settings/tenancy-api.ts b/apps/web/src/settings/tenancy-api.ts index 2fc19037b..50d9d63d4 100644 --- a/apps/web/src/settings/tenancy-api.ts +++ b/apps/web/src/settings/tenancy-api.ts @@ -9,6 +9,7 @@ import type { ArkErrors } from "arktype"; import { EvaluateResult, GrantResponse, + InviteMember, PrincipalResponse, RoleResponse, paginatedSchema, @@ -90,6 +91,19 @@ export function removePrincipal(tenantId: string, principalId: string): Promise< ); } +export type InviteMemberInput = typeof InviteMember.infer; + +/** Invites an existing platform user to this tenant by email over the + * stock `/members/invite` route — creates an `invited`-status principal + * and optionally assigns a role. The invited user must already have an + * account; there is no separate invite-by-email-only flow. */ +export function inviteMember(tenantId: string, input: InviteMemberInput): Promise { + return request(`/api/tenants/${tenantId}/members/invite`, PrincipalResponse, { + method: "POST", + body: JSON.stringify(input), + }); +} + // -- Roles --------------------------------------------------------------- export function listRoles(tenantId: string): Promise {