Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions ee/apps/den-web/app/(den)/_components/auth-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,370 @@
"use client";

import { ArrowRight, CheckCircle2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { isSamePathname } from "../_lib/client-route";
import type { AuthMode } from "../_lib/den-flow";
import { useDenFlow } from "../_providers/den-flow-provider";

function getDesktopGrant(url: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
const grant = parsed.searchParams.get("grant")?.trim() ?? "";
return grant || null;
} catch {
return null;
}
}

function GitHubLogo() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-4 w-4 shrink-0">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.5 7.5 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8 8 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
/>
</svg>
);
}

function GoogleLogo() {
return (
<svg viewBox="0 0 18 18" aria-hidden="true" className="h-4 w-4 shrink-0">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62Z" />
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.31-1.58-5.01-3.7H.96v2.33A9 9 0 0 0 9 18Z" />
<path fill="#FBBC05" d="M3.99 10.72A5.41 5.41 0 0 1 3.71 9c0-.6.1-1.18.28-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.03-2.33Z" />
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.43 1.33l2.57-2.57C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.03 2.33c.7-2.12 2.67-3.7 5.01-3.7Z" />
</svg>
);
}

function SocialButton({
children,
onClick,
disabled,
}: {
children: ReactNode;
onClick: () => void;
disabled: boolean;
}) {
return (
<button
type="button"
className="flex w-full items-center justify-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-[14px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}

export function AuthPanel({
panelTitle,
panelCopy,
prefilledEmail,
prefillKey,
initialMode = "sign-up",
lockEmail = false,
hideSocialAuth = false,
hideEmailField = false,
eyebrow = "Account",
}: {
panelTitle?: string;
panelCopy?: string;
prefilledEmail?: string;
prefillKey?: string;
initialMode?: AuthMode;
lockEmail?: boolean;
hideSocialAuth?: boolean;
hideEmailField?: boolean;
eyebrow?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const prefillRef = useRef<string | null>(null);
const [copiedDesktopField, setCopiedDesktopField] = useState<"link" | "code" | null>(null);
const {
authMode,
setAuthMode,
email,
setEmail,
password,
setPassword,
verificationCode,
setVerificationCode,
verificationRequired,
authBusy,
authInfo,
authError,
desktopAuthRequested,
desktopRedirectUrl,
desktopRedirectBusy,
showAuthFeedback,
submitAuth,
submitVerificationCode,
resendVerificationCode,
cancelVerification,
beginSocialAuth,
resolveUserLandingRoute,
} = useDenFlow();

const desktopGrant = getDesktopGrant(desktopRedirectUrl);
const resolvedPanelTitle = verificationRequired
? "Verify your email."
: panelTitle ?? (authMode === "sign-up" ? "Create your Cloud account." : "Sign in to Cloud.");
const resolvedPanelCopy = verificationRequired
? "Enter the six-digit code from your inbox to finish setup."
: panelCopy ?? (
authMode === "sign-up"
? "Start with email, GitHub, or Google."
: "Welcome back. Keep your team setup in sync across Cloud and desktop."
);
const showLockedEmailSummary = Boolean(prefilledEmail && lockEmail && hideEmailField);

useEffect(() => {
const key = prefillKey ?? prefilledEmail?.trim() ?? null;
if (!key || prefillRef.current === key) {
return;
}

prefillRef.current = key;
setAuthMode(initialMode);
setEmail(prefilledEmail?.trim() ?? "");
setPassword("");
setVerificationCode("");
}, [initialMode, prefillKey, prefilledEmail, setAuthMode, setEmail, setPassword, setVerificationCode]);

const copyDesktopValue = async (field: "link" | "code", value: string | null) => {
if (!value) return;
await navigator.clipboard.writeText(value);
setCopiedDesktopField(field);
window.setTimeout(() => {
setCopiedDesktopField((current) => (current === field ? null : current));
}, 1800);
};

return (
<div className="rounded-[28px] border border-gray-100 bg-white p-6 shadow-[0_10px_30px_-24px_rgba(15,23,42,0.22)] md:p-7">
<div className="grid gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">
{eyebrow}
</p>
<h2 className="text-[28px] font-semibold tracking-[-0.04em] text-gray-900">{resolvedPanelTitle}</h2>
<p className="text-[14px] leading-relaxed text-gray-500">{resolvedPanelCopy}</p>
</div>

{desktopAuthRequested ? (
<div className="mt-5 rounded-2xl border border-sky-100 bg-sky-50 p-4 text-[13px] text-sky-900">
Finish auth here and we&apos;ll send you back into the OpenWork desktop app.
{desktopRedirectUrl ? (
<div className="mt-4 grid gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-medium text-sky-900 transition-colors hover:bg-sky-100"
onClick={() => window.location.assign(desktopRedirectUrl)}
>
Open OpenWork
</button>
<button
type="button"
className="rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-medium text-sky-900 transition-colors hover:bg-sky-100"
onClick={() => void copyDesktopValue("link", desktopRedirectUrl)}
>
{copiedDesktopField === "link" ? "Copied link" : "Copy sign-in link"}
</button>
{desktopGrant ? (
<button
type="button"
className="rounded-full border border-sky-200 bg-white px-4 py-2 text-xs font-medium text-sky-900 transition-colors hover:bg-sky-100"
onClick={() => void copyDesktopValue("code", desktopGrant)}
>
{copiedDesktopField === "code" ? "Copied code" : "Copy one-time code"}
</button>
) : null}
</div>
<p className="text-xs leading-5 text-sky-800/80">
If OpenWork does not open automatically, copy the sign-in link or one-time code and paste it into the OpenWork desktop app.
</p>
</div>
) : null}
</div>
) : null}

<form
className="mt-5 grid gap-3"
onSubmit={async (event) => {
const next = verificationRequired
? await submitVerificationCode(event)
: await submitAuth(event);
if (next === "dashboard" || next === "join-org") {
const target = await resolveUserLandingRoute();
if (target && !isSamePathname(pathname, target)) {
router.replace(target);
}
} else if (next === "checkout" && !isSamePathname(pathname, "/checkout")) {
router.replace("/checkout");
}
}}
>
{!verificationRequired && !hideSocialAuth ? (
<>
<SocialButton
onClick={() => void beginSocialAuth("github")}
disabled={authBusy || desktopRedirectBusy}
>
<GitHubLogo />
<span>Continue with GitHub</span>
</SocialButton>

<SocialButton
onClick={() => void beginSocialAuth("google")}
disabled={authBusy || desktopRedirectBusy}
>
<GoogleLogo />
<span>Continue with Google</span>
</SocialButton>

<div
className="flex items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400"
aria-hidden="true"
>
<span className="h-px flex-1 bg-gray-200" />
<span>or</span>
<span className="h-px flex-1 bg-gray-200" />
</div>
</>
) : null}

{showLockedEmailSummary ? (
<div className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Invited email
</p>
<p className="mt-1 text-[14px] font-medium text-gray-900">{prefilledEmail}</p>
</div>
) : null}

{!hideEmailField ? (
<label className="grid gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Email
</span>
<input
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-[14px] text-gray-900 outline-none transition focus:border-gray-300 focus:ring-4 focus:ring-gray-900/5 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
readOnly={lockEmail}
disabled={lockEmail}
required
/>
</label>
) : null}

{!verificationRequired ? (
<label className="grid gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Password
</span>
<input
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-[14px] text-gray-900 outline-none transition focus:border-gray-300 focus:ring-4 focus:ring-gray-900/5"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={authMode === "sign-up" ? "new-password" : "current-password"}
required
/>
</label>
) : (
<label className="grid gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Verification code
</span>
<input
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-center text-[18px] font-semibold tracking-[0.35em] text-gray-900 outline-none transition focus:border-gray-300 focus:ring-4 focus:ring-gray-900/5"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={verificationCode}
onChange={(event) =>
setVerificationCode(event.target.value.replace(/\D+/g, "").slice(0, 6))
}
autoComplete="one-time-code"
required
/>
</label>
)}

<button
type="submit"
className="flex w-full items-center justify-center gap-2 rounded-full bg-gray-900 px-5 py-3 text-[14px] font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
disabled={authBusy || desktopRedirectBusy}
>
{authBusy || desktopRedirectBusy
? "Working..."
: verificationRequired
? "Verify email"
: authMode === "sign-in"
? "Sign in to Cloud"
: "Create Cloud account"}
{!authBusy && !desktopRedirectBusy ? <ArrowRight className="h-4 w-4" /> : null}
</button>

{verificationRequired ? (
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
className="w-full rounded-full border border-gray-200 bg-white px-4 py-3 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void resendVerificationCode()}
disabled={authBusy || desktopRedirectBusy}
>
Resend code
</button>
<button
type="button"
className="w-full rounded-full border border-gray-200 bg-white px-4 py-3 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => cancelVerification()}
disabled={authBusy || desktopRedirectBusy}
>
Change email
</button>
</div>
) : null}
</form>

{!verificationRequired ? (
<div className="mt-4 flex items-center justify-between gap-3 border-t border-gray-200 pt-4 text-sm text-gray-500">
<p>{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}</p>
<button
type="button"
className="font-medium text-gray-900 transition hover:opacity-70"
onClick={() => setAuthMode(authMode === "sign-in" ? "sign-up" : "sign-in")}
>
{authMode === "sign-in" ? "Create account" : "Switch to sign in"}
</button>
</div>
) : null}

{showAuthFeedback ? (
<div
className="mt-4 grid gap-1 rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3 text-center text-[13px] text-gray-500"
aria-live="polite"
>
<p>{authInfo}</p>
{authError ? <p className="font-medium text-rose-600">{authError}</p> : null}
{!authError && verificationRequired ? (
<div className="mt-1 inline-flex items-center justify-center gap-1 text-emerald-600">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>Waiting for your verification code</span>
</div>
) : null}
</div>
) : null}
</div>
);
}
Loading
Loading