Add frontend edit credential UI (#SKY-7883) (#4762)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
@@ -501,6 +501,7 @@ export type Createv2TaskRequest = {
|
|||||||
export type PasswordCredentialApiResponse = {
|
export type PasswordCredentialApiResponse = {
|
||||||
username: string;
|
username: string;
|
||||||
totp_type: "authenticator" | "email" | "text" | "none";
|
totp_type: "authenticator" | "email" | "text" | "none";
|
||||||
|
totp_identifier?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreditCardCredentialApiResponse = {
|
export type CreditCardCredentialApiResponse = {
|
||||||
|
|||||||
@@ -4,14 +4,27 @@ import {
|
|||||||
isPasswordCredential,
|
isPasswordCredential,
|
||||||
isSecretCredential,
|
isSecretCredential,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pencil1Icon } from "@radix-ui/react-icons";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { DeleteCredentialButton } from "./DeleteCredentialButton";
|
import { DeleteCredentialButton } from "./DeleteCredentialButton";
|
||||||
|
import { CredentialsModal } from "./CredentialsModal";
|
||||||
|
import { credentialTypeToModalType } from "./useCredentialModalState";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
credential: CredentialApiResponse;
|
credential: CredentialApiResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CredentialItem({ credential }: Props) {
|
function CredentialItem({ credential }: Props) {
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const credentialData = credential.credential;
|
const credentialData = credential.credential;
|
||||||
|
const modalType = credentialTypeToModalType(credential.credential_type);
|
||||||
const getTotpTypeDisplay = (totpType: string) => {
|
const getTotpTypeDisplay = (totpType: string) => {
|
||||||
switch (totpType) {
|
switch (totpType) {
|
||||||
case "authenticator":
|
case "authenticator":
|
||||||
@@ -98,9 +111,31 @@ function CredentialItem({ credential }: Props) {
|
|||||||
<p className="text-sm text-slate-400">{credential.credential_id}</p>
|
<p className="text-sm text-slate-400">{credential.credential_id}</p>
|
||||||
</div>
|
</div>
|
||||||
{credentialDetails}
|
{credentialDetails}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="tertiary"
|
||||||
|
className="h-8 w-9"
|
||||||
|
onClick={() => setEditModalOpen(true)}
|
||||||
|
aria-label="Edit credential"
|
||||||
|
>
|
||||||
|
<Pencil1Icon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit Credential</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<DeleteCredentialButton credential={credential} />
|
<DeleteCredentialButton credential={credential} />
|
||||||
</div>
|
</div>
|
||||||
|
<CredentialsModal
|
||||||
|
isOpen={editModalOpen}
|
||||||
|
onOpenChange={setEditModalOpen}
|
||||||
|
editingCredential={credential}
|
||||||
|
overrideType={modalType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,26 @@ import {
|
|||||||
useCredentialModalState,
|
useCredentialModalState,
|
||||||
CredentialModalTypes,
|
CredentialModalTypes,
|
||||||
} from "./useCredentialModalState";
|
} from "./useCredentialModalState";
|
||||||
|
import type { CredentialModalType } from "./useCredentialModalState";
|
||||||
import { PasswordCredentialContent } from "./PasswordCredentialContent";
|
import { PasswordCredentialContent } from "./PasswordCredentialContent";
|
||||||
import { SecretCredentialContent } from "./SecretCredentialContent";
|
import { SecretCredentialContent } from "./SecretCredentialContent";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CreditCardCredentialContent } from "./CreditCardCredentialContent";
|
import { CreditCardCredentialContent } from "./CreditCardCredentialContent";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CreateCredentialRequest } from "@/api/types";
|
import {
|
||||||
|
CreateCredentialRequest,
|
||||||
|
CredentialApiResponse,
|
||||||
|
isPasswordCredential,
|
||||||
|
isCreditCardCredential,
|
||||||
|
isSecretCredential,
|
||||||
|
} from "@/api/types";
|
||||||
import { getClient } from "@/api/AxiosClient";
|
import { getClient } from "@/api/AxiosClient";
|
||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
import { InfoCircledIcon, ReloadIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
|
import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery";
|
||||||
|
|
||||||
const PASSWORD_CREDENTIAL_INITIAL_VALUES = {
|
const PASSWORD_CREDENTIAL_INITIAL_VALUES = {
|
||||||
@@ -70,24 +78,33 @@ type Props = {
|
|||||||
/** Optional controlled mode: pass isOpen and onOpenChange to control modal state locally */
|
/** Optional controlled mode: pass isOpen and onOpenChange to control modal state locally */
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** When provided, the modal opens in edit mode and pre-fills available fields */
|
||||||
|
editingCredential?: CredentialApiResponse;
|
||||||
|
/** Override the modal type (used in edit mode to set the correct form) */
|
||||||
|
overrideType?: CredentialModalType;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CredentialsModal({
|
function CredentialsModal({
|
||||||
onCredentialCreated,
|
onCredentialCreated,
|
||||||
isOpen: controlledIsOpen,
|
isOpen: controlledIsOpen,
|
||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
editingCredential,
|
||||||
|
overrideType,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const credentialGetter = useCredentialGetter();
|
const credentialGetter = useCredentialGetter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
isOpen: urlIsOpen,
|
isOpen: urlIsOpen,
|
||||||
type,
|
type: urlType,
|
||||||
setIsOpen: setUrlIsOpen,
|
setIsOpen: setUrlIsOpen,
|
||||||
} = useCredentialModalState();
|
} = useCredentialModalState();
|
||||||
|
|
||||||
|
const isEditMode = !!editingCredential;
|
||||||
|
|
||||||
// Use controlled props if provided, otherwise fall back to URL-based state
|
// Use controlled props if provided, otherwise fall back to URL-based state
|
||||||
const isOpen = controlledIsOpen ?? urlIsOpen;
|
const isOpen = controlledIsOpen ?? urlIsOpen;
|
||||||
const setIsOpen = controlledOnOpenChange ?? setUrlIsOpen;
|
const setIsOpen = controlledOnOpenChange ?? setUrlIsOpen;
|
||||||
|
const type = overrideType ?? urlType;
|
||||||
const { data: credentials } = useCredentialsQuery({
|
const { data: credentials } = useCredentialsQuery({
|
||||||
page_size: 100,
|
page_size: 100,
|
||||||
});
|
});
|
||||||
@@ -101,10 +118,43 @@ function CredentialsModal({
|
|||||||
SECRET_CREDENTIAL_INITIAL_VALUES,
|
SECRET_CREDENTIAL_INITIAL_VALUES,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set default name when modal opens
|
// Set default name when modal opens, or pre-populate fields in edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && credentials) {
|
if (!isOpen) return;
|
||||||
const existingNames = credentials.map((cred) => cred.name);
|
|
||||||
|
if (isEditMode) {
|
||||||
|
reset();
|
||||||
|
const cred = editingCredential.credential;
|
||||||
|
if (isPasswordCredential(cred)) {
|
||||||
|
setPasswordCredentialValues({
|
||||||
|
name: editingCredential.name,
|
||||||
|
username: cred.username,
|
||||||
|
password: "",
|
||||||
|
totp: "",
|
||||||
|
totp_type: cred.totp_type,
|
||||||
|
totp_identifier: cred.totp_identifier ?? "",
|
||||||
|
});
|
||||||
|
} else if (isCreditCardCredential(cred)) {
|
||||||
|
setCreditCardCredentialValues({
|
||||||
|
name: editingCredential.name,
|
||||||
|
cardNumber: "",
|
||||||
|
cardExpirationDate: "",
|
||||||
|
cardCode: "",
|
||||||
|
cardBrand: cred.brand,
|
||||||
|
cardHolderName: "",
|
||||||
|
});
|
||||||
|
} else if (isSecretCredential(cred)) {
|
||||||
|
setSecretCredentialValues({
|
||||||
|
name: editingCredential.name,
|
||||||
|
secretLabel: cred.secret_label ?? "",
|
||||||
|
secretValue: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
const existingNames = credentials.map((c) => c.name);
|
||||||
const defaultName = generateDefaultCredentialName(existingNames);
|
const defaultName = generateDefaultCredentialName(existingNames);
|
||||||
|
|
||||||
setPasswordCredentialValues((prev) => ({
|
setPasswordCredentialValues((prev) => ({
|
||||||
@@ -120,7 +170,7 @@ function CredentialsModal({
|
|||||||
name: defaultName,
|
name: defaultName,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [isOpen, credentials]);
|
}, [isOpen, credentials, isEditMode, editingCredential]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES);
|
setPasswordCredentialValues(PASSWORD_CREDENTIAL_INITIAL_VALUES);
|
||||||
@@ -157,6 +207,41 @@ function CredentialsModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateCredentialMutation = useMutation({
|
||||||
|
mutationFn: async (request: CreateCredentialRequest) => {
|
||||||
|
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||||
|
const response = await client.post(
|
||||||
|
`/credentials/${editingCredential?.credential_id}/update`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
setIsOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["credentials"],
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Credential updated",
|
||||||
|
description: "Your credential has been updated successfully",
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: AxiosError) => {
|
||||||
|
const detail = (error.response?.data as { detail?: string })?.detail;
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: detail ? detail : error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeMutation = isEditMode
|
||||||
|
? updateCredentialMutation
|
||||||
|
: createCredentialMutation;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const name =
|
const name =
|
||||||
type === CredentialModalTypes.PASSWORD
|
type === CredentialModalTypes.PASSWORD
|
||||||
@@ -187,7 +272,7 @@ function CredentialsModal({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createCredentialMutation.mutate({
|
activeMutation.mutate({
|
||||||
name,
|
name,
|
||||||
credential_type: "password",
|
credential_type: "password",
|
||||||
credential: {
|
credential: {
|
||||||
@@ -242,7 +327,7 @@ function CredentialsModal({
|
|||||||
}
|
}
|
||||||
// remove all spaces from the card number
|
// remove all spaces from the card number
|
||||||
const number = creditCardCredentialValues.cardNumber.replace(/\s/g, "");
|
const number = creditCardCredentialValues.cardNumber.replace(/\s/g, "");
|
||||||
createCredentialMutation.mutate({
|
activeMutation.mutate({
|
||||||
name,
|
name,
|
||||||
credential_type: "credit_card",
|
credential_type: "credit_card",
|
||||||
credential: {
|
credential: {
|
||||||
@@ -267,7 +352,7 @@ function CredentialsModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createCredentialMutation.mutate({
|
activeMutation.mutate({
|
||||||
name,
|
name,
|
||||||
credential_type: "secret",
|
credential_type: "secret",
|
||||||
credential: {
|
credential: {
|
||||||
@@ -315,18 +400,26 @@ function CredentialsModal({
|
|||||||
>
|
>
|
||||||
<DialogContent className="w-[700px] max-w-[700px]">
|
<DialogContent className="w-[700px] max-w-[700px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-bold">Add Credential</DialogTitle>
|
<DialogTitle className="font-bold">
|
||||||
|
{isEditMode ? "Edit Credential" : "Add Credential"}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{isEditMode && (
|
||||||
|
<Alert>
|
||||||
|
<InfoCircledIcon className="size-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
For security, saved passwords and secrets are never retrieved.
|
||||||
|
Please re-enter all fields to update this credential.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{credentialContent}
|
{credentialContent}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={activeMutation.isPending}>
|
||||||
onClick={handleSave}
|
{activeMutation.isPending ? (
|
||||||
disabled={createCredentialMutation.isPending}
|
|
||||||
>
|
|
||||||
{createCredentialMutation.isPending ? (
|
|
||||||
<ReloadIcon className="mr-2 size-4 animate-spin" />
|
<ReloadIcon className="mr-2 size-4 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
Save
|
{isEditMode ? "Update" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -57,4 +57,25 @@ function useCredentialModalState(): ReturnType {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a backend credential_type ("password" | "credit_card" | "secret")
|
||||||
|
* to the modal type used by CredentialsModal ("password" | "credit-card" | "secret").
|
||||||
|
*/
|
||||||
|
export function credentialTypeToModalType(
|
||||||
|
credentialType: "password" | "credit_card" | "secret",
|
||||||
|
): CredentialModalType {
|
||||||
|
switch (credentialType) {
|
||||||
|
case "password":
|
||||||
|
return CredentialModalTypes.PASSWORD;
|
||||||
|
case "credit_card":
|
||||||
|
return CredentialModalTypes.CREDIT_CARD;
|
||||||
|
case "secret":
|
||||||
|
return CredentialModalTypes.SECRET;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = credentialType;
|
||||||
|
throw new Error(`Unhandled credential type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { useCredentialModalState };
|
export { useCredentialModalState };
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
"""Credential management API endpoints.
|
||||||
|
|
||||||
|
SECURITY INVARIANT — NO RAW CREDENTIAL RETRIEVAL
|
||||||
|
=================================================
|
||||||
|
Credential endpoints must NEVER return sensitive credential data (passwords,
|
||||||
|
TOTP secrets, full card numbers, CVVs, expiration dates, card holder names,
|
||||||
|
or secret values) in any API response. The only fields that may be returned
|
||||||
|
are non-sensitive metadata:
|
||||||
|
|
||||||
|
- Password credentials: ``username``, ``totp_type``, ``totp_identifier``
|
||||||
|
- Credit card credentials: ``last_four``, ``brand``
|
||||||
|
- Secret credentials: ``secret_label``
|
||||||
|
|
||||||
|
This is enforced by the ``*CredentialResponse`` Pydantic models and the
|
||||||
|
``_convert_to_response()`` helper. When adding new credential types or
|
||||||
|
modifying existing ones, ensure that:
|
||||||
|
|
||||||
|
1. The response model never includes the raw secret material.
|
||||||
|
2. The ``_convert_to_response()`` function only maps non-sensitive fields.
|
||||||
|
3. No endpoint (including ``get_credential`` and ``get_credentials``) ever
|
||||||
|
fetches and returns the decrypted secret from the vault.
|
||||||
|
|
||||||
|
Violating this invariant would allow any caller with a valid API key to
|
||||||
|
exfiltrate stored passwords, card numbers, and secrets — which is the
|
||||||
|
exact threat the vault architecture is designed to prevent.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -442,6 +469,13 @@ async def get_credential(
|
|||||||
),
|
),
|
||||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||||
) -> CredentialResponse:
|
) -> CredentialResponse:
|
||||||
|
"""Return non-sensitive metadata for a single credential.
|
||||||
|
|
||||||
|
SECURITY: This endpoint intentionally does NOT return the raw secret
|
||||||
|
material (password, card number, CVV, secret value, etc.). Only
|
||||||
|
non-sensitive fields are included in the response. See the module
|
||||||
|
docstring for the full security invariant.
|
||||||
|
"""
|
||||||
credential = await app.DATABASE.get_credential(
|
credential = await app.DATABASE.get_credential(
|
||||||
credential_id=credential_id, organization_id=current_org.organization_id
|
credential_id=credential_id, organization_id=current_org.organization_id
|
||||||
)
|
)
|
||||||
@@ -493,6 +527,11 @@ async def get_credentials(
|
|||||||
openapi_extra={"x-fern-sdk-parameter-name": "page_size"},
|
openapi_extra={"x-fern-sdk-parameter-name": "page_size"},
|
||||||
),
|
),
|
||||||
) -> list[CredentialResponse]:
|
) -> list[CredentialResponse]:
|
||||||
|
"""Return non-sensitive metadata for all credentials (paginated).
|
||||||
|
|
||||||
|
SECURITY: Like ``get_credential``, this endpoint never returns raw secret
|
||||||
|
material. See the module docstring for the full security invariant.
|
||||||
|
"""
|
||||||
credentials = await app.DATABASE.get_credentials(current_org.organization_id, page=page, page_size=page_size)
|
credentials = await app.DATABASE.get_credentials(current_org.organization_id, page=page, page_size=page_size)
|
||||||
return [_convert_to_response(credential) for credential in credentials]
|
return [_convert_to_response(credential) for credential in credentials]
|
||||||
|
|
||||||
@@ -825,6 +864,13 @@ async def _get_credential_vault_service() -> CredentialVaultService:
|
|||||||
|
|
||||||
|
|
||||||
def _convert_to_response(credential: Credential) -> CredentialResponse:
|
def _convert_to_response(credential: Credential) -> CredentialResponse:
|
||||||
|
"""Convert an internal ``Credential`` to a safe API response.
|
||||||
|
|
||||||
|
SECURITY: This function must ONLY copy non-sensitive metadata into the
|
||||||
|
response. Never include passwords, TOTP secrets, full card numbers, CVVs,
|
||||||
|
expiration dates, card holder names, or secret values. See the module
|
||||||
|
docstring for the full security invariant.
|
||||||
|
"""
|
||||||
if credential.credential_type == CredentialType.PASSWORD:
|
if credential.credential_type == CredentialType.PASSWORD:
|
||||||
credential_response = PasswordCredentialResponse(
|
credential_response = PasswordCredentialResponse(
|
||||||
username=credential.username or credential.credential_id,
|
username=credential.username or credential.credential_id,
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ class TotpType(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class PasswordCredentialResponse(BaseModel):
|
class PasswordCredentialResponse(BaseModel):
|
||||||
"""Response model for password credentials, containing only the username."""
|
"""Response model for password credentials — non-sensitive fields only.
|
||||||
|
|
||||||
|
SECURITY: Must NEVER include password, TOTP secret, or TOTP identifier.
|
||||||
|
"""
|
||||||
|
|
||||||
username: str = Field(..., description="The username associated with the credential", examples=["user@example.com"])
|
username: str = Field(..., description="The username associated with the credential", examples=["user@example.com"])
|
||||||
totp_type: TotpType = Field(
|
totp_type: TotpType = Field(
|
||||||
@@ -44,14 +47,20 @@ class PasswordCredentialResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreditCardCredentialResponse(BaseModel):
|
class CreditCardCredentialResponse(BaseModel):
|
||||||
"""Response model for credit card credentials, containing only the last four digits and brand."""
|
"""Response model for credit card credentials — non-sensitive fields only.
|
||||||
|
|
||||||
|
SECURITY: Must NEVER include full card number, CVV, expiration date, or card holder name.
|
||||||
|
"""
|
||||||
|
|
||||||
last_four: str = Field(..., description="Last four digits of the credit card number", examples=["1234"])
|
last_four: str = Field(..., description="Last four digits of the credit card number", examples=["1234"])
|
||||||
brand: str = Field(..., description="Brand of the credit card", examples=["visa"])
|
brand: str = Field(..., description="Brand of the credit card", examples=["visa"])
|
||||||
|
|
||||||
|
|
||||||
class SecretCredentialResponse(BaseModel):
|
class SecretCredentialResponse(BaseModel):
|
||||||
"""Response model for secret credentials."""
|
"""Response model for secret credentials — non-sensitive fields only.
|
||||||
|
|
||||||
|
SECURITY: Must NEVER include the secret_value.
|
||||||
|
"""
|
||||||
|
|
||||||
secret_label: str | None = Field(default=None, description="Optional label for the stored secret")
|
secret_label: str | None = Field(default=None, description="Optional label for the stored secret")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user