Add missing-user-secret warning path and access-event owner metadata

- MissingUserSecretsBanner: reusable warning listing the current user's
  unset user secrets with inline "Set value" (shared SetMyUserSecretDialog).
  Renders nothing when there is nothing missing, so it is safe to embed.
  Mounted on the New Issue dialog (task creation surface).
- Secret access events now show the User-secret scope badge, responsible
  user, and credential owner (resolved via the company user directory),
  satisfying the user-secret owner metadata deliverable.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 18:20:59 +00:00
1 parent a5673e8149
commit 34076590d7
3 files changed
+168 -4

No files matched your search

+7
View File
@@ -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() {
/>
</div>
{effectiveCompanyId ? (
<div className="px-4 pb-2">
<MissingUserSecretsBanner companyId={effectiveCompanyId} />
</div>
) : null}
<div className="px-4 pb-2">
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
+55 -4
View File
@@ -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() {
<SecretUsageTab loading={usageQuery.isPending} bindings={usageQuery.data?.bindings ?? []} />
</TabsContent>
<TabsContent value="events">
<SecretEventsTab loading={eventsQuery.isPending} events={eventsQuery.data ?? []} />
<SecretEventsTab
loading={eventsQuery.isPending}
events={eventsQuery.data ?? []}
companyId={selectedCompanyId}
/>
</TabsContent>
</div>
</Tabs>
@@ -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 <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
@@ -2600,15 +2632,34 @@ function SecretEventsTab({ loading, events }: { loading: boolean; events: Secret
<div className="space-y-1.5">
{events.map((event) => (
<div key={event.id} className="rounded border border-border px-2 py-1.5 text-xs">
<div className="flex items-center justify-between">
<span className="capitalize">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 capitalize">
{event.consumerType} · {event.outcome}
{event.secretScope === "user" ? (
<Badge
variant="outline"
className="border-violet-500/30 bg-violet-500/10 text-[10px] text-violet-700 dark:text-violet-300"
>
User secret
</Badge>
) : null}
</span>
<span className="text-[11px] text-muted-foreground">{formatRelative(event.createdAt)}</span>
</div>
<div className="font-mono text-[11px] text-muted-foreground break-all">
{event.consumerId}
</div>
{event.responsibleUserId ? (
<div className="text-[11px] text-muted-foreground">
Responsible user: <span className="text-foreground">{userLabel(event.responsibleUserId)}</span>
</div>
) : null}
{event.credentialOwnerUserId &&
event.credentialOwnerUserId !== event.responsibleUserId ? (
<div className="text-[11px] text-muted-foreground">
Credential owner: <span className="text-foreground">{userLabel(event.credentialOwnerUserId)}</span>
</div>
) : null}
{event.errorCode ? (
<div className="text-[11px] text-destructive">{event.errorCode}</div>
) : null}
@@ -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<MyUserSecretEntry | null>(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 (
<div
className={
"rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-xs text-amber-800 dark:text-amber-200" +
(className ? ` ${className}` : "")
}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium">{title}</p>
<p className="mt-0.5 text-amber-700/90 dark:text-amber-300/90">
{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.
</p>
<ul className="mt-2 space-y-1.5">
{missing.map((entry) => (
<li
key={entry.definition.id}
className="flex items-center justify-between gap-2 rounded border border-amber-500/30 bg-background/40 px-2 py-1"
>
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{entry.definition.name}</span>{" "}
<code className="text-[11px] text-muted-foreground">{entry.definition.key}</code>
</span>
<Button size="sm" onClick={() => setDialogFor(entry)}>
Set value
</Button>
</li>
))}
</ul>
{secretsPath ? (
<Link
to={secretsPath}
className="mt-2 inline-block text-[11px] font-medium underline underline-offset-2"
>
Manage all my secrets
</Link>
) : null}
</div>
</div>
<SetMyUserSecretDialog
companyId={companyId}
definition={dialogFor?.definition ?? null}
existingSecret={dialogFor?.secret ?? null}
open={dialogFor !== null}
onOpenChange={(open) => {
if (!open) setDialogFor(null);
}}
/>
</div>
);
}