Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/web/src/components/admin/CredentialBindingSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Secret } from "../../lib/api-types"

interface CredentialBindingSelectProps {
value: string
secrets: Secret[]
allowPersonal: boolean
allowCreateNew?: boolean
personalLabel: string
sharedLabel: string
personalPlaceholder?: string
createNewLabel?: string
onChange: (value: string) => void
className?: string
}

/** Shared source selector used by Agent creation and Capability enabling. */
export function CredentialBindingSelect({
value,
secrets,
allowPersonal,
allowCreateNew = false,
personalLabel,
sharedLabel,
personalPlaceholder,
createNewLabel,
onChange,
className = "h-7 w-full rounded border border-line bg-surface px-2 text-sm",
}: CredentialBindingSelectProps) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
className={className}
>
{allowPersonal && <option value="">{personalLabel}</option>}
{!allowPersonal && !value && <option value="">{personalPlaceholder ?? personalLabel}</option>}
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{sharedLabel}: {secret.name}
</option>
))}
{allowCreateNew && <option value="__new__">{createNewLabel ?? sharedLabel}</option>}
</select>
)
}
45 changes: 18 additions & 27 deletions apps/web/src/components/admin/CredentialCheckPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Check, ChevronDown, ChevronRight, Eye, EyeOff, ExternalLink, Loader2, S

import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { CredentialBindingSelect } from "./CredentialBindingSelect"
import { useMyCredentials } from "../../lib/api-credentials"
import {
credentialKindLabel,
Expand All @@ -12,12 +13,9 @@ import {
type KnownCredentialKind,
} from "../../lib/credential-kind-ui"
import type { AgentInlineNewSecret, RequiredCredential, Secret } from "../../lib/api-types"
import { hasCredentialKind, sharedSecretsForKind, type PerKindBindingChoice } from "../../lib/credential-bindings"

/** PerKindBinding is the per-credential decision made in the picker. */
export type PerKindBindingChoice =
| { source: "personal" }
| { source: "shared"; existing_secret_id: string }
| { source: "shared"; new_secret: { display_name: string; plaintext: string } }
export type { PerKindBindingChoice } from "../../lib/credential-bindings"

interface CredentialCheckPanelProps {
/** Only entries with required===true should be passed. */
Expand Down Expand Up @@ -171,7 +169,7 @@ export function CredentialCheckPanel({
for (const rc of requiredKinds) {
const choice = choices[rc.kind]
if (choice?.source !== "personal") continue
if (!(credentials ?? []).some((c) => c.kind === rc.kind)) {
if (!hasCredentialKind(credentials ?? [], rc.kind)) {
// Personal but the creator has not configured this kind. Allow
// the pick (other callers may have it), but signal invalid so
// the create button stays disabled until they add it OR switch
Expand Down Expand Up @@ -222,15 +220,8 @@ export function CredentialCheckPanel({
{requiredKinds.map((rc) => {
const { displayName, placeholder, getUrl } = getKindMeta(rc.kind)
const choice = choices[rc.kind]
const hasPersonalCredential = (credentials ?? []).some((c) => c.kind === rc.kind)
const kindSecrets = sharedSecrets.filter((s) => {
if (s.kind !== "capability_inline") return false
const metaCode = (s.metadata as { credential_kind_code?: unknown } | undefined)?.credential_kind_code
// Untagged legacy secrets surface for every kind (operator's
// responsibility to pick the right one); new secrets are
// always tagged so this only matters for pre-2026-06 rows.
return typeof metaCode !== "string" || metaCode === "" || metaCode === rc.kind
})
const hasPersonalCredential = hasCredentialKind(credentials ?? [], rc.kind)
const kindSecrets = sharedSecretsForKind(sharedSecrets, rc.kind)

return (
<div key={rc.kind} className="rounded-md border border-line bg-surface">
Expand Down Expand Up @@ -304,29 +295,29 @@ export function CredentialCheckPanel({
{choice?.source === "shared" && (
<div className="mt-1 space-y-1.5">
{kindSecrets.length > 0 && (
<select
<CredentialBindingSelect
value={"existing_secret_id" in choice ? choice.existing_secret_id : "__new__"}
onChange={(e) => {
e.stopPropagation()
if (e.target.value === "__new__") {
secrets={kindSecrets}
allowPersonal={false}
allowCreateNew
personalLabel={t("credentialCheck.sourcePersonal")}
personalPlaceholder={t("credentialCheck.sharedPlaceholder")}
sharedLabel={t("credentialCheck.sourceShared")}
createNewLabel={t("credentialCheck.createNewShared")}
onChange={(value) => {
if (value === "__new__") {
setExpandedNewSecretFor(rc.kind)
if (!("new_secret" in choice)) {
setNewSecretDisplayName("")
setNewSecretPlaintext("")
}
} else {
setKindChoice(rc.kind, { source: "shared", existing_secret_id: e.target.value })
setKindChoice(rc.kind, { source: "shared", existing_secret_id: value })
if (expandedNewSecretFor === rc.kind) setExpandedNewSecretFor(null)
}
}}
onClick={(e) => e.stopPropagation()}
className="h-7 w-full rounded border border-line bg-surface px-2 text-sm"
>
{kindSecrets.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
<option value="__new__">{t("credentialCheck.createNewShared")}</option>
</select>
/>
)}
{kindSecrets.length === 0 && expandedNewSecretFor !== rc.kind && !("new_secret" in choice) && (
<button
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/credential-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Secret, UserCredential } from "./api-types"

export type PerKindBindingChoice =
| { source: "personal" }
| { source: "shared"; existing_secret_id: string }
| { source: "shared"; new_secret: { display_name: string; plaintext: string } }

export type CredentialBinding = {
source: "personal" | "shared"
secretID: string
}

export function secretCredentialKind(secret: Secret) {
const value = secret.metadata?.credential_kind_code
return typeof value === "string" ? value.trim() : ""
}

export function sharedSecretsForKind(secrets: Secret[], kind: string, catalogID = "") {
return secrets.filter((secret) => {
const secretKind = secretCredentialKind(secret)
const matchesKind = secret.kind === "capability_inline"
&& secret.status === "active"
&& (secretKind === "" || secretKind === kind)
if (!matchesKind) return false
if (kind !== "mcp_oauth" || !catalogID) return true
return secretKind === kind
&& secret.auth_type === "oauth2"
&& secret.provider === catalogID
})
}

export function credentialBinding(config: Record<string, unknown> | undefined, kind: string): CredentialBinding | undefined {
const bindings = config?.credential_bindings
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) return undefined
const binding = (bindings as Record<string, unknown>)[kind]
if (!binding || typeof binding !== "object" || Array.isArray(binding)) return undefined
const value = binding as Record<string, unknown>
if (value.source !== "personal" && value.source !== "shared") return undefined
return {
source: value.source,
secretID: value.source === "shared" && typeof value.secret_id === "string" ? value.secret_id : "",
}
}

export function hasCredentialKind(credentials: UserCredential[], kind: string) {
return credentials.some((credential) => credential.kind === kind)
}
8 changes: 8 additions & 0 deletions apps/web/src/lib/credential-kind-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const CREDENTIAL_KIND_LABELS = {
zh: "Notion 集成 token",
en: "Notion Integration Token",
},
mcp_oauth: {
zh: "MCP OAuth",
en: "MCP OAuth",
},
jira_api_token: {
zh: "Jira API Token",
en: "Jira API Token",
Expand All @@ -47,6 +51,7 @@ export const CREDENTIAL_KIND_OPTIONS: KnownCredentialKind[] = [
"slack_bot_token",
"postgres_dsn",
"notion_integration",
"mcp_oauth",
"jira_api_token",
]

Expand All @@ -66,6 +71,9 @@ export const CREDENTIAL_KIND_META: Record<KnownCredentialKind, { placeholder: {
placeholder: { zh: "secret_…", en: "secret_…" },
getUrl: "https://www.notion.so/profile/integrations",
},
mcp_oauth: {
placeholder: { zh: "通过连接器目录授权", en: "Authorize from the connector directory" },
},
jira_api_token: {
placeholder: { zh: "ATATT…", en: "ATATT…" },
getUrl: "https://id.atlassian.com/manage-profile/security/api-tokens",
Expand Down
Loading