Add GET /v1/credentials/totp to list recent 2FA codes per org (#3770)

This commit is contained in:
Marc Kelechava
2025-10-29 20:49:25 -07:00
committed by GitHub
parent c78ee6a8d0
commit cae59a3c19
10 changed files with 822 additions and 39 deletions

View File

@@ -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).

View File

@@ -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}

View File

@@ -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<string, string>;
};

View File

@@ -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<HTMLFormElement> = (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 (
<form
onSubmit={handleSubmit}
className={cn("space-y-4", className)}
autoComplete="off"
>
<div className="space-y-1">
<Label htmlFor="totp-identifier-input">Identifier</Label>
<Input
id="totp-identifier-input"
placeholder="Email or phone receiving the code"
autoComplete="off"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
disabled={mutation.isPending}
/>
</div>
<div className="space-y-1">
<Label htmlFor="totp-content-input">Verification content</Label>
<AutoResizingTextarea
id="totp-content-input"
placeholder="Paste the full email/SMS body or the 6-digit code"
value={content}
onChange={(event) => setContent(event.target.value)}
readOnly={mutation.isPending}
className="min-h-[4.5rem]"
/>
<p className="text-xs text-slate-400">
We only store this to help the current login. Avoid pasting unrelated
sensitive data.
</p>
</div>
{showAdvancedFields && (
<div className="space-y-2">
<button
type="button"
onClick={() => setAdvancedOpen((current) => !current)}
className="text-xs text-blue-300 underline-offset-2 hover:text-blue-200"
>
{advancedOpen ? "Hide optional metadata" : "Add optional metadata"}
</button>
{advancedOpen && (
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="totp-workflow-run-input">Workflow run ID</Label>
<Input
id="totp-workflow-run-input"
placeholder="wr_123"
autoComplete="off"
value={workflowRunId}
onChange={(event) => setWorkflowRunId(event.target.value)}
disabled={mutation.isPending}
/>
</div>
<div className="space-y-1">
<Label htmlFor="totp-workflow-id-input">Workflow ID</Label>
<Input
id="totp-workflow-id-input"
placeholder="wf_123"
autoComplete="off"
value={workflowId}
onChange={(event) => setWorkflowId(event.target.value)}
disabled={mutation.isPending}
/>
</div>
<div className="space-y-1">
<Label htmlFor="totp-task-id-input">Task ID</Label>
<Input
id="totp-task-id-input"
placeholder="tsk_123"
autoComplete="off"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
disabled={mutation.isPending}
/>
</div>
</div>
)}
</div>
)}
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{mutation.isPending ? "Sending…" : "Send 2FA Code"}
</Button>
</form>
);
}
export { PushTotpCodeForm };

View File

@@ -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<QueryFnData, unknown, QueryFnData, QueryKey>,
"queryKey" | "queryFn"
>;
};
type UseTotpCodesQueryReturn = ReturnType<
typeof useQuery<QueryFnData, unknown, QueryFnData, QueryKey>
> & {
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<QueryFnData, unknown, QueryFnData, QueryKey>({
queryKey: ["totpCodes", params],
enabled,
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.get<QueryFnData>("/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 };

View File

@@ -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<CredentialFilter, string> = {
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 (
<div className="rounded-md border border-slate-700 bg-slate-elevation1 p-6 text-sm text-slate-300">
{filter ? EMPTY_MESSAGE[filter] : "No credentials stored yet."}
</div>
);
}
return (
<div className="space-y-5">
{credentials.map((credential) => (
{filteredCredentials.map((credential) => (
<CredentialItem
key={credential.credential_id}
credential={credential}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CardStackIcon, PlusIcon } from "@radix-ui/react-icons";
import {
@@ -13,12 +14,16 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { KeyIcon } from "@/components/icons/KeyIcon";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CredentialsTotpTab } from "./CredentialsTotpTab";
const subHeaderText =
"Securely store your passwords or credit cards here to link them throughout your workflows.";
"Securely store your passwords, credit cards, and manage incoming 2FA codes for your workflows.";
function CredentialsPage() {
const { setIsOpen, setType } = useCredentialModalState();
const [activeTab, setActiveTab] = useState("passwords");
return (
<div className="space-y-5">
<h1 className="text-2xl">Credentials</h1>
@@ -54,45 +59,69 @@ function CredentialsPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<CredentialsList />
<Tabs
defaultValue="passwords"
className="space-y-4"
onValueChange={setActiveTab}
>
<TabsList className="bg-slate-elevation1">
<TabsTrigger value="passwords">Passwords</TabsTrigger>
<TabsTrigger value="creditCards">Credit Cards</TabsTrigger>
<TabsTrigger value="twoFactor">2FA</TabsTrigger>
</TabsList>
<TabsContent value="passwords" className="space-y-4">
<CredentialsList filter="password" />
</TabsContent>
<TabsContent value="creditCards" className="space-y-4">
<CredentialsList filter="credit_card" />
</TabsContent>
<TabsContent value="twoFactor" className="space-y-4">
<CredentialsTotpTab />
</TabsContent>
</Tabs>
<CredentialsModal />
{/* Footer note */}
<div className="mt-8 border-t border-slate-700 pt-4">
<div className="text-sm italic text-slate-400">
<strong>Note:</strong> This feature requires a Bitwarden-compatible
server ({" "}
<a
href="https://bitwarden.com/help/self-host-an-organization/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
self-hosted Bitwarden
</a>{" "}
) or{" "}
<a
href="https://github.com/dani-garcia/vaultwarden"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
this community version
</a>{" "}
or a paid Bitwarden account. Make sure the relevant
`SKYVERN_AUTH_BITWARDEN_*` environment variables are configured. See
details{" "}
<a
href="https://docs.skyvern.com/credentials/bitwarden"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
here
</a>
.
{/* Footer note - only for Passwords and Credit Cards tabs */}
{activeTab !== "twoFactor" && (
<div className="mt-8 border-t border-slate-700 pt-4">
<div className="text-sm italic text-slate-400">
<strong>Note:</strong> This feature requires a Bitwarden-compatible
server ({" "}
<a
href="https://bitwarden.com/help/self-host-an-organization/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
self-hosted Bitwarden
</a>{" "}
) or{" "}
<a
href="https://github.com/dani-garcia/vaultwarden"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
this community version
</a>{" "}
or a paid Bitwarden account. Make sure the relevant
`SKYVERN_AUTH_BITWARDEN_*` environment variables are configured. See
details{" "}
<a
href="https://docs.skyvern.com/credentials/bitwarden"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300"
>
here
</a>
.
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<OtpTypeFilter>("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 (
<div className="space-y-6">
<div className="rounded-lg border border-slate-700 bg-slate-elevation1 p-6">
<h2 className="text-lg font-semibold">Push a 2FA Code</h2>
<p className="mt-1 text-sm text-slate-400">
Paste the verification message you received. Skyvern extracts the code
and attaches it to the relevant run.
</p>
<PushTotpCodeForm
className="mt-4"
showAdvancedFields
onSuccess={handleFormSuccess}
/>
</div>
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<Label htmlFor="totp-identifier-filter">Identifier</Label>
<Input
id="totp-identifier-filter"
placeholder="Filter by email or phone"
value={identifierFilter}
onChange={(event) => setIdentifierFilter(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="totp-type-filter">OTP Type</Label>
<Select
value={otpTypeFilter}
onValueChange={(value: OtpTypeFilter) =>
setOtpTypeFilter(value)
}
>
<SelectTrigger id="totp-type-filter" className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="totp">Numeric code</SelectItem>
<SelectItem value="magic_link">Magic link</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="totp-limit-filter">Limit</Label>
<Select
value={String(limit)}
onValueChange={(value) =>
setLimit(Number(value) as (typeof LIMIT_OPTIONS)[number])
}
>
<SelectTrigger id="totp-limit-filter" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LIMIT_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIdentifierFilter("");
setOtpTypeFilter("all");
setLimit(50);
}}
disabled={!hasFilters}
>
Clear filters
</Button>
</div>
{isFeatureUnavailable && (
<Alert variant="destructive">
<AlertTitle>2FA listing unavailable</AlertTitle>
<AlertDescription>
Upgrade the backend to include{" "}
<code>GET /v1/credentials/totp</code>. Once available, this tab
will automatically populate with codes.
</AlertDescription>
</Alert>
)}
{!isFeatureUnavailable && (
<div className="rounded-lg border border-slate-700 bg-slate-elevation1">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[220px]">Identifier</TableHead>
<TableHead>Code</TableHead>
<TableHead>Source</TableHead>
<TableHead>Workflow Run</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={6}>
<div className="space-y-2 p-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-3/4" />
</div>
</TableCell>
</TableRow>
) : null}
{!isLoading && !isFetching && codes.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-sm text-slate-300"
>
No 2FA codes yet. Paste a verification message above or
configure automatic forwarding.
</TableCell>
</TableRow>
) : null}
{!isLoading &&
!isFetching &&
codes.map((code) => (
<TableRow key={code.totp_code_id}>
<TableCell className="font-mono text-xs">
{code.totp_identifier ?? "—"}
</TableCell>
<TableCell className="font-semibold">
{renderCodeContent(code)}
</TableCell>
<TableCell>
<Badge variant="outline">
{code.otp_type ?? "unknown"}
</Badge>
{code.source ? (
<span className="ml-2 text-xs text-slate-400">
{code.source}
</span>
) : null}
</TableCell>
<TableCell className="text-xs">
{code.workflow_run_id ?? "—"}
</TableCell>
<TableCell className="text-xs">
{formatDateTime(code.created_at)}
</TableCell>
<TableCell className="text-xs">
{formatDateTime(code.expired_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}
export { CredentialsTotpTab };

View File

@@ -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,

View File

@@ -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(