Files
Dorod-Sky/skyvern-frontend/src/routes/credentials/CredentialsTotpTab.tsx

251 lines
8.4 KiB
TypeScript

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