diff --git a/fern/credentials/totp.mdx b/fern/credentials/totp.mdx index 0c97f66c..b102e35d 100644 --- a/fern/credentials/totp.mdx +++ b/fern/credentials/totp.mdx @@ -211,3 +211,20 @@ One time login links are supported by breaking your workflow / task into two par You will need to set up something (e.g. Zapier) that monitors the email inbox for incoming magic links. Once you get the link, you can use it to trigger the rest of your task / workflow. When triggering the rest of your task / workflow, you can pass the one time login link as the starting point (e.g. url parameter), and Skyvern will start the new session already logged in. + +## Listing Recent TOTP Codes + +Once your integration is forwarding codes, you can retrieve recent entries for your organization. + +```bash +curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@example.com&limit=10" \ + -H "x-api-key: $SKYVERN_API_KEY" +``` + +**Query parameters** +- `totp_identifier` *(optional)* – filter by the identifier you provided when the code was sent (email, phone, etc.). +- `workflow_run_id` *(optional)* – restrict results to a particular workflow run. +- `otp_type` *(optional)* – filter on `totp` or `magic_link`. +- `limit` *(optional)* – number of records to return (default `50`, maximum `200`). + +The response is a list of TOTP objects, ordered newest first. Skyvern only returns codes created within the last `TOTP_LIFESPAN_MINUTES` (10 minutes by default). diff --git a/fern/docs.yml b/fern/docs.yml index e1863704..aa5548f2 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -192,6 +192,7 @@ navigation: - POST /v1/credentials/{credential_id}/delete - GET /v1/credentials - POST /v1/credentials/totp + - GET /v1/credentials/totp - section: Artifacts contents: - GET /v1/artifacts/{artifact_id} diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 7c8d39d0..5e31688e 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -478,6 +478,35 @@ export type CreditCardCredential = { card_holder_name: string; }; +export const OtpType = { + Totp: "totp", + MagicLink: "magic_link", +} as const; + +export type OtpType = (typeof OtpType)[keyof typeof OtpType]; + +export type TotpCode = { + totp_code_id: string; + totp_identifier: string | null; + code: string; + content: string; + workflow_run_id: string | null; + workflow_id: string | null; + task_id: string | null; + source: string | null; + otp_type: OtpType | null; + expired_at: string | null; + created_at: string; + modified_at: string; +}; + +export type TotpCodeListParams = { + totp_identifier?: string; + workflow_run_id?: string; + otp_type?: OtpType; + limit?: number; +}; + export type ModelsResponse = { models: Record; }; diff --git a/skyvern-frontend/src/components/PushTotpCodeForm.tsx b/skyvern-frontend/src/components/PushTotpCodeForm.tsx new file mode 100644 index 00000000..346cf61a --- /dev/null +++ b/skyvern-frontend/src/components/PushTotpCodeForm.tsx @@ -0,0 +1,239 @@ +import { type FormEventHandler, useEffect, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/use-toast"; +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { cn } from "@/util/utils"; + +type Props = { + className?: string; + defaultIdentifier?: string | null; + defaultWorkflowRunId?: string | null; + defaultWorkflowId?: string | null; + defaultTaskId?: string | null; + showAdvancedFields?: boolean; + onSuccess?: () => void; +}; + +type SendTotpCodeRequest = { + totp_identifier: string; + content: string; + workflow_run_id?: string; + workflow_id?: string; + task_id?: string; + source?: string; +}; + +function PushTotpCodeForm({ + className, + defaultIdentifier, + defaultWorkflowRunId, + defaultWorkflowId, + defaultTaskId, + showAdvancedFields = false, + onSuccess, +}: Props) { + const [identifier, setIdentifier] = useState(defaultIdentifier?.trim() ?? ""); + const [content, setContent] = useState(""); + const [workflowRunId, setWorkflowRunId] = useState( + defaultWorkflowRunId?.trim() ?? "", + ); + const [workflowId, setWorkflowId] = useState(defaultWorkflowId?.trim() ?? ""); + const [taskId, setTaskId] = useState(defaultTaskId?.trim() ?? ""); + const [advancedOpen, setAdvancedOpen] = useState(false); + + const credentialGetter = useCredentialGetter(); + const { toast } = useToast(); + + useEffect(() => { + if ( + typeof defaultIdentifier === "string" && + defaultIdentifier.trim() !== "" && + identifier.trim() === "" + ) { + setIdentifier(defaultIdentifier.trim()); + } + }, [defaultIdentifier, identifier]); + + useEffect(() => { + if ( + typeof defaultWorkflowRunId === "string" && + defaultWorkflowRunId.trim() !== "" && + workflowRunId.trim() === "" + ) { + setWorkflowRunId(defaultWorkflowRunId.trim()); + } + }, [defaultWorkflowRunId, workflowRunId]); + + useEffect(() => { + if ( + typeof defaultWorkflowId === "string" && + defaultWorkflowId.trim() !== "" && + workflowId.trim() === "" + ) { + setWorkflowId(defaultWorkflowId.trim()); + } + }, [defaultWorkflowId, workflowId]); + + useEffect(() => { + if ( + typeof defaultTaskId === "string" && + defaultTaskId.trim() !== "" && + taskId.trim() === "" + ) { + setTaskId(defaultTaskId.trim()); + } + }, [defaultTaskId, taskId]); + + const trimmedIdentifier = useMemo(() => identifier.trim(), [identifier]); + const trimmedContent = useMemo(() => content.trim(), [content]); + const trimmedWorkflowRunId = useMemo( + () => workflowRunId.trim(), + [workflowRunId], + ); + const trimmedWorkflowId = useMemo(() => workflowId.trim(), [workflowId]); + const trimmedTaskId = useMemo(() => taskId.trim(), [taskId]); + + const canSubmit = trimmedIdentifier !== "" && trimmedContent !== ""; + + const mutation = useMutation({ + mutationFn: async (payload: SendTotpCodeRequest) => { + const client = await getClient(credentialGetter, "sans-api-v1"); + return client.post("/credentials/totp", payload); + }, + onSuccess: () => { + toast({ + title: "2FA code sent", + description: "Skyvern will process it shortly.", + }); + setContent(""); + onSuccess?.(); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Failed to send code", + description: "Check the identifier and message format, then retry.", + }); + }, + }); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + if (!canSubmit || mutation.isPending) { + return; + } + + const payload: SendTotpCodeRequest = { + totp_identifier: trimmedIdentifier, + content: trimmedContent, + source: "manual_ui", + }; + + if (trimmedWorkflowRunId !== "") { + payload.workflow_run_id = trimmedWorkflowRunId; + } + if (trimmedWorkflowId !== "") { + payload.workflow_id = trimmedWorkflowId; + } + if (trimmedTaskId !== "") { + payload.task_id = trimmedTaskId; + } + + mutation.mutate(payload); + }; + + return ( +
+
+ + setIdentifier(event.target.value)} + disabled={mutation.isPending} + /> +
+
+ + setContent(event.target.value)} + readOnly={mutation.isPending} + className="min-h-[4.5rem]" + /> +

+ We only store this to help the current login. Avoid pasting unrelated + sensitive data. +

+
+ + {showAdvancedFields && ( +
+ + {advancedOpen && ( +
+
+ + setWorkflowRunId(event.target.value)} + disabled={mutation.isPending} + /> +
+
+ + setWorkflowId(event.target.value)} + disabled={mutation.isPending} + /> +
+
+ + setTaskId(event.target.value)} + disabled={mutation.isPending} + /> +
+
+ )} +
+ )} + + +
+ ); +} + +export { PushTotpCodeForm }; diff --git a/skyvern-frontend/src/hooks/useTotpCodesQuery.ts b/skyvern-frontend/src/hooks/useTotpCodesQuery.ts new file mode 100644 index 00000000..8a9f72c5 --- /dev/null +++ b/skyvern-frontend/src/hooks/useTotpCodesQuery.ts @@ -0,0 +1,89 @@ +import { getClient } from "@/api/AxiosClient"; +import type { TotpCode, TotpCodeListParams } from "@/api/types"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { isAxiosError } from "axios"; +import { useMemo } from "react"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +type QueryFnData = TotpCode[]; +type QueryKey = ["totpCodes", TotpCodeListParams]; + +type Options = { + params: TotpCodeListParams; + enabled?: boolean; + queryOptions?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + >; +}; + +type UseTotpCodesQueryReturn = ReturnType< + typeof useQuery +> & { + isFeatureUnavailable: boolean; + isCredentialAccountMissing: boolean; +}; + +function useTotpCodesQuery({ + params, + enabled = true, + queryOptions, +}: Options): UseTotpCodesQueryReturn { + const credentialGetter = useCredentialGetter(); + + const searchParams = useMemo(() => { + const result = new URLSearchParams(); + if (params.totp_identifier) { + result.set("totp_identifier", params.totp_identifier); + } + if (params.workflow_run_id) { + result.set("workflow_run_id", params.workflow_run_id); + } + if (params.otp_type) { + result.set("otp_type", params.otp_type); + } + if (typeof params.limit === "number") { + result.set("limit", String(params.limit)); + } + return result; + }, [params]); + + const query = useQuery({ + queryKey: ["totpCodes", params], + enabled, + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + const response = await client.get("/credentials/totp", { + params: searchParams, + }); + return response.data; + }, + retry(failureCount, error) { + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + return failureCount < 3; + }, + ...queryOptions, + }); + + const axiosError = isAxiosError(query.error) ? query.error : null; + const errorStatus = axiosError?.response?.status; + const errorDetail = + (axiosError?.response?.data as { detail?: string } | undefined)?.detail ?? + ""; + + const isCredentialAccountMissing = + errorStatus === 404 && errorDetail.includes("Credential account not found"); + + const isFeatureUnavailable = + errorStatus === 404 && !isCredentialAccountMissing; + + return { + ...query, + isFeatureUnavailable, + isCredentialAccountMissing, + }; +} + +export { useTotpCodesQuery }; diff --git a/skyvern-frontend/src/routes/credentials/CredentialsList.tsx b/skyvern-frontend/src/routes/credentials/CredentialsList.tsx index e57d21d5..ad9be3f6 100644 --- a/skyvern-frontend/src/routes/credentials/CredentialsList.tsx +++ b/skyvern-frontend/src/routes/credentials/CredentialsList.tsx @@ -2,7 +2,18 @@ import { Skeleton } from "@/components/ui/skeleton"; import { CredentialItem } from "./CredentialItem"; import { useCredentialsQuery } from "@/routes/workflows/hooks/useCredentialsQuery"; -function CredentialsList() { +type CredentialFilter = "password" | "credit_card"; + +type Props = { + filter?: CredentialFilter; +}; + +const EMPTY_MESSAGE: Record = { + password: "No password credentials stored yet.", + credit_card: "No credit cards stored yet.", +}; + +function CredentialsList({ filter }: Props = {}) { const { data: credentials, isLoading } = useCredentialsQuery(); if (isLoading) { @@ -18,9 +29,29 @@ function CredentialsList() { return null; } + const filteredCredentials = (() => { + if (!credentials) { + return []; + } + if (!filter) { + return credentials; + } + return credentials.filter( + (credential) => credential.credential_type === filter, + ); + })(); + + if (filteredCredentials.length === 0) { + return ( +
+ {filter ? EMPTY_MESSAGE[filter] : "No credentials stored yet."} +
+ ); + } + return (
- {credentials.map((credential) => ( + {filteredCredentials.map((credential) => (

Credentials

@@ -54,45 +59,69 @@ function CredentialsPage() {
- + + + Passwords + Credit Cards + 2FA + + + + + + + + + + + + + + - {/* Footer note */} -
-
- Note: This feature requires a Bitwarden-compatible - server ({" "} - - self-hosted Bitwarden - {" "} - ) or{" "} - - this community version - {" "} - or a paid Bitwarden account. Make sure the relevant - `SKYVERN_AUTH_BITWARDEN_*` environment variables are configured. See - details{" "} - - here - - . + {/* Footer note - only for Passwords and Credit Cards tabs */} + {activeTab !== "twoFactor" && ( +
+
+ Note: This feature requires a Bitwarden-compatible + server ({" "} + + self-hosted Bitwarden + {" "} + ) or{" "} + + this community version + {" "} + or a paid Bitwarden account. Make sure the relevant + `SKYVERN_AUTH_BITWARDEN_*` environment variables are configured. See + details{" "} + + here + + . +
-
+ )}
); } diff --git a/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx b/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx new file mode 100644 index 00000000..86834733 --- /dev/null +++ b/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx @@ -0,0 +1,250 @@ +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { PushTotpCodeForm } from "@/components/PushTotpCodeForm"; +import { useTotpCodesQuery } from "@/hooks/useTotpCodesQuery"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import type { OtpType, TotpCode } from "@/api/types"; +import { Skeleton } from "@/components/ui/skeleton"; + +type OtpTypeFilter = "all" | OtpType; + +const LIMIT_OPTIONS = [25, 50, 100] as const; + +function formatDateTime(value: string | null): string { + if (!value) { + return "—"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +function renderCodeContent(code: TotpCode): string { + if (!code.code) { + return "—"; + } + return code.code; +} + +function CredentialsTotpTab() { + const [identifierFilter, setIdentifierFilter] = useState(""); + const [otpTypeFilter, setOtpTypeFilter] = useState("all"); + const [limit, setLimit] = useState<(typeof LIMIT_OPTIONS)[number]>(50); + + const queryClient = useQueryClient(); + + const queryParams = useMemo(() => { + return { + totp_identifier: identifierFilter.trim() || undefined, + otp_type: otpTypeFilter === "all" ? undefined : otpTypeFilter, + limit, + }; + }, [identifierFilter, limit, otpTypeFilter]); + + const { data, isLoading, isFetching, isFeatureUnavailable } = + useTotpCodesQuery({ + params: queryParams, + }); + + const codes = data ?? []; + const hasFilters = + identifierFilter.trim() !== "" || otpTypeFilter !== "all" || limit !== 50; + + const handleFormSuccess = () => { + void queryClient.invalidateQueries({ + queryKey: ["totpCodes"], + }); + }; + + return ( +
+
+

Push a 2FA Code

+

+ Paste the verification message you received. Skyvern extracts the code + and attaches it to the relevant run. +

+ +
+ +
+
+
+
+ + setIdentifierFilter(event.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + +
+ + {isFeatureUnavailable && ( + + 2FA listing unavailable + + Upgrade the backend to include{" "} + GET /v1/credentials/totp. Once available, this tab + will automatically populate with codes. + + + )} + + {!isFeatureUnavailable && ( +
+ + + + Identifier + Code + Source + Workflow Run + Created + Expires + + + + {isLoading || isFetching ? ( + + +
+ + + +
+
+
+ ) : null} + + {!isLoading && !isFetching && codes.length === 0 ? ( + + + No 2FA codes yet. Paste a verification message above or + configure automatic forwarding. + + + ) : null} + + {!isLoading && + !isFetching && + codes.map((code) => ( + + + {code.totp_identifier ?? "—"} + + + {renderCodeContent(code)} + + + + {code.otp_type ?? "unknown"} + + {code.source ? ( + + {code.source} + + ) : null} + + + {code.workflow_run_id ?? "—"} + + + {formatDateTime(code.created_at)} + + + {formatDateTime(code.expired_at)} + + + ))} +
+
+
+ )} +
+
+ ); +} + +export { CredentialsTotpTab }; diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index f229b433..d0584f09 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -2799,13 +2799,17 @@ class AgentDB: totp_identifier: str, valid_lifespan_minutes: int = settings.TOTP_LIFESPAN_MINUTES, otp_type: OTPType | None = None, + workflow_run_id: str | None = None, + limit: int | None = None, ) -> list[TOTPCode]: """ 1. filter by: - organization_id - totp_identifier + - workflow_run_id (optional) 2. make sure created_at is within the valid lifespan 3. sort by task_id/workflow_id/workflow_run_id nullslast and created_at desc + 4. apply an optional limit at the DB layer """ all_null = and_( TOTPCodeModel.task_id.is_(None), @@ -2821,10 +2825,45 @@ class AgentDB: ) if otp_type: query = query.filter(TOTPCodeModel.otp_type == otp_type) + if workflow_run_id is not None: + query = query.filter(TOTPCodeModel.workflow_run_id == workflow_run_id) query = query.order_by(asc(all_null), TOTPCodeModel.created_at.desc()) + if limit is not None: + query = query.limit(limit) totp_code = (await session.scalars(query)).all() return [TOTPCode.model_validate(totp_code) for totp_code in totp_code] + async def get_recent_otp_codes( + self, + organization_id: str, + limit: int = 50, + valid_lifespan_minutes: int = settings.TOTP_LIFESPAN_MINUTES, + otp_type: OTPType | None = None, + workflow_run_id: str | None = None, + ) -> list[TOTPCode]: + """ + Return recent otp codes for an organization ordered by newest first with optional + workflow_run_id filtering. + """ + all_null = and_( + TOTPCodeModel.task_id.is_(None), + TOTPCodeModel.workflow_id.is_(None), + TOTPCodeModel.workflow_run_id.is_(None), + ) + async with self.Session() as session: + query = ( + select(TOTPCodeModel) + .filter_by(organization_id=organization_id) + .filter(TOTPCodeModel.created_at > datetime.utcnow() - timedelta(minutes=valid_lifespan_minutes)) + ) + if otp_type: + query = query.filter(TOTPCodeModel.otp_type == otp_type) + if workflow_run_id is not None: + query = query.filter(TOTPCodeModel.workflow_run_id == workflow_run_id) + query = query.order_by(asc(all_null), TOTPCodeModel.created_at.desc()).limit(limit) + totp_codes = (await session.scalars(query)).all() + return [TOTPCode.model_validate(totp_code) for totp_code in totp_codes] + async def create_otp_code( self, organization_id: str, diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 9333a635..bf0302f6 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -124,6 +124,65 @@ async def send_totp_code( ) +@base_router.get( + "/credentials/totp", + response_model=list[TOTPCode], + summary="List TOTP codes", + description="Retrieves recent TOTP codes for the current organization.", + tags=["Credentials"], + openapi_extra={ + "x-fern-sdk-method-name": "get_totp_codes", + }, + include_in_schema=False, +) +@base_router.get( + "/credentials/totp/", + response_model=list[TOTPCode], + include_in_schema=False, +) +async def get_totp_codes( + curr_org: Organization = Depends(org_auth_service.get_current_org), + totp_identifier: str | None = Query( + None, + description="Filter by TOTP identifier such as an email or phone number.", + examples=["john.doe@example.com"], + ), + workflow_run_id: str | None = Query( + None, + description="Filter by workflow run ID.", + examples=["wr_123456"], + ), + otp_type: OTPType | None = Query( + None, + description="Filter by OTP type (e.g. totp, magic_link).", + examples=[OTPType.TOTP.value], + ), + limit: int = Query( + 50, + ge=1, + le=200, + description="Maximum number of codes to return.", + ), +) -> list[TOTPCode]: + if totp_identifier: + codes = await app.DATABASE.get_otp_codes( + organization_id=curr_org.organization_id, + totp_identifier=totp_identifier, + otp_type=otp_type, + workflow_run_id=workflow_run_id, + limit=limit, + ) + else: + codes = await app.DATABASE.get_recent_otp_codes( + organization_id=curr_org.organization_id, + limit=limit, + otp_type=otp_type, + workflow_run_id=workflow_run_id, + ) + + return codes + + @legacy_base_router.post("/credentials") @legacy_base_router.post("/credentials/", include_in_schema=False) @base_router.post(