diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index bcbda2d457d..bd88484b8b1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -7,6 +7,7 @@ import { useCompany } from "../context/CompanyContext"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; +import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -1354,6 +1355,12 @@ export function NewIssueDialog() { /> + {effectiveCompanyId ? ( +
+ +
+ ) : null} +
diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index c1a79b79ba1..034bc7fc6d5 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -49,6 +49,7 @@ import { type UpdateSecretProviderConfigInput, } from "../api/secrets"; import { ApiError } from "../api/client"; +import { accessApi, type CompanyUserDirectoryEntry } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { Button } from "@/components/ui/button"; @@ -1176,7 +1177,11 @@ export function Secrets() { - +
@@ -2585,7 +2590,34 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com ); } -function SecretEventsTab({ loading, events }: { loading: boolean; events: SecretAccessEvent[] }) { +function SecretEventsTab({ + loading, + events, + companyId, +}: { + loading: boolean; + events: SecretAccessEvent[]; + companyId: string; +}) { + // Resolve responsible/owner user ids to human names for user-scoped events. + const anyUserScoped = events.some( + (event) => + event.secretScope === "user" || event.responsibleUserId || event.credentialOwnerUserId, + ); + const { data: directory } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(companyId), + queryFn: () => accessApi.listUserDirectory(companyId), + enabled: anyUserScoped, + staleTime: 60_000, + }); + const userLabel = (userId: string | null): string => { + if (!userId) return "—"; + const entry: CompanyUserDirectoryEntry | undefined = directory?.users.find( + (u) => u.principalId === userId, + ); + return entry?.user?.name?.trim() || entry?.user?.email?.trim() || `${userId.slice(0, 8)}…`; + }; + if (loading) { return
Loading…
; } @@ -2600,15 +2632,34 @@ function SecretEventsTab({ loading, events }: { loading: boolean; events: Secret
{events.map((event) => (
-
- +
+ {event.consumerType} · {event.outcome} + {event.secretScope === "user" ? ( + + User secret + + ) : null} {formatRelative(event.createdAt)}
{event.consumerId}
+ {event.responsibleUserId ? ( +
+ Responsible user: {userLabel(event.responsibleUserId)} +
+ ) : null} + {event.credentialOwnerUserId && + event.credentialOwnerUserId !== event.responsibleUserId ? ( +
+ Credential owner: {userLabel(event.credentialOwnerUserId)} +
+ ) : null} {event.errorCode ? (
{event.errorCode}
) : null} diff --git a/ui/src/pages/secrets/MissingUserSecretsBanner.tsx b/ui/src/pages/secrets/MissingUserSecretsBanner.tsx new file mode 100644 index 00000000000..68ebfe8521f --- /dev/null +++ b/ui/src/pages/secrets/MissingUserSecretsBanner.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { secretsApi, type MyUserSecretEntry } from "../../api/secrets"; +import { queryKeys } from "../../lib/queryKeys"; +import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog"; + +/** + * Warning surface for user secrets the current user has not yet set. Renders + * nothing when there is nothing missing, so it is safe to embed on task + * creation / run and issue-failure surfaces. Lets the user satisfy a missing + * required secret inline via the shared value dialog. + * + * Pass `definitionKeys` to scope the warning to a specific set (e.g. the user + * secrets a blocked run reported as missing); omit it to warn about every + * active definition the user has not set. + */ +export function MissingUserSecretsBanner({ + companyId, + definitionKeys, + title = "Set your user secrets", + secretsPath, + className, +}: { + companyId: string; + definitionKeys?: string[]; + title?: string; + /** Optional route to the Secrets → My secrets tab for the "Manage all" link. */ + secretsPath?: string; + className?: string; +}) { + const [dialogFor, setDialogFor] = useState(null); + + const mySecretsQuery = useQuery({ + queryKey: queryKeys.secrets.myUserSecrets(companyId), + queryFn: () => secretsApi.listMyUserSecrets(companyId), + retry: false, + }); + + const keyFilter = definitionKeys ? new Set(definitionKeys) : null; + const missing = (mySecretsQuery.data ?? []).filter( + (entry) => + entry.definition.status === "active" && + !entry.secret && + (!keyFilter || keyFilter.has(entry.definition.key)), + ); + + if (missing.length === 0) return null; + + return ( +
+
+ +
+

{title}

+

+ {missing.length} user secret{missing.length === 1 ? "" : "s"} you are responsible for + {missing.length === 1 ? " has" : " have"} no value yet. Runs that require + {missing.length === 1 ? " it" : " them"} will fail until you set your value. +

+
    + {missing.map((entry) => ( +
  • + + {entry.definition.name}{" "} + {entry.definition.key} + + +
  • + ))} +
+ {secretsPath ? ( + + Manage all my secrets + + ) : null} +
+
+ + { + if (!open) setDialogFor(null); + }} + /> +
+ ); +}