mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Add Gmail integration frontend (#6784)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
parent
3235d4e648
commit
6f46a25fca
16 changed files with 545 additions and 45 deletions
|
|
@ -12027,7 +12027,7 @@
|
|||
"continue_on_empty": {
|
||||
"type": "boolean",
|
||||
"title": "Continue On Empty",
|
||||
"description": "When the run download directory has no files, fail the block (False, default) instead of reporting success. Set True to allow an empty upload (success with no files uploaded).",
|
||||
"description": "When the run download directory has no files, allow the empty upload only after confirming no registered, browser-session, or alternate candidate downloads exist (False, default). Set True to always allow an empty upload.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -314,8 +314,12 @@ export interface GoogleOAuthCredential {
|
|||
id: string;
|
||||
organization_id: string;
|
||||
credential_name: string;
|
||||
scopes: string | null;
|
||||
valid: boolean;
|
||||
provider?: string;
|
||||
state?: string;
|
||||
scopes_requested?: string[] | string | null;
|
||||
scopes_granted?: string[] | string | null;
|
||||
scopes?: string[] | string | null;
|
||||
valid?: boolean | null;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
}
|
||||
|
|
@ -332,6 +336,7 @@ export interface GoogleOAuthCredentialListResponse {
|
|||
export interface CreateGoogleOAuthAuthorizeRequest {
|
||||
redirect_uri: string;
|
||||
credential_name?: string;
|
||||
scope_profile?: string;
|
||||
app_origin?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
44
skyvern-frontend/src/components/PushTotpCodeForm.test.ts
Normal file
44
skyvern-frontend/src/components/PushTotpCodeForm.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { OtpType } from "@/api/types";
|
||||
import { buildSendTotpCodeRequest } from "./pushTotpCodeRequest";
|
||||
|
||||
describe("buildSendTotpCodeRequest", () => {
|
||||
it("includes explicit magic-link type and optional metadata", () => {
|
||||
expect(
|
||||
buildSendTotpCodeRequest({
|
||||
identifier: " user@example.com ",
|
||||
content: " https://example.com/login?token=abc ",
|
||||
otpType: OtpType.MagicLink,
|
||||
workflowRunId: " wr_123 ",
|
||||
workflowId: " wf_123 ",
|
||||
taskId: " tsk_123 ",
|
||||
}),
|
||||
).toEqual({
|
||||
totp_identifier: "user@example.com",
|
||||
content: "https://example.com/login?token=abc",
|
||||
type: OtpType.MagicLink,
|
||||
source: "manual_ui",
|
||||
workflow_run_id: "wr_123",
|
||||
workflow_id: "wf_123",
|
||||
task_id: "tsk_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits blank optional metadata for numeric codes", () => {
|
||||
expect(
|
||||
buildSendTotpCodeRequest({
|
||||
identifier: " user@example.com ",
|
||||
content: " 123456 ",
|
||||
otpType: OtpType.Totp,
|
||||
workflowRunId: " ",
|
||||
workflowId: "",
|
||||
taskId: "",
|
||||
}),
|
||||
).toEqual({
|
||||
totp_identifier: "user@example.com",
|
||||
content: "123456",
|
||||
type: OtpType.Totp,
|
||||
source: "manual_ui",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,25 @@
|
|||
import { type FormEventHandler, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { OtpType, type OtpType as OtpTypeValue } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { cn } from "@/util/utils";
|
||||
import {
|
||||
buildSendTotpCodeRequest,
|
||||
type SendTotpCodeRequest,
|
||||
} from "./pushTotpCodeRequest";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
|
|
@ -19,15 +31,6 @@ type Props = {
|
|||
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,
|
||||
|
|
@ -44,6 +47,7 @@ function PushTotpCodeForm({
|
|||
);
|
||||
const [workflowId, setWorkflowId] = useState(defaultWorkflowId?.trim() ?? "");
|
||||
const [taskId, setTaskId] = useState(defaultTaskId?.trim() ?? "");
|
||||
const [otpType, setOtpType] = useState<OtpTypeValue>(OtpType.Totp);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
|
@ -128,21 +132,14 @@ function PushTotpCodeForm({
|
|||
return;
|
||||
}
|
||||
|
||||
const payload: SendTotpCodeRequest = {
|
||||
totp_identifier: trimmedIdentifier,
|
||||
const payload = buildSendTotpCodeRequest({
|
||||
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;
|
||||
}
|
||||
otpType,
|
||||
workflowRunId: trimmedWorkflowRunId,
|
||||
workflowId: trimmedWorkflowId,
|
||||
taskId: trimmedTaskId,
|
||||
});
|
||||
|
||||
mutation.mutate(payload);
|
||||
};
|
||||
|
|
@ -168,7 +165,11 @@ function PushTotpCodeForm({
|
|||
<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"
|
||||
placeholder={
|
||||
otpType === OtpType.MagicLink
|
||||
? "Paste the full email body or magic link"
|
||||
: "Paste the full email/SMS body or the 6-digit code"
|
||||
}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
readOnly={mutation.isPending}
|
||||
|
|
@ -179,6 +180,22 @@ function PushTotpCodeForm({
|
|||
sensitive data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="totp-type-input">OTP Type</Label>
|
||||
<Select
|
||||
value={otpType}
|
||||
onValueChange={(value: OtpTypeValue) => setOtpType(value)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="totp-type-input" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={OtpType.Totp}>Numeric code</SelectItem>
|
||||
<SelectItem value={OtpType.MagicLink}>Magic link</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showAdvancedFields && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
39
skyvern-frontend/src/components/icons/GmailIcon.tsx
Normal file
39
skyvern-frontend/src/components/icons/GmailIcon.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function GmailIcon({ className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 256 193"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M58.181 192.05V93.14L27.507 65.077 0 49.504V174.595C0 184.253 7.825 192.05 17.455 192.05H58.181Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M197.819 192.05H238.546C248.204 192.05 256 184.224 256 174.595V49.505L224.844 67.342 197.819 93.14V192.05Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M58.181 93.14L54.007 54.493 58.181 17.504 128 69.868 197.819 17.504 202.488 52.496 197.819 93.14 128 145.504 58.181 93.14Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M197.819 17.504V93.14L256 49.504V26.231C256 4.646 231.36 -7.659 214.11 5.286L197.819 17.504Z"
|
||||
fill="#FBBC04"
|
||||
/>
|
||||
<path
|
||||
d="M0 49.504L26.759 69.573 58.181 93.14V17.504L41.89 5.286C24.61 -7.659 0 4.646 0 26.231V49.504Z"
|
||||
fill="#C5221F"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { GmailIcon };
|
||||
52
skyvern-frontend/src/components/pushTotpCodeRequest.ts
Normal file
52
skyvern-frontend/src/components/pushTotpCodeRequest.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { OtpType as OtpTypeValue } from "@/api/types";
|
||||
|
||||
export type SendTotpCodeRequest = {
|
||||
totp_identifier: string;
|
||||
content: string;
|
||||
type: OtpTypeValue;
|
||||
workflow_run_id?: string;
|
||||
workflow_id?: string;
|
||||
task_id?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type BuildSendTotpCodeRequestInput = {
|
||||
identifier: string;
|
||||
content: string;
|
||||
otpType: OtpTypeValue;
|
||||
workflowRunId: string;
|
||||
workflowId: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export function buildSendTotpCodeRequest({
|
||||
identifier,
|
||||
content,
|
||||
otpType,
|
||||
workflowRunId,
|
||||
workflowId,
|
||||
taskId,
|
||||
}: BuildSendTotpCodeRequestInput): SendTotpCodeRequest {
|
||||
const payload: SendTotpCodeRequest = {
|
||||
totp_identifier: identifier.trim(),
|
||||
content: content.trim(),
|
||||
type: otpType,
|
||||
source: "manual_ui",
|
||||
};
|
||||
|
||||
const trimmedWorkflowRunId = workflowRunId.trim();
|
||||
const trimmedWorkflowId = workflowId.trim();
|
||||
const trimmedTaskId = taskId.trim();
|
||||
|
||||
if (trimmedWorkflowRunId !== "") {
|
||||
payload.workflow_run_id = trimmedWorkflowRunId;
|
||||
}
|
||||
if (trimmedWorkflowId !== "") {
|
||||
payload.workflow_id = trimmedWorkflowId;
|
||||
}
|
||||
if (trimmedTaskId !== "") {
|
||||
payload.task_id = trimmedTaskId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
85
skyvern-frontend/src/hooks/useGoogleOAuthCredentials.test.ts
Normal file
85
skyvern-frontend/src/hooks/useGoogleOAuthCredentials.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { GoogleOAuthCredential } from "@/api/types";
|
||||
import {
|
||||
getGoogleOAuthCredentialScopesGranted,
|
||||
getGoogleOAuthCredentialScopesRequested,
|
||||
matchesGoogleOAuthIntegrationScopes,
|
||||
normalizeGoogleOAuthScopes,
|
||||
} from "./useGoogleOAuthCredentials";
|
||||
|
||||
const baseCredential: GoogleOAuthCredential = {
|
||||
id: "credential_1",
|
||||
organization_id: "org_1",
|
||||
credential_name: "Google",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
modified_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("Google OAuth credential scope helpers", () => {
|
||||
it("normalizes array and string scope payloads", () => {
|
||||
expect(normalizeGoogleOAuthScopes(["scope:a", "scope:b"])).toEqual([
|
||||
"scope:a",
|
||||
"scope:b",
|
||||
]);
|
||||
expect(normalizeGoogleOAuthScopes("scope:a scope:b,scope:c")).toEqual([
|
||||
"scope:a",
|
||||
"scope:b",
|
||||
"scope:c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to legacy string scopes when scopes_granted is absent", () => {
|
||||
expect(
|
||||
getGoogleOAuthCredentialScopesGranted({
|
||||
...baseCredential,
|
||||
scopes: "https://www.googleapis.com/auth/gmail.readonly openid",
|
||||
}),
|
||||
).toEqual(["https://www.googleapis.com/auth/gmail.readonly", "openid"]);
|
||||
});
|
||||
|
||||
it("prefers scopes_granted over legacy scopes", () => {
|
||||
expect(
|
||||
getGoogleOAuthCredentialScopesGranted({
|
||||
...baseCredential,
|
||||
scopes_granted: ["new:scope"],
|
||||
scopes: "legacy:scope",
|
||||
}),
|
||||
).toEqual(["new:scope"]);
|
||||
});
|
||||
|
||||
it("normalizes requested scopes", () => {
|
||||
expect(
|
||||
getGoogleOAuthCredentialScopesRequested({
|
||||
...baseCredential,
|
||||
scopes_requested: "requested:a requested:b",
|
||||
}),
|
||||
).toEqual(["requested:a", "requested:b"]);
|
||||
});
|
||||
|
||||
it("matches integrations by requested scopes before cumulative granted scopes", () => {
|
||||
const credential = {
|
||||
...baseCredential,
|
||||
scopes_requested: ["gmail"],
|
||||
scopes_granted: ["gmail", "sheets"],
|
||||
};
|
||||
|
||||
expect(matchesGoogleOAuthIntegrationScopes(credential, ["gmail"])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesGoogleOAuthIntegrationScopes(credential, ["sheets"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to granted scopes when requested scopes are absent", () => {
|
||||
expect(
|
||||
matchesGoogleOAuthIntegrationScopes(
|
||||
{
|
||||
...baseCredential,
|
||||
scopes_granted: ["sheets"],
|
||||
},
|
||||
["sheets"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
GoogleOAuthCredentialResponse,
|
||||
} from "@/api/types";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
export { GOOGLE_SHEETS_REQUIRED_SCOPES } from "@/util/googleScopes";
|
||||
|
||||
const BROADCAST_CHANNEL_NAME = "skyvern:google-oauth-credentials";
|
||||
|
||||
|
|
@ -24,12 +25,74 @@ function broadcastCredentialsChanged() {
|
|||
credentialBroadcastChannel?.postMessage("invalidate");
|
||||
}
|
||||
|
||||
// Falls back to the first credential even when none are valid, so a single
|
||||
export function normalizeGoogleOAuthScopes(
|
||||
scopes: readonly string[] | string | null | undefined,
|
||||
): readonly string[] | undefined {
|
||||
if (Array.isArray(scopes)) {
|
||||
return scopes;
|
||||
}
|
||||
if (typeof scopes === "string") {
|
||||
return scopes
|
||||
.split(/[\s,]+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getGoogleOAuthCredentialScopesGranted(
|
||||
credential: GoogleOAuthCredential,
|
||||
): readonly string[] {
|
||||
return (
|
||||
normalizeGoogleOAuthScopes(credential.scopes_granted) ??
|
||||
normalizeGoogleOAuthScopes(credential.scopes) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export function getGoogleOAuthCredentialScopesRequested(
|
||||
credential: GoogleOAuthCredential,
|
||||
): readonly string[] {
|
||||
return normalizeGoogleOAuthScopes(credential.scopes_requested) ?? [];
|
||||
}
|
||||
|
||||
export function isGoogleOAuthCredentialActive(
|
||||
credential: GoogleOAuthCredential,
|
||||
): boolean {
|
||||
if (credential.state) {
|
||||
return credential.state === "active";
|
||||
}
|
||||
return credential.valid === true;
|
||||
}
|
||||
|
||||
export function hasGoogleOAuthCredentialScopes(
|
||||
credential: GoogleOAuthCredential,
|
||||
requiredScopes: readonly string[],
|
||||
): boolean {
|
||||
const granted = new Set(getGoogleOAuthCredentialScopesGranted(credential));
|
||||
return requiredScopes.every((scope) => granted.has(scope));
|
||||
}
|
||||
|
||||
export function matchesGoogleOAuthIntegrationScopes(
|
||||
credential: GoogleOAuthCredential,
|
||||
requiredScopes: readonly string[],
|
||||
): boolean {
|
||||
const requested = getGoogleOAuthCredentialScopesRequested(credential);
|
||||
if (requested.length > 0) {
|
||||
const requestedSet = new Set(requested);
|
||||
return requiredScopes.every((scope) => requestedSet.has(scope));
|
||||
}
|
||||
return hasGoogleOAuthCredentialScopes(credential, requiredScopes);
|
||||
}
|
||||
|
||||
// Falls back to the first credential even when none are active, so a single
|
||||
// needs-reconnect account is still selected rather than left blank.
|
||||
export function getDefaultGoogleOAuthCredentialId(
|
||||
credentials: GoogleOAuthCredential[],
|
||||
): string | undefined {
|
||||
return credentials.find((c) => c.valid)?.id ?? credentials[0]?.id;
|
||||
return (
|
||||
credentials.find(isGoogleOAuthCredentialActive)?.id ?? credentials[0]?.id
|
||||
);
|
||||
}
|
||||
|
||||
type ApiError = { response?: { data?: { detail?: string } } } & Error;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRightIcon } from "@radix-ui/react-icons";
|
||||
import { PushTotpCodeForm } from "@/components/PushTotpCodeForm";
|
||||
import { useTotpCodesQuery } from "@/hooks/useTotpCodesQuery";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -22,18 +24,65 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { OtpType, TotpCode } from "@/api/types";
|
||||
import {
|
||||
OtpType,
|
||||
type OtpType as OtpTypeValue,
|
||||
type TotpCode,
|
||||
} from "@/api/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
|
||||
import { GmailIcon } from "@/components/icons/GmailIcon";
|
||||
|
||||
type OtpTypeFilter = "all" | OtpType;
|
||||
type OtpTypeFilter = "all" | OtpTypeValue;
|
||||
|
||||
const LIMIT_OPTIONS = [25, 50, 100] as const;
|
||||
|
||||
function renderCodeContent(code: TotpCode): string {
|
||||
function safeHttpUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:"
|
||||
? url.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function otpTypeLabel(otpType: OtpTypeValue | null): string {
|
||||
switch (otpType) {
|
||||
case OtpType.Totp:
|
||||
return "Numeric code";
|
||||
case OtpType.MagicLink:
|
||||
return "Magic link";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function renderCodeContent(code: TotpCode) {
|
||||
if (!code.code) {
|
||||
return "—";
|
||||
}
|
||||
if (code.otp_type === OtpType.MagicLink) {
|
||||
const href = safeHttpUrl(code.code);
|
||||
if (!href) {
|
||||
return (
|
||||
<span className="block max-w-xs truncate" title={code.code}>
|
||||
{code.code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 underline underline-offset-2 hover:text-blue-300"
|
||||
>
|
||||
Open magic link
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return code.code;
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +118,30 @@ function CredentialsTotpTab() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-elevation1 p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-neutral-200 bg-white dark:border-slate-700">
|
||||
<GmailIcon className="size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">
|
||||
Connect Gmail for automatic 2FA
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-neutral-600 dark:text-slate-400">
|
||||
Skyvern can find verification codes and magic links in a
|
||||
connected Gmail inbox without manual forwarding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild size="sm" className="shrink-0">
|
||||
<Link to="/integrations?query=gmail">
|
||||
Connect Gmail <ArrowRightIcon className="ml-1.5 size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-neutral-600 dark:text-slate-400">
|
||||
|
|
@ -119,8 +192,8 @@ function CredentialsTotpTab() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="totp">Numeric code</SelectItem>
|
||||
<SelectItem value="magic_link">Magic link</SelectItem>
|
||||
<SelectItem value={OtpType.Totp}>Numeric code</SelectItem>
|
||||
<SelectItem value={OtpType.MagicLink}>Magic link</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -177,7 +250,7 @@ function CredentialsTotpTab() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[220px]">Identifier</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Verification</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Agent Run</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
|
|
@ -221,7 +294,7 @@ function CredentialsTotpTab() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{code.otp_type ?? "unknown"}
|
||||
{otpTypeLabel(code.otp_type)}
|
||||
</Badge>
|
||||
{code.source ? (
|
||||
<span className="ml-2 text-xs text-neutral-600 dark:text-slate-400">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import {
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
getDefaultGoogleOAuthCredentialId,
|
||||
hasGoogleOAuthCredentialScopes,
|
||||
useGoogleOAuthCredentials,
|
||||
} from "@/hooks/useGoogleOAuthCredentials";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
|
|
@ -30,8 +32,15 @@ function GoogleOAuthCredentialSelector({
|
|||
value,
|
||||
onChange,
|
||||
}: Readonly<Props>) {
|
||||
const { credentials, isLoading, isFetching } = useGoogleOAuthCredentials();
|
||||
const {
|
||||
credentials: allCredentials,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useGoogleOAuthCredentials();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const credentials = allCredentials.filter((credential) =>
|
||||
hasGoogleOAuthCredentialScopes(credential, GOOGLE_SHEETS_REQUIRED_SCOPES),
|
||||
);
|
||||
|
||||
// Keep latest callback without forcing effect re-runs.
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ vi.mock("@/store/WorkflowHasChangesStore", () => ({
|
|||
let mockCredentials: GoogleOAuthCredential[] = [];
|
||||
let mockIsLoading = false;
|
||||
let mockIsFetching = false;
|
||||
const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
|
||||
const GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
||||
const GOOGLE_DRIVE_METADATA_SCOPE =
|
||||
"https://www.googleapis.com/auth/drive.metadata.readonly";
|
||||
const GOOGLE_SHEETS_REQUIRED_SCOPES = [
|
||||
GOOGLE_SHEETS_SCOPE,
|
||||
GOOGLE_DRIVE_FILE_SCOPE,
|
||||
GOOGLE_DRIVE_METADATA_SCOPE,
|
||||
];
|
||||
const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
|
||||
|
||||
vi.mock("@/hooks/useGoogleOAuthCredentials", async () => {
|
||||
const actual = await vi.importActual<
|
||||
|
|
@ -42,12 +52,34 @@ vi.mock("@/hooks/useGoogleOAuthCredentials", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
function credential(id: string, valid: boolean = true): GoogleOAuthCredential {
|
||||
function credential(
|
||||
id: string,
|
||||
state: string = "active",
|
||||
scopesGranted: string[] = GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
): GoogleOAuthCredential {
|
||||
return {
|
||||
id,
|
||||
organization_id: "o_1",
|
||||
credential_name: id,
|
||||
scopes: null,
|
||||
provider: "google",
|
||||
state,
|
||||
scopes_requested: scopesGranted,
|
||||
scopes_granted: scopesGranted,
|
||||
created_at: "",
|
||||
modified_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCredential(
|
||||
id: string,
|
||||
valid: boolean = true,
|
||||
scopes: string[] = GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
): GoogleOAuthCredential {
|
||||
return {
|
||||
id,
|
||||
organization_id: "o_1",
|
||||
credential_name: id,
|
||||
scopes,
|
||||
valid,
|
||||
created_at: "",
|
||||
modified_at: "",
|
||||
|
|
@ -153,10 +185,19 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("fills from the legacy credential response shape during split deploys", () => {
|
||||
mockCredentials = [legacyCredential("cred_legacy")];
|
||||
render(<Harness nodes={[writeNode("g1", "")]} />);
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("g1", {
|
||||
credentialId: "cred_legacy",
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers the first valid credential over an invalid one", () => {
|
||||
mockCredentials = [
|
||||
credential("cred_invalid", false),
|
||||
credential("cred_valid", true),
|
||||
credential("cred_invalid", "revoked"),
|
||||
credential("cred_valid", "active"),
|
||||
];
|
||||
render(<Harness nodes={[writeNode("g1", "")]} />);
|
||||
|
||||
|
|
@ -166,7 +207,7 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
|
|||
});
|
||||
|
||||
test("falls back to the only (invalid) credential rather than leaving it blank", () => {
|
||||
mockCredentials = [credential("cred_only", false)];
|
||||
mockCredentials = [credential("cred_only", "revoked")];
|
||||
render(<Harness nodes={[writeNode("g1", "")]} />);
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("g1", {
|
||||
|
|
@ -174,6 +215,30 @@ describe("useResolveDefaultGoogleSheetsCredential (SKY-11219)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("ignores Gmail-only credentials when filling Sheets blocks", () => {
|
||||
mockCredentials = [
|
||||
credential("cred_gmail", "active", [GMAIL_SCOPE]),
|
||||
credential("cred_sheets"),
|
||||
];
|
||||
render(<Harness nodes={[writeNode("g1", "")]} />);
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("g1", {
|
||||
credentialId: "cred_sheets",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores partial Sheets grants that are missing Drive scopes", () => {
|
||||
mockCredentials = [
|
||||
credential("cred_spreadsheets_only", "active", [GOOGLE_SHEETS_SCOPE]),
|
||||
credential("cred_sheets"),
|
||||
];
|
||||
render(<Harness nodes={[writeNode("g1", "")]} />);
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("g1", {
|
||||
credentialId: "cred_sheets",
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves an already-configured credential untouched", () => {
|
||||
mockCredentials = [credential("cred_default")];
|
||||
render(<Harness nodes={[writeNode("g1", "cred_existing")]} />);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { useReactFlow } from "@xyflow/react";
|
|||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import {
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
getDefaultGoogleOAuthCredentialId,
|
||||
hasGoogleOAuthCredentialScopes,
|
||||
useGoogleOAuthCredentials,
|
||||
} from "@/hooks/useGoogleOAuthCredentials";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
|
|
@ -45,7 +47,19 @@ export function useResolveDefaultGoogleSheetsCredential(
|
|||
const { credentials, isLoading, isFetching } = useGoogleOAuthCredentials({
|
||||
enabled: !readOnly && unconfiguredNodeIds.length > 0,
|
||||
});
|
||||
const defaultCredentialId = getDefaultGoogleOAuthCredentialId(credentials);
|
||||
const googleSheetsCredentials = useMemo(
|
||||
() =>
|
||||
credentials.filter((credential) =>
|
||||
hasGoogleOAuthCredentialScopes(
|
||||
credential,
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
),
|
||||
),
|
||||
[credentials],
|
||||
);
|
||||
const defaultCredentialId = getDefaultGoogleOAuthCredentialId(
|
||||
googleSheetsCredentials,
|
||||
);
|
||||
|
||||
// Stable for a given set of unconfigured blocks regardless of node ordering.
|
||||
const unconfiguredKey = unconfiguredNodeIds.join(",");
|
||||
|
|
|
|||
9
skyvern-frontend/src/util/googleScopes.ts
Normal file
9
skyvern-frontend/src/util/googleScopes.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const GOOGLE_SHEETS_REQUIRED_SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
||||
] as const;
|
||||
|
||||
export const GOOGLE_GMAIL_REQUIRED_SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
] as const;
|
||||
|
|
@ -8,8 +8,10 @@ from typing import Any
|
|||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
GMAIL_API_BASE = "https://gmail.googleapis.com/gmail/v1"
|
||||
LOG = structlog.get_logger()
|
||||
|
||||
_OTP_QUERY_TERMS = "(verification OR verify OR code OR passcode OR otp OR 2fa OR one-time OR password)"
|
||||
_SAFE_EMAIL_QUERY_IDENTIFIER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+$")
|
||||
|
|
@ -169,8 +171,14 @@ async def search_recent_otp_messages(
|
|||
params={"format": "full"},
|
||||
)
|
||||
candidate = _candidate(message)
|
||||
if candidate and candidate.internal_date and (not cutoff or candidate.internal_date >= cutoff):
|
||||
candidates_.append(candidate)
|
||||
if not candidate:
|
||||
continue
|
||||
if not candidate.internal_date:
|
||||
LOG.debug("Skipping Gmail OTP candidate without internalDate", message_id=candidate.message_id)
|
||||
continue
|
||||
if cutoff and candidate.internal_date < cutoff:
|
||||
continue
|
||||
candidates_.append(candidate)
|
||||
return candidates_
|
||||
|
||||
if client is None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
MAX_SEEN_GMAIL_MESSAGE_IDS = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class GmailOTPVerificationContext:
|
||||
|
|
@ -10,3 +13,15 @@ class GmailOTPVerificationContext:
|
|||
credential_ids_loaded_at: datetime | None = None
|
||||
last_searched_at_by_credential: dict[str, datetime] = field(default_factory=dict)
|
||||
seen_message_ids: set[str] = field(default_factory=set)
|
||||
seen_message_id_order: deque[str] = field(default_factory=deque)
|
||||
|
||||
def has_seen_message_id(self, message_id: str) -> bool:
|
||||
return message_id in self.seen_message_ids
|
||||
|
||||
def remember_message_id(self, message_id: str) -> None:
|
||||
if message_id in self.seen_message_ids:
|
||||
return
|
||||
self.seen_message_ids.add(message_id)
|
||||
self.seen_message_id_order.append(message_id)
|
||||
while len(self.seen_message_id_order) > MAX_SEEN_GMAIL_MESSAGE_IDS:
|
||||
self.seen_message_ids.discard(self.seen_message_id_order.popleft())
|
||||
|
|
|
|||
|
|
@ -465,6 +465,8 @@ async def poll_otp_value(
|
|||
# intentional backstops only when the webhook has no code yet.
|
||||
if totp_verification_url:
|
||||
if org_token is None:
|
||||
# The org token is only needed for webhook polling. Gmail
|
||||
# and DB-only polling should not fail on missing webhook auth.
|
||||
org_token = await app.DATABASE.organizations.get_valid_org_auth_token(
|
||||
organization_id, OrganizationAuthTokenType.api.value
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue