Add GET /v1/credentials/totp to list recent 2FA codes per org (#3770)
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user