From f0f2917484febc6d20a4242626815f40dc52f046 Mon Sep 17 00:00:00 2001 From: Gavin Williams Date: Thu, 13 Aug 2026 22:02:43 +0100 Subject: [PATCH 1/2] feat: add service accounts (machine identities for API keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `User.type` discriminator (`HUMAN`/`SERVICE`) so an org can create non-interactive service accounts that own their own API keys, instead of machine access always borrowing a real person's credentials. Design (Option A — machine `User` with a `type` discriminator): a service account is a `User` row with its own `UserToOrg` membership/role, so it reuses the existing role-resolution, repo-scoping (`userScopedPrismaClientExtension`), and API-key verification pipeline in `withAuth.ts` unchanged. - **Schema**: `UserType` enum, `User.type`/`description`/`createdBy` self-relation, migration. - **Auth**: no `AuthPrincipal` changes needed — a service account's `UserToOrg.role` gates its API key exactly like a human member's. Added a defensive guard in `withAuth.ts` rejecting a `SERVICE`-type user presenting a session. - **Seats/membership**: new `humanMembershipWhere()` in `features/membership/utils.ts`, folded into `orgHasAvailability`/`countActiveOwners` and the member-listing call sites (Members table, public `/ee/users`, SCIM, chat share-picker, chat-access validation, invite pre-check) so service accounts don't consume seats or leak into human-facing listings. - **Audit**: `"service_account"` actor/target type, `auditActorForUser()` helper, and a `targetType` override on `membership.service.ts`'s shared remove/suspend/role functions so delegated service-account operations audit correctly. - **Backend**: new `features/serviceAccounts/` module (`serviceAccount.service.ts`, `actions.ts`, `errors.ts`) — OWNER-gated create/update/role/suspend/reactivate/remove plus API key CRUD, wrapping the existing `membership.service.ts` primitives. - **UI**: new Settings → Service Accounts page (list/create/edit/role/suspend/remove) and a per-account API key management page, wired into the settings nav. - **Tests**: new/updated coverage in `withAuth.test.ts`, `membership.service.test.ts`, `features/membership/utils.test.ts`, and the new `serviceAccounts` test files. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 10 + packages/db/prisma/schema.prisma | 24 ++ packages/web/src/__mocks__/prisma.ts | 22 +- packages/web/src/actions.ts | 21 +- .../web/src/app/(app)/settings/layout.tsx | 6 + .../settings/serviceAccounts/[id]/page.tsx | 53 +++ .../[id]/serviceAccountApiKeysPage.tsx | 243 ++++++++++++ .../(app)/settings/serviceAccounts/layout.tsx | 6 + .../(app)/settings/serviceAccounts/page.tsx | 29 ++ .../serviceAccounts/serviceAccountsPage.tsx | 327 ++++++++++++++++ .../ee/chat/[chatId]/searchMembers/route.ts | 5 +- .../api/(server)/ee/scim/v2/Users/route.ts | 10 +- .../src/app/api/(server)/ee/users/route.ts | 10 +- packages/web/src/ee/features/audit/types.ts | 4 +- packages/web/src/ee/features/audit/utils.ts | 13 + packages/web/src/features/chat/actions.ts | 19 +- .../web/src/features/mcp/prismaScope.test.ts | 4 + .../features/membership/actions/invites.ts | 10 +- .../features/membership/actions/members.ts | 11 +- .../membership/membership.service.test.ts | 24 ++ .../features/membership/membership.service.ts | 30 +- .../web/src/features/membership/utils.test.ts | 81 ++++ packages/web/src/features/membership/utils.ts | 18 +- .../features/serviceAccounts/actions.test.ts | 151 +++++++ .../src/features/serviceAccounts/actions.ts | 150 +++++++ .../src/features/serviceAccounts/errors.ts | 9 + .../serviceAccount.service.test.ts | 268 +++++++++++++ .../serviceAccounts/serviceAccount.service.ts | 369 ++++++++++++++++++ packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/posthogEvents.ts | 6 + packages/web/src/middleware/withAuth.test.ts | 72 +++- packages/web/src/middleware/withAuth.ts | 12 +- 32 files changed, 1964 insertions(+), 54 deletions(-) create mode 100644 packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql create mode 100644 packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx create mode 100644 packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx create mode 100644 packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx create mode 100644 packages/web/src/app/(app)/settings/serviceAccounts/page.tsx create mode 100644 packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx create mode 100644 packages/web/src/ee/features/audit/utils.ts create mode 100644 packages/web/src/features/membership/utils.test.ts create mode 100644 packages/web/src/features/serviceAccounts/actions.test.ts create mode 100644 packages/web/src/features/serviceAccounts/actions.ts create mode 100644 packages/web/src/features/serviceAccounts/errors.ts create mode 100644 packages/web/src/features/serviceAccounts/serviceAccount.service.test.ts create mode 100644 packages/web/src/features/serviceAccounts/serviceAccount.service.ts diff --git a/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql b/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql new file mode 100644 index 000000000..115131006 --- /dev/null +++ b/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('HUMAN', 'SERVICE'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdById" TEXT, +ADD COLUMN "description" TEXT, +ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'HUMAN'; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b4b43c8f0..d73a313f7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -399,6 +399,16 @@ enum OrgRole { MEMBER } +/// Discriminates a normal human account from a non-interactive service +/// account. Service accounts never authenticate via session/OAuth — only +/// via API key — and are excluded from seat counts and member-facing +/// listings (see `humanMembershipWhere` and its call sites in +/// `features/membership`). +enum UserType { + HUMAN + SERVICE +} + enum McpServerClientInfoSource { DYNAMIC STATIC @@ -569,6 +579,20 @@ model User { /// Last time the user performed an authenticated action. lastActiveAt DateTime? + /// Discriminates a human account from a service account. See `UserType`. + type UserType @default(HUMAN) + + /// Free-text description shown in the Service Accounts settings UI. Unused + /// for HUMAN users. + description String? + + /// The human member who created this service account, kept for + /// attribution/display only (never used in an auth check). Null for HUMAN + /// users, and for SERVICE users whose creator was later removed. + createdBy User? @relation("ServiceAccountCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) + createdById String? + + createdServiceAccounts User[] @relation("ServiceAccountCreatedBy") } enum AccountPermissionSyncJobStatus { diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index c2ac79cd0..0020964e7 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -1,5 +1,5 @@ import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants'; -import { Account, ApiKey, OAuthRefreshToken, OAuthToken, Org, PrismaClient, User } from '@prisma/client'; +import { Account, ApiKey, OAuthRefreshToken, OAuthToken, Org, PrismaClient, User, UserType } from '@prisma/client'; import { beforeEach, vi } from 'vitest'; import { mockDeep, mockReset } from 'vitest-mock-extended'; @@ -46,6 +46,26 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = { lastActiveAt: null, image: null, sessionVersion: 0, + type: UserType.HUMAN, + description: null, + createdById: null, + accounts: [], +} + +export const MOCK_SERVICE_ACCOUNT_USER: User & { accounts: Account[] } = { + id: 'service-1', + name: 'Test Service Account', + email: 'svc+service-1@service.internal', + createdAt: new Date(), + updatedAt: new Date(), + hashedPassword: null, + emailVerified: null, + lastActiveAt: new Date(), + image: null, + sessionVersion: 0, + type: UserType.SERVICE, + description: 'A test service account', + createdById: MOCK_USER_WITH_ACCOUNTS.id, accounts: [], } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index c64979173..5cab01407 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { createAudit } from "@/ee/features/audit/audit"; +import { auditActorForUser } from "@/ee/features/audit/utils"; import { ErrorCode } from "@/lib/errorCodes"; import { notFound, ServiceError } from "@/lib/serviceError"; import { sew } from "@/middleware/sew"; @@ -54,10 +55,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv if (existingApiKey) { await createAudit({ action: "api_key.creation_failed", - actor: { - id: user.id, - type: "user" - }, + actor: auditActorForUser(user), target: { id: org.id.toString(), type: "org" @@ -87,10 +85,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv await createAudit({ action: "api_key.created", - actor: { - id: user.id, - type: "user" - }, + actor: auditActorForUser(user), target: { id: apiKey.hash, type: "api_key" @@ -115,10 +110,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | if (!apiKey) { await createAudit({ action: "api_key.deletion_failed", - actor: { - id: user.id, - type: "user" - }, + actor: auditActorForUser(user), target: { id: org.id.toString(), type: "org" @@ -144,10 +136,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | await createAudit({ action: "api_key.deleted", - actor: { - id: user.id, - type: "user" - }, + actor: auditActorForUser(user), target: { id: apiKey.hash, type: "api_key" diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index 655ca28f5..7ad6a5032 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -116,6 +116,12 @@ export const getSidebarNavGroups = async () => href: `/settings/members`, icon: "users" as const, }, + { + title: "Service Accounts", + href: `/settings/serviceAccounts`, + hrefRegex: `/settings/serviceAccounts(/.*)?$`, + icon: "server" as const, + }, { title: "Connections", href: `/settings/connections`, diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx new file mode 100644 index 000000000..b07840b29 --- /dev/null +++ b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx @@ -0,0 +1,53 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { getServiceAccountApiKeysAction, listServiceAccounts } from "@/features/serviceAccounts/actions"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { OrgRole } from "@sourcebot/db"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { ServiceAccountApiKeysPage } from "./serviceAccountApiKeysPage"; + +export default authenticatedPage<{ params: Promise<{ id: string }> }>(async (_auth, { params }) => { + const { id } = await params; + + const [serviceAccounts, apiKeys] = await Promise.all([ + listServiceAccounts(), + getServiceAccountApiKeysAction(id), + ]); + + if (isServiceError(serviceAccounts)) { + throw new ServiceErrorException(serviceAccounts); + } + + const serviceAccount = serviceAccounts.find((sa) => sa.id === id); + if (!serviceAccount) { + return notFound(); + } + + if (isServiceError(apiKeys)) { + throw new ServiceErrorException(apiKeys); + } + + return ( +
+
+ + + Service Accounts + +

{serviceAccount.name}

+

+ Create and manage API keys for this service account. +

+
+ +
+ ); +}, { + minRole: OrgRole.OWNER, + redirectTo: '/settings', +}); diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx new file mode 100644 index 000000000..dcfe84fcd --- /dev/null +++ b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createServiceAccountApiKeyAction, deleteServiceAccountApiKeyAction } from "@/features/serviceAccounts/actions"; +import { isServiceError } from "@/lib/utils"; +import { formatDistanceToNow } from "date-fns"; +import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; + +interface ApiKey { + name: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +interface ServiceAccountApiKeysPageProps { + serviceAccountId: string; + apiKeys: ApiKey[]; +} + +export function ServiceAccountApiKeysPage({ serviceAccountId, apiKeys }: ServiceAccountApiKeysPageProps) { + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + const router = useRouter(); + + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast({ title: "Error", description: "API key name cannot be empty", variant: "destructive" }); + return; + } + + setIsCreatingKey(true); + try { + const result = await createServiceAccountApiKeyAction(serviceAccountId, newKeyName.trim()); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to create API key: ${result.message}`, variant: "destructive" }); + captureEvent('wa_service_account_api_key_creation_fail', {}); + return; + } + + setNewlyCreatedKey(result.key); + router.refresh(); + captureEvent('wa_service_account_api_key_created', {}); + } finally { + setIsCreatingKey(false); + } + }; + + const handleCopyApiKey = () => { + if (!newlyCreatedKey) { + return; + } + + navigator.clipboard.writeText(newlyCreatedKey) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ title: "Error", description: "Failed to copy API key to clipboard", variant: "destructive" }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewKeyName(""); + setNewlyCreatedKey(null); + setCopySuccess(false); + }; + + const handleDeleteApiKey = async (name: string) => { + const result = await deleteServiceAccountApiKeyAction(serviceAccountId, name); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to delete API key: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + toast({ description: "API key deleted" }); + }; + + const sortedKeys = useMemo( + () => [...apiKeys].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + [apiKeys] + ); + + return ( +
+
+ + {apiKeys.length} API key{apiKeys.length !== 1 ? "s" : ""} + + + + + + + + + {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'} + + + {newlyCreatedKey ? ( +
+
+ +

+ This is the only time you'll see this API key. Make sure to copy it now. +

+
+ +
+
+ {newlyCreatedKey} +
+ +
+
+ ) : ( +
+ setNewKeyName(e.target.value)} + placeholder="Enter a name for this API key" + className="mb-2" + /> +
+ )} + + + {newlyCreatedKey ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + {sortedKeys.length === 0 ? ( +
+ No API keys yet. +
+ ) : ( +
+ {sortedKeys.map((key) => ( +
+
+ +
+
+ {key.name} + + Created {formatDistanceToNow(key.createdAt, { addSuffix: true })} + {" · "} + {key.lastUsedAt + ? `last used ${formatDistanceToNow(key.lastUsedAt, { addSuffix: true })}` + : "never used"} + +
+ + + + + + + Delete API Key + + Are you sure you want to delete the API key {key.name}? Any applications using this key will no longer be able to access the API. This action cannot be undone. + + + + Cancel + handleDeleteApiKey(key.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx new file mode 100644 index 000000000..599b986cb --- /dev/null +++ b/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { SettingsContainer } from "../components/settingsContainer"; + +export default function ServiceAccountsSettingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx new file mode 100644 index 000000000..07181ba94 --- /dev/null +++ b/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx @@ -0,0 +1,29 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { listServiceAccounts } from "@/features/serviceAccounts/actions"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { OrgRole } from "@sourcebot/db"; +import { ServiceAccountsPage } from "./serviceAccountsPage"; + +export default authenticatedPage(async () => { + const serviceAccounts = await listServiceAccounts(); + if (isServiceError(serviceAccounts)) { + throw new ServiceErrorException(serviceAccounts); + } + + return ( +
+
+

Service Accounts

+

+ Service accounts are non-human identities for programmatic access to Sourcebot. + Unlike a member's own API keys, service accounts are managed centrally, don't consume a seat, and don't appear in your members list. +

+
+ +
+ ); +}, { + minRole: OrgRole.OWNER, + redirectTo: '/settings', +}); diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx new file mode 100644 index 000000000..c130cb371 --- /dev/null +++ b/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { isServiceError } from "@/lib/utils"; +import { + createServiceAccountAction, + reactivateServiceAccountAction, + removeServiceAccountAction, + renameServiceAccountAction, + setServiceAccountRoleAction, + suspendServiceAccountAction, +} from "@/features/serviceAccounts/actions"; +import type { ServiceAccountSummary } from "@/features/serviceAccounts/serviceAccount.service"; +import { OrgRole } from "@sourcebot/db"; +import { formatDistanceToNow } from "date-fns"; +import { KeyRound, MoreVertical, Plus, Server } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +interface ServiceAccountsPageProps { + serviceAccounts: ServiceAccountSummary[]; +} + +export function ServiceAccountsPage({ serviceAccounts }: ServiceAccountsPageProps) { + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + const router = useRouter(); + + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [newDescription, setNewDescription] = useState(""); + const [newRole, setNewRole] = useState(OrgRole.MEMBER); + const [isCreating, setIsCreating] = useState(false); + + const [editingAccount, setEditingAccount] = useState(null); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [isSavingEdit, setIsSavingEdit] = useState(false); + + const openEditDialog = (serviceAccount: ServiceAccountSummary) => { + setEditingAccount(serviceAccount); + setEditName(serviceAccount.name ?? ""); + setEditDescription(serviceAccount.description ?? ""); + }; + + const handleSaveEdit = async () => { + if (!editingAccount || !editName.trim()) { + return; + } + + setIsSavingEdit(true); + try { + const result = await renameServiceAccountAction(editingAccount.id, { + name: editName.trim(), + description: editDescription.trim() || undefined, + }); + + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to update service account: ${result.message}`, variant: "destructive" }); + return; + } + + setEditingAccount(null); + router.refresh(); + toast({ description: "Service account updated" }); + } finally { + setIsSavingEdit(false); + } + }; + + const handleCreate = async () => { + if (!newName.trim()) { + toast({ title: "Error", description: "Service account name cannot be empty", variant: "destructive" }); + return; + } + + setIsCreating(true); + try { + const result = await createServiceAccountAction({ + name: newName.trim(), + description: newDescription.trim() || undefined, + role: newRole, + }); + + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to create service account: ${result.message}`, variant: "destructive" }); + captureEvent('wa_service_account_creation_fail', {}); + return; + } + + setIsCreateDialogOpen(false); + setNewName(""); + setNewDescription(""); + setNewRole(OrgRole.MEMBER); + router.refresh(); + captureEvent('wa_service_account_created', {}); + toast({ description: `Service account "${result.name}" created` }); + } finally { + setIsCreating(false); + } + }; + + const handleSetRole = async (id: string, role: OrgRole) => { + const result = await setServiceAccountRoleAction(id, role); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to change role: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + }; + + const handleSuspend = async (id: string, suspend: boolean) => { + const result = suspend ? await suspendServiceAccountAction(id) : await reactivateServiceAccountAction(id); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to ${suspend ? 'suspend' : 'reactivate'} service account: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + toast({ description: suspend ? "Service account suspended" : "Service account reactivated" }); + }; + + const handleRemove = async (id: string) => { + const result = await removeServiceAccountAction(id); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to remove service account: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + captureEvent('wa_service_account_removed', {}); + toast({ description: "Service account removed" }); + }; + + return ( +
+
+ + {serviceAccounts.length} service account{serviceAccounts.length !== 1 ? "s" : ""} + + + + + + + + + Create Service Account + +
+ setNewName(e.target.value)} + placeholder="Name (e.g. CI Pipeline)" + /> +