From 0d5671c7adcbdf7b88efafda08431b15f8fdfba6 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:45:01 +0000 Subject: [PATCH 01/19] fix: rename SCIM user audit entity type from User to ScimUser --- src/server/services/scim.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/services/scim.ts b/src/server/services/scim.ts index 616f27bd..6975f5c5 100644 --- a/src/server/services/scim.ts +++ b/src/server/services/scim.ts @@ -129,7 +129,7 @@ export async function scimCreateUser(scimUser: ScimUser): Promise<{ user: ScimUs await writeAuditLog({ userId: null, action: "scim.user_adopted", - entityType: "User", + entityType: "ScimUser", entityId: updated.id, metadata: { email, scimExternalId: scimUser.externalId }, }); @@ -157,7 +157,7 @@ export async function scimCreateUser(scimUser: ScimUser): Promise<{ user: ScimUs await writeAuditLog({ userId: null, action: "scim.user_created", - entityType: "User", + entityType: "ScimUser", entityId: user.id, metadata: { email, scimExternalId: scimUser.externalId }, }); @@ -209,7 +209,7 @@ export async function scimUpdateUser(id: string, scimUser: Partial) { await writeAuditLog({ userId: null, action: "scim.user_updated", - entityType: "User", + entityType: "ScimUser", entityId: id, metadata: { fields: Object.keys(data) }, }); @@ -292,7 +292,7 @@ export async function scimPatchUser( await writeAuditLog({ userId: null, action: "scim.user_patched", - entityType: "User", + entityType: "ScimUser", entityId: id, metadata: { fields: Object.keys(data), operations: operations.map((o) => o.op) }, }); @@ -321,7 +321,7 @@ export async function scimDeleteUser(id: string) { await writeAuditLog({ userId: null, action: "scim.user_deactivated", - entityType: "User", + entityType: "ScimUser", entityId: id, }); } \ No newline at end of file From cf1ff2678ea1529a4118ec71471a7aa1b54b4383 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:46:30 +0000 Subject: [PATCH 02/19] feat: add combined SCIM filter to audit log entity type dropdown --- src/app/(dashboard)/audit/page.tsx | 11 ++++++++++- src/server/routers/audit.ts | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/audit/page.tsx b/src/app/(dashboard)/audit/page.tsx index 52bfc370..ff1d90ba 100644 --- a/src/app/(dashboard)/audit/page.tsx +++ b/src/app/(dashboard)/audit/page.tsx @@ -29,6 +29,7 @@ import { PageHeader } from "@/components/page-header"; import { useTeamStore } from "@/stores/team-store"; const ALL_VALUE = "__all__"; +const SCIM_VALUE = "__SCIM__"; function formatTimestamp(date: Date | string): string { const d = new Date(date); @@ -95,9 +96,16 @@ export default function AuditPage() { // Build query input — explicit team filter overrides global team selector const effectiveTeamId = teamFilter || selectedTeamId; const effectiveEnvironmentId = environmentFilter || undefined; + // Map entity type filter to entityTypes array for the query + const entityTypesParam = entityTypeFilter + ? entityTypeFilter === SCIM_VALUE + ? ["ScimUser", "ScimGroup"] + : [entityTypeFilter] + : undefined; + const queryInput = { ...(actionFilter ? { action: actionFilter } : {}), - ...(entityTypeFilter ? { entityType: entityTypeFilter } : {}), + ...(entityTypesParam ? { entityTypes: entityTypesParam } : {}), ...(userFilter ? { userId: userFilter } : {}), ...(startDate ? { startDate } : {}), ...(endDate ? { endDate } : {}), @@ -195,6 +203,7 @@ export default function AuditPage() { All types + SCIM (All) {entityTypes.map((t) => ( {t} diff --git a/src/server/routers/audit.ts b/src/server/routers/audit.ts index e8889d35..fcfe67e9 100644 --- a/src/server/routers/audit.ts +++ b/src/server/routers/audit.ts @@ -8,7 +8,7 @@ export const auditRouter = router({ z.object({ action: z.string().optional(), userId: z.string().optional(), - entityType: z.string().optional(), + entityTypes: z.array(z.string()).optional(), search: z.string().optional(), teamId: z.string().optional(), environmentId: z.string().optional(), @@ -21,7 +21,7 @@ export const auditRouter = router({ const { action, userId, - entityType, + entityTypes, search, startDate, endDate, @@ -39,8 +39,8 @@ export const auditRouter = router({ conditions.push({ userId }); } - if (entityType) { - conditions.push({ entityType }); + if (entityTypes && entityTypes.length > 0) { + conditions.push({ entityType: { in: entityTypes } }); } if (input.teamId) { From b9cef320171f1b0603d23ead44fb3bc677ee5be5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:50:45 +0000 Subject: [PATCH 03/19] feat: add UserPreference model and API for default team/env --- .../migration.sql | 24 +++++++++++++ prisma/schema.prisma | 35 +++++++++++++------ src/server/routers/team.ts | 26 ++++++++++++++ src/server/routers/user-preference.ts | 32 +++++++++++++++++ src/trpc/router.ts | 2 ++ 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260309000000_add_user_preferences_and_team_default_env/migration.sql create mode 100644 src/server/routers/user-preference.ts diff --git a/prisma/migrations/20260309000000_add_user_preferences_and_team_default_env/migration.sql b/prisma/migrations/20260309000000_add_user_preferences_and_team_default_env/migration.sql new file mode 100644 index 00000000..50adbac9 --- /dev/null +++ b/prisma/migrations/20260309000000_add_user_preferences_and_team_default_env/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "UserPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "defaultEnvironmentId" TEXT; + +-- CreateIndex +CREATE INDEX "UserPreference_userId_idx" ON "UserPreference"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserPreference_userId_key_key" ON "UserPreference"("userId", "key"); + +-- AddForeignKey +ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_defaultEnvironmentId_fkey" FOREIGN KEY ("defaultEnvironmentId") REFERENCES "Environment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89121e42..fa85ca4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,25 +33,39 @@ model User { serviceAccounts ServiceAccount[] deployRequestsMade DeployRequest[] @relation("deployRequester") deployRequestsReviewed DeployRequest[] @relation("deployReviewer") + preferences UserPreference[] createdAt DateTime @default(now()) } +model UserPreference { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + key String + value String + + @@unique([userId, key]) + @@index([userId]) +} + enum AuthMethod { LOCAL OIDC } model Team { - id String @id @default(cuid()) - name String - requireTwoFactor Boolean @default(false) - members TeamMember[] - environments Environment[] - templates Template[] - vrlSnippets VrlSnippet[] - alertRules AlertRule[] - availableTags Json? @default("[]") // string[] of admin-defined classification tags - createdAt DateTime @default(now()) + id String @id @default(cuid()) + name String + requireTwoFactor Boolean @default(false) + defaultEnvironmentId String? + defaultEnvironment Environment? @relation("teamDefault", fields: [defaultEnvironmentId], references: [id], onDelete: SetNull) + members TeamMember[] + environments Environment[] + templates Template[] + vrlSnippets VrlSnippet[] + alertRules AlertRule[] + availableTags Json? @default("[]") // string[] of admin-defined classification tags + createdAt DateTime @default(now()) } model ScimGroup { @@ -117,6 +131,7 @@ model Environment { notificationChannels NotificationChannel[] serviceAccounts ServiceAccount[] deployRequests DeployRequest[] + teamDefaults Team[] @relation("teamDefault") createdAt DateTime @default(now()) } diff --git a/src/server/routers/team.ts b/src/server/routers/team.ts index f70c441d..a1d3c5fa 100644 --- a/src/server/routers/team.ts +++ b/src/server/routers/team.ts @@ -390,6 +390,32 @@ export const teamRouter = router({ }); }), + updateDefaultEnvironment: protectedProcedure + .input(z.object({ + teamId: z.string(), + defaultEnvironmentId: z.string().nullable(), + })) + .use(withTeamAccess("ADMIN")) + .use(withAudit("team.updated", "Team")) + .mutation(async ({ input }) => { + if (input.defaultEnvironmentId) { + const env = await prisma.environment.findUnique({ + where: { id: input.defaultEnvironmentId }, + }); + if (!env || env.teamId !== input.teamId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Environment not found or does not belong to this team", + }); + } + } + return prisma.team.update({ + where: { id: input.teamId }, + data: { defaultEnvironmentId: input.defaultEnvironmentId }, + select: { id: true, defaultEnvironmentId: true }, + }); + }), + getAvailableTags: protectedProcedure .input(z.object({ teamId: z.string() })) .use(withTeamAccess("VIEWER")) diff --git a/src/server/routers/user-preference.ts b/src/server/routers/user-preference.ts new file mode 100644 index 00000000..4f73ce77 --- /dev/null +++ b/src/server/routers/user-preference.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { router, protectedProcedure } from "@/trpc/init"; +import { prisma } from "@/lib/prisma"; + +export const userPreferenceRouter = router({ + get: protectedProcedure.query(async ({ ctx }) => { + const prefs = await prisma.userPreference.findMany({ + where: { userId: ctx.session.user!.id! }, + }); + return Object.fromEntries(prefs.map((p) => [p.key, p.value])); + }), + + set: protectedProcedure + .input(z.object({ key: z.string().max(100), value: z.string().max(500) })) + .mutation(async ({ ctx, input }) => { + await prisma.userPreference.upsert({ + where: { + userId_key: { userId: ctx.session.user!.id!, key: input.key }, + }, + create: { userId: ctx.session.user!.id!, key: input.key, value: input.value }, + update: { value: input.value }, + }); + }), + + delete: protectedProcedure + .input(z.object({ key: z.string() })) + .mutation(async ({ ctx, input }) => { + await prisma.userPreference.deleteMany({ + where: { userId: ctx.session.user!.id!, key: input.key }, + }); + }), +}); diff --git a/src/trpc/router.ts b/src/trpc/router.ts index 2b3edc5c..032e185a 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -18,6 +18,7 @@ import { certificateRouter } from "@/server/routers/certificate"; import { vrlSnippetRouter } from "@/server/routers/vrl-snippet"; import { alertRouter } from "@/server/routers/alert"; import { serviceAccountRouter } from "@/server/routers/service-account"; +import { userPreferenceRouter } from "@/server/routers/user-preference"; export const appRouter = router({ team: teamRouter, @@ -39,6 +40,7 @@ export const appRouter = router({ vrlSnippet: vrlSnippetRouter, alert: alertRouter, serviceAccount: serviceAccountRouter, + userPreference: userPreferenceRouter, }); export type AppRouter = typeof appRouter; From 130c6b591a50bdbae55d9afb9a23d247970b185b Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:53:09 +0000 Subject: [PATCH 04/19] feat: add deployedBy/deployedAt fields to DeployRequest --- .../migration.sql | 6 ++++++ prisma/schema.prisma | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260309010000_add_deploy_request_executor/migration.sql diff --git a/prisma/migrations/20260309010000_add_deploy_request_executor/migration.sql b/prisma/migrations/20260309010000_add_deploy_request_executor/migration.sql new file mode 100644 index 00000000..5b33dcba --- /dev/null +++ b/prisma/migrations/20260309010000_add_deploy_request_executor/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "DeployRequest" ADD COLUMN "deployedAt" TIMESTAMP(3); +ALTER TABLE "DeployRequest" ADD COLUMN "deployedById" TEXT; + +-- AddForeignKey +ALTER TABLE "DeployRequest" ADD CONSTRAINT "DeployRequest_deployedById_fkey" FOREIGN KEY ("deployedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa85ca4c..afb92e5d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { serviceAccounts ServiceAccount[] deployRequestsMade DeployRequest[] @relation("deployRequester") deployRequestsReviewed DeployRequest[] @relation("deployReviewer") + deployRequestsExecuted DeployRequest[] @relation("deployExecutor") preferences UserPreference[] createdAt DateTime @default(now()) } @@ -555,12 +556,15 @@ model DeployRequest { configYaml String changelog String nodeSelector Json? - status String @default("PENDING") // PENDING | APPROVED | REJECTED | CANCELLED + status String @default("PENDING") // PENDING | APPROVED | REJECTED | CANCELLED | DEPLOYED reviewedById String? reviewedBy User? @relation("deployReviewer", fields: [reviewedById], references: [id]) reviewNote String? createdAt DateTime @default(now()) reviewedAt DateTime? + deployedById String? + deployedBy User? @relation("deployExecutor", fields: [deployedById], references: [id], onDelete: SetNull) + deployedAt DateTime? @@index([pipelineId, status]) @@index([environmentId, status]) From 9c1c3ec238f814d6546143be0c5e62602544fa01 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:54:21 +0000 Subject: [PATCH 05/19] feat: add star/default selector for teams and environments --- src/components/environment-selector.tsx | 186 +++++++++++++++++++----- src/components/team-selector.tsx | 141 +++++++++++++++--- 2 files changed, 267 insertions(+), 60 deletions(-) diff --git a/src/components/environment-selector.tsx b/src/components/environment-selector.tsx index 52ee2b29..1a3d218b 100644 --- a/src/components/environment-selector.tsx +++ b/src/components/environment-selector.tsx @@ -1,31 +1,31 @@ "use client"; -import { useEffect, useMemo, useCallback } from "react"; +import { useEffect, useMemo, useCallback, useState, useRef } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useEnvironmentStore } from "@/stores/environment-store"; import { useTeamStore } from "@/stores/team-store"; import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Layers, Plus, Settings } from "lucide-react"; +import { Layers, Plus, Settings, Star, CheckIcon, ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; export function EnvironmentSelector() { const trpc = useTRPC(); + const queryClient = useQueryClient(); const { selectedEnvironmentId, setSelectedEnvironmentId, setIsSystemEnvironment } = useEnvironmentStore(); const selectedTeamId = useTeamStore((s) => s.selectedTeamId); const pathname = usePathname(); const router = useRouter(); + const [open, setOpen] = useState(false); // Navigate back to list pages when switching environments from a detail page const handleEnvironmentChange = useCallback((id: string) => { @@ -57,11 +57,40 @@ export function EnvironmentSelector() { ); const systemEnvironment = systemEnvQuery.data; + // Fetch user preferences for default environment + const prefsQuery = useQuery(trpc.userPreference.get.queryOptions()); + const prefKey = selectedTeamId ? `defaultEnvironmentId:${selectedTeamId}` : null; + const defaultEnvironmentId = prefKey ? (prefsQuery.data?.[prefKey] ?? null) : null; + + const setPref = useMutation( + trpc.userPreference.set.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.userPreference.get.queryKey(), + }); + }, + }), + ); + + const deletePref = useMutation( + trpc.userPreference.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.userPreference.get.queryKey(), + }); + }, + }), + ); + + // Track whether initial selection was auto-selected (first-in-list) + const wasAutoSelected = useRef(false); + // Auto-select first environment if none selected useEffect(() => { if (!selectedEnvironmentId && environments.length > 0) { setSelectedEnvironmentId(environments[0].id); setIsSystemEnvironment(false); + wasAutoSelected.current = true; } }, [environments, selectedEnvironmentId, setSelectedEnvironmentId, setIsSystemEnvironment]); @@ -74,6 +103,20 @@ export function EnvironmentSelector() { } }, [selectedEnvironmentId, systemEnvironment, setIsSystemEnvironment]); + // Sync with user preference: if user has a default and current was auto-selected, switch to default + useEffect(() => { + if ( + defaultEnvironmentId && + environments.length > 0 && + environments.find((e) => e.id === defaultEnvironmentId) && + wasAutoSelected.current + ) { + setSelectedEnvironmentId(defaultEnvironmentId); + setIsSystemEnvironment(false); + wasAutoSelected.current = false; + } + }, [defaultEnvironmentId, environments, setSelectedEnvironmentId, setIsSystemEnvironment]); + if (envsQuery.isLoading) { return ; } @@ -89,34 +132,101 @@ export function EnvironmentSelector() { ); } + const selectedEnv = + environments.find((e) => e.id === selectedEnvironmentId) ?? + (systemEnvironment && selectedEnvironmentId === systemEnvironment.id + ? systemEnvironment + : null); + return ( - + + + + + +
+ {environments.map((env) => { + const isSelected = env.id === selectedEnvironmentId; + const isDefault = env.id === defaultEnvironmentId; + return ( +
{ + handleEnvironmentChange(env.id); + wasAutoSelected.current = false; + setOpen(false); + }} + > + {/* Check indicator */} + + {isSelected && } + + {env.name} + {/* Star button */} + {prefKey && ( + + )} +
+ ); + })} + {isSuperAdmin && systemEnvironment && ( + <> +
+
{ + handleEnvironmentChange(systemEnvironment.id); + wasAutoSelected.current = false; + setOpen(false); + }} + > + + {selectedEnvironmentId === systemEnvironment.id && } + + + System +
+ + )} +
+ + ); } diff --git a/src/components/team-selector.tsx b/src/components/team-selector.tsx index 0a9da3b0..0db1db9e 100644 --- a/src/components/team-selector.tsx +++ b/src/components/team-selector.tsx @@ -1,30 +1,59 @@ "use client"; -import { useEffect, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useTeamStore } from "@/stores/team-store"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Users } from "lucide-react"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Users, Star, CheckIcon, ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; export function TeamSelector() { const trpc = useTRPC(); + const queryClient = useQueryClient(); const selectedTeamId = useTeamStore((s) => s.selectedTeamId); const setSelectedTeamId = useTeamStore((s) => s.setSelectedTeamId); + const [open, setOpen] = useState(false); const teamsQuery = useQuery(trpc.team.list.queryOptions()); const teams = useMemo(() => teamsQuery.data ?? [], [teamsQuery.data]); + // Fetch user preferences for default team + const prefsQuery = useQuery(trpc.userPreference.get.queryOptions()); + const defaultTeamId = prefsQuery.data?.defaultTeamId ?? null; + + const setPref = useMutation( + trpc.userPreference.set.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.userPreference.get.queryKey(), + }); + }, + }), + ); + + const deletePref = useMutation( + trpc.userPreference.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.userPreference.get.queryKey(), + }); + }, + }), + ); + + // Track whether initial selection was auto-selected (first-in-list) + const wasAutoSelected = useRef(false); + // Auto-select first team if none selected useEffect(() => { if (!selectedTeamId && teams.length > 0) { setSelectedTeamId(teams[0].id); + wasAutoSelected.current = true; } }, [teams, selectedTeamId, setSelectedTeamId]); @@ -32,11 +61,27 @@ export function TeamSelector() { useEffect(() => { if (selectedTeamId && teams.length > 0 && !teams.find((t) => t.id === selectedTeamId)) { setSelectedTeamId(teams[0].id); + wasAutoSelected.current = true; } }, [teams, selectedTeamId, setSelectedTeamId]); + // Sync with user preference: if user has a default and current was auto-selected, switch to default + useEffect(() => { + if ( + defaultTeamId && + teams.length > 0 && + teams.find((t) => t.id === defaultTeamId) && + wasAutoSelected.current + ) { + setSelectedTeamId(defaultTeamId); + wasAutoSelected.current = false; + } + }, [defaultTeamId, teams, setSelectedTeamId]); + if (teams.length === 0) return null; + const selectedTeam = teams.find((t) => t.id === selectedTeamId); + // Single team — show name only, no dropdown if (teams.length === 1) { return ( @@ -48,18 +93,70 @@ export function TeamSelector() { } return ( - + + + + + +
+ {teams.map((team) => { + const isSelected = team.id === selectedTeamId; + const isDefault = team.id === defaultTeamId; + return ( +
{ + setSelectedTeamId(team.id); + wasAutoSelected.current = false; + setOpen(false); + }} + > + {/* Check indicator */} + + {isSelected && } + + {team.name} + {/* Star button */} + +
+ ); + })} +
+
+
); } From ba7620045be7deedc94151869306b4f55d6f89b5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:56:25 +0000 Subject: [PATCH 06/19] feat: add admin default environment setting for teams --- src/app/(dashboard)/settings/page.tsx | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 2fe55d78..1261cbf8 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1216,6 +1216,27 @@ function TeamSettings() { }) ); + // Environments for default environment dropdown + const environmentsQuery = useQuery( + trpc.environment.list.queryOptions( + { teamId: selectedTeamId! }, + { enabled: !!selectedTeamId } + ) + ); + const environments = environmentsQuery.data ?? []; + + const updateDefaultEnvMutation = useMutation( + trpc.team.updateDefaultEnvironment.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("Default environment updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update default environment"); + }, + }) + ); + // Data classification tags const availableTagsQuery = useQuery( trpc.team.getAvailableTags.queryOptions( @@ -1344,6 +1365,35 @@ function TeamSettings() { Save + +
+ +

+ Fallback environment for team members who haven't set a personal default. +

+ +
From f7cf0da2eede931309c3ef91ffd3e9a9f0471c72 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 11:56:31 +0000 Subject: [PATCH 07/19] feat: decouple deploy approval from execution --- src/server/routers/deploy.ts | 41 +++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 46633868..25a1bc48 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -344,7 +344,6 @@ export const deployRouter = router({ .mutation(async ({ input, ctx }) => { const request = await prisma.deployRequest.findUnique({ where: { id: input.requestId }, - include: { pipeline: true }, }); if (!request || request.status !== "PENDING") { throw new TRPCError({ code: "NOT_FOUND", message: "Deploy request not found or not pending" }); @@ -362,8 +361,33 @@ export const deployRouter = router({ throw new TRPCError({ code: "BAD_REQUEST", message: "Request is no longer pending" }); } + return { success: true }; + }), + + executeApprovedRequest: protectedProcedure + .input(z.object({ requestId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("deployRequest.deployed", "DeployRequest")) + .mutation(async ({ input, ctx }) => { + // Atomically claim the APPROVED request — prevents double-deploy race condition + const updated = await prisma.deployRequest.updateMany({ + where: { id: input.requestId, status: "APPROVED" }, + data: { status: "DEPLOYED", deployedById: ctx.session.user.id, deployedAt: new Date() }, + }); + if (updated.count === 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Request is not in APPROVED state" }); + } + + // Fetch the full request to get configYaml, pipelineId, changelog + const request = await prisma.deployRequest.findUnique({ + where: { id: input.requestId }, + }); + if (!request) { + throw new TRPCError({ code: "NOT_FOUND", message: "Deploy request not found" }); + } + // Deploy the reviewed YAML snapshot — NOT the current pipeline state - // If deploy fails, revert request status to PENDING so it can be retried + // If deploy fails, revert request status back to APPROVED try { const result = await deployAgent( request.pipelineId, @@ -386,13 +410,14 @@ export const deployRouter = router({ return result; } catch (err) { + // Revert status back to APPROVED so it can be retried await prisma.deployRequest.updateMany({ - where: { id: input.requestId, status: "APPROVED" }, - data: { status: "PENDING", reviewedById: null, reviewedAt: null }, + where: { id: input.requestId, status: "DEPLOYED" }, + data: { status: "APPROVED", deployedById: null, deployedAt: null }, }); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Deploy failed after approval — request reverted to pending", + message: "Deploy failed — request reverted to approved", cause: err, }); } @@ -429,13 +454,13 @@ export const deployRouter = router({ .input(z.object({ requestId: z.string() })) .use(withTeamAccess("EDITOR")) .use(withAudit("deploy.cancel_request", "DeployRequest")) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const updated = await prisma.deployRequest.updateMany({ - where: { id: input.requestId, status: "PENDING", requestedById: ctx.session.user.id }, + where: { id: input.requestId, status: { in: ["PENDING", "APPROVED"] } }, data: { status: "CANCELLED" }, }); if (updated.count === 0) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Request is no longer pending or not owned by you" }); + throw new TRPCError({ code: "BAD_REQUEST", message: "Request is not pending or approved" }); } return { cancelled: true }; From 6867d9865424ecb2972855cc2a964d69bc19341a Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:00:29 +0000 Subject: [PATCH 08/19] feat: add event-based alert metrics and nullable threshold fields Add 10 new event-based AlertMetric enum values (deploy_requested, deploy_completed, deploy_rejected, deploy_cancelled, new_version_available, scim_sync_failed, backup_failed, certificate_expiring, node_joined, node_left) for alerts that fire on occurrence rather than polling. Make condition, threshold, and durationSeconds nullable on AlertRule so event-based rules can omit threshold configuration. Add null guards in the alert evaluator to skip event-based rules during polling, and update the UI and API to handle nullable fields. --- .../migration.sql | 17 +++++++++++ prisma/schema.prisma | 19 +++++++++++-- src/app/(dashboard)/alerts/page.tsx | 28 +++++++++++++------ src/app/api/agent/heartbeat/route.ts | 2 +- src/server/routers/alert.ts | 6 ++-- src/server/services/alert-evaluator.ts | 18 +++++++++++- 6 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260309020000_add_event_based_alert_metrics/migration.sql diff --git a/prisma/migrations/20260309020000_add_event_based_alert_metrics/migration.sql b/prisma/migrations/20260309020000_add_event_based_alert_metrics/migration.sql new file mode 100644 index 00000000..7269a61b --- /dev/null +++ b/prisma/migrations/20260309020000_add_event_based_alert_metrics/migration.sql @@ -0,0 +1,17 @@ +-- Add new event-based values to AlertMetric enum +ALTER TYPE "AlertMetric" ADD VALUE 'deploy_requested'; +ALTER TYPE "AlertMetric" ADD VALUE 'deploy_completed'; +ALTER TYPE "AlertMetric" ADD VALUE 'deploy_rejected'; +ALTER TYPE "AlertMetric" ADD VALUE 'deploy_cancelled'; +ALTER TYPE "AlertMetric" ADD VALUE 'new_version_available'; +ALTER TYPE "AlertMetric" ADD VALUE 'scim_sync_failed'; +ALTER TYPE "AlertMetric" ADD VALUE 'backup_failed'; +ALTER TYPE "AlertMetric" ADD VALUE 'certificate_expiring'; +ALTER TYPE "AlertMetric" ADD VALUE 'node_joined'; +ALTER TYPE "AlertMetric" ADD VALUE 'node_left'; + +-- Make threshold fields nullable for event-based rules +ALTER TABLE "AlertRule" ALTER COLUMN "condition" DROP NOT NULL; +ALTER TABLE "AlertRule" ALTER COLUMN "threshold" DROP NOT NULL; +ALTER TABLE "AlertRule" ALTER COLUMN "durationSeconds" DROP NOT NULL; +ALTER TABLE "AlertRule" ALTER COLUMN "durationSeconds" DROP DEFAULT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index afb92e5d..b0d69de6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,6 +571,7 @@ model DeployRequest { } enum AlertMetric { + // Infrastructure (threshold-based) node_unreachable cpu_usage memory_usage @@ -578,6 +579,18 @@ enum AlertMetric { error_rate discarded_rate pipeline_crashed + + // Events (fire on occurrence) + deploy_requested + deploy_completed + deploy_rejected + deploy_cancelled + new_version_available + scim_sync_failed + backup_failed + certificate_expiring + node_joined + node_left } enum AlertCondition { @@ -602,9 +615,9 @@ model AlertRule { teamId String team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) metric AlertMetric - condition AlertCondition - threshold Float - durationSeconds Int @default(60) + condition AlertCondition? + threshold Float? + durationSeconds Int? events AlertEvent[] channels AlertRuleChannel[] createdAt DateTime @default(now()) diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx index 7c5e7534..fa411ee4 100644 --- a/src/app/(dashboard)/alerts/page.tsx +++ b/src/app/(dashboard)/alerts/page.tsx @@ -63,6 +63,7 @@ import { PageHeader } from "@/components/page-header"; // ─── Constants ────────────────────────────────────────────────────────────────── const METRIC_LABELS: Record = { + // Infrastructure (threshold-based) node_unreachable: "Node Unreachable", cpu_usage: "CPU Usage", memory_usage: "Memory Usage", @@ -70,6 +71,17 @@ const METRIC_LABELS: Record = { error_rate: "Error Rate", discarded_rate: "Discarded Rate", pipeline_crashed: "Pipeline Crashed", + // Events (fire on occurrence) + deploy_requested: "Deploy Requested", + deploy_completed: "Deploy Completed", + deploy_rejected: "Deploy Rejected", + deploy_cancelled: "Deploy Cancelled", + new_version_available: "New Version Available", + scim_sync_failed: "SCIM Sync Failed", + backup_failed: "Backup Failed", + certificate_expiring: "Certificate Expiring", + node_joined: "Node Joined", + node_left: "Node Left", }; const CONDITION_LABELS: Record = { @@ -223,9 +235,9 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { name: rule.name, pipelineId: rule.pipelineId ?? "", metric: rule.metric, - condition: rule.condition, - threshold: String(rule.threshold), - durationSeconds: String(rule.durationSeconds), + condition: rule.condition ?? "gt", + threshold: String(rule.threshold ?? ""), + durationSeconds: String(rule.durationSeconds ?? ""), channelIds: rule.channels?.map((c) => c.channelId) ?? [], }); setDialogOpen(true); @@ -321,11 +333,11 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { - {CONDITION_LABELS[rule.condition] ?? rule.condition} + {rule.condition ? (CONDITION_LABELS[rule.condition] ?? rule.condition) : "—"} - {rule.threshold} + {rule.threshold ?? "—"} - {rule.durationSeconds}s + {rule.durationSeconds != null ? `${rule.durationSeconds}s` : "—"} {rule.pipeline ? ( @@ -1675,8 +1687,8 @@ function AlertHistorySection({ environmentId }: { environmentId: string }) { id: string; name: string; metric: string; - condition: string; - threshold: number; + condition: string | null; + threshold: number | null; pipeline: { id: string; name: string } | null; }; }> diff --git a/src/app/api/agent/heartbeat/route.ts b/src/app/api/agent/heartbeat/route.ts index 1fb7ee26..76a85dfb 100644 --- a/src/app/api/agent/heartbeat/route.ts +++ b/src/app/api/agent/heartbeat/route.ts @@ -425,7 +425,7 @@ export async function POST(request: Request) { pipeline: pipeline?.name, metric: alert.rule.metric, value: alert.event.value, - threshold: alert.rule.threshold, + threshold: alert.rule.threshold ?? 0, message: alert.event.message ?? "", timestamp: alert.event.firedAt.toISOString(), dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, diff --git a/src/server/routers/alert.ts b/src/server/routers/alert.ts index 3a899930..c4057542 100644 --- a/src/server/routers/alert.ts +++ b/src/server/routers/alert.ts @@ -38,9 +38,9 @@ export const alertRouter = router({ environmentId: z.string(), pipelineId: z.string().optional(), metric: z.nativeEnum(AlertMetric), - condition: z.nativeEnum(AlertCondition), - threshold: z.number(), - durationSeconds: z.number().int().min(1).default(60), + condition: z.nativeEnum(AlertCondition).nullable().optional(), + threshold: z.number().nullable().optional(), + durationSeconds: z.number().int().min(1).nullable().optional(), teamId: z.string(), channelIds: z.array(z.string()).optional(), }), diff --git a/src/server/services/alert-evaluator.ts b/src/server/services/alert-evaluator.ts index 9eabb110..6d55027d 100644 --- a/src/server/services/alert-evaluator.ts +++ b/src/server/services/alert-evaluator.ts @@ -237,6 +237,9 @@ export async function evaluateAlerts( const results: FiredAlertEvent[] = []; for (const rule of rules) { + // Skip event-based rules — they fire inline, not via polling + if (!rule.condition || rule.threshold == null) continue; + const value = await readMetricValue( rule.metric, nodeId, @@ -265,7 +268,7 @@ export async function evaluateAlerts( const elapsedSeconds = (now.getTime() - firstSeen.getTime()) / 1000; // Only fire if the condition has persisted for the required duration - if (elapsedSeconds >= rule.durationSeconds) { + if (elapsedSeconds >= (rule.durationSeconds ?? 0)) { // Check if there is already an open (firing) event for this rule const existingEvent = await prisma.alertEvent.findFirst({ where: { @@ -333,6 +336,16 @@ const METRIC_LABELS: Record = { error_rate: "Error rate", discarded_rate: "Discarded event rate", pipeline_crashed: "Pipeline crashed", + deploy_requested: "Deploy requested", + deploy_completed: "Deploy completed", + deploy_rejected: "Deploy rejected", + deploy_cancelled: "Deploy cancelled", + new_version_available: "New version available", + scim_sync_failed: "SCIM sync failed", + backup_failed: "Backup failed", + certificate_expiring: "Certificate expiring", + node_joined: "Node joined", + node_left: "Node left", }; const CONDITION_LABELS: Record = { @@ -343,6 +356,9 @@ const CONDITION_LABELS: Record = { function buildMessage(rule: AlertRule, value: number): string { const metricLabel = METRIC_LABELS[rule.metric] ?? rule.metric; + if (!rule.condition || rule.threshold == null) { + return `${metricLabel} event fired`; + } const condLabel = CONDITION_LABELS[rule.condition] ?? rule.condition; return `${metricLabel} is ${value.toFixed(2)} (threshold: ${condLabel} ${rule.threshold})`; } From 083ee15e05127d7393ae3220b76843b55dc138fb Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:03:21 +0000 Subject: [PATCH 09/19] feat: add fireEventAlert helper for event-based alerting --- src/server/services/event-alerts.ts | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/server/services/event-alerts.ts diff --git a/src/server/services/event-alerts.ts b/src/server/services/event-alerts.ts new file mode 100644 index 00000000..13381bb1 --- /dev/null +++ b/src/server/services/event-alerts.ts @@ -0,0 +1,125 @@ +import { prisma } from "@/lib/prisma"; +import type { AlertMetric } from "@/generated/prisma"; +import { deliverToChannels } from "@/server/services/channels"; +import { deliverWebhooks } from "@/server/services/webhook-delivery"; + +// --------------------------------------------------------------------------- +// Event-based alert metrics +// --------------------------------------------------------------------------- + +/** The set of AlertMetric values that fire on occurrence rather than polling. */ +export const EVENT_METRICS = new Set([ + "deploy_requested", + "deploy_completed", + "deploy_rejected", + "deploy_cancelled", + "new_version_available", + "scim_sync_failed", + "backup_failed", + "certificate_expiring", + "node_joined", + "node_left", +]); + +/** Returns true if the given metric is event-based (fires inline). */ +export function isEventMetric(metric: AlertMetric): boolean { + return EVENT_METRICS.has(metric); +} + +// --------------------------------------------------------------------------- +// Fire an event-based alert +// --------------------------------------------------------------------------- + +/** + * Fire an event-based alert inline at the source of the event. + * + * Queries active AlertRule entries matching the metric and environment, + * creates AlertEvent records, and delivers notifications through the + * configured channels. + * + * Errors are logged but never thrown — alert failures must not break the + * calling operation. + */ +export async function fireEventAlert( + metric: AlertMetric, + environmentId: string, + metadata: { + message: string; + nodeId?: string; + pipelineId?: string; + [key: string]: unknown; + }, +): Promise { + try { + // 1. Query active AlertRule entries matching the metric + environment + const rules = await prisma.alertRule.findMany({ + where: { + environmentId, + metric, + enabled: true, + ...(metadata.pipelineId + ? { + OR: [ + { pipelineId: metadata.pipelineId as string }, + { pipelineId: null }, + ], + } + : {}), + }, + include: { + pipeline: { select: { name: true } }, + environment: { + select: { name: true, team: { select: { name: true } } }, + }, + }, + }); + + if (rules.length === 0) return; + + for (const rule of rules) { + // 2. Create an AlertEvent record + const event = await prisma.alertEvent.create({ + data: { + alertRuleId: rule.id, + nodeId: (metadata.nodeId as string) ?? null, + status: "firing", + value: 0, + message: metadata.message, + }, + }); + + // 3. Build the channel payload + const payload = { + alertId: event.id, + status: "firing" as const, + ruleName: rule.name, + severity: "warning", + environment: rule.environment.name, + team: rule.environment.team?.name, + node: (metadata.nodeId as string) ?? undefined, + pipeline: rule.pipeline?.name ?? undefined, + metric: rule.metric, + value: 0, + threshold: rule.threshold ?? 0, + message: metadata.message, + timestamp: event.firedAt.toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; + + // 4. Deliver to legacy webhooks and notification channels + await deliverWebhooks(rule.environmentId, payload); + await deliverToChannels(rule.environmentId, rule.id, payload); + + // 5. Update the AlertEvent with notifiedAt timestamp + await prisma.alertEvent.update({ + where: { id: event.id }, + data: { notifiedAt: new Date() }, + }); + } + } catch (err) { + console.error( + `fireEventAlert error (metric=${metric}, env=${environmentId}):`, + err, + ); + } +} From 09abb66d4ba409524c7226999525d1ea4bdb73f6 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:04:34 +0000 Subject: [PATCH 10/19] feat: deploy dialog shows approved requests with deploy/cancel buttons --- src/components/flow/deploy-dialog.tsx | 211 ++++++++++++++++++++------ src/server/routers/deploy.ts | 4 +- 2 files changed, 167 insertions(+), 48 deletions(-) diff --git a/src/components/flow/deploy-dialog.tsx b/src/components/flow/deploy-dialog.tsx index ddb7c19a..d6cfa7dd 100644 --- a/src/components/flow/deploy-dialog.tsx +++ b/src/components/flow/deploy-dialog.tsx @@ -6,7 +6,7 @@ import { useTRPC } from "@/trpc/client"; import { useTeamStore } from "@/stores/team-store"; import { useSession } from "next-auth/react"; import { toast } from "sonner"; -import { Rocket, CheckCircle, XCircle, Loader2, Radio, ChevronsUpDown, Check, X, ShieldCheck, ShieldX, Clock, AlertTriangle } from "lucide-react"; +import { Rocket, CheckCircle, CheckCircle2, XCircle, Loader2, Radio, ChevronsUpDown, Check, X, ShieldCheck, ShieldX, Clock, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Command, @@ -174,7 +174,9 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro trpc.deploy.approveDeployRequest.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries(); - toast.success("Deploy request approved and deployed"); + toast.success("Deploy request approved", { + description: "The request is now ready to be deployed.", + }); onOpenChange(false); }, onError: (err) => { @@ -197,6 +199,41 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro }) ); + const executeMutation = useMutation( + trpc.deploy.executeApprovedRequest.mutationOptions({ + onSuccess: (result) => { + queryClient.invalidateQueries(); + if (!result.success) { + const errorMsg = ("validationErrors" in result && result.validationErrors) + ? result.validationErrors.map((e: { message: string }) => e.message).join("; ") + : ("error" in result ? result.error : "Unknown error"); + toast.error("Deploy failed", { description: errorMsg as string }); + return; + } + toast.success("Pipeline published to agents", { + description: "versionNumber" in result && result.versionNumber ? `Version v${result.versionNumber}` : undefined, + }); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Deploy failed", { description: err.message }); + }, + }) + ); + + const cancelMutation = useMutation( + trpc.deploy.cancelDeployRequest.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries(); + toast.success("Deploy request cancelled"); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Cancel failed", { description: err.message }); + }, + }) + ); + const env = envQuery.data; const preview = previewQuery.data; const userRole = roleQuery.data?.role; @@ -206,15 +243,18 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro const isAdmin = userRole === "ADMIN"; const requiresApproval = env?.requireDeployApproval && userRole === "EDITOR"; const adminBypassingApproval = env?.requireDeployApproval && isAdmin; - const pendingRequests = pendingRequestsQuery.data ?? []; + const allRequests = pendingRequestsQuery.data ?? []; + const pendingRequests = allRequests.filter((r) => r.status === "PENDING"); + const approvedRequests = allRequests.filter((r) => r.status === "APPROVED"); const pendingRequest = pendingRequests[0]; + const approvedRequest = approvedRequests[0]; const isOwnRequest = pendingRequest?.requestedById === session?.user?.id; const canReview = !!pendingRequest && !isOwnRequest && (userRole === "EDITOR" || isAdmin); const isReviewMode = canReview; - const pendingRequestTimeAgo = useMemo(() => { - if (!pendingRequest) return ""; - const d = new Date(pendingRequest.createdAt); + const formatRelativeTime = (date: Date | string | null | undefined): string => { + if (!date) return ""; + const d = typeof date === "string" ? new Date(date) : date; const seconds = Math.floor((new Date().getTime() - d.getTime()) / 1000); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); @@ -223,6 +263,11 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; + }; + + const pendingRequestTimeAgo = useMemo(() => { + if (!pendingRequest) return ""; + return formatRelativeTime(pendingRequest.createdAt); }, [pendingRequest]); function handleDeploy() { @@ -244,6 +289,11 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro Review Deploy Request + ) : approvedRequest ? ( + <> + + Deploy Approved Request + ) : ( <> @@ -254,9 +304,11 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro {isReviewMode ? "Review and approve or reject this deploy request." - : requiresApproval - ? "This environment requires admin approval for deployments." - : "Review and deploy to your environment." + : approvedRequest + ? "This request has been approved and is ready to deploy." + : requiresApproval + ? "This environment requires admin approval for deployments." + : "Review and deploy to your environment." } @@ -322,6 +374,62 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro />
+ ) : approvedRequest ? ( + /* Approved request — ready to deploy */ +
+
+
+ + + Approved request from {approvedRequest.requestedBy?.name ?? approvedRequest.requestedBy?.email ?? "Unknown"} + +
+

+ Approved by {approvedRequest.reviewedBy?.name ?? approvedRequest.reviewedBy?.email ?? "Unknown"} · {formatRelativeTime(approvedRequest.reviewedAt)} +

+

{approvedRequest.changelog}

+ {approvedRequest.configYaml && preview && ( +
+ + View config + +
+ +
+
+ )} +
+ + +
+
+
) : ( /* Normal deploy / request mode */
@@ -512,48 +620,57 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro
- - {isReviewMode && pendingRequest ? ( - /* Review mode buttons */ + {approvedRequest && !isReviewMode ? ( + /* Approved request — action buttons are inline above */ + + ) : ( <> - - - - ) : ( - /* Normal deploy / request button */ - + + ) : ( - <>Publish to Agents + /* Normal deploy / request button */ + )} - + )} diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 25a1bc48..5386a585 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -300,12 +300,13 @@ export const deployRouter = router({ .input(z.object({ environmentId: z.string().optional(), pipelineId: z.string().optional(), + statuses: z.array(z.string()).optional().default(["PENDING", "APPROVED"]), })) .use(withTeamAccess("VIEWER")) .query(async ({ input, ctx }) => { const teamId = (ctx as Record).teamId as string | null ?? null; const where: Record = { - status: "PENDING", + status: { in: input.statuses }, ...(input.environmentId && { environmentId: input.environmentId }), ...(input.pipelineId && { pipelineId: input.pipelineId }), environment: { teamId }, @@ -331,6 +332,7 @@ export const deployRouter = router({ // configYaml included for editors/admins who can review configYaml: canReview, requestedBy: { select: { name: true, email: true } }, + reviewedBy: { select: { name: true, email: true } }, pipeline: { select: { name: true } }, }, orderBy: { createdAt: "desc" }, From 44928e59e02aabd0e02ad7ab6743ea6bb325ab75 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:11:06 +0000 Subject: [PATCH 11/19] feat: split settings page into sub-routes --- .../_components/audit-shipping-section.tsx | 174 + .../settings/_components/auth-settings.tsx | 610 +++ .../settings/_components/backup-settings.tsx | 395 ++ .../settings/_components/fleet-settings.tsx | 149 + .../settings/_components/scim-settings.tsx | 283 ++ .../settings/_components/team-settings.tsx | 860 ++++ .../settings/_components/teams-management.tsx | 231 ++ .../settings/_components/users-settings.tsx | 813 ++++ .../_components/version-check-section.tsx | 170 + .../settings/audit-shipping/page.tsx | 7 + src/app/(dashboard)/settings/auth/page.tsx | 7 + src/app/(dashboard)/settings/backup/page.tsx | 7 + src/app/(dashboard)/settings/fleet/page.tsx | 7 + src/app/(dashboard)/settings/layout.tsx | 3 + src/app/(dashboard)/settings/page.tsx | 3529 +---------------- src/app/(dashboard)/settings/scim/page.tsx | 7 + src/app/(dashboard)/settings/team/page.tsx | 7 + src/app/(dashboard)/settings/teams/page.tsx | 7 + src/app/(dashboard)/settings/users/page.tsx | 7 + src/app/(dashboard)/settings/version/page.tsx | 7 + 20 files changed, 3758 insertions(+), 3522 deletions(-) create mode 100644 src/app/(dashboard)/settings/_components/audit-shipping-section.tsx create mode 100644 src/app/(dashboard)/settings/_components/auth-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/backup-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/fleet-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/scim-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/team-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/teams-management.tsx create mode 100644 src/app/(dashboard)/settings/_components/users-settings.tsx create mode 100644 src/app/(dashboard)/settings/_components/version-check-section.tsx create mode 100644 src/app/(dashboard)/settings/audit-shipping/page.tsx create mode 100644 src/app/(dashboard)/settings/auth/page.tsx create mode 100644 src/app/(dashboard)/settings/backup/page.tsx create mode 100644 src/app/(dashboard)/settings/fleet/page.tsx create mode 100644 src/app/(dashboard)/settings/layout.tsx create mode 100644 src/app/(dashboard)/settings/scim/page.tsx create mode 100644 src/app/(dashboard)/settings/team/page.tsx create mode 100644 src/app/(dashboard)/settings/teams/page.tsx create mode 100644 src/app/(dashboard)/settings/users/page.tsx create mode 100644 src/app/(dashboard)/settings/version/page.tsx diff --git a/src/app/(dashboard)/settings/_components/audit-shipping-section.tsx b/src/app/(dashboard)/settings/_components/audit-shipping-section.tsx new file mode 100644 index 00000000..943a0d6b --- /dev/null +++ b/src/app/(dashboard)/settings/_components/audit-shipping-section.tsx @@ -0,0 +1,174 @@ +"use client"; + +import Link from "next/link"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { Loader2, CheckCircle2, XCircle, ExternalLink } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; + +// ─── Audit Log Shipping Section ───────────────────────────────────────────── + +export function AuditLogShippingSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const systemPipelineQuery = useQuery( + trpc.pipeline.getSystemPipeline.queryOptions(), + ); + + const createSystemPipelineMutation = useMutation( + trpc.pipeline.createSystemPipeline.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.pipeline.getSystemPipeline.queryKey(), + }); + toast.success("Audit log shipping pipeline created"); + }, + onError: (error) => { + if (error.message?.includes("already exists")) { + queryClient.invalidateQueries({ + queryKey: trpc.pipeline.getSystemPipeline.queryKey(), + }); + } else { + toast.error(error.message || "Failed to create system pipeline"); + } + }, + }), + ); + + const undeployMutation = useMutation( + trpc.deploy.undeploy.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.pipeline.getSystemPipeline.queryKey(), + }); + toast.success("Audit log shipping disabled"); + }, + onError: (error) => { + toast.error(error.message || "Failed to disable audit log shipping"); + }, + }), + ); + + const deployMutation = useMutation( + trpc.deploy.agent.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.pipeline.getSystemPipeline.queryKey(), + }); + toast.success("Audit log shipping enabled"); + }, + onError: (error: { message?: string }) => { + toast.error(error.message || "Failed to enable audit log shipping"); + }, + }), + ); + + const systemPipeline = systemPipelineQuery.data; + const isLoading = systemPipelineQuery.isLoading; + const isDeployed = systemPipeline && !systemPipeline.isDraft && systemPipeline.deployedAt; + const isToggling = undeployMutation.isPending || deployMutation.isPending; + + return ( + + +
+
+ Audit Log Shipping + + Ship audit logs to external destinations via Vector. Configure + transforms and sinks in the pipeline editor. + +
+ {!isLoading && systemPipeline && ( + + {isDeployed ? ( + <> + + Active + + ) : ( + <> + + Disabled + + )} + + )} +
+
+ + {isLoading ? ( + + ) : systemPipeline ? ( +
+ + Audit log shipping is {isDeployed ? "active" : "configured but disabled"}. + + +
+ { + if (checked) { + deployMutation.mutate({ pipelineId: systemPipeline.id, changelog: "Enabled system pipeline from settings" }); + } else { + undeployMutation.mutate({ pipelineId: systemPipeline.id }); + } + }} + disabled={isToggling} + /> + + {isToggling ? (isDeployed ? "Disabling..." : "Enabling...") : (isDeployed ? "Active" : "Disabled")} + +
+
+ ) : ( +
+ + Audit log shipping is not configured. + + +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/auth-settings.tsx b/src/app/(dashboard)/settings/_components/auth-settings.tsx new file mode 100644 index 00000000..cb908562 --- /dev/null +++ b/src/app/(dashboard)/settings/_components/auth-settings.tsx @@ -0,0 +1,610 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { + Loader2, + CheckCircle2, + XCircle, + Trash2, + Plus, + X, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScimSettings } from "./scim-settings"; + +// ─── Auth Tab ────────────────────────────────────────────────────────────────── + +export function AuthSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const settingsQuery = useQuery(trpc.settings.get.queryOptions()); + const settings = settingsQuery.data; + + const hasLoadedRef = useRef(false); + const [isDirty, setIsDirty] = useState(false); + const markDirty = useCallback(() => setIsDirty(true), []); + + const [issuer, setIssuer] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [displayName, setDisplayName] = useState("SSO"); + const [tokenAuthMethod, setTokenAuthMethod] = useState<"client_secret_post" | "client_secret_basic">("client_secret_post"); + + useEffect(() => { + if (!settings) return; + if (hasLoadedRef.current && isDirty) return; // Don't overwrite dirty state on refetch + hasLoadedRef.current = true; + setIssuer(settings.oidcIssuer ?? ""); + setClientId(settings.oidcClientId ?? ""); + setDisplayName(settings.oidcDisplayName ?? "SSO"); + setTokenAuthMethod((settings.oidcTokenEndpointAuthMethod as "client_secret_post" | "client_secret_basic") ?? "client_secret_post"); + // Don't populate clientSecret - it's masked + }, [settings, isDirty]); + + const updateOidcMutation = useMutation( + // eslint-disable-next-line react-hooks/refs + trpc.settings.updateOidc.mutationOptions({ + onSuccess: () => { + setIsDirty(false); + hasLoadedRef.current = false; // Allow next sync from server + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("OIDC settings saved successfully"); + setClientSecret(""); + }, + onError: (error) => { + toast.error(error.message || "Failed to save OIDC settings"); + }, + }) + ); + + const testOidcMutation = useMutation( + trpc.settings.testOidc.mutationOptions({ + onSuccess: (data) => { + toast.success(`OIDC connection successful. Issuer: ${data.issuer}`); + }, + onError: (error) => { + toast.error(error.message || "OIDC connection test failed"); + }, + }) + ); + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + if (!clientSecret && !settings?.oidcClientSecret) { + toast.error("Client secret is required"); + return; + } + updateOidcMutation.mutate({ + issuer, + clientId, + clientSecret: clientSecret || "unchanged", + displayName, + tokenEndpointAuthMethod: tokenAuthMethod, + }); + }; + + const handleTest = () => { + if (!issuer) { + toast.error("Please enter an issuer URL first"); + return; + } + testOidcMutation.mutate({ issuer }); + }; + + const [teamMappings, setTeamMappings] = useState>([]); + + function mergeMappings( + flat: Array<{ group: string; teamId: string; role: string }> + ): Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> { + const map = new Map(); + for (const m of flat) { + const key = `${m.group}::${m.role}`; + const existing = map.get(key); + if (existing) { + existing.teamIds.push(m.teamId); + } else { + map.set(key, { group: m.group, teamIds: [m.teamId], role: m.role as "VIEWER" | "EDITOR" | "ADMIN" }); + } + } + return [...map.values()]; + } + + function flattenMappings( + grouped: Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> + ): Array<{ group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN" }> { + return grouped.flatMap((row) => + row.teamIds.map((teamId) => ({ group: row.group, teamId, role: row.role })) + ); + } + + const [defaultTeamId, setDefaultTeamId] = useState(""); + const [defaultRole, setDefaultRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); + const [groupSyncEnabled, setGroupSyncEnabled] = useState(false); + const [groupsScope, setGroupsScope] = useState("groups"); + const [groupsClaim, setGroupsClaim] = useState("groups"); + + const teamsQuery = useQuery(trpc.admin.listTeams.queryOptions()); + + useEffect(() => { + if (!settings) return; + if (hasLoadedRef.current && isDirty) return; // Don't overwrite dirty state on refetch + setDefaultRole((settings.oidcDefaultRole as "VIEWER" | "EDITOR" | "ADMIN") ?? "VIEWER"); + setGroupSyncEnabled(settings.oidcGroupSyncEnabled ?? false); + setGroupsScope(settings.oidcGroupsScope ?? ""); + setGroupsClaim(settings.oidcGroupsClaim ?? "groups"); + setTeamMappings( + mergeMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: string}>) + ); + setDefaultTeamId(settings.oidcDefaultTeamId ?? ""); + }, [settings, isDirty]); + + const updateTeamMappingMutation = useMutation( + // eslint-disable-next-line react-hooks/refs + trpc.settings.updateOidcTeamMappings.mutationOptions({ + onSuccess: () => { + setIsDirty(false); + hasLoadedRef.current = false; // Allow next sync from server + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("OIDC team mapping saved"); + }, + onError: (error) => { + toast.error(error.message || "Failed to save team mapping"); + }, + }) + ); + + function addMapping() { + markDirty(); + setTeamMappings([...teamMappings, { group: "", teamIds: [], role: "VIEWER" }]); + } + + function removeMapping(index: number) { + markDirty(); + setTeamMappings(teamMappings.filter((_, i) => i !== index)); + } + + function updateMapping(index: number, field: "group" | "role", value: string) { + markDirty(); + setTeamMappings(teamMappings.map((m, i) => + i === index ? { ...m, [field]: value } : m + )); + } + + function updateMappingTeams(index: number, teamIds: string[]) { + markDirty(); + setTeamMappings(teamMappings.map((m, i) => + i === index ? { ...m, teamIds } : m + )); + } + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) e.preventDefault(); + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + if (settingsQuery.isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + OIDC / SSO Configuration + + Configure an OpenID Connect provider to enable single sign-on for your + team. + + + +
+
+ + { markDirty(); setIssuer(e.target.value); }} + required + /> +

+ The OIDC issuer URL (must support .well-known/openid-configuration) +

+
+ +
+ + { markDirty(); setClientId(e.target.value); }} + required + /> +
+ +
+ + { markDirty(); setClientSecret(e.target.value); }} + required={!settings?.oidcClientSecret} + /> +

+ {settings?.oidcClientSecret + ? "Leave blank to keep the existing secret, or enter a new one to replace it." + : "The client secret from your OIDC provider."} +

+
+ +
+ + { markDirty(); setDisplayName(e.target.value); }} + required + /> +

+ The label shown on the login button (e.g., "Sign in with + Okta") +

+
+ +
+ + +

+ How the client secret is sent to the token endpoint. Most providers use client_secret_post. +

+
+ + + +
+ + +
+ +
+
+ + + + IdP Group Mappings + + Map identity provider groups to teams and roles. Used by both OIDC login (via groups claim) and SCIM sync (via group membership). + + + +
{ + e.preventDefault(); + updateTeamMappingMutation.mutate({ + mappings: flattenMappings(teamMappings).filter((m) => m.group && m.teamId), + defaultTeamId: defaultTeamId || undefined, + defaultRole, + groupSyncEnabled, + groupsScope, + groupsClaim, + }); + }} className="space-y-6"> +
+
+ +

+ Request group claims from your OIDC provider and sync team memberships +

+
+ +
+ + {groupSyncEnabled && (<> +
+
+ + { setGroupsScope(e.target.value); }} + /> +

+ Extra scope to request. Leave empty if your provider includes groups automatically (e.g., Azure AD, Cognito). +

+
+
+ + { setGroupsClaim(e.target.value); }} + required + /> +

+ Token claim containing group names (e.g., "groups", "cognito:groups") +

+
+
+ +
+ + {teamMappings.length > 0 && ( + + + + Group Name + Team + Role + + + + + {teamMappings.map((mapping, index) => ( + + + updateMapping(index, "group", e.target.value)} + placeholder="e.g., vectorflow-admins" + /> + + + + + + + +
+ {(teamsQuery.data ?? []).map((team) => { + const checked = mapping.teamIds.includes(team.id); + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ))} +
+
+ )} + + {teamMappings.length === 0 && ( +

+ No mappings configured. SSO users will be assigned to the default team with the default role. +

+ )} +
+ + + +
+
+ + +

+ Fallback team for users who don't match any group mapping +

+
+
+ + +
+
+ + )} + + +
+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/backup-settings.tsx b/src/app/(dashboard)/settings/_components/backup-settings.tsx new file mode 100644 index 00000000..cc5f503a --- /dev/null +++ b/src/app/(dashboard)/settings/_components/backup-settings.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { + Loader2, + Trash2, + Download, + AlertTriangle, + Clock, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { ConfirmDialog } from "@/components/confirm-dialog"; + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + const diffMs = now - d.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ─── Backup Settings ──────────────────────────────────────────────────────────── + +export function BackupSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const settingsQuery = useQuery(trpc.settings.get.queryOptions()); + const backupsQuery = useQuery(trpc.settings.listBackups.queryOptions()); + + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [scheduleCron, setScheduleCron] = useState("0 2 * * *"); + const [retentionCount, setRetentionCount] = useState(7); + const [restoreTarget, setRestoreTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + useEffect(() => { + if (settingsQuery.data) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setScheduleEnabled(settingsQuery.data.backupEnabled ?? false); + setScheduleCron(settingsQuery.data.backupCron ?? "0 2 * * *"); + setRetentionCount(settingsQuery.data.backupRetentionCount ?? 7); + } + }, [settingsQuery.data]); + + const createBackupMutation = useMutation( + trpc.settings.createBackup.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.settings.listBackups.queryKey() }); + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("Backup created successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to create backup"); + }, + }), + ); + + const deleteBackupMutation = useMutation( + trpc.settings.deleteBackup.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.settings.listBackups.queryKey() }); + setDeleteTarget(null); + toast.success("Backup deleted"); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete backup"); + }, + }), + ); + + const restoreBackupMutation = useMutation( + trpc.settings.restoreBackup.mutationOptions({ + onSuccess: () => { + setRestoreTarget(null); + toast.success("Backup restored successfully. Please restart the application."); + }, + onError: (error) => { + toast.error(error.message || "Failed to restore backup"); + }, + }), + ); + + const updateScheduleMutation = useMutation( + trpc.settings.updateBackupSchedule.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("Backup schedule updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update backup schedule"); + }, + }), + ); + + return ( +
+ {/* Backup Schedule */} + + + + + Backup Schedule + + + Configure automatic database backups on a schedule. + + + +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + {/* Failed Backup Alert */} + {settingsQuery.data?.lastBackupStatus === "failed" && ( + + + +
+

Last backup failed

+

+ {settingsQuery.data.lastBackupError || "Unknown error"} —{" "} + {formatRelativeTime(settingsQuery.data.lastBackupAt)} +

+
+
+
+ )} + + {/* Manual Backup */} + + + Manual Backup + + Create an on-demand backup of the database. + + + + + {settingsQuery.data?.lastBackupAt && ( +

+ Last backup: {formatRelativeTime(settingsQuery.data.lastBackupAt)} + {settingsQuery.data.lastBackupStatus && ( + <> — {settingsQuery.data.lastBackupStatus} + )} + {settingsQuery.data.lastBackupError && ( + ({settingsQuery.data.lastBackupError}) + )} +

+ )} +
+
+ + {/* Available Backups */} + + + Available Backups + + Manage existing database backups. You can restore or delete them. + + + + {backupsQuery.isLoading ? ( +
+ + +
+ ) : !backupsQuery.data?.length ? ( +

No backups found.

+ ) : ( + + + + Date + Size + Version + Migrations + Actions + + + + {backupsQuery.data.map((backup) => ( + + + {new Date(backup.timestamp).toLocaleString()} + + {formatBytes(backup.sizeBytes)} + + {backup.version} + + {backup.migrationCount} + +
+ + + +
+
+
+ ))} +
+
+ )} +
+
+ + {/* Warning Banner */} + + + +
+

Important

+

+ Database backups do not include your .env file or + encryption secrets. Make sure to keep those backed up separately in + a secure location. +

+
+
+
+ + {/* Restore Confirmation Dialog */} + { + if (!open) setRestoreTarget(null); + }} + title="Restore from backup?" + description="This will overwrite the current database with the selected backup. This action cannot be undone. The application should be restarted after restoring." + confirmLabel="Restore" + variant="destructive" + isPending={restoreBackupMutation.isPending} + onConfirm={() => { + if (restoreTarget) { + restoreBackupMutation.mutate({ filename: restoreTarget }); + } + }} + /> + + {/* Delete Confirmation Dialog */} + { + if (!open) setDeleteTarget(null); + }} + title="Delete backup?" + description="This will permanently delete the selected backup file. This action cannot be undone." + confirmLabel="Delete" + variant="destructive" + isPending={deleteBackupMutation.isPending} + onConfirm={() => { + if (deleteTarget) { + deleteBackupMutation.mutate({ filename: deleteTarget }); + } + }} + /> +
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/fleet-settings.tsx b/src/app/(dashboard)/settings/_components/fleet-settings.tsx new file mode 100644 index 00000000..c4b53656 --- /dev/null +++ b/src/app/(dashboard)/settings/_components/fleet-settings.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +// ─── Fleet Tab ───────────────────────────────────────────────────────────────── + +export function FleetSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const settingsQuery = useQuery(trpc.settings.get.queryOptions()); + const settings = settingsQuery.data; + + const [pollIntervalSec, setPollIntervalSec] = useState(15); + const [unhealthyThreshold, setUnhealthyThreshold] = useState(3); + const [metricsRetentionDays, setMetricsRetentionDays] = useState(7); + const [fleetDirty, setFleetDirty] = useState(false); + + useEffect(() => { + if (!settings) return; + if (fleetDirty) return; // Don't overwrite dirty state on refetch + // eslint-disable-next-line react-hooks/set-state-in-effect + setPollIntervalSec(Math.round(settings.fleetPollIntervalMs / 1000)); + setUnhealthyThreshold(settings.fleetUnhealthyThreshold); + if (settings.metricsRetentionDays) setMetricsRetentionDays(settings.metricsRetentionDays); + }, [settings, fleetDirty]); + + const updateFleetMutation = useMutation( + trpc.settings.updateFleet.mutationOptions({ + onSuccess: () => { + setFleetDirty(false); + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("Fleet settings saved successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to save fleet settings"); + }, + }) + ); + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + updateFleetMutation.mutate({ + pollIntervalMs: pollIntervalSec * 1000, + unhealthyThreshold, + metricsRetentionDays, + }); + }; + + if (settingsQuery.isLoading) { + return ( +
+ + +
+ ); + } + + return ( + + + Fleet Polling Configuration + + Configure how frequently VectorFlow polls fleet nodes for health status + updates. + + + +
+
+ + { setFleetDirty(true); setPollIntervalSec(Number(e.target.value)); }} + required + /> +

+ How often to check node health (1-300 seconds) +

+
+ +
+ + { setFleetDirty(true); setUnhealthyThreshold(Number(e.target.value)); }} + required + /> +

+ Number of consecutive failed polls before marking a node as + unhealthy +

+
+ +
+ + { setFleetDirty(true); setMetricsRetentionDays(Number(e.target.value)); }} + required + /> +

+ How long to keep pipeline metrics data (1-365 days) +

+
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/scim-settings.tsx b/src/app/(dashboard)/settings/_components/scim-settings.tsx new file mode 100644 index 00000000..d58b26ab --- /dev/null +++ b/src/app/(dashboard)/settings/_components/scim-settings.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Loader2, + CheckCircle2, + XCircle, + KeyRound, + Copy, + AlertTriangle, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// ─── SCIM Provisioning Section ────────────────────────────────────────────── + +export function ScimSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const settingsQuery = useQuery(trpc.settings.get.queryOptions()); + const settings = settingsQuery.data; + + const [tokenDialogOpen, setTokenDialogOpen] = useState(false); + const [generatedToken, setGeneratedToken] = useState(""); + const [copied, setCopied] = useState(false); + + const updateScimMutation = useMutation( + trpc.settings.updateScim.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + toast.success("SCIM settings updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update SCIM settings"); + }, + }) + ); + + const generateTokenMutation = useMutation( + trpc.settings.generateScimToken.mutationOptions({ + onSuccess: (data) => { + setGeneratedToken(data.token); + setTokenDialogOpen(true); + setCopied(false); + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + }, + onError: (error) => { + toast.error(error.message || "Failed to generate SCIM token"); + }, + }) + ); + + const handleCopyToken = async () => { + await copyToClipboard(generatedToken); + setCopied(true); + toast.success("Token copied to clipboard"); + }; + + const scimBaseUrl = typeof window !== "undefined" + ? `${window.location.origin}/api/scim/v2` + : "/api/scim/v2"; + + if (settingsQuery.isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+
+ SCIM Provisioning + + Enable SCIM 2.0 to automatically provision and deprovision users + from your identity provider (Okta, Entra ID, etc.). + +
+ + {settings?.scimEnabled ? ( + <> + + Enabled + + ) : ( + <> + + Disabled + + )} + +
+
+ +
+
+ +

+ Allow your identity provider to manage users and groups via SCIM 2.0 +

+
+ updateScimMutation.mutate({ enabled: checked })} + disabled={updateScimMutation.isPending} + /> +
+ + + +
+ +
+ + {scimBaseUrl} + + +
+

+ Enter this URL in your identity provider's SCIM configuration +

+
+ +
+ +
+ + {settings?.scimTokenConfigured ? ( + <> + + Token configured + + ) : ( + "No token configured" + )} + + +
+

+ {settings?.scimTokenConfigured + ? "Generating a new token will invalidate the previous one. Update your identity provider after regenerating." + : "Generate a bearer token and configure it in your identity provider."} +

+
+ + + +
+ +
+

Quick setup instructions:

+
    +
  1. In your IdP (Okta, Entra ID, etc.), navigate to SCIM provisioning settings
  2. +
  3. Set the SCIM connector base URL to the URL shown above
  4. +
  5. Set the authentication mode to "HTTP Header" / "Bearer Token"
  6. +
  7. Paste the generated bearer token
  8. +
  9. Enable provisioning actions: Create Users, Update User Attributes, Deactivate Users
  10. +
  11. Test the connection from your IdP and assign users/groups
  12. +
+
+
+
+
+ + {/* Token display dialog -- shown once after generation */} + { + if (!open) { + setGeneratedToken(""); + setCopied(false); + } + setTokenDialogOpen(open); + }}> + + + SCIM Bearer Token Generated + + Copy this token now. It will not be shown again. Configure it in + your identity provider's SCIM settings. + + +
+
+ + {generatedToken} + + +
+
+ + + This token will not be shown again. Make sure to save it before closing this dialog. + +
+
+ + + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/team-settings.tsx b/src/app/(dashboard)/settings/_components/team-settings.tsx new file mode 100644 index 00000000..b379ddbb --- /dev/null +++ b/src/app/(dashboard)/settings/_components/team-settings.tsx @@ -0,0 +1,860 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Shield, + Loader2, + Trash2, + Lock, + Unlock, + KeyRound, + Copy, + Plus, + X, + Link2, + Info, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// ─── Team Tab ────────────────────────────────────────────────────────────────── + +export function TeamSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const selectedTeamId = useTeamStore((s) => s.selectedTeamId); + + const teamQuery = useQuery( + trpc.team.get.queryOptions( + { id: selectedTeamId! }, + { enabled: !!selectedTeamId } + ) + ); + + const team = teamQuery.data; + + const [teamName, setTeamName] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">( + "VIEWER" + ); + const [newTag, setNewTag] = useState(""); + + const updateRoleMutation = useMutation( + trpc.team.updateMemberRole.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("Member role updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update role"); + }, + }) + ); + + const removeMemberMutation = useMutation( + trpc.team.removeMember.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("Member removed"); + }, + onError: (error) => { + toast.error(error.message || "Failed to remove member"); + }, + }) + ); + + const addMemberMutation = useMutation( + trpc.team.addMember.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("Member added"); + setInviteEmail(""); + setInviteRole("VIEWER"); + }, + onError: (error) => { + toast.error(error.message || "Failed to add member"); + }, + }) + ); + + const settingsQuery = useQuery(trpc.settings.get.queryOptions()); + const oidcConfigured = !!(settingsQuery.data?.oidcIssuer && settingsQuery.data?.oidcClientId); + + const [resetPasswordOpen, setResetPasswordOpen] = useState(false); + const [tempPassword, setTempPassword] = useState(""); + const [resetPasswordConfirm, setResetPasswordConfirm] = useState<{ userId: string; name: string } | null>(null); + const [lockConfirm, setLockConfirm] = useState<{ userId: string; name: string; action: "lock" | "unlock" } | null>(null); + const [removeMember, setRemoveMember] = useState<{ userId: string; name: string } | null>(null); + const [linkToOidcConfirm, setLinkToOidcConfirm] = useState<{ userId: string; name: string } | null>(null); + + const linkToOidcMutation = useMutation( + trpc.team.linkMemberToOidc.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); + toast.success("User linked to SSO"); + setLinkToOidcConfirm(null); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const lockMutation = useMutation( + trpc.team.lockMember.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); + toast.success("User locked"); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const unlockMutation = useMutation( + trpc.team.unlockMember.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); + toast.success("User unlocked"); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const resetPasswordMutation = useMutation( + trpc.team.resetMemberPassword.mutationOptions({ + onSuccess: (data) => { + setTempPassword(data.temporaryPassword); + setResetPasswordOpen(true); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const renameMutation = useMutation( + trpc.team.rename.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); + toast.success("Team renamed"); + }, + onError: (error) => { + toast.error(error.message || "Failed to rename team"); + }, + }) + ); + + const requireTwoFactorMutation = useMutation( + trpc.team.updateRequireTwoFactor.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("2FA requirement updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update 2FA requirement"); + }, + }) + ); + + // Environments for default environment dropdown + const environmentsQuery = useQuery( + trpc.environment.list.queryOptions( + { teamId: selectedTeamId! }, + { enabled: !!selectedTeamId } + ) + ); + const environments = environmentsQuery.data ?? []; + + const updateDefaultEnvMutation = useMutation( + trpc.team.updateDefaultEnvironment.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); + toast.success("Default environment updated"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update default environment"); + }, + }) + ); + + // Data classification tags + const availableTagsQuery = useQuery( + trpc.team.getAvailableTags.queryOptions( + { teamId: selectedTeamId! }, + { enabled: !!selectedTeamId }, + ), + ); + const availableTags = availableTagsQuery.data ?? []; + const tagsQueryKey = trpc.team.getAvailableTags.queryKey({ teamId: selectedTeamId! }); + + const updateTagsMutation = useMutation( + trpc.team.updateAvailableTags.mutationOptions({ + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: tagsQueryKey }); + const previous = queryClient.getQueryData(tagsQueryKey); + const previousInput = newTag; + queryClient.setQueryData(tagsQueryKey, variables.tags); + setNewTag(""); + return { previous, previousInput }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(tagsQueryKey, context.previous); + } + if (context?.previousInput !== undefined) { + setNewTag(context.previousInput); + } + toast.error(error.message || "Failed to update tags"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: tagsQueryKey }); + }, + onSuccess: () => { + toast.success("Tags updated"); + }, + }), + ); + + const handleAddTag = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTag.trim(); + if (!selectedTeamId || !trimmed) return; + if (availableTags.includes(trimmed)) { + toast.error("Tag already exists"); + return; + } + updateTagsMutation.mutate({ + teamId: selectedTeamId, + tags: [...availableTags, trimmed], + }); + }; + + const handleRemoveTag = (tag: string) => { + if (!selectedTeamId) return; + updateTagsMutation.mutate({ + teamId: selectedTeamId, + tags: availableTags.filter((t) => t !== tag), + }); + }; + + const handleRename = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedTeamId || !teamName.trim()) return; + renameMutation.mutate({ teamId: selectedTeamId, name: teamName.trim() }); + }; + + // Sync team name state when data loads + useEffect(() => { + if (team?.name && !teamName) setTeamName(team.name); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [team?.name]); + + const handleInvite = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedTeamId || !inviteEmail) return; + addMemberMutation.mutate({ + teamId: selectedTeamId, + email: inviteEmail, + role: inviteRole, + }); + }; + + if (teamQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (!team) { + return ( + + +

No team found

+
+
+ ); + } + + return ( +
+ + + Organization + + Manage your team name and settings. + + + +
+
+ + setTeamName(e.target.value)} + placeholder="Team name" + /> +
+ +
+ +
+ +

+ Fallback environment for team members who haven't set a personal default. +

+ +
+
+
+ + + + + + Security + + + Security settings for {team.name}. + + + +
+
+ +

+ Members without 2FA enabled will be prompted to set it up on login. +

+
+ { + if (selectedTeamId) { + requireTwoFactorMutation.mutate({ + teamId: selectedTeamId, + requireTwoFactor: checked, + }); + } + }} + disabled={requireTwoFactorMutation.isPending} + /> +
+
+
+ + + + Team Members + + Manage members and their roles for {team.name}. + + + + + + + Name + Email + Role + 2FA + Status + Actions + + + + {team.members.map((member) => ( + + + {member.user.name || "Unnamed"} + + +
+ {member.user.email} + {member.user.authMethod === "LOCAL" && ( + Local + )} + {member.user.authMethod === "OIDC" && ( + SSO + )} +
+
+ + {(member.user.authMethod === "OIDC" || member.user.scimExternalId) ? ( +
+ + {member.role} + + + + +
+ ) : ( + + )} +
+ + {member.user.authMethod === "OIDC" ? ( + N/A + ) : member.user.totpEnabled ? ( + + + Enabled + + ) : ( + + )} + + + {member.user.lockedAt ? ( + + + Locked + + ) : ( + + Active + + )} + + +
+ {member.user.lockedAt ? ( + + ) : ( + + )} + {member.user.authMethod !== "OIDC" && ( + + )} + {member.user.authMethod === "LOCAL" && oidcConfigured && ( + + )} + +
+
+
+ ))} +
+
+
+
+ + {/* Reset Password Confirmation Dialog */} + { + if (!open) { + setResetPasswordConfirm(null); + } + }}> + + {resetPasswordOpen && tempPassword ? ( + <> + + Temporary Password + + Share this temporary password with the user. They will be required to change it on next login. + + +
+ + +
+ + + + + ) : ( + <> + + Reset password? + + This will generate a new temporary password for {resetPasswordConfirm?.name}. They will be required to change it on next login. + + + + + + + + )} +
+
+ + {/* Lock/Unlock Confirmation Dialog */} + !open && setLockConfirm(null)}> + + + {lockConfirm?.action === "lock" ? "Lock user?" : "Unlock user?"} + + {lockConfirm?.action === "lock" + ? <>{lockConfirm?.name} will be unable to log in until unlocked. + : <>{lockConfirm?.name} will be able to log in again.} + + + + + + + + + + {/* Link to SSO Confirmation Dialog */} + !open && setLinkToOidcConfirm(null)}> + + + Link to SSO? + + This will convert {linkToOidcConfirm?.name} from local authentication to SSO. This action: + + +
    +
  • Removes their password — they can no longer log in with email/password
  • +
  • Disables their TOTP 2FA — the SSO provider handles MFA
  • +
  • Requires them to log in via SSO going forward
  • +
+ + + + +
+
+ + !open && setRemoveMember(null)}> + + + Remove team member? + + This will remove {removeMember?.name} from the team. They will lose access to all environments and pipelines. + + + + + + + + + + + + Add Member + + Add an existing user to the team by their email address. + + + + {(settingsQuery.data?.scimEnabled || settingsQuery.data?.oidcGroupSyncEnabled) && ( +
+ + SSO users are managed by your identity provider. Only local users can be added manually. +
+ )} +
+
+ + setInviteEmail(e.target.value)} + required + /> +
+
+ + +
+ +
+
+
+ + + + Data Classification Tags + + Define classification tags that can be applied to pipelines in this team (e.g., PII, PHI, PCI-DSS). + + + +
+ {availableTags.length === 0 && ( + No tags defined yet. + )} + {availableTags.map((tag) => ( + + {tag} + + + ))} +
+
+
+ + setNewTag(e.target.value)} + placeholder="e.g., PII, Internal, PCI-DSS" + maxLength={30} + /> +
+ +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/teams-management.tsx b/src/app/(dashboard)/settings/_components/teams-management.tsx new file mode 100644 index 00000000..56c2cde7 --- /dev/null +++ b/src/app/(dashboard)/settings/_components/teams-management.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useTeamStore } from "@/stores/team-store"; +import { toast } from "sonner"; +import { Loader2, Trash2, Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// ─── Teams Management (Super Admin) ───────────────────────────────────────────── + +export function TeamsManagement() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const teamsQuery = useQuery(trpc.team.list.queryOptions()); + + const createMutation = useMutation( + trpc.team.create.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); + toast.success("Team created"); + setCreateOpen(false); + setNewTeamName(""); + }, + onError: (error) => { + toast.error(error.message || "Failed to create team"); + }, + }) + ); + + const deleteMutation = useMutation( + trpc.team.delete.mutationOptions({ + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); + toast.success("Team deleted"); + const selectedTeamId = useTeamStore.getState().selectedTeamId; + if (selectedTeamId === variables.teamId) { + useTeamStore.getState().setSelectedTeamId(null); + } + setDeleteTeam(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete team"); + }, + }) + ); + + const [createOpen, setCreateOpen] = useState(false); + const [newTeamName, setNewTeamName] = useState(""); + const [deleteTeam, setDeleteTeam] = useState<{ id: string; name: string } | null>(null); + + if (teamsQuery.isLoading) { + return ( +
+ + +
+ ); + } + + const teams = teamsQuery.data ?? []; + + return ( +
+ + +
+
+ Teams + + Manage all teams on the platform. Create new teams or remove unused ones. + +
+ +
+
+ + + + + Name + Members + Environments + Created + Actions + + + + {teams.map((team) => ( + + {team.name} + {team._count.members} + {team._count.environments} + + {new Date(team.createdAt).toLocaleDateString()} + + + + + + ))} + +
+
+
+ + {/* Create Team Dialog */} + + + + Create Team + + Create a new team. You will be added as an admin. + + +
+
+ + setNewTeamName(e.target.value)} + /> +
+
+ + + + +
+
+ + {/* Delete Team Confirmation Dialog */} + !open && setDeleteTeam(null)}> + + + Delete team? + + Are you sure you want to delete {deleteTeam?.name}? This will permanently delete the team, its members, and templates. This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/users-settings.tsx b/src/app/(dashboard)/settings/_components/users-settings.tsx new file mode 100644 index 00000000..e52ab38e --- /dev/null +++ b/src/app/(dashboard)/settings/_components/users-settings.tsx @@ -0,0 +1,813 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { copyToClipboard } from "@/lib/utils"; +import { toast } from "sonner"; +import { + Shield, + Loader2, + Trash2, + Lock, + Unlock, + KeyRound, + Copy, + UserPlus, + Crown, + Plus, + X, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +// ─── Users Tab (Super Admin) ──────────────────────────────────────────────────── + +export function UsersSettings() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const usersQuery = useQuery(trpc.admin.listUsers.queryOptions()); + const teamsQuery = useQuery(trpc.admin.listTeams.queryOptions()); + + const [assignDialog, setAssignDialog] = useState<{ userId: string; userName: string } | null>(null); + const [assignTeamId, setAssignTeamId] = useState(""); + const [assignRole, setAssignRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); + const [deleteDialog, setDeleteDialog] = useState<{ userId: string; userName: string } | null>(null); + const [removeFromTeamConfirm, setRemoveFromTeamConfirm] = useState<{ userId: string; userName: string; teamId: string; teamName: string } | null>(null); + const [toggleSuperAdminConfirm, setToggleSuperAdminConfirm] = useState<{ userId: string; userName: string; isSuperAdmin: boolean } | null>(null); + const [createUserOpen, setCreateUserOpen] = useState(false); + const [newUserEmail, setNewUserEmail] = useState(""); + const [newUserName, setNewUserName] = useState(""); + const [newUserTeamId, setNewUserTeamId] = useState(""); + const [newUserRole, setNewUserRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); + const [showCreatedPassword, setShowCreatedPassword] = useState(false); + const [createdPassword, setCreatedPassword] = useState(""); + + const assignMutation = useMutation( + trpc.admin.assignToTeam.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User assigned to team"); + setAssignDialog(null); + setAssignTeamId(""); + setAssignRole("VIEWER"); + }, + onError: (error) => { + toast.error(error.message || "Failed to assign user to team"); + }, + }) + ); + + const toggleSuperAdminMutation = useMutation( + trpc.admin.toggleSuperAdmin.mutationOptions({ + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success( + data.isSuperAdmin ? "User promoted to super admin" : "Super admin status removed" + ); + setToggleSuperAdminConfirm(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to toggle super admin status"); + }, + }) + ); + + const deleteUserMutation = useMutation( + trpc.admin.deleteUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User deleted"); + setDeleteDialog(null); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete user"); + }, + }) + ); + + const createUserMutation = useMutation( + trpc.admin.createUser.mutationOptions({ + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User created"); + setCreatedPassword(data.generatedPassword); + setShowCreatedPassword(true); + setCreateUserOpen(false); + setNewUserEmail(""); + setNewUserName(""); + setNewUserTeamId(""); + setNewUserRole("VIEWER"); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const removeFromTeamMutation = useMutation( + trpc.admin.removeFromTeam.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User removed from team"); + setRemoveFromTeamConfirm(null); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const lockUserMutation = useMutation( + trpc.admin.lockUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User locked"); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const unlockUserMutation = useMutation( + trpc.admin.unlockUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + toast.success("User unlocked"); + }, + onError: (error) => toast.error(error.message), + }) + ); + + const [resetPasswordDialog, setResetPasswordDialog] = useState<{ userId: string; userName: string } | null>(null); + const [resetPasswordResult, setResetPasswordResult] = useState(""); + const [lockDialog, setLockDialog] = useState<{ userId: string; userName: string; action: "lock" | "unlock" } | null>(null); + + const resetPasswordMutation = useMutation( + trpc.admin.resetPassword.mutationOptions({ + onSuccess: (data) => { + setResetPasswordResult(data.temporaryPassword); + }, + onError: (error) => toast.error(error.message), + }) + ); + + if (usersQuery.isLoading) { + return ( +
+ + +
+ ); + } + + const users = usersQuery.data ?? []; + + return ( +
+ + +
+ Platform Users + Manage all users across the platform. +
+ +
+ + + + + Name + Email + Auth Method + Teams + Super Admin + 2FA + Status + Created + Actions + + + + {users.map((user) => ( + + + {user.name || "Unnamed"} + + {user.email} + + {user.authMethod === "LOCAL" && ( + Local + )} + {user.authMethod === "OIDC" && ( + SSO + )} + + +
+ {user.memberships.length === 0 && ( + No teams + )} + {user.memberships.length > 0 && ( + + + + + +

Team Memberships

+
+ {user.memberships.map((m) => ( +
+
+ {m.team.name} + + {m.role.charAt(0) + m.role.slice(1).toLowerCase()} + +
+ +
+ ))} +
+
+
+ )} +
+
+ + {user.isSuperAdmin ? ( + + + Yes + + ) : ( + + )} + + + {user.authMethod === "OIDC" ? ( + N/A + ) : user.totpEnabled ? ( + + + Enabled + + ) : ( + + )} + + + {user.lockedAt ? ( + + + Locked + + ) : ( + + Active + + )} + + + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + + {user.authMethod !== "OIDC" && ( + + )} + + +
+
+
+ ))} +
+
+
+
+ + {/* Assign to Team Dialog */} + { + if (!open) { + setAssignDialog(null); + setAssignTeamId(""); + setAssignRole("VIEWER"); + } + }}> + + + Assign to Team + + Assign {assignDialog?.userName} to a team with a specific role. + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + {/* Lock/Unlock Confirmation Dialog */} + !open && setLockDialog(null)}> + + + {lockDialog?.action === "lock" ? "Lock user?" : "Unlock user?"} + + {lockDialog?.action === "lock" + ? <>{lockDialog?.userName} will be unable to log in until unlocked. + : <>{lockDialog?.userName} will be able to log in again.} + + + + + + + + + + {/* Delete User Dialog */} + !open && setDeleteDialog(null)}> + + + Delete user? + + This will permanently delete {deleteDialog?.userName} and all their data. This action cannot be undone. + + + + + + + + + + {/* Create User Dialog */} + { + setCreateUserOpen(open); + if (!open) { + setNewUserEmail(""); + setNewUserName(""); + setNewUserTeamId(""); + setNewUserRole("VIEWER"); + } + }}> + + + Create User + + Create a new local user account. + + +
{ + e.preventDefault(); + createUserMutation.mutate({ + email: newUserEmail, + name: newUserName, + ...(newUserTeamId ? { teamId: newUserTeamId, role: newUserRole } : {}), + }); + }} className="space-y-4"> +
+ + setNewUserEmail(e.target.value)} + placeholder="user@example.com" + required + /> +
+
+ + setNewUserName(e.target.value)} + placeholder="Full name" + required + /> +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ + {/* Reset Password Confirmation Dialog */} + { + if (!open) { + if (resetPasswordResult) { + queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); + } + setResetPasswordDialog(null); + setResetPasswordResult(""); + } + }}> + + {resetPasswordResult ? ( + <> + + Temporary Password + + Share this temporary password with the user. They will be required to change it on first login. + + +
+ + +
+ + + + + ) : ( + <> + + Reset password? + + This will generate a new temporary password for {resetPasswordDialog?.userName}. They will be required to change it on next login. + + + + + + + + )} +
+
+ + {/* Password Display Dialog */} + + + + User Created + + Share this password with the user. It will only be shown once. + + +
+ + +
+ + + +
+
+ + {/* Remove from team confirmation */} + !open && setRemoveFromTeamConfirm(null)} + title="Remove from team?" + description={<>Remove {removeFromTeamConfirm?.userName} from {removeFromTeamConfirm?.teamName}? They will lose access to all environments and pipelines in this team.} + confirmLabel="Remove" + isPending={removeFromTeamMutation.isPending} + pendingLabel="Removing..." + onConfirm={() => { + if (!removeFromTeamConfirm) return; + removeFromTeamMutation.mutate({ userId: removeFromTeamConfirm.userId, teamId: removeFromTeamConfirm.teamId }); + }} + /> + + {/* Toggle super admin confirmation */} + !open && setToggleSuperAdminConfirm(null)} + title={toggleSuperAdminConfirm?.isSuperAdmin ? "Grant super admin?" : "Remove super admin?"} + description={toggleSuperAdminConfirm?.isSuperAdmin + ? <>{toggleSuperAdminConfirm?.userName} will get full platform access including all teams, user management, and system settings. + : <>{toggleSuperAdminConfirm?.userName} will lose platform-wide admin access and only see teams they are a member of. + } + confirmLabel={toggleSuperAdminConfirm?.isSuperAdmin ? "Grant" : "Remove"} + variant={toggleSuperAdminConfirm?.isSuperAdmin ? "default" : "destructive"} + isPending={toggleSuperAdminMutation.isPending} + onConfirm={() => { + if (!toggleSuperAdminConfirm) return; + toggleSuperAdminMutation.mutate({ userId: toggleSuperAdminConfirm.userId, isSuperAdmin: toggleSuperAdminConfirm.isSuperAdmin }); + }} + /> +
+ ); +} diff --git a/src/app/(dashboard)/settings/_components/version-check-section.tsx b/src/app/(dashboard)/settings/_components/version-check-section.tsx new file mode 100644 index 00000000..f5738086 --- /dev/null +++ b/src/app/(dashboard)/settings/_components/version-check-section.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; +import { Loader2, RefreshCw, ExternalLink } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; + +// ─── Relative Time Helper ─────────────────────────────────────────────────────── + +function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return "Never"; + const d = typeof date === "string" ? new Date(date) : date; + const now = Date.now(); + const diffMs = now - d.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "Just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +// ─── Version Check Section ────────────────────────────────────────────────────── + +export function VersionCheckSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [isChecking, setIsChecking] = useState(false); + + const versionQuery = useQuery( + trpc.settings.checkVersion.queryOptions(undefined, { + refetchInterval: false, + staleTime: Infinity, + }), + ); + + const handleCheckNow = async () => { + setIsChecking(true); + try { + await queryClient.fetchQuery( + trpc.settings.checkVersion.queryOptions({ force: true }, { staleTime: 0 }), + ); + // Invalidate to pick up the fresh data + await queryClient.invalidateQueries({ + queryKey: trpc.settings.checkVersion.queryKey(), + }); + } catch { + toast.error("Failed to check for updates"); + } finally { + setIsChecking(false); + } + }; + + const server = versionQuery.data?.server; + const agent = versionQuery.data?.agent; + + return ( + + +
+
+ Version Information + + Current and latest versions of VectorFlow components + +
+ +
+
+ + {versionQuery.isLoading ? ( +
+ + +
+ ) : ( +
+ {/* Server version */} +
+ Server version + {server?.currentVersion ?? "unknown"} + + Latest server +
+ + {server?.latestVersion ?? "unknown"} + + {server?.updateAvailable && ( + + Update available + + )} + {server?.latestVersion && + !server.updateAvailable && + server.currentVersion !== "dev" && ( + + Up to date + + )} + {server?.releaseUrl && ( + + Release notes + + + )} +
+
+ + + + {/* Agent version */} +
+ Latest agent + + {agent?.latestVersion ?? "unknown"} + +
+ + + + {/* Last checked */} +

+ Last checked: {formatRelativeTime(server?.checkedAt)} +

+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/audit-shipping/page.tsx b/src/app/(dashboard)/settings/audit-shipping/page.tsx new file mode 100644 index 00000000..bcbbe2bb --- /dev/null +++ b/src/app/(dashboard)/settings/audit-shipping/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { AuditLogShippingSection } from "../_components/audit-shipping-section"; + +export default function AuditShippingPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/auth/page.tsx b/src/app/(dashboard)/settings/auth/page.tsx new file mode 100644 index 00000000..9ebd2b1d --- /dev/null +++ b/src/app/(dashboard)/settings/auth/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { AuthSettings } from "../_components/auth-settings"; + +export default function AuthPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/backup/page.tsx b/src/app/(dashboard)/settings/backup/page.tsx new file mode 100644 index 00000000..95727087 --- /dev/null +++ b/src/app/(dashboard)/settings/backup/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { BackupSettings } from "../_components/backup-settings"; + +export default function BackupPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/fleet/page.tsx b/src/app/(dashboard)/settings/fleet/page.tsx new file mode 100644 index 00000000..a767ddaf --- /dev/null +++ b/src/app/(dashboard)/settings/fleet/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { FleetSettings } from "../_components/fleet-settings"; + +export default function FleetPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/layout.tsx b/src/app/(dashboard)/settings/layout.tsx new file mode 100644 index 00000000..31a2fe6e --- /dev/null +++ b/src/app/(dashboard)/settings/layout.tsx @@ -0,0 +1,3 @@ +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 1261cbf8..3540021e 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,3527 +1,12 @@ "use client"; -import Link from "next/link"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "@/trpc/client"; -import { useTeamStore } from "@/stores/team-store"; -import { copyToClipboard } from "@/lib/utils"; -import { toast } from "sonner"; -import { - Shield, - Server, - Users, - Loader2, - CheckCircle2, - XCircle, - Trash2, - Lock, - Unlock, - KeyRound, - Copy, - UserPlus, - Crown, - Layers, - Plus, - X, - RefreshCw, - ExternalLink, - HardDrive, - Download, - AlertTriangle, - Clock, - Link2, - Info, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Separator } from "@/components/ui/separator"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { ConfirmDialog } from "@/components/confirm-dialog"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { PageHeader } from "@/components/page-header"; -import { ServiceAccountsSettings } from "@/app/(dashboard)/settings/service-accounts/page"; - - -// ─── Relative Time Helper ─────────────────────────────────────────────────────── - -function formatRelativeTime(date: Date | string | null | undefined): string { - if (!date) return "Never"; - const d = typeof date === "string" ? new Date(date) : date; - const now = Date.now(); - const diffMs = now - d.getTime(); - const diffSec = Math.floor(diffMs / 1000); - if (diffSec < 60) return "Just now"; - const diffMin = Math.floor(diffSec / 60); - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - const diffDay = Math.floor(diffHr / 24); - return `${diffDay}d ago`; -} - -// ─── Version Check Section ────────────────────────────────────────────────────── - -function VersionCheckSection() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [isChecking, setIsChecking] = useState(false); - - const versionQuery = useQuery( - trpc.settings.checkVersion.queryOptions(undefined, { - refetchInterval: false, - staleTime: Infinity, - }), - ); - - const handleCheckNow = async () => { - setIsChecking(true); - try { - await queryClient.fetchQuery( - trpc.settings.checkVersion.queryOptions({ force: true }, { staleTime: 0 }), - ); - // Invalidate to pick up the fresh data - await queryClient.invalidateQueries({ - queryKey: trpc.settings.checkVersion.queryKey(), - }); - } catch { - toast.error("Failed to check for updates"); - } finally { - setIsChecking(false); - } - }; - - const server = versionQuery.data?.server; - const agent = versionQuery.data?.agent; - - return ( - - -
-
- Version Information - - Current and latest versions of VectorFlow components - -
- -
-
- - {versionQuery.isLoading ? ( -
- - -
- ) : ( -
- {/* Server version */} -
- Server version - {server?.currentVersion ?? "unknown"} - - Latest server -
- - {server?.latestVersion ?? "unknown"} - - {server?.updateAvailable && ( - - Update available - - )} - {server?.latestVersion && - !server.updateAvailable && - server.currentVersion !== "dev" && ( - - Up to date - - )} - {server?.releaseUrl && ( - - Release notes - - - )} -
-
- - - - {/* Agent version */} -
- Latest agent - - {agent?.latestVersion ?? "unknown"} - -
- - - - {/* Last checked */} -

- Last checked: {formatRelativeTime(server?.checkedAt)} -

-
- )} -
-
- ); -} - -// ─── Audit Log Shipping Section ───────────────────────────────────────────── - -function AuditLogShippingSection() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const systemPipelineQuery = useQuery( - trpc.pipeline.getSystemPipeline.queryOptions(), - ); - - const createSystemPipelineMutation = useMutation( - trpc.pipeline.createSystemPipeline.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.pipeline.getSystemPipeline.queryKey(), - }); - toast.success("Audit log shipping pipeline created"); - }, - onError: (error) => { - if (error.message?.includes("already exists")) { - queryClient.invalidateQueries({ - queryKey: trpc.pipeline.getSystemPipeline.queryKey(), - }); - } else { - toast.error(error.message || "Failed to create system pipeline"); - } - }, - }), - ); - - const undeployMutation = useMutation( - trpc.deploy.undeploy.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.pipeline.getSystemPipeline.queryKey(), - }); - toast.success("Audit log shipping disabled"); - }, - onError: (error) => { - toast.error(error.message || "Failed to disable audit log shipping"); - }, - }), - ); - - const deployMutation = useMutation( - trpc.deploy.agent.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.pipeline.getSystemPipeline.queryKey(), - }); - toast.success("Audit log shipping enabled"); - }, - onError: (error: { message?: string }) => { - toast.error(error.message || "Failed to enable audit log shipping"); - }, - }), - ); - - const systemPipeline = systemPipelineQuery.data; - const isLoading = systemPipelineQuery.isLoading; - const isDeployed = systemPipeline && !systemPipeline.isDraft && systemPipeline.deployedAt; - const isToggling = undeployMutation.isPending || deployMutation.isPending; - - return ( - - -
-
- Audit Log Shipping - - Ship audit logs to external destinations via Vector. Configure - transforms and sinks in the pipeline editor. - -
- {!isLoading && systemPipeline && ( - - {isDeployed ? ( - <> - - Active - - ) : ( - <> - - Disabled - - )} - - )} -
-
- - {isLoading ? ( - - ) : systemPipeline ? ( -
- - Audit log shipping is {isDeployed ? "active" : "configured but disabled"}. - - -
- { - if (checked) { - deployMutation.mutate({ pipelineId: systemPipeline.id, changelog: "Enabled system pipeline from settings" }); - } else { - undeployMutation.mutate({ pipelineId: systemPipeline.id }); - } - }} - disabled={isToggling} - /> - - {isToggling ? (isDeployed ? "Disabling..." : "Enabling...") : (isDeployed ? "Active" : "Disabled")} - -
-
- ) : ( -
- - Audit log shipping is not configured. - - -
- )} -
-
- ); -} - -// ─── Auth Tab ────────────────────────────────────────────────────────────────── - -function AuthSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const settingsQuery = useQuery(trpc.settings.get.queryOptions()); - const settings = settingsQuery.data; - - const hasLoadedRef = useRef(false); - const [isDirty, setIsDirty] = useState(false); - const markDirty = useCallback(() => setIsDirty(true), []); - - const [issuer, setIssuer] = useState(""); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [displayName, setDisplayName] = useState("SSO"); - const [tokenAuthMethod, setTokenAuthMethod] = useState<"client_secret_post" | "client_secret_basic">("client_secret_post"); - - useEffect(() => { - if (!settings) return; - if (hasLoadedRef.current && isDirty) return; // Don't overwrite dirty state on refetch - hasLoadedRef.current = true; - setIssuer(settings.oidcIssuer ?? ""); - setClientId(settings.oidcClientId ?? ""); - setDisplayName(settings.oidcDisplayName ?? "SSO"); - setTokenAuthMethod((settings.oidcTokenEndpointAuthMethod as "client_secret_post" | "client_secret_basic") ?? "client_secret_post"); - // Don't populate clientSecret - it's masked - }, [settings, isDirty]); - - const updateOidcMutation = useMutation( - // eslint-disable-next-line react-hooks/refs - trpc.settings.updateOidc.mutationOptions({ - onSuccess: () => { - setIsDirty(false); - hasLoadedRef.current = false; // Allow next sync from server - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("OIDC settings saved successfully"); - setClientSecret(""); - }, - onError: (error) => { - toast.error(error.message || "Failed to save OIDC settings"); - }, - }) - ); - - const testOidcMutation = useMutation( - trpc.settings.testOidc.mutationOptions({ - onSuccess: (data) => { - toast.success(`OIDC connection successful. Issuer: ${data.issuer}`); - }, - onError: (error) => { - toast.error(error.message || "OIDC connection test failed"); - }, - }) - ); - - const handleSave = (e: React.FormEvent) => { - e.preventDefault(); - if (!clientSecret && !settings?.oidcClientSecret) { - toast.error("Client secret is required"); - return; - } - updateOidcMutation.mutate({ - issuer, - clientId, - clientSecret: clientSecret || "unchanged", - displayName, - tokenEndpointAuthMethod: tokenAuthMethod, - }); - }; - - const handleTest = () => { - if (!issuer) { - toast.error("Please enter an issuer URL first"); - return; - } - testOidcMutation.mutate({ issuer }); - }; - - const [teamMappings, setTeamMappings] = useState>([]); - - function mergeMappings( - flat: Array<{ group: string; teamId: string; role: string }> - ): Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> { - const map = new Map(); - for (const m of flat) { - const key = `${m.group}::${m.role}`; - const existing = map.get(key); - if (existing) { - existing.teamIds.push(m.teamId); - } else { - map.set(key, { group: m.group, teamIds: [m.teamId], role: m.role as "VIEWER" | "EDITOR" | "ADMIN" }); - } - } - return [...map.values()]; - } - - function flattenMappings( - grouped: Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> - ): Array<{ group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN" }> { - return grouped.flatMap((row) => - row.teamIds.map((teamId) => ({ group: row.group, teamId, role: row.role })) - ); - } - - const [defaultTeamId, setDefaultTeamId] = useState(""); - const [defaultRole, setDefaultRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); - const [groupSyncEnabled, setGroupSyncEnabled] = useState(false); - const [groupsScope, setGroupsScope] = useState("groups"); - const [groupsClaim, setGroupsClaim] = useState("groups"); - - const teamsQuery = useQuery(trpc.admin.listTeams.queryOptions()); - - useEffect(() => { - if (!settings) return; - if (hasLoadedRef.current && isDirty) return; // Don't overwrite dirty state on refetch - setDefaultRole((settings.oidcDefaultRole as "VIEWER" | "EDITOR" | "ADMIN") ?? "VIEWER"); - setGroupSyncEnabled(settings.oidcGroupSyncEnabled ?? false); - setGroupsScope(settings.oidcGroupsScope ?? ""); - setGroupsClaim(settings.oidcGroupsClaim ?? "groups"); - setTeamMappings( - mergeMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: string}>) - ); - setDefaultTeamId(settings.oidcDefaultTeamId ?? ""); - }, [settings, isDirty]); - - const updateTeamMappingMutation = useMutation( - // eslint-disable-next-line react-hooks/refs - trpc.settings.updateOidcTeamMappings.mutationOptions({ - onSuccess: () => { - setIsDirty(false); - hasLoadedRef.current = false; // Allow next sync from server - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("OIDC team mapping saved"); - }, - onError: (error) => { - toast.error(error.message || "Failed to save team mapping"); - }, - }) - ); - - function addMapping() { - markDirty(); - setTeamMappings([...teamMappings, { group: "", teamIds: [], role: "VIEWER" }]); - } - - function removeMapping(index: number) { - markDirty(); - setTeamMappings(teamMappings.filter((_, i) => i !== index)); - } - - function updateMapping(index: number, field: "group" | "role", value: string) { - markDirty(); - setTeamMappings(teamMappings.map((m, i) => - i === index ? { ...m, [field]: value } : m - )); - } - - function updateMappingTeams(index: number, teamIds: string[]) { - markDirty(); - setTeamMappings(teamMappings.map((m, i) => - i === index ? { ...m, teamIds } : m - )); - } - - useEffect(() => { - const handler = (e: BeforeUnloadEvent) => { - if (isDirty) e.preventDefault(); - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, [isDirty]); - - if (settingsQuery.isLoading) { - return ( -
- - -
- ); - } - - return ( -
- - - OIDC / SSO Configuration - - Configure an OpenID Connect provider to enable single sign-on for your - team. - - - -
-
- - { markDirty(); setIssuer(e.target.value); }} - required - /> -

- The OIDC issuer URL (must support .well-known/openid-configuration) -

-
- -
- - { markDirty(); setClientId(e.target.value); }} - required - /> -
- -
- - { markDirty(); setClientSecret(e.target.value); }} - required={!settings?.oidcClientSecret} - /> -

- {settings?.oidcClientSecret - ? "Leave blank to keep the existing secret, or enter a new one to replace it." - : "The client secret from your OIDC provider."} -

-
- -
- - { markDirty(); setDisplayName(e.target.value); }} - required - /> -

- The label shown on the login button (e.g., "Sign in with - Okta") -

-
- -
- - -

- How the client secret is sent to the token endpoint. Most providers use client_secret_post. -

-
- - - -
- - -
- -
-
- - - - IdP Group Mappings - - Map identity provider groups to teams and roles. Used by both OIDC login (via groups claim) and SCIM sync (via group membership). - - - -
{ - e.preventDefault(); - updateTeamMappingMutation.mutate({ - mappings: flattenMappings(teamMappings).filter((m) => m.group && m.teamId), - defaultTeamId: defaultTeamId || undefined, - defaultRole, - groupSyncEnabled, - groupsScope, - groupsClaim, - }); - }} className="space-y-6"> -
-
- -

- Request group claims from your OIDC provider and sync team memberships -

-
- -
- - {groupSyncEnabled && (<> -
-
- - { setGroupsScope(e.target.value); }} - /> -

- Extra scope to request. Leave empty if your provider includes groups automatically (e.g., Azure AD, Cognito). -

-
-
- - { setGroupsClaim(e.target.value); }} - required - /> -

- Token claim containing group names (e.g., "groups", "cognito:groups") -

-
-
- -
- - {teamMappings.length > 0 && ( - - - - Group Name - Team - Role - - - - - {teamMappings.map((mapping, index) => ( - - - updateMapping(index, "group", e.target.value)} - placeholder="e.g., vectorflow-admins" - /> - - - - - - - -
- {(teamsQuery.data ?? []).map((team) => { - const checked = mapping.teamIds.includes(team.id); - return ( - - ); - })} -
-
-
-
- - - - - - -
- ))} -
-
- )} - - {teamMappings.length === 0 && ( -

- No mappings configured. SSO users will be assigned to the default team with the default role. -

- )} -
- - - -
-
- - -

- Fallback team for users who don't match any group mapping -

-
-
- - -
-
- - )} - - -
-
- - -
- ); -} - -// ─── Fleet Tab ───────────────────────────────────────────────────────────────── - -function FleetSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const settingsQuery = useQuery(trpc.settings.get.queryOptions()); - const settings = settingsQuery.data; - - const [pollIntervalSec, setPollIntervalSec] = useState(15); - const [unhealthyThreshold, setUnhealthyThreshold] = useState(3); - const [metricsRetentionDays, setMetricsRetentionDays] = useState(7); - const [fleetDirty, setFleetDirty] = useState(false); - - useEffect(() => { - if (!settings) return; - if (fleetDirty) return; // Don't overwrite dirty state on refetch - // eslint-disable-next-line react-hooks/set-state-in-effect - setPollIntervalSec(Math.round(settings.fleetPollIntervalMs / 1000)); - setUnhealthyThreshold(settings.fleetUnhealthyThreshold); - if (settings.metricsRetentionDays) setMetricsRetentionDays(settings.metricsRetentionDays); - }, [settings, fleetDirty]); - - const updateFleetMutation = useMutation( - trpc.settings.updateFleet.mutationOptions({ - onSuccess: () => { - setFleetDirty(false); - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("Fleet settings saved successfully"); - }, - onError: (error) => { - toast.error(error.message || "Failed to save fleet settings"); - }, - }) - ); - - const handleSave = (e: React.FormEvent) => { - e.preventDefault(); - updateFleetMutation.mutate({ - pollIntervalMs: pollIntervalSec * 1000, - unhealthyThreshold, - metricsRetentionDays, - }); - }; - - if (settingsQuery.isLoading) { - return ( -
- - -
- ); - } - - return ( - - - Fleet Polling Configuration - - Configure how frequently VectorFlow polls fleet nodes for health status - updates. - - - -
-
- - { setFleetDirty(true); setPollIntervalSec(Number(e.target.value)); }} - required - /> -

- How often to check node health (1-300 seconds) -

-
- -
- - { setFleetDirty(true); setUnhealthyThreshold(Number(e.target.value)); }} - required - /> -

- Number of consecutive failed polls before marking a node as - unhealthy -

-
- -
- - { setFleetDirty(true); setMetricsRetentionDays(Number(e.target.value)); }} - required - /> -

- How long to keep pipeline metrics data (1-365 days) -

-
- - -
-
-
- ); -} - -// ─── Team Tab ────────────────────────────────────────────────────────────────── - -function TeamSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const selectedTeamId = useTeamStore((s) => s.selectedTeamId); - - const teamQuery = useQuery( - trpc.team.get.queryOptions( - { id: selectedTeamId! }, - { enabled: !!selectedTeamId } - ) - ); - - const team = teamQuery.data; - - const [teamName, setTeamName] = useState(""); - const [inviteEmail, setInviteEmail] = useState(""); - const [inviteRole, setInviteRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">( - "VIEWER" - ); - const [newTag, setNewTag] = useState(""); - - const updateRoleMutation = useMutation( - trpc.team.updateMemberRole.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - toast.success("Member role updated"); - }, - onError: (error) => { - toast.error(error.message || "Failed to update role"); - }, - }) - ); - - const removeMemberMutation = useMutation( - trpc.team.removeMember.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - toast.success("Member removed"); - }, - onError: (error) => { - toast.error(error.message || "Failed to remove member"); - }, - }) - ); - - const addMemberMutation = useMutation( - trpc.team.addMember.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - toast.success("Member added"); - setInviteEmail(""); - setInviteRole("VIEWER"); - }, - onError: (error) => { - toast.error(error.message || "Failed to add member"); - }, - }) - ); - - const settingsQuery = useQuery(trpc.settings.get.queryOptions()); - const oidcConfigured = !!(settingsQuery.data?.oidcIssuer && settingsQuery.data?.oidcClientId); - - const [resetPasswordOpen, setResetPasswordOpen] = useState(false); - const [tempPassword, setTempPassword] = useState(""); - const [resetPasswordConfirm, setResetPasswordConfirm] = useState<{ userId: string; name: string } | null>(null); - const [lockConfirm, setLockConfirm] = useState<{ userId: string; name: string; action: "lock" | "unlock" } | null>(null); - const [removeMember, setRemoveMember] = useState<{ userId: string; name: string } | null>(null); - const [linkToOidcConfirm, setLinkToOidcConfirm] = useState<{ userId: string; name: string } | null>(null); - - const linkToOidcMutation = useMutation( - trpc.team.linkMemberToOidc.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); - toast.success("User linked to SSO"); - setLinkToOidcConfirm(null); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const lockMutation = useMutation( - trpc.team.lockMember.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); - toast.success("User locked"); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const unlockMutation = useMutation( - trpc.team.unlockMember.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey({ id: selectedTeamId! }) }); - toast.success("User unlocked"); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const resetPasswordMutation = useMutation( - trpc.team.resetMemberPassword.mutationOptions({ - onSuccess: (data) => { - setTempPassword(data.temporaryPassword); - setResetPasswordOpen(true); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const renameMutation = useMutation( - trpc.team.rename.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); - toast.success("Team renamed"); - }, - onError: (error) => { - toast.error(error.message || "Failed to rename team"); - }, - }) - ); - - const requireTwoFactorMutation = useMutation( - trpc.team.updateRequireTwoFactor.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - toast.success("2FA requirement updated"); - }, - onError: (error) => { - toast.error(error.message || "Failed to update 2FA requirement"); - }, - }) - ); - - // Environments for default environment dropdown - const environmentsQuery = useQuery( - trpc.environment.list.queryOptions( - { teamId: selectedTeamId! }, - { enabled: !!selectedTeamId } - ) - ); - const environments = environmentsQuery.data ?? []; - - const updateDefaultEnvMutation = useMutation( - trpc.team.updateDefaultEnvironment.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.get.queryKey() }); - toast.success("Default environment updated"); - }, - onError: (error) => { - toast.error(error.message || "Failed to update default environment"); - }, - }) - ); - - // Data classification tags - const availableTagsQuery = useQuery( - trpc.team.getAvailableTags.queryOptions( - { teamId: selectedTeamId! }, - { enabled: !!selectedTeamId }, - ), - ); - const availableTags = availableTagsQuery.data ?? []; - const tagsQueryKey = trpc.team.getAvailableTags.queryKey({ teamId: selectedTeamId! }); - - const updateTagsMutation = useMutation( - trpc.team.updateAvailableTags.mutationOptions({ - onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: tagsQueryKey }); - const previous = queryClient.getQueryData(tagsQueryKey); - const previousInput = newTag; - queryClient.setQueryData(tagsQueryKey, variables.tags); - setNewTag(""); - return { previous, previousInput }; - }, - onError: (error, _variables, context) => { - if (context?.previous !== undefined) { - queryClient.setQueryData(tagsQueryKey, context.previous); - } - if (context?.previousInput !== undefined) { - setNewTag(context.previousInput); - } - toast.error(error.message || "Failed to update tags"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tagsQueryKey }); - }, - onSuccess: () => { - toast.success("Tags updated"); - }, - }), - ); - - const handleAddTag = (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = newTag.trim(); - if (!selectedTeamId || !trimmed) return; - if (availableTags.includes(trimmed)) { - toast.error("Tag already exists"); - return; - } - updateTagsMutation.mutate({ - teamId: selectedTeamId, - tags: [...availableTags, trimmed], - }); - }; - - const handleRemoveTag = (tag: string) => { - if (!selectedTeamId) return; - updateTagsMutation.mutate({ - teamId: selectedTeamId, - tags: availableTags.filter((t) => t !== tag), - }); - }; - - const handleRename = (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedTeamId || !teamName.trim()) return; - renameMutation.mutate({ teamId: selectedTeamId, name: teamName.trim() }); - }; - - // Sync team name state when data loads - useEffect(() => { - if (team?.name && !teamName) setTeamName(team.name); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [team?.name]); - - const handleInvite = (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedTeamId || !inviteEmail) return; - addMemberMutation.mutate({ - teamId: selectedTeamId, - email: inviteEmail, - role: inviteRole, - }); - }; - - if (teamQuery.isLoading) { - return ( -
- - -
- ); - } - - if (!team) { - return ( - - -

No team found

-
-
- ); - } - - return ( -
- - - Organization - - Manage your team name and settings. - - - -
-
- - setTeamName(e.target.value)} - placeholder="Team name" - /> -
- -
- -
- -

- Fallback environment for team members who haven't set a personal default. -

- -
-
-
- - - - - - Security - - - Security settings for {team.name}. - - - -
-
- -

- Members without 2FA enabled will be prompted to set it up on login. -

-
- { - if (selectedTeamId) { - requireTwoFactorMutation.mutate({ - teamId: selectedTeamId, - requireTwoFactor: checked, - }); - } - }} - disabled={requireTwoFactorMutation.isPending} - /> -
-
-
- - - - Team Members - - Manage members and their roles for {team.name}. - - - - - - - Name - Email - Role - 2FA - Status - Actions - - - - {team.members.map((member) => ( - - - {member.user.name || "Unnamed"} - - -
- {member.user.email} - {member.user.authMethod === "LOCAL" && ( - Local - )} - {member.user.authMethod === "OIDC" && ( - SSO - )} -
-
- - {(member.user.authMethod === "OIDC" || member.user.scimExternalId) ? ( -
- - {member.role} - - - - -
- ) : ( - - )} -
- - {member.user.authMethod === "OIDC" ? ( - N/A - ) : member.user.totpEnabled ? ( - - - Enabled - - ) : ( - - )} - - - {member.user.lockedAt ? ( - - - Locked - - ) : ( - - Active - - )} - - -
- {member.user.lockedAt ? ( - - ) : ( - - )} - {member.user.authMethod !== "OIDC" && ( - - )} - {member.user.authMethod === "LOCAL" && oidcConfigured && ( - - )} - -
-
-
- ))} -
-
-
-
- - {/* Reset Password Confirmation Dialog */} - { - if (!open) { - setResetPasswordConfirm(null); - } - }}> - - {resetPasswordOpen && tempPassword ? ( - <> - - Temporary Password - - Share this temporary password with the user. They will be required to change it on next login. - - -
- - -
- - - - - ) : ( - <> - - Reset password? - - This will generate a new temporary password for {resetPasswordConfirm?.name}. They will be required to change it on next login. - - - - - - - - )} -
-
- - {/* Lock/Unlock Confirmation Dialog */} - !open && setLockConfirm(null)}> - - - {lockConfirm?.action === "lock" ? "Lock user?" : "Unlock user?"} - - {lockConfirm?.action === "lock" - ? <>{lockConfirm?.name} will be unable to log in until unlocked. - : <>{lockConfirm?.name} will be able to log in again.} - - - - - - - - - - {/* Link to SSO Confirmation Dialog */} - !open && setLinkToOidcConfirm(null)}> - - - Link to SSO? - - This will convert {linkToOidcConfirm?.name} from local authentication to SSO. This action: - - -
    -
  • Removes their password — they can no longer log in with email/password
  • -
  • Disables their TOTP 2FA — the SSO provider handles MFA
  • -
  • Requires them to log in via SSO going forward
  • -
- - - - -
-
- - !open && setRemoveMember(null)}> - - - Remove team member? - - This will remove {removeMember?.name} from the team. They will lose access to all environments and pipelines. - - - - - - - - - - - - Add Member - - Add an existing user to the team by their email address. - - - - {(settingsQuery.data?.scimEnabled || settingsQuery.data?.oidcGroupSyncEnabled) && ( -
- - SSO users are managed by your identity provider. Only local users can be added manually. -
- )} -
-
- - setInviteEmail(e.target.value)} - required - /> -
-
- - -
- -
-
-
- - - - Data Classification Tags - - Define classification tags that can be applied to pipelines in this team (e.g., PII, PHI, PCI-DSS). - - - -
- {availableTags.length === 0 && ( - No tags defined yet. - )} - {availableTags.map((tag) => ( - - {tag} - - - ))} -
-
-
- - setNewTag(e.target.value)} - placeholder="e.g., PII, Internal, PCI-DSS" - maxLength={30} - /> -
- -
-
-
-
- ); -} - -// ─── Users Tab (Super Admin) ──────────────────────────────────────────────────── - -function UsersSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const usersQuery = useQuery(trpc.admin.listUsers.queryOptions()); - const teamsQuery = useQuery(trpc.admin.listTeams.queryOptions()); - - const [assignDialog, setAssignDialog] = useState<{ userId: string; userName: string } | null>(null); - const [assignTeamId, setAssignTeamId] = useState(""); - const [assignRole, setAssignRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); - const [deleteDialog, setDeleteDialog] = useState<{ userId: string; userName: string } | null>(null); - const [removeFromTeamConfirm, setRemoveFromTeamConfirm] = useState<{ userId: string; userName: string; teamId: string; teamName: string } | null>(null); - const [toggleSuperAdminConfirm, setToggleSuperAdminConfirm] = useState<{ userId: string; userName: string; isSuperAdmin: boolean } | null>(null); - const [createUserOpen, setCreateUserOpen] = useState(false); - const [newUserEmail, setNewUserEmail] = useState(""); - const [newUserName, setNewUserName] = useState(""); - const [newUserTeamId, setNewUserTeamId] = useState(""); - const [newUserRole, setNewUserRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); - const [showCreatedPassword, setShowCreatedPassword] = useState(false); - const [createdPassword, setCreatedPassword] = useState(""); - - const assignMutation = useMutation( - trpc.admin.assignToTeam.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User assigned to team"); - setAssignDialog(null); - setAssignTeamId(""); - setAssignRole("VIEWER"); - }, - onError: (error) => { - toast.error(error.message || "Failed to assign user to team"); - }, - }) - ); - - const toggleSuperAdminMutation = useMutation( - trpc.admin.toggleSuperAdmin.mutationOptions({ - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success( - data.isSuperAdmin ? "User promoted to super admin" : "Super admin status removed" - ); - setToggleSuperAdminConfirm(null); - }, - onError: (error) => { - toast.error(error.message || "Failed to toggle super admin status"); - }, - }) - ); - - const deleteUserMutation = useMutation( - trpc.admin.deleteUser.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User deleted"); - setDeleteDialog(null); - }, - onError: (error) => { - toast.error(error.message || "Failed to delete user"); - }, - }) - ); - - const createUserMutation = useMutation( - trpc.admin.createUser.mutationOptions({ - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User created"); - setCreatedPassword(data.generatedPassword); - setShowCreatedPassword(true); - setCreateUserOpen(false); - setNewUserEmail(""); - setNewUserName(""); - setNewUserTeamId(""); - setNewUserRole("VIEWER"); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const removeFromTeamMutation = useMutation( - trpc.admin.removeFromTeam.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User removed from team"); - setRemoveFromTeamConfirm(null); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const lockUserMutation = useMutation( - trpc.admin.lockUser.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User locked"); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const unlockUserMutation = useMutation( - trpc.admin.unlockUser.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - toast.success("User unlocked"); - }, - onError: (error) => toast.error(error.message), - }) - ); - - const [resetPasswordDialog, setResetPasswordDialog] = useState<{ userId: string; userName: string } | null>(null); - const [resetPasswordResult, setResetPasswordResult] = useState(""); - const [lockDialog, setLockDialog] = useState<{ userId: string; userName: string; action: "lock" | "unlock" } | null>(null); - - const resetPasswordMutation = useMutation( - trpc.admin.resetPassword.mutationOptions({ - onSuccess: (data) => { - setResetPasswordResult(data.temporaryPassword); - }, - onError: (error) => toast.error(error.message), - }) - ); - - if (usersQuery.isLoading) { - return ( -
- - -
- ); - } - - const users = usersQuery.data ?? []; - - return ( -
- - -
- Platform Users - Manage all users across the platform. -
- -
- - - - - Name - Email - Auth Method - Teams - Super Admin - 2FA - Status - Created - Actions - - - - {users.map((user) => ( - - - {user.name || "Unnamed"} - - {user.email} - - {user.authMethod === "LOCAL" && ( - Local - )} - {user.authMethod === "OIDC" && ( - SSO - )} - - -
- {user.memberships.length === 0 && ( - No teams - )} - {user.memberships.length > 0 && ( - - - - - -

Team Memberships

-
- {user.memberships.map((m) => ( -
-
- {m.team.name} - - {m.role.charAt(0) + m.role.slice(1).toLowerCase()} - -
- -
- ))} -
-
-
- )} -
-
- - {user.isSuperAdmin ? ( - - - Yes - - ) : ( - - )} - - - {user.authMethod === "OIDC" ? ( - N/A - ) : user.totpEnabled ? ( - - - Enabled - - ) : ( - - )} - - - {user.lockedAt ? ( - - - Locked - - ) : ( - - Active - - )} - - - {new Date(user.createdAt).toLocaleDateString()} - - -
- - - {user.authMethod !== "OIDC" && ( - - )} - - -
-
-
- ))} -
-
-
-
- - {/* Assign to Team Dialog */} - { - if (!open) { - setAssignDialog(null); - setAssignTeamId(""); - setAssignRole("VIEWER"); - } - }}> - - - Assign to Team - - Assign {assignDialog?.userName} to a team with a specific role. - - -
-
- - -
-
- - -
-
- - - - -
-
- - {/* Lock/Unlock Confirmation Dialog */} - !open && setLockDialog(null)}> - - - {lockDialog?.action === "lock" ? "Lock user?" : "Unlock user?"} - - {lockDialog?.action === "lock" - ? <>{lockDialog?.userName} will be unable to log in until unlocked. - : <>{lockDialog?.userName} will be able to log in again.} - - - - - - - - - - {/* Delete User Dialog */} - !open && setDeleteDialog(null)}> - - - Delete user? - - This will permanently delete {deleteDialog?.userName} and all their data. This action cannot be undone. - - - - - - - - - - {/* Create User Dialog */} - { - setCreateUserOpen(open); - if (!open) { - setNewUserEmail(""); - setNewUserName(""); - setNewUserTeamId(""); - setNewUserRole("VIEWER"); - } - }}> - - - Create User - - Create a new local user account. - - -
{ - e.preventDefault(); - createUserMutation.mutate({ - email: newUserEmail, - name: newUserName, - ...(newUserTeamId ? { teamId: newUserTeamId, role: newUserRole } : {}), - }); - }} className="space-y-4"> -
- - setNewUserEmail(e.target.value)} - placeholder="user@example.com" - required - /> -
-
- - setNewUserName(e.target.value)} - placeholder="Full name" - required - /> -
-
-
- - -
-
- - -
-
- - - - -
-
-
- - {/* Reset Password Confirmation Dialog */} - { - if (!open) { - if (resetPasswordResult) { - queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() }); - } - setResetPasswordDialog(null); - setResetPasswordResult(""); - } - }}> - - {resetPasswordResult ? ( - <> - - Temporary Password - - Share this temporary password with the user. They will be required to change it on first login. - - -
- - -
- - - - - ) : ( - <> - - Reset password? - - This will generate a new temporary password for {resetPasswordDialog?.userName}. They will be required to change it on next login. - - - - - - - - )} -
-
- - {/* Password Display Dialog */} - - - - User Created - - Share this password with the user. It will only be shown once. - - -
- - -
- - - -
-
- - {/* Remove from team confirmation */} - !open && setRemoveFromTeamConfirm(null)} - title="Remove from team?" - description={<>Remove {removeFromTeamConfirm?.userName} from {removeFromTeamConfirm?.teamName}? They will lose access to all environments and pipelines in this team.} - confirmLabel="Remove" - isPending={removeFromTeamMutation.isPending} - pendingLabel="Removing..." - onConfirm={() => { - if (!removeFromTeamConfirm) return; - removeFromTeamMutation.mutate({ userId: removeFromTeamConfirm.userId, teamId: removeFromTeamConfirm.teamId }); - }} - /> - - {/* Toggle super admin confirmation */} - !open && setToggleSuperAdminConfirm(null)} - title={toggleSuperAdminConfirm?.isSuperAdmin ? "Grant super admin?" : "Remove super admin?"} - description={toggleSuperAdminConfirm?.isSuperAdmin - ? <>{toggleSuperAdminConfirm?.userName} will get full platform access including all teams, user management, and system settings. - : <>{toggleSuperAdminConfirm?.userName} will lose platform-wide admin access and only see teams they are a member of. - } - confirmLabel={toggleSuperAdminConfirm?.isSuperAdmin ? "Grant" : "Remove"} - variant={toggleSuperAdminConfirm?.isSuperAdmin ? "default" : "destructive"} - isPending={toggleSuperAdminMutation.isPending} - onConfirm={() => { - if (!toggleSuperAdminConfirm) return; - toggleSuperAdminMutation.mutate({ userId: toggleSuperAdminConfirm.userId, isSuperAdmin: toggleSuperAdminConfirm.isSuperAdmin }); - }} - /> -
- ); -} - -// ─── Teams Management (Super Admin) ───────────────────────────────────────────── - -function TeamsManagement() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const teamsQuery = useQuery(trpc.team.list.queryOptions()); - - const createMutation = useMutation( - trpc.team.create.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); - toast.success("Team created"); - setCreateOpen(false); - setNewTeamName(""); - }, - onError: (error) => { - toast.error(error.message || "Failed to create team"); - }, - }) - ); - - const deleteMutation = useMutation( - trpc.team.delete.mutationOptions({ - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: trpc.team.list.queryKey() }); - toast.success("Team deleted"); - const selectedTeamId = useTeamStore.getState().selectedTeamId; - if (selectedTeamId === variables.teamId) { - useTeamStore.getState().setSelectedTeamId(null); - } - setDeleteTeam(null); - }, - onError: (error) => { - toast.error(error.message || "Failed to delete team"); - }, - }) - ); - - const [createOpen, setCreateOpen] = useState(false); - const [newTeamName, setNewTeamName] = useState(""); - const [deleteTeam, setDeleteTeam] = useState<{ id: string; name: string } | null>(null); - - if (teamsQuery.isLoading) { - return ( -
- - -
- ); - } - - const teams = teamsQuery.data ?? []; - - return ( -
- - -
-
- Teams - - Manage all teams on the platform. Create new teams or remove unused ones. - -
- -
-
- - - - - Name - Members - Environments - Created - Actions - - - - {teams.map((team) => ( - - {team.name} - {team._count.members} - {team._count.environments} - - {new Date(team.createdAt).toLocaleDateString()} - - - - - - ))} - -
-
-
- - {/* Create Team Dialog */} - - - - Create Team - - Create a new team. You will be added as an admin. - - -
-
- - setNewTeamName(e.target.value)} - /> -
-
- - - - -
-
- - {/* Delete Team Confirmation Dialog */} - !open && setDeleteTeam(null)}> - - - Delete team? - - Are you sure you want to delete {deleteTeam?.name}? This will permanently delete the team, its members, and templates. This action cannot be undone. - - - - - - - - -
- ); -} - -// ─── Backup Settings ──────────────────────────────────────────────────────────── - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function BackupSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const settingsQuery = useQuery(trpc.settings.get.queryOptions()); - const backupsQuery = useQuery(trpc.settings.listBackups.queryOptions()); - - const [scheduleEnabled, setScheduleEnabled] = useState(false); - const [scheduleCron, setScheduleCron] = useState("0 2 * * *"); - const [retentionCount, setRetentionCount] = useState(7); - const [restoreTarget, setRestoreTarget] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - - useEffect(() => { - if (settingsQuery.data) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setScheduleEnabled(settingsQuery.data.backupEnabled ?? false); - setScheduleCron(settingsQuery.data.backupCron ?? "0 2 * * *"); - setRetentionCount(settingsQuery.data.backupRetentionCount ?? 7); - } - }, [settingsQuery.data]); - - const createBackupMutation = useMutation( - trpc.settings.createBackup.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.settings.listBackups.queryKey() }); - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("Backup created successfully"); - }, - onError: (error) => { - toast.error(error.message || "Failed to create backup"); - }, - }), - ); - - const deleteBackupMutation = useMutation( - trpc.settings.deleteBackup.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.settings.listBackups.queryKey() }); - setDeleteTarget(null); - toast.success("Backup deleted"); - }, - onError: (error) => { - toast.error(error.message || "Failed to delete backup"); - }, - }), - ); - - const restoreBackupMutation = useMutation( - trpc.settings.restoreBackup.mutationOptions({ - onSuccess: () => { - setRestoreTarget(null); - toast.success("Backup restored successfully. Please restart the application."); - }, - onError: (error) => { - toast.error(error.message || "Failed to restore backup"); - }, - }), - ); - - const updateScheduleMutation = useMutation( - trpc.settings.updateBackupSchedule.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("Backup schedule updated"); - }, - onError: (error) => { - toast.error(error.message || "Failed to update backup schedule"); - }, - }), - ); - - return ( -
- {/* Backup Schedule */} - - - - - Backup Schedule - - - Configure automatic database backups on a schedule. - - - -
- - -
- -
-
- - -
- -
- - -
-
- - -
-
- - {/* Failed Backup Alert */} - {settingsQuery.data?.lastBackupStatus === "failed" && ( - - - -
-

Last backup failed

-

- {settingsQuery.data.lastBackupError || "Unknown error"} —{" "} - {formatRelativeTime(settingsQuery.data.lastBackupAt)} -

-
-
-
- )} - - {/* Manual Backup */} - - - Manual Backup - - Create an on-demand backup of the database. - - - - - {settingsQuery.data?.lastBackupAt && ( -

- Last backup: {formatRelativeTime(settingsQuery.data.lastBackupAt)} - {settingsQuery.data.lastBackupStatus && ( - <> — {settingsQuery.data.lastBackupStatus} - )} - {settingsQuery.data.lastBackupError && ( - ({settingsQuery.data.lastBackupError}) - )} -

- )} -
-
- - {/* Available Backups */} - - - Available Backups - - Manage existing database backups. You can restore or delete them. - - - - {backupsQuery.isLoading ? ( -
- - -
- ) : !backupsQuery.data?.length ? ( -

No backups found.

- ) : ( - - - - Date - Size - Version - Migrations - Actions - - - - {backupsQuery.data.map((backup) => ( - - - {new Date(backup.timestamp).toLocaleString()} - - {formatBytes(backup.sizeBytes)} - - {backup.version} - - {backup.migrationCount} - -
- - - -
-
-
- ))} -
-
- )} -
-
- - {/* Warning Banner */} - - - -
-

Important

-

- Database backups do not include your .env file or - encryption secrets. Make sure to keep those backed up separately in - a secure location. -

-
-
-
- - {/* Restore Confirmation Dialog */} - { - if (!open) setRestoreTarget(null); - }} - title="Restore from backup?" - description="This will overwrite the current database with the selected backup. This action cannot be undone. The application should be restarted after restoring." - confirmLabel="Restore" - variant="destructive" - isPending={restoreBackupMutation.isPending} - onConfirm={() => { - if (restoreTarget) { - restoreBackupMutation.mutate({ filename: restoreTarget }); - } - }} - /> - - {/* Delete Confirmation Dialog */} - { - if (!open) setDeleteTarget(null); - }} - title="Delete backup?" - description="This will permanently delete the selected backup file. This action cannot be undone." - confirmLabel="Delete" - variant="destructive" - isPending={deleteBackupMutation.isPending} - onConfirm={() => { - if (deleteTarget) { - deleteBackupMutation.mutate({ filename: deleteTarget }); - } - }} - /> -
- ); -} - -// ─── SCIM Provisioning Section ────────────────────────────────────────────── - -function ScimSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const settingsQuery = useQuery(trpc.settings.get.queryOptions()); - const settings = settingsQuery.data; - - const [tokenDialogOpen, setTokenDialogOpen] = useState(false); - const [generatedToken, setGeneratedToken] = useState(""); - const [copied, setCopied] = useState(false); - - const updateScimMutation = useMutation( - trpc.settings.updateScim.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - toast.success("SCIM settings updated"); - }, - onError: (error) => { - toast.error(error.message || "Failed to update SCIM settings"); - }, - }) - ); - - const generateTokenMutation = useMutation( - trpc.settings.generateScimToken.mutationOptions({ - onSuccess: (data) => { - setGeneratedToken(data.token); - setTokenDialogOpen(true); - setCopied(false); - queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); - }, - onError: (error) => { - toast.error(error.message || "Failed to generate SCIM token"); - }, - }) - ); - - const handleCopyToken = async () => { - await copyToClipboard(generatedToken); - setCopied(true); - toast.success("Token copied to clipboard"); - }; - - const scimBaseUrl = typeof window !== "undefined" - ? `${window.location.origin}/api/scim/v2` - : "/api/scim/v2"; - - if (settingsQuery.isLoading) { - return ( -
- - -
- ); - } - - return ( -
- - -
-
- SCIM Provisioning - - Enable SCIM 2.0 to automatically provision and deprovision users - from your identity provider (Okta, Entra ID, etc.). - -
- - {settings?.scimEnabled ? ( - <> - - Enabled - - ) : ( - <> - - Disabled - - )} - -
-
- -
-
- -

- Allow your identity provider to manage users and groups via SCIM 2.0 -

-
- updateScimMutation.mutate({ enabled: checked })} - disabled={updateScimMutation.isPending} - /> -
- - - -
- -
- - {scimBaseUrl} - - -
-

- Enter this URL in your identity provider's SCIM configuration -

-
- -
- -
- - {settings?.scimTokenConfigured ? ( - <> - - Token configured - - ) : ( - "No token configured" - )} - - -
-

- {settings?.scimTokenConfigured - ? "Generating a new token will invalidate the previous one. Update your identity provider after regenerating." - : "Generate a bearer token and configure it in your identity provider."} -

-
- - - -
- -
-

Quick setup instructions:

-
    -
  1. In your IdP (Okta, Entra ID, etc.), navigate to SCIM provisioning settings
  2. -
  3. Set the SCIM connector base URL to the URL shown above
  4. -
  5. Set the authentication mode to "HTTP Header" / "Bearer Token"
  6. -
  7. Paste the generated bearer token
  8. -
  9. Enable provisioning actions: Create Users, Update User Attributes, Deactivate Users
  10. -
  11. Test the connection from your IdP and assign users/groups
  12. -
-
-
-
-
- - {/* Token display dialog -- shown once after generation */} - { - if (!open) { - setGeneratedToken(""); - setCopied(false); - } - setTokenDialogOpen(open); - }}> - - - SCIM Bearer Token Generated - - Copy this token now. It will not be shown again. Configure it in - your identity provider's SCIM settings. - - -
-
- - {generatedToken} - - -
-
- - - This token will not be shown again. Make sure to save it before closing this dialog. - -
-
- - - -
-
-
- ); -} - -// ─── Main Settings Page ──────────────────────────────────────────────────────── +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function SettingsPage() { - const trpc = useTRPC(); - - const selectedTeamId = useTeamStore((s) => s.selectedTeamId); - const teamRoleQuery = useQuery( - trpc.team.teamRole.queryOptions( - { teamId: selectedTeamId! }, - { enabled: !!selectedTeamId }, - ), - ); - const isSuperAdmin = teamRoleQuery.data?.isSuperAdmin ?? false; - const isTeamAdmin = teamRoleQuery.data?.role === "ADMIN"; - - if (teamRoleQuery.isLoading) { - return ( -
- - -
- ); - } - - return ( -
- - - - {isTeamAdmin && ( - - - Team - - )} - {isTeamAdmin && ( - - - API Keys - - )} - {isSuperAdmin && ( - <> - {isTeamAdmin && } - - - Auth - - - - - Users - - - - Teams - - - - - Fleet - - - - Version - - - - Audit Shipping - - - - Backup - - - )} - - - {isTeamAdmin && ( - - - - )} - {isTeamAdmin && ( - - - - )} - - {isSuperAdmin && ( - <> - - - - - - - - - - - - - - - - - - - - - - - )} - -
- ); + const router = useRouter(); + useEffect(() => { + router.replace("/settings/version"); + }, [router]); + return null; } diff --git a/src/app/(dashboard)/settings/scim/page.tsx b/src/app/(dashboard)/settings/scim/page.tsx new file mode 100644 index 00000000..6c1ee88d --- /dev/null +++ b/src/app/(dashboard)/settings/scim/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ScimSettings } from "../_components/scim-settings"; + +export default function ScimPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/team/page.tsx b/src/app/(dashboard)/settings/team/page.tsx new file mode 100644 index 00000000..d176a48a --- /dev/null +++ b/src/app/(dashboard)/settings/team/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { TeamSettings } from "../_components/team-settings"; + +export default function TeamPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/teams/page.tsx b/src/app/(dashboard)/settings/teams/page.tsx new file mode 100644 index 00000000..29893174 --- /dev/null +++ b/src/app/(dashboard)/settings/teams/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { TeamsManagement } from "../_components/teams-management"; + +export default function TeamsPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/users/page.tsx b/src/app/(dashboard)/settings/users/page.tsx new file mode 100644 index 00000000..02f38b05 --- /dev/null +++ b/src/app/(dashboard)/settings/users/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { UsersSettings } from "../_components/users-settings"; + +export default function UsersPage() { + return ; +} diff --git a/src/app/(dashboard)/settings/version/page.tsx b/src/app/(dashboard)/settings/version/page.tsx new file mode 100644 index 00000000..4f427252 --- /dev/null +++ b/src/app/(dashboard)/settings/version/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { VersionCheckSection } from "../_components/version-check-section"; + +export default function VersionCheckPage() { + return ; +} From 4904054799d6f15200017717d3ff8bc99402aedc Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:13:22 +0000 Subject: [PATCH 12/19] feat: settings sidebar navigation with animated mode swap --- src/components/app-sidebar.tsx | 130 +++++++++++++++++------- src/components/settings-sidebar-nav.tsx | 45 ++++++++ 2 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 src/components/settings-sidebar-nav.tsx diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 830fc753..d766a82d 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -15,11 +15,14 @@ import { Settings, ChevronsLeft, ChevronsRight, + ArrowLeft, } from "lucide-react"; import { useTRPC } from "@/trpc/client"; +import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; import { useTeamStore } from "@/stores/team-store"; import { useEnvironmentStore } from "@/stores/environment-store"; +import { settingsNavGroups } from "@/components/settings-sidebar-nav"; import { Sidebar, @@ -27,6 +30,7 @@ import { SidebarFooter, SidebarGroup, SidebarGroupContent, + SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, @@ -75,6 +79,8 @@ export function AppSidebar() { return (roleLevel[userRole] ?? 0) >= (roleLevel[item.requiredRole] ?? 0); }); + const isSettingsMode = pathname.startsWith("/settings"); + const { state, toggleSidebar } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -82,44 +88,100 @@ export function AppSidebar() {
- - - Vector - Flow - - Vf - + {isSettingsMode ? ( + + + Settings + + ) : ( + + + Vector + Flow + + + Vf + + + )}
- - - - - {visibleItems.map((item) => { - const isActive = - item.href === "/" - ? pathname === "/" - : pathname.startsWith(item.href); + + {/* Main nav panel */} +
+ + + + {visibleItems.map((item) => { + const isActive = + item.href === "/" + ? pathname === "/" + : pathname.startsWith(item.href); + + return ( + + + + + {item.title} + + + + ); + })} + + + +
- return ( - - - - - {item.title} - - - - ); - })} -
-
-
+ {/* Settings nav panel */} +
+ {settingsNavGroups.map((group) => { + const visibleGroupItems = group.items.filter((item) => { + if (item.requiredSuperAdmin) return isSuperAdmin; + return isSuperAdmin || userRole === "ADMIN"; + }); + if (visibleGroupItems.length === 0) return null; + return ( + + {group.label} + + + {visibleGroupItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ); + })} +
diff --git a/src/components/settings-sidebar-nav.tsx b/src/components/settings-sidebar-nav.tsx new file mode 100644 index 00000000..0b012812 --- /dev/null +++ b/src/components/settings-sidebar-nav.tsx @@ -0,0 +1,45 @@ +import { + RefreshCw, + Upload, + Shield, + Server, + Users, + UserCog, + Building2, + HardDrive, + KeyRound, + Bot, +} from "lucide-react"; + +export const settingsNavGroups = [ + { + label: "System", + items: [ + { title: "Version Check", href: "/settings/version", icon: RefreshCw, requiredSuperAdmin: true }, + { title: "Backup", href: "/settings/backup", icon: HardDrive, requiredSuperAdmin: true }, + ], + }, + { + label: "Security", + items: [ + { title: "Authentication", href: "/settings/auth", icon: Shield, requiredSuperAdmin: true }, + { title: "SCIM", href: "/settings/scim", icon: KeyRound, requiredSuperAdmin: true }, + { title: "Users", href: "/settings/users", icon: UserCog, requiredSuperAdmin: true }, + ], + }, + { + label: "Organization", + items: [ + { title: "Teams", href: "/settings/teams", icon: Building2, requiredSuperAdmin: true }, + { title: "Team Settings", href: "/settings/team", icon: Users, requiredSuperAdmin: false }, + { title: "Service Accounts", href: "/settings/service-accounts", icon: Bot, requiredSuperAdmin: false }, + ], + }, + { + label: "Operations", + items: [ + { title: "Fleet", href: "/settings/fleet", icon: Server, requiredSuperAdmin: true }, + { title: "Audit Log Shipping", href: "/settings/audit-shipping", icon: Upload, requiredSuperAdmin: true }, + ], + }, +]; From 94c8b9330b19d1513720c493531f14a8ee31c1d2 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:15:41 +0000 Subject: [PATCH 13/19] feat: event-based alert rule creation UI with conditional threshold fields --- src/app/(dashboard)/alerts/page.tsx | 121 ++++++++++++++++++---------- src/lib/alert-metrics.ts | 26 ++++++ src/server/routers/alert.ts | 15 ++++ src/server/services/event-alerts.ts | 25 +----- 4 files changed, 123 insertions(+), 64 deletions(-) create mode 100644 src/lib/alert-metrics.ts diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx index fa411ee4..725105d0 100644 --- a/src/app/(dashboard)/alerts/page.tsx +++ b/src/app/(dashboard)/alerts/page.tsx @@ -52,13 +52,16 @@ import { import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { Separator } from "@/components/ui/separator"; import { PageHeader } from "@/components/page-header"; +import { isEventMetric } from "@/lib/alert-metrics"; // ─── Constants ────────────────────────────────────────────────────────────────── @@ -231,13 +234,14 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { const openEdit = (rule: (typeof rules)[0]) => { setEditingRuleId(rule.id); + const isEvent = isEventMetric(rule.metric); setForm({ name: rule.name, pipelineId: rule.pipelineId ?? "", metric: rule.metric, - condition: rule.condition ?? "gt", - threshold: String(rule.threshold ?? ""), - durationSeconds: String(rule.durationSeconds ?? ""), + condition: isEvent ? "" : (rule.condition ?? "gt"), + threshold: isEvent ? "" : String(rule.threshold ?? ""), + durationSeconds: isEvent ? "" : String(rule.durationSeconds ?? ""), channelIds: rule.channels?.map((c) => c.channelId) ?? [], }); setDialogOpen(true); @@ -254,7 +258,8 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { const handleSubmit = () => { const isBinary = BINARY_METRICS.has(form.metric); - if (!form.name || !form.metric || (!isBinary && !form.threshold)) { + const isEvent = isEventMetric(form.metric); + if (!form.name || !form.metric || (!isBinary && !isEvent && !form.threshold)) { toast.error("Please fill in all required fields"); return; } @@ -263,8 +268,12 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { updateMutation.mutate({ id: editingRuleId, name: form.name, - threshold: parseFloat(form.threshold), - durationSeconds: parseInt(form.durationSeconds, 10) || 60, + ...(isEvent + ? {} + : { + threshold: parseFloat(form.threshold), + durationSeconds: parseInt(form.durationSeconds, 10) || 60, + }), channelIds: form.channelIds, }); } else { @@ -273,9 +282,9 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { environmentId, pipelineId: form.pipelineId || undefined, metric: form.metric as AlertMetric, - condition: form.condition as AlertCondition, - threshold: parseFloat(form.threshold), - durationSeconds: parseInt(form.durationSeconds, 10) || 60, + condition: isEvent ? null : (form.condition as AlertCondition), + threshold: isEvent ? null : parseFloat(form.threshold), + durationSeconds: isEvent ? null : (parseInt(form.durationSeconds, 10) || 60), teamId: selectedTeamId!, channelIds: form.channelIds.length > 0 ? form.channelIds : undefined, }); @@ -453,10 +462,12 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { setForm((f) => ({ ...f, metric: v, - condition: BINARY_METRICS.has(v) ? "eq" : "gt", + condition: BINARY_METRICS.has(v) ? "eq" : isEventMetric(v) ? "" : "gt", ...(BINARY_METRICS.has(v) ? { threshold: "1" } - : {}), + : isEventMetric(v) + ? { threshold: "", durationSeconds: "" } + : {}), })) } > @@ -464,11 +475,29 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { - {Object.values(AlertMetric).map((m) => ( - - {METRIC_LABELS[m] ?? m} - - ))} + + Infrastructure + CPU Usage + Memory Usage + Disk Usage + Error Rate + Discarded Rate + Node Unreachable + Pipeline Crashed + + + Events + Deploy Requested + Deploy Completed + Deploy Rejected + Deploy Cancelled + New Version Available + SCIM Sync Failed + Backup Failed + Certificate Expiring + Node Joined + Node Left + @@ -476,33 +505,41 @@ function AlertRulesSection({ environmentId }: { environmentId: string }) { )} - {!BINARY_METRICS.has(form.metric) && ( -
- - - setForm((f) => ({ ...f, threshold: e.target.value })) - } - /> -
- )} + {isEventMetric(form.metric) ? ( +

+ Notifications will be sent when this event occurs. +

+ ) : ( + <> + {!BINARY_METRICS.has(form.metric) && ( +
+ + + setForm((f) => ({ ...f, threshold: e.target.value })) + } + /> +
+ )} -
- - - setForm((f) => ({ ...f, durationSeconds: e.target.value })) - } - /> -
+
+ + + setForm((f) => ({ ...f, durationSeconds: e.target.value })) + } + /> +
+ + )} {channels.length > 0 && (
diff --git a/src/lib/alert-metrics.ts b/src/lib/alert-metrics.ts new file mode 100644 index 00000000..cf493063 --- /dev/null +++ b/src/lib/alert-metrics.ts @@ -0,0 +1,26 @@ +/** + * Shared constants for event-based alert metrics. + * + * This file is safe to import from both client ("use client") and server code + * because it has no Node.js / Prisma dependencies — only plain values. + */ + +export const EVENT_METRIC_VALUES = [ + "deploy_requested", + "deploy_completed", + "deploy_rejected", + "deploy_cancelled", + "new_version_available", + "scim_sync_failed", + "backup_failed", + "certificate_expiring", + "node_joined", + "node_left", +] as const; + +export const EVENT_METRICS: ReadonlySet = new Set(EVENT_METRIC_VALUES); + +/** Returns true if the given metric string is event-based (fires inline). */ +export function isEventMetric(metric: string): boolean { + return EVENT_METRICS.has(metric); +} diff --git a/src/server/routers/alert.ts b/src/server/routers/alert.ts index c4057542..54e2cc7a 100644 --- a/src/server/routers/alert.ts +++ b/src/server/routers/alert.ts @@ -11,6 +11,7 @@ import { } from "@/server/services/webhook-delivery"; import { validatePublicUrl, validateSmtpHost } from "@/server/services/url-validation"; import { getDriver } from "@/server/services/channels"; +import { isEventMetric } from "@/server/services/event-alerts"; export const alertRouter = router({ // ─── Alert Rules ─────────────────────────────────────────────────── @@ -86,6 +87,20 @@ export const alertRouter = router({ } } + // Event-based metrics fire on occurrence — they don't use thresholds + if (isEventMetric(input.metric)) { + input.condition = null; + input.threshold = null; + input.durationSeconds = null; + } else { + if (!input.condition || input.threshold == null) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Infrastructure metrics require condition and threshold", + }); + } + } + const rule = await prisma.alertRule.create({ data: { name: input.name, diff --git a/src/server/services/event-alerts.ts b/src/server/services/event-alerts.ts index 13381bb1..666a7444 100644 --- a/src/server/services/event-alerts.ts +++ b/src/server/services/event-alerts.ts @@ -3,28 +3,9 @@ import type { AlertMetric } from "@/generated/prisma"; import { deliverToChannels } from "@/server/services/channels"; import { deliverWebhooks } from "@/server/services/webhook-delivery"; -// --------------------------------------------------------------------------- -// Event-based alert metrics -// --------------------------------------------------------------------------- - -/** The set of AlertMetric values that fire on occurrence rather than polling. */ -export const EVENT_METRICS = new Set([ - "deploy_requested", - "deploy_completed", - "deploy_rejected", - "deploy_cancelled", - "new_version_available", - "scim_sync_failed", - "backup_failed", - "certificate_expiring", - "node_joined", - "node_left", -]); - -/** Returns true if the given metric is event-based (fires inline). */ -export function isEventMetric(metric: AlertMetric): boolean { - return EVENT_METRICS.has(metric); -} +// Re-export from the shared (client-safe) module so existing server imports +// continue to work without changes. +export { EVENT_METRICS, isEventMetric } from "@/lib/alert-metrics"; // --------------------------------------------------------------------------- // Fire an event-based alert From 97de4ba39f7b9b4f337532ff28e588dce74ae28c Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:17:21 +0000 Subject: [PATCH 14/19] feat: wire event-based alerts to deploy, SCIM, backup, fleet, and cert sources --- src/app/api/agent/enroll/route.ts | 6 ++++ src/app/api/scim/v2/Groups/[id]/route.ts | 3 ++ src/app/api/scim/v2/Groups/route.ts | 2 ++ src/app/api/scim/v2/Users/[id]/route.ts | 4 +++ src/app/api/scim/v2/Users/route.ts | 3 +- src/server/routers/deploy.ts | 36 ++++++++++++++++++++++++ src/server/services/backup-scheduler.ts | 12 ++++++++ src/server/services/event-alerts.ts | 6 ++++ src/server/services/fleet-health.ts | 18 ++++++++++++ src/server/services/scim.ts | 18 ++++++++++++ src/server/services/version-check.ts | 20 +++++++++++++ 11 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/app/api/agent/enroll/route.ts b/src/app/api/agent/enroll/route.ts index c0bd9ac0..3082f184 100644 --- a/src/app/api/agent/enroll/route.ts +++ b/src/app/api/agent/enroll/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { verifyEnrollmentToken, generateNodeToken } from "@/server/services/agent-token"; +import { fireEventAlert } from "@/server/services/event-alerts"; import { debugLog } from "@/lib/logger"; const enrollSchema = z.object({ @@ -82,6 +83,11 @@ export async function POST(request: Request) { }); debugLog("enroll", `SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name}"`); + void fireEventAlert("node_joined", matchedEnv.id, { + message: `Node "${hostname}" enrolled in environment "${matchedEnv.name}"`, + nodeId: node.id, + }); + return NextResponse.json({ nodeId: node.id, nodeToken: nodeToken.token, diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index c1883352..956f6df8 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; +import { fireScimSyncFailedAlert } from "@/server/services/scim"; import { debugLog } from "@/lib/logger"; import { authenticateScim } from "../../auth"; import { @@ -194,6 +195,7 @@ export async function PATCH( } catch (error) { const message = error instanceof Error ? error.message : "Failed to patch group"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } @@ -318,6 +320,7 @@ export async function PUT( } catch (error) { const message = error instanceof Error ? error.message : "Failed to update group"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index 1104d95c..962db49b 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; +import { fireScimSyncFailedAlert } from "@/server/services/scim"; import { debugLog } from "@/lib/logger"; import { authenticateScim } from "../auth"; import { @@ -151,6 +152,7 @@ export async function POST(req: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : "Failed to create group"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } diff --git a/src/app/api/scim/v2/Users/[id]/route.ts b/src/app/api/scim/v2/Users/[id]/route.ts index 42d2786c..69983a54 100644 --- a/src/app/api/scim/v2/Users/[id]/route.ts +++ b/src/app/api/scim/v2/Users/[id]/route.ts @@ -5,6 +5,7 @@ import { scimUpdateUser, scimPatchUser, scimDeleteUser, + fireScimSyncFailedAlert, } from "@/server/services/scim"; function scimError(detail: string, status: number) { @@ -58,6 +59,7 @@ export async function PUT( } catch (error) { const message = error instanceof Error ? error.message : "Failed to update user"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } @@ -89,6 +91,7 @@ export async function PATCH( } catch (error) { const message = error instanceof Error ? error.message : "Failed to patch user"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } @@ -115,6 +118,7 @@ export async function DELETE( } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete user"; + void fireScimSyncFailedAlert(message); return scimError(message, 400); } } diff --git a/src/app/api/scim/v2/Users/route.ts b/src/app/api/scim/v2/Users/route.ts index 69326beb..62055df4 100644 --- a/src/app/api/scim/v2/Users/route.ts +++ b/src/app/api/scim/v2/Users/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { authenticateScim } from "../auth"; -import { scimListUsers, scimCreateUser } from "@/server/services/scim"; +import { scimListUsers, scimCreateUser, fireScimSyncFailedAlert } from "@/server/services/scim"; export async function GET(req: NextRequest) { if (!(await authenticateScim(req))) { @@ -49,6 +49,7 @@ export async function POST(req: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : "Failed to create user"; + void fireScimSyncFailedAlert(message); // RFC 7644 §3.3: uniqueness conflicts use 409 const isConflict = error instanceof Error && (error as Error & { scimConflict?: boolean }).scimConflict === true; const status = isConflict ? 409 : 400; diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 5386a585..7576dcda 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -9,6 +9,7 @@ import { validateConfig } from "@/server/services/validator"; import { decryptNodeConfig } from "@/server/services/config-crypto"; import { withAudit } from "@/server/middleware/audit"; import { writeAuditLog } from "@/server/services/audit"; +import { fireEventAlert } from "@/server/services/event-alerts"; export const deployRouter = router({ preview: protectedProcedure @@ -200,6 +201,11 @@ export const deployRouter = router({ userName: ctx.session?.user?.name ?? null, }).catch(() => {}); + void fireEventAlert("deploy_requested", pipeline.environment.id, { + message: `Deploy request created for pipeline "${pipeline.name}"`, + pipelineId: input.pipelineId, + }); + return { success: true, pendingApproval: true, @@ -238,6 +244,13 @@ export const deployRouter = router({ userName: ctx.session?.user?.name ?? null, }).catch(() => {}); + if (result.success) { + void fireEventAlert("deploy_completed", pipeline.environment.id, { + message: `Pipeline "${pipeline.name}" deployed`, + pipelineId: input.pipelineId, + }); + } + return result; }), @@ -410,6 +423,13 @@ export const deployRouter = router({ }); } + if (result.success) { + void fireEventAlert("deploy_completed", request.environmentId, { + message: `Pipeline deployed via approved request`, + pipelineId: request.pipelineId, + }); + } + return result; } catch (err) { // Revert status back to APPROVED so it can be retried @@ -449,6 +469,11 @@ export const deployRouter = router({ throw new TRPCError({ code: "BAD_REQUEST", message: "Request is no longer pending" }); } + void fireEventAlert("deploy_rejected", request.environmentId, { + message: `Deploy request rejected`, + pipelineId: request.pipelineId, + }); + return { rejected: true }; }), @@ -465,6 +490,17 @@ export const deployRouter = router({ throw new TRPCError({ code: "BAD_REQUEST", message: "Request is not pending or approved" }); } + const cancelledRequest = await prisma.deployRequest.findUnique({ + where: { id: input.requestId }, + select: { environmentId: true, pipelineId: true }, + }); + if (cancelledRequest) { + void fireEventAlert("deploy_cancelled", cancelledRequest.environmentId, { + message: `Deploy request cancelled`, + pipelineId: cancelledRequest.pipelineId, + }); + } + return { cancelled: true }; }), }); diff --git a/src/server/services/backup-scheduler.ts b/src/server/services/backup-scheduler.ts index d96a3ce7..3ce7daa4 100644 --- a/src/server/services/backup-scheduler.ts +++ b/src/server/services/backup-scheduler.ts @@ -1,6 +1,7 @@ import cron, { type ScheduledTask } from "node-cron"; import { prisma } from "@/lib/prisma"; import { createBackup, runRetentionCleanup } from "./backup"; +import { fireEventAlert } from "./event-alerts"; let scheduledTask: ScheduledTask | null = null; @@ -47,6 +48,17 @@ function scheduleJob(cronExpression: string): void { await runRetentionCleanup(); } catch (error) { console.error("[backup] Scheduled backup failed:", error); + const msg = error instanceof Error ? error.message : "Unknown error"; + // Backup is system-wide — fire alert for all environments + prisma.environment.findMany({ where: { isSystem: false }, select: { id: true } }) + .then((envs) => { + for (const env of envs) { + void fireEventAlert("backup_failed", env.id, { + message: `Scheduled backup failed: ${msg}`, + }); + } + }) + .catch(() => {}); } }); diff --git a/src/server/services/event-alerts.ts b/src/server/services/event-alerts.ts index 666a7444..8f4da679 100644 --- a/src/server/services/event-alerts.ts +++ b/src/server/services/event-alerts.ts @@ -104,3 +104,9 @@ export async function fireEventAlert( ); } } + +// TODO: certificate_expiring — no existing certificate expiry check exists. +// Certificates are stored as encrypted PEM blobs without parsed expiry metadata. +// To implement: add a periodic job that parses the PEM notAfter date from each +// Certificate record and fires fireEventAlert("certificate_expiring", ...) when +// a certificate is within N days of expiration. diff --git a/src/server/services/fleet-health.ts b/src/server/services/fleet-health.ts index 30ca6803..81ee0243 100644 --- a/src/server/services/fleet-health.ts +++ b/src/server/services/fleet-health.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { fireEventAlert } from "./event-alerts"; /** * Check all agent-enrolled nodes and mark unhealthy if heartbeat exceeded threshold. @@ -13,6 +14,16 @@ export async function checkNodeHealth(): Promise { const threshold = settings?.fleetUnhealthyThreshold ?? 3; const maxAge = new Date(Date.now() - pollMs * threshold); + // Find nodes that are about to become unreachable so we can fire alerts + const goingUnreachable = await prisma.vectorNode.findMany({ + where: { + nodeTokenHash: { not: null }, + lastHeartbeat: { lt: maxAge }, + status: { not: "UNREACHABLE" }, + }, + select: { id: true, name: true, environmentId: true }, + }); + await prisma.vectorNode.updateMany({ where: { nodeTokenHash: { not: null }, @@ -21,4 +32,11 @@ export async function checkNodeHealth(): Promise { }, data: { status: "UNREACHABLE" }, }); + + for (const node of goingUnreachable) { + void fireEventAlert("node_left", node.environmentId, { + message: `Node "${node.name}" is unreachable`, + nodeId: node.id, + }); + } } diff --git a/src/server/services/scim.ts b/src/server/services/scim.ts index 6975f5c5..833ab40c 100644 --- a/src/server/services/scim.ts +++ b/src/server/services/scim.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; +import { fireEventAlert } from "./event-alerts"; import { debugLog } from "@/lib/logger"; import bcrypt from "bcryptjs"; import crypto from "crypto"; @@ -307,6 +308,23 @@ export async function scimPatchUser( return user ? toScimUser(user) : null; } +/** + * Fire a scim_sync_failed event alert for all non-system environments. + * SCIM is system-wide and has no single environmentId, so we broadcast + * the failure to every environment that exists. + */ +export async function fireScimSyncFailedAlert(errorMessage: string): Promise { + const environments = await prisma.environment.findMany({ + where: { isSystem: false }, + select: { id: true }, + }); + for (const env of environments) { + void fireEventAlert("scim_sync_failed", env.id, { + message: `SCIM sync failed: ${errorMessage}`, + }); + } +} + export async function scimDeleteUser(id: string) { debugLog("scim", `DELETE /Users/${id}`); // Don't actually delete -- lock the account diff --git a/src/server/services/version-check.ts b/src/server/services/version-check.ts index 92d7748f..26559b35 100644 --- a/src/server/services/version-check.ts +++ b/src/server/services/version-check.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { fireEventAlert } from "./event-alerts"; const GITHUB_API = "https://api.github.com"; const SERVER_REPO = "TerrifiedBug/vectorflow"; @@ -110,6 +111,7 @@ export async function checkServerVersion(force = false): Promise<{ create: { id: "singleton", latestServerReleaseCheckedAt: checkedAt }, }); } else if (release) { + const previousVersion = latestVersion; latestVersion = release.tag_name.replace(/^v/, ""); releaseUrl = release.html_url; await prisma.systemSettings.upsert({ @@ -126,6 +128,24 @@ export async function checkServerVersion(force = false): Promise<{ latestServerReleaseEtag: etag, }, }); + + // Fire alert when a genuinely new version is detected + if ( + latestVersion !== currentVersion && + latestVersion !== previousVersion && + currentVersion !== "dev" + ) { + // Version check is system-wide — fire for all environments + prisma.environment.findMany({ where: { isSystem: false }, select: { id: true } }) + .then((envs) => { + for (const env of envs) { + void fireEventAlert("new_version_available", env.id, { + message: `New VectorFlow version available: ${latestVersion}`, + }); + } + }) + .catch(() => {}); + } } } From bbac62577999f096bea989702f616bff09644a85 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:21:14 +0000 Subject: [PATCH 15/19] docs: update docs for event alerts, deploy approvals, and settings navigation --- docs/public/operations/configuration.md | 19 +++++++++++- docs/public/operations/scim.md | 12 +++++++- docs/public/operations/security.md | 8 ++++- docs/public/user-guide/alerts.md | 25 ++++++++++++++++ docs/public/user-guide/environments.md | 40 ++++++++++++++++++++++--- docs/public/user-guide/pipelines.md | 25 ++++++++++++---- 6 files changed, 116 insertions(+), 13 deletions(-) diff --git a/docs/public/operations/configuration.md b/docs/public/operations/configuration.md index 632acdfb..0713d944 100644 --- a/docs/public/operations/configuration.md +++ b/docs/public/operations/configuration.md @@ -99,7 +99,24 @@ VF_LOG_LEVEL=info ## System settings (UI) -The following settings are configured through the **Settings** page in the VectorFlow UI. Only Super Admins can access this page. These values are stored in the database and take effect immediately. +The following settings are configured through the **Settings** page in the VectorFlow UI. These values are stored in the database and take effect immediately. + +### Settings navigation + +The Settings page has its own dedicated sidebar navigation, separate from the main application sidebar. When you click **Settings** in the main navigation, the sidebar transitions to show the settings menu organized into four sections: + +| Section | Pages | Visibility | +|---------|-------|------------| +| **System** | Fleet, Backup | Super Admin only | +| **Security** | Authentication, SCIM | Super Admin only | +| **Organization** | Team, Users, Service Accounts | Team: Admin+, Users: Super Admin, Service Accounts: Admin+ | +| **Operations** | Audit | Admin+ | + +Click the back arrow at the top of the settings sidebar to return to the main navigation. The transition between the main sidebar and settings sidebar is animated for a smooth experience. + +{% hint style="info" %} +Team admins see a subset of the settings pages (Team, Service Accounts, Audit). Super admins see all settings pages. Viewers and editors do not have access to the Settings page. +{% endhint %} ### Fleet settings diff --git a/docs/public/operations/scim.md b/docs/public/operations/scim.md index 54742330..fac80161 100644 --- a/docs/public/operations/scim.md +++ b/docs/public/operations/scim.md @@ -174,7 +174,17 @@ GET /api/scim/v2/Groups?filter=displayName eq "Platform Team" - The token is shown only once when generated; VectorFlow does not store the plaintext - SCIM endpoints require a valid bearer token on every request - Disabling SCIM clears the stored token -- All SCIM operations are recorded in the audit log +- All SCIM operations are recorded in the audit log under the **ScimUser** entity type + +### Audit logging + +SCIM user operations (create, update, deactivate, delete) are logged with the `ScimUser` entity type to distinguish them from manual user operations. On the **Audit** page, you can filter by: + +- **ScimUser** -- shows only SCIM user provisioning events +- **ScimGroup** -- shows only SCIM group operations +- **SCIM (All)** -- a combined filter that shows all SCIM-related activity (both user and group operations) in a single view + +This makes it easy to audit all identity provider-driven changes for compliance purposes. {% hint style="info" %} SCIM provisioning works best alongside OIDC/SSO. Users created via SCIM receive a random password and should authenticate through your identity provider, not with local credentials. diff --git a/docs/public/operations/security.md b/docs/public/operations/security.md index 17dc0950..32ea47be 100644 --- a/docs/public/operations/security.md +++ b/docs/public/operations/security.md @@ -147,7 +147,13 @@ Every mutation in VectorFlow is logged to an audit trail. Audit entries include: Sensitive fields (passwords, tokens, secrets) are automatically redacted in audit log entries. -View the audit log from the **Audit** page in the sidebar. +View the audit log from the **Audit** page in the sidebar. The audit log supports filtering by entity type, including dedicated filters for SCIM operations: + +- **ScimUser** -- SCIM user provisioning events (create, update, deactivate) +- **ScimGroup** -- SCIM group operations (create, update members, delete) +- **SCIM (All)** -- Combined filter showing all SCIM activity in one view + +These filters make it straightforward to review all identity provider-driven changes for compliance audits. ## Security hardening checklist diff --git a/docs/public/user-guide/alerts.md b/docs/public/user-guide/alerts.md index 60cd9bd2..463541d5 100644 --- a/docs/public/user-guide/alerts.md +++ b/docs/public/user-guide/alerts.md @@ -45,6 +45,10 @@ Click **Create Rule**. The rule is enabled by default and begins evaluating on t ### Supported metrics +VectorFlow supports three categories of alert metrics: **Infrastructure** metrics that monitor resource utilization with thresholds, **Binary** metrics that fire on detected conditions, and **Event** metrics that fire when specific system events occur. + +#### Infrastructure metrics + | Metric | Type | Description | |--------|------|-------------| | **CPU Usage** | Percentage | CPU utilization derived from cumulative CPU seconds. | @@ -57,6 +61,27 @@ Click **Create Rule**. The rule is enabled by default and begins evaluating on t Percentage-based metrics use the conditions **>** (greater than), **<** (less than), or **=** (equals) against a threshold value. Binary metrics (Node Unreachable, Pipeline Crashed) fire automatically when the condition is detected -- no threshold is needed. +#### Event metrics + +Event metrics fire whenever a specific system event occurs. Unlike infrastructure metrics, they have **no threshold** -- the alert triggers on each occurrence. Event rules are created the same way as infrastructure rules, but you select a metric from the **Events** category in the metric dropdown. + +| Metric | Description | +|--------|-------------| +| **Deploy Requested** | A deploy request was submitted for approval. | +| **Deploy Completed** | A pipeline was successfully deployed to agents. | +| **Deploy Rejected** | A deploy request was rejected by a reviewer. | +| **Deploy Cancelled** | A deploy request was cancelled. | +| **New Version Available** | A new VectorFlow server version is available. | +| **SCIM Sync Failed** | A SCIM provisioning operation failed. | +| **Backup Failed** | A scheduled database backup failed. | +| **Certificate Expiring** | A TLS certificate is approaching its expiration date. | +| **Node Joined** | A new agent node enrolled in the environment. | +| **Node Left** | An agent node was removed or disconnected from the environment. | + +{% hint style="info" %} +Event alerts use the same notification channels as infrastructure alerts (Slack, Email, PagerDuty, Webhook). You can route event alerts to specific channels by linking channels to the rule, just like any other alert rule. +{% endhint %} + ### Condition evaluation Alert rules are evaluated during each agent heartbeat cycle. The evaluation logic works as follows: diff --git a/docs/public/user-guide/environments.md b/docs/public/user-guide/environments.md index 1386bfad..f0c2f8cf 100644 --- a/docs/public/user-guide/environments.md +++ b/docs/public/user-guide/environments.md @@ -19,6 +19,24 @@ The environment selector is the dropdown in the header bar. Switching it changes When you switch environments, the pipeline list, fleet view, and alerts page update to show only resources for that environment. Your selection is persisted across sessions. {% endhint %} +### Default environment + +You can set a **default environment** so VectorFlow automatically selects it when you log in or switch teams. + +**User default (per-user):** Click the star icon next to any environment in the environment selector dropdown. The starred environment becomes your personal default for that team. Click the star again to clear it. + +**Admin default (per-team):** Team admins can set a team-wide default environment from **Settings > Team**. This applies to all team members who have not set their own personal default. + +The fallback chain when loading the app is: + +1. **User default** -- your personally starred environment (if set) +2. **Admin team default** -- the team-level default environment (if configured by an admin) +3. **First in list** -- the first environment alphabetically + +{% hint style="info" %} +The team selector in the header also supports starring. Click the star next to a team to set it as your default team on login. +{% endhint %} + ## Creating an environment {% stepper %} @@ -91,7 +109,7 @@ Secrets and certificates are stripped during promotion. After promoting a pipeli ## Deploy approval -Environments can require **admin approval** before pipelines are deployed. This is useful for production environments where you want a second pair of eyes on every configuration change. +Environments can require **approval** before pipelines are deployed. This is useful for production environments where you want a second pair of eyes on every configuration change. Approval and deployment are **separate actions** -- a reviewer approves the request, and then anyone with deploy access can execute the deployment. ### Enabling approval @@ -112,13 +130,27 @@ Click **Save** to apply the change. When enabled: - Users with the **Editor** role will see a **Request Deploy** button instead of **Publish to Agents** in the deploy dialog. Their deploy requests are queued for review. -- Users with the **Admin** role can deploy directly (no approval needed) and can review, approve, or reject pending requests from other users. -- A **Pending Approval** badge appears on the pipeline list and in the pipeline editor toolbar while a request is outstanding. +- Any team member with deploy access (editor or admin) can **approve** a pending request. Approval does not automatically deploy -- it marks the request as ready. +- Once approved, any team member with deploy access can **deploy** the approved request. The deploy dialog shows a **Deploy** button for approved requests. +- Approved requests can also be **cancelled** by anyone with deploy access if the deployment is no longer needed. +- Users with the **Admin** role can deploy directly (no approval needed), bypassing the approval flow entirely. +- A **Pending Approval** or **Approved** badge appears on the pipeline list and in the pipeline editor toolbar to indicate the request status. {% hint style="info" %} -An admin cannot approve their own deploy request. This ensures a genuine four-eyes review process. +The person who submitted a deploy request cannot approve their own request. This enforces a four-eyes principle -- a second team member must always review the deployment. {% endhint %} +### Deploy tracking + +VectorFlow tracks who actually deployed a pipeline separately from who approved it. The deploy history records: + +- **Requested by** -- the user who submitted the deploy request +- **Approved by** -- the user who approved the request +- **Deployed by** -- the user who executed the deployment +- **Status** -- the request lifecycle: `PENDING` → `APPROVED` → `DEPLOYED` (or `REJECTED` / `CANCELLED`) + +This separation provides a clear audit trail for compliance, especially in regulated environments where you need to know exactly who authorized and executed each deployment. + For more details on how the approval workflow operates, see [Pipelines -- Deploy approval workflows](pipelines.md#deploy-approval-workflows). ## Editing and deleting environments diff --git a/docs/public/user-guide/pipelines.md b/docs/public/user-guide/pipelines.md index 61656f6c..30df9301 100644 --- a/docs/public/user-guide/pipelines.md +++ b/docs/public/user-guide/pipelines.md @@ -182,18 +182,31 @@ Tags are metadata labels only -- they do not enforce any access controls or data ## Deploy approval workflows -Environments can optionally require **deploy approval** before a pipeline goes live. When enabled, editors who click **Deploy** will submit a deploy request instead of deploying directly. Another team member (editor or admin) can then review, approve, or reject the request. +Environments can optionally require **deploy approval** before a pipeline goes live. When enabled, editors who click **Deploy** will submit a deploy request instead of deploying directly. Approval and deployment are **separate actions** -- a reviewer approves the request, and then anyone with deploy access can execute the deployment. ### How it works 1. An admin enables **Require approval for deploys** on the environment settings page (see [Environments](environments.md#deploy-approval)). 2. When an editor clicks **Deploy** in the pipeline editor, the deploy dialog shows a **Request Deploy** button instead of **Publish to Agents**. 3. The editor submits a deploy request with a changelog entry. The pipeline list and pipeline editor toolbar show a **Pending Approval** badge. -4. Another team member (editor or admin) opens the deploy dialog for the pipeline and sees the request in **review mode** — displaying the requester, changelog, and a config diff. -5. The reviewer can **Approve & Deploy** (which immediately deploys the pipeline) or **Reject** (with an optional note). +4. Another team member (editor or admin) opens the deploy dialog for the pipeline and sees the request in **review mode** -- displaying the requester, changelog, and a config diff. +5. The reviewer clicks **Approve** to mark the request as approved. This does **not** deploy the pipeline. +6. Once approved, any team member with deploy access can click **Deploy** on the approved request to push the configuration to agents. + +### Request lifecycle + +A deploy request moves through these statuses: + +| Status | Description | +|--------|-------------| +| **Pending** | The request is waiting for review. | +| **Approved** | A reviewer has approved the request. It is ready to be deployed. | +| **Deployed** | The approved request has been deployed to agents. | +| **Rejected** | A reviewer has rejected the request (with an optional note). | +| **Cancelled** | The request was cancelled before deployment. | {% hint style="warning" %} -**Self-approval is blocked.** The person who submitted a deploy request cannot approve their own request. This enforces a four-eyes principle — a second team member must always review and approve the deployment. +**Self-approval is blocked.** The person who submitted a deploy request cannot approve their own request. This enforces a four-eyes principle -- a second team member must always review the deployment. {% endhint %} {% hint style="info" %} @@ -202,11 +215,11 @@ Admins can always deploy directly, even when approval is required. When an admin ### Cancelling a request -The editor who submitted a pending deploy request can cancel it from the pipeline editor toolbar by clicking the **X** button next to the **Pending Approval** badge. +Anyone with deploy access can cancel a pending or approved deploy request. For pending requests, click the **X** button next to the **Pending Approval** badge in the pipeline editor toolbar. For approved requests, click **Cancel** in the deploy dialog. ### Pipeline list indicators -Pipelines with pending deploy requests show a **Pending Approval** badge in the status column on the Pipelines page, so admins can quickly identify which pipelines need attention. +Pipelines with pending or approved deploy requests show a status badge (**Pending Approval** or **Approved**) in the status column on the Pipelines page, so team members can quickly identify which pipelines need attention. ## Filtering by environment From 553591ed75cb6f40003d17e0d0b0f070e6eebf3d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:27:22 +0000 Subject: [PATCH 16/19] fix: validate deploy request statuses and preference key length --- src/server/routers/deploy.ts | 2 +- src/server/routers/user-preference.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 7576dcda..497b5a8e 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -313,7 +313,7 @@ export const deployRouter = router({ .input(z.object({ environmentId: z.string().optional(), pipelineId: z.string().optional(), - statuses: z.array(z.string()).optional().default(["PENDING", "APPROVED"]), + statuses: z.array(z.enum(["PENDING", "APPROVED"])).optional().default(["PENDING", "APPROVED"]), })) .use(withTeamAccess("VIEWER")) .query(async ({ input, ctx }) => { diff --git a/src/server/routers/user-preference.ts b/src/server/routers/user-preference.ts index 4f73ce77..418d1e93 100644 --- a/src/server/routers/user-preference.ts +++ b/src/server/routers/user-preference.ts @@ -23,7 +23,7 @@ export const userPreferenceRouter = router({ }), delete: protectedProcedure - .input(z.object({ key: z.string() })) + .input(z.object({ key: z.string().max(100) })) .mutation(async ({ ctx, input }) => { await prisma.userPreference.deleteMany({ where: { userId: ctx.session.user!.id!, key: input.key }, From af44c22b31dfb3b2df3c7a6bcca6009c7c02a9c9 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 12:42:42 +0000 Subject: [PATCH 17/19] fix: revert deploy request status on non-throwing deploy failure --- src/server/routers/deploy.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 497b5a8e..14c9ae09 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -411,8 +411,17 @@ export const deployRouter = router({ request.configYaml, ); + // Non-throwing failure (e.g. validation errors) — revert to APPROVED + if (!result.success) { + await prisma.deployRequest.updateMany({ + where: { id: input.requestId, status: "DEPLOYED" }, + data: { status: "APPROVED", deployedById: null, deployedAt: null }, + }); + return result; + } + // Persist nodeSelector from the original deploy request - if (result.success && request.nodeSelector) { + if (request.nodeSelector) { const ns = request.nodeSelector as Record; await prisma.pipeline.update({ where: { id: request.pipelineId }, @@ -423,12 +432,10 @@ export const deployRouter = router({ }); } - if (result.success) { - void fireEventAlert("deploy_completed", request.environmentId, { - message: `Pipeline deployed via approved request`, - pipelineId: request.pipelineId, - }); - } + void fireEventAlert("deploy_completed", request.environmentId, { + message: `Pipeline deployed via approved request`, + pipelineId: request.pipelineId, + }); return result; } catch (err) { From f7200b0aa9df1ff19212231745ded73834cacc8f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 13:09:54 +0000 Subject: [PATCH 18/19] fix: restore cancel ownership guard for pending requests and isolate per-rule alert delivery --- src/server/routers/deploy.ts | 17 ++++++- src/server/services/event-alerts.ts | 77 ++++++++++++++++------------- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/server/routers/deploy.ts b/src/server/routers/deploy.ts index 14c9ae09..342d6f9c 100644 --- a/src/server/routers/deploy.ts +++ b/src/server/routers/deploy.ts @@ -488,13 +488,26 @@ export const deployRouter = router({ .input(z.object({ requestId: z.string() })) .use(withTeamAccess("EDITOR")) .use(withAudit("deploy.cancel_request", "DeployRequest")) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + // PENDING requests can only be cancelled by the requester. + // APPROVED requests can be cancelled by anyone with deploy access. + const request = await prisma.deployRequest.findUnique({ + where: { id: input.requestId }, + select: { status: true, requestedById: true }, + }); + if (!request || !["PENDING", "APPROVED"].includes(request.status)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Request is not pending or approved" }); + } + if (request.status === "PENDING" && request.requestedById !== ctx.session.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "Only the requester can cancel a pending request" }); + } + const updated = await prisma.deployRequest.updateMany({ where: { id: input.requestId, status: { in: ["PENDING", "APPROVED"] } }, data: { status: "CANCELLED" }, }); if (updated.count === 0) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Request is not pending or approved" }); + throw new TRPCError({ code: "CONFLICT", message: "Request status changed — try again" }); } const cancelledRequest = await prisma.deployRequest.findUnique({ diff --git a/src/server/services/event-alerts.ts b/src/server/services/event-alerts.ts index 8f4da679..03b83c25 100644 --- a/src/server/services/event-alerts.ts +++ b/src/server/services/event-alerts.ts @@ -58,44 +58,53 @@ export async function fireEventAlert( if (rules.length === 0) return; for (const rule of rules) { - // 2. Create an AlertEvent record - const event = await prisma.alertEvent.create({ - data: { - alertRuleId: rule.id, - nodeId: (metadata.nodeId as string) ?? null, - status: "firing", + try { + // 2. Create an AlertEvent record + const event = await prisma.alertEvent.create({ + data: { + alertRuleId: rule.id, + nodeId: (metadata.nodeId as string) ?? null, + status: "firing", + value: 0, + message: metadata.message, + }, + }); + + // 3. Build the channel payload + const payload = { + alertId: event.id, + status: "firing" as const, + ruleName: rule.name, + severity: "warning", + environment: rule.environment.name, + team: rule.environment.team?.name, + node: (metadata.nodeId as string) ?? undefined, + pipeline: rule.pipeline?.name ?? undefined, + metric: rule.metric, value: 0, + threshold: rule.threshold ?? 0, message: metadata.message, - }, - }); - - // 3. Build the channel payload - const payload = { - alertId: event.id, - status: "firing" as const, - ruleName: rule.name, - severity: "warning", - environment: rule.environment.name, - team: rule.environment.team?.name, - node: (metadata.nodeId as string) ?? undefined, - pipeline: rule.pipeline?.name ?? undefined, - metric: rule.metric, - value: 0, - threshold: rule.threshold ?? 0, - message: metadata.message, - timestamp: event.firedAt.toISOString(), - dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, - }; + timestamp: event.firedAt.toISOString(), + dashboardUrl: `${process.env.NEXTAUTH_URL ?? ""}/alerts`, + }; - // 4. Deliver to legacy webhooks and notification channels - await deliverWebhooks(rule.environmentId, payload); - await deliverToChannels(rule.environmentId, rule.id, payload); + // 4. Deliver to legacy webhooks and notification channels + await deliverWebhooks(rule.environmentId, payload); + await deliverToChannels(rule.environmentId, rule.id, payload); - // 5. Update the AlertEvent with notifiedAt timestamp - await prisma.alertEvent.update({ - where: { id: event.id }, - data: { notifiedAt: new Date() }, - }); + // 5. Update the AlertEvent with notifiedAt timestamp + await prisma.alertEvent.update({ + where: { id: event.id }, + data: { notifiedAt: new Date() }, + }); + } catch (ruleErr) { + // Per-rule isolation: one rule's delivery failure must not + // prevent other rules from being processed. + console.error( + `fireEventAlert delivery error (rule=${rule.id}, metric=${metric}):`, + ruleErr, + ); + } } } catch (err) { console.error( From 1eb09ca4cea217caf478cbd241c438e9c56fd205 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 9 Mar 2026 13:37:30 +0000 Subject: [PATCH 19/19] fix: skip sync-failed alert on SCIM 409 conflict responses --- src/app/api/scim/v2/Users/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/api/scim/v2/Users/route.ts b/src/app/api/scim/v2/Users/route.ts index 62055df4..3348e5b4 100644 --- a/src/app/api/scim/v2/Users/route.ts +++ b/src/app/api/scim/v2/Users/route.ts @@ -49,10 +49,14 @@ export async function POST(req: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : "Failed to create user"; - void fireScimSyncFailedAlert(message); // RFC 7644 §3.3: uniqueness conflicts use 409 const isConflict = error instanceof Error && (error as Error & { scimConflict?: boolean }).scimConflict === true; const status = isConflict ? 409 : 400; + // Don't fire sync-failed alerts for 409 conflicts — these are routine + // IdP probes for existing users, not actual sync failures. + if (!isConflict) { + void fireScimSyncFailedAlert(message); + } return NextResponse.json( { schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],