Add GET /v1/credentials/totp to list recent 2FA codes per org (#3770)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
239
skyvern-frontend/src/components/PushTotpCodeForm.tsx
Normal file
239
skyvern-frontend/src/components/PushTotpCodeForm.tsx
Normal 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 };
|
||||
89
skyvern-frontend/src/hooks/useTotpCodesQuery.ts
Normal file
89
skyvern-frontend/src/hooks/useTotpCodesQuery.ts
Normal 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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
250
skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx
Normal file
250
skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user