mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): streamline org invite signup (#1291)
This commit is contained in:
370
ee/apps/den-web/app/(den)/_components/auth-panel.tsx
Normal file
370
ee/apps/den-web/app/(den)/_components/auth-panel.tsx
Normal file
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -2,44 +2,11 @@
|
||||
|
||||
import { PaperMeshGradient } from "@openwork/ui/react";
|
||||
import { Dithering } from "@paper-design/shaders-react";
|
||||
import { ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { isSamePathname } from "../_lib/client-route";
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { AuthPanel } from "./auth-panel";
|
||||
|
||||
function FeatureCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
@@ -50,27 +17,6 @@ function FeatureCard({ title, body }: { title: string; body: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SocialButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.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>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingPanel({ title, body }: { title: string; body: string }) {
|
||||
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">
|
||||
@@ -92,45 +38,9 @@ export function AuthScreen() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const routingRef = useRef(false);
|
||||
const [copiedDesktopField, setCopiedDesktopField] = useState<"link" | "code" | null>(null);
|
||||
const {
|
||||
authMode,
|
||||
setAuthMode,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
verificationCode,
|
||||
setVerificationCode,
|
||||
verificationRequired,
|
||||
authBusy,
|
||||
authInfo,
|
||||
authError,
|
||||
user,
|
||||
sessionHydrated,
|
||||
desktopAuthRequested,
|
||||
desktopRedirectUrl,
|
||||
desktopRedirectBusy,
|
||||
showAuthFeedback,
|
||||
submitAuth,
|
||||
submitVerificationCode,
|
||||
resendVerificationCode,
|
||||
cancelVerification,
|
||||
beginSocialAuth,
|
||||
resolveUserLandingRoute,
|
||||
} = useDenFlow();
|
||||
const desktopGrant = getDesktopGrant(desktopRedirectUrl);
|
||||
const { user, sessionHydrated, desktopAuthRequested, resolveUserLandingRoute } = useDenFlow();
|
||||
const hasResolvedSession = sessionHydrated && Boolean(user) && !desktopAuthRequested;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResolvedSession || routingRef.current) {
|
||||
return;
|
||||
@@ -148,18 +58,6 @@ export function AuthScreen() {
|
||||
});
|
||||
}, [hasResolvedSession, pathname, resolveUserLandingRoute, router]);
|
||||
|
||||
const panelTitle = verificationRequired
|
||||
? "Verify your email."
|
||||
: authMode === "sign-up"
|
||||
? "Create your Cloud account."
|
||||
: "Sign in to Cloud.";
|
||||
|
||||
const panelCopy = verificationRequired
|
||||
? "Enter the six-digit code from your inbox to finish setup."
|
||||
: authMode === "sign-up"
|
||||
? "Start with email, GitHub, or Google."
|
||||
: "Welcome back. Keep your team setup in sync across Cloud and desktop.";
|
||||
|
||||
if (!sessionHydrated) {
|
||||
return (
|
||||
<section className="den-page flex w-full items-center py-4 lg:min-h-[calc(100vh-2.5rem)]">
|
||||
@@ -241,212 +139,7 @@ export function AuthScreen() {
|
||||
body="We found your account and are sending you to the right Cloud destination now."
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
Account
|
||||
</p>
|
||||
<h2 className="text-[28px] font-semibold tracking-[-0.04em] text-gray-900">{panelTitle}</h2>
|
||||
<p className="text-[14px] leading-relaxed text-gray-500">{panelCopy}</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'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 ? (
|
||||
<>
|
||||
<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}
|
||||
|
||||
<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"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!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>
|
||||
<AuthPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getErrorMessage, requestJson } from "../_lib/den-flow";
|
||||
import {
|
||||
PENDING_ORG_INVITATION_STORAGE_KEY,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type DenInvitationPreview,
|
||||
} from "../_lib/den-org";
|
||||
import { useDenFlow } from "../_providers/den-flow-provider";
|
||||
import { AuthPanel } from "./auth-panel";
|
||||
|
||||
function LoadingCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
@@ -51,22 +52,6 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
|
||||
const [joinBusy, setJoinBusy] = useState(false);
|
||||
const [joinError, setJoinError] = useState<string | null>(null);
|
||||
|
||||
const signUpHref = useMemo(() => {
|
||||
if (!invitationId) {
|
||||
return "/?mode=sign-up";
|
||||
}
|
||||
|
||||
return `/?mode=sign-up&invite=${encodeURIComponent(invitationId)}`;
|
||||
}, [invitationId]);
|
||||
|
||||
const signInHref = useMemo(() => {
|
||||
if (!invitationId) {
|
||||
return "/?mode=sign-in";
|
||||
}
|
||||
|
||||
return `/?mode=sign-in&invite=${encodeURIComponent(invitationId)}`;
|
||||
}, [invitationId]);
|
||||
|
||||
const invitedEmailMatches = preview && user
|
||||
? preview.invitation.email.trim().toLowerCase() === user.email.trim().toLowerCase()
|
||||
: false;
|
||||
@@ -173,6 +158,9 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
|
||||
|
||||
async function handleSwitchAccount() {
|
||||
await signOut();
|
||||
if (typeof window !== "undefined" && invitationId) {
|
||||
window.sessionStorage.setItem(PENDING_ORG_INVITATION_STORAGE_KEY, invitationId);
|
||||
}
|
||||
router.replace(getJoinOrgRoute(invitationId));
|
||||
}
|
||||
|
||||
@@ -197,6 +185,45 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.invitation.status === "pending" && !user) {
|
||||
return (
|
||||
<section className="mx-auto grid w-full max-w-[64rem] gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)]">
|
||||
<div className="grid gap-6 rounded-[32px] border border-gray-100 bg-white p-6 shadow-[0_10px_30px_-24px_rgba(15,23,42,0.22)] md:p-8">
|
||||
<div className="grid gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">OpenWork Cloud</p>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-[15px] font-medium text-gray-500">You've been invited to</p>
|
||||
<h1 className="text-[2.5rem] font-semibold tracking-[-0.06em] text-gray-900">{preview.organization.name}</h1>
|
||||
</div>
|
||||
<p className="text-[14px] leading-relaxed text-gray-500">Role: {formatRoleLabel(preview.invitation.role)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-3xl border border-gray-100 bg-gray-50 p-5">
|
||||
<div className="grid gap-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Invited email</p>
|
||||
<p className="text-[16px] font-medium text-gray-900">{preview.invitation.email}</p>
|
||||
</div>
|
||||
<p className="text-[14px] leading-relaxed text-gray-600">
|
||||
Set a password for this invited email to create your OpenWork Cloud account, or switch to sign in if you already use it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthPanel
|
||||
eyebrow="Invite"
|
||||
panelTitle={`Join ${preview.organization.name}.`}
|
||||
panelCopy="We prefilled your invited email so you can finish account setup without going through the generic signup flow first."
|
||||
prefilledEmail={preview.invitation.email}
|
||||
prefillKey={preview.invitation.id}
|
||||
initialMode="sign-up"
|
||||
lockEmail
|
||||
hideSocialAuth
|
||||
hideEmailField
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const showAcceptAction = preview.invitation.status === "pending" && Boolean(user) && invitedEmailMatches;
|
||||
|
||||
return (
|
||||
@@ -228,18 +255,6 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : !user ? (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-[15px] leading-relaxed text-gray-600">Create an account or sign in first, then come back here to confirm the invitation.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={signUpHref} className="inline-flex items-center rounded-full bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800">
|
||||
Create account to continue
|
||||
</Link>
|
||||
<Link href={signInHref} className="inline-flex items-center rounded-full border border-gray-200 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : !invitedEmailMatches ? (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-[15px] leading-relaxed text-gray-600">
|
||||
|
||||
Reference in New Issue
Block a user