Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,449 @@
/**
* Signup Page - Self-signup for allowed domains
*
* This component is NOT wrapped in the admin Shell.
* It's a standalone public page for self-signup.
*
* Flow:
* 1. Email input form
* 2. "Check your email" confirmation
* 3. After clicking email link: Passkey registration
*/
import { Button, Input, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import * as React from "react";
import { requestSignup, verifySignupToken, type SignupVerifyResult } from "../lib/api";
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
import { LogoLockup } from "./Logo.js";
// ============================================================================
// Types
// ============================================================================
type SignupStep = "email" | "check-email" | "verify" | "complete" | "error";
// ============================================================================
// Step Components
// ============================================================================
interface EmailStepProps {
onSubmit: (email: string) => void;
isLoading: boolean;
error?: string;
}
function EmailStep({ onSubmit, isLoading, error }: EmailStepProps) {
const { t } = useLingui();
const [email, setEmail] = React.useState("");
const [validationError, setValidationError] = React.useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setValidationError(null);
if (!email.trim()) {
setValidationError(t`Email is required`);
return;
}
if (!email.includes("@") || !email.includes(".")) {
setValidationError(t`Please enter a valid email address`);
return;
}
onSubmit(email.trim().toLowerCase());
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div>
<Input
label={t`Email address`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t`you@company.com`}
className={validationError ? "border-kumo-danger" : ""}
disabled={isLoading}
autoComplete="email"
autoFocus
/>
{validationError && <p className="text-sm text-kumo-danger mt-1">{validationError}</p>}
</div>
</div>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">{error}</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader size="sm" />
{t`Sending...`}
</>
) : (
t`Continue`
)}
</Button>
<p className="text-xs text-kumo-subtle text-center">
{t`Only email addresses from allowed domains can sign up.`}
</p>
</form>
);
}
interface CheckEmailStepProps {
email: string;
onResend: () => void;
isResending: boolean;
resendCooldown: number;
}
function CheckEmailStep({ email, onResend, isResending, resendCooldown }: CheckEmailStepProps) {
const { t } = useLingui();
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold">{t`Check your email`}</h2>
<p className="text-kumo-subtle mt-2">
{t`We've sent a verification link to`}{" "}
<span className="font-medium text-kumo-default">{email}</span>
</p>
</div>
<div className="text-sm text-kumo-subtle">
<p>{t`Click the link in the email to continue setting up your account.`}</p>
<p className="mt-2">{t`The link will expire in 15 minutes.`}</p>
</div>
<div className="pt-4 border-t">
<p className="text-sm text-kumo-subtle mb-2">{t`Didn't receive the email?`}</p>
<Button
variant="outline"
size="sm"
onClick={onResend}
disabled={isResending || resendCooldown > 0}
>
{isResending
? t`Sending...`
: resendCooldown > 0
? t`Resend in ${resendCooldown}s`
: t`Resend email`}
</Button>
</div>
</div>
);
}
interface VerifyStepProps {
verifyResult: SignupVerifyResult;
token: string;
onBack: () => void;
}
function handleSignupSuccess() {
// Redirect to admin dashboard after successful signup
window.location.href = "/_emdash/admin";
}
function VerifyStep({ verifyResult, token, onBack: _onBack }: VerifyStepProps) {
const { t } = useLingui();
const [name, setName] = React.useState("");
return (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10 mx-auto mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold">{t`Email verified!`}</h2>
<p className="text-kumo-subtle mt-2">
{t`You'll be signing up as`}{" "}
<span className="font-medium text-kumo-default">{verifyResult.roleName}</span>
</p>
</div>
{/* Email display (read-only) */}
<Input label={t`Email`} value={verifyResult.email} disabled className="bg-kumo-tint" />
{/* Name input (optional) */}
<Input
label={t`Your name (optional)`}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t`Jane Doe`}
autoComplete="name"
/>
{/* Passkey registration */}
<div className="pt-4 border-t">
<h3 className="text-sm font-medium mb-3">{t`Create your passkey`}</h3>
<p className="text-sm text-kumo-subtle mb-4">
{t`Passkeys are a secure, passwordless way to sign in using your device's biometrics, PIN, or security key.`}
</p>
<PasskeyRegistration
optionsEndpoint="/_emdash/api/setup/admin"
verifyEndpoint="/_emdash/api/auth/signup/complete"
onSuccess={handleSignupSuccess}
buttonText={t`Create Account`}
additionalData={{ token, name: name || undefined }}
/>
</div>
</div>
);
}
interface ErrorStepProps {
message: string;
code?: string;
onRetry?: () => void;
}
function ErrorStep({ message, code, onRetry }: ErrorStepProps) {
const { t } = useLingui();
return (
<div className="space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-danger/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-danger"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-kumo-danger">
{code === "token_expired"
? t`Link expired`
: code === "invalid_token"
? t`Invalid link`
: code === "user_exists"
? t`Account exists`
: t`Something went wrong`}
</h2>
<p className="text-kumo-subtle mt-2">{message}</p>
</div>
<div className="space-y-2">
{code === "user_exists" ? (
<Link to="/login">
<Button className="w-full">{t`Sign in instead`}</Button>
</Link>
) : (
onRetry && (
<Button onClick={onRetry} className="w-full">
{t`Request a new link`}
</Button>
)
)}
<Link to="/login">
<Button variant="ghost" className="w-full">
{t`Back to login`}
</Button>
</Link>
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function SignupPage() {
const [step, setStep] = React.useState<SignupStep>("email");
const [email, setEmail] = React.useState("");
const [error, setError] = React.useState<string | undefined>();
const [errorCode, setErrorCode] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(false);
const [verifyResult, setVerifyResult] = React.useState<SignupVerifyResult | null>(null);
const [token, setToken] = React.useState<string | null>(null);
const [resendCooldown, setResendCooldown] = React.useState(0);
// Check for token in URL on mount
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get("token");
if (urlToken) {
setToken(urlToken);
void verifyToken(urlToken);
}
}, []);
// Resend cooldown timer
React.useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
const verifyToken = async (tokenToVerify: string) => {
setIsLoading(true);
setError(undefined);
setErrorCode(undefined);
try {
const result = await verifySignupToken(tokenToVerify);
setVerifyResult(result);
setStep("verify");
} catch (err) {
const verifyError = err instanceof Error ? err : new Error(String(err));
const errorWithCode = verifyError as Error & { code?: string };
setError(verifyError.message);
setErrorCode(typeof errorWithCode.code === "string" ? errorWithCode.code : undefined);
setStep("error");
} finally {
setIsLoading(false);
}
};
const handleEmailSubmit = async (submittedEmail: string) => {
setIsLoading(true);
setError(undefined);
setEmail(submittedEmail);
try {
await requestSignup(submittedEmail);
setStep("check-email");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send verification email");
} finally {
setIsLoading(false);
}
};
const handleResend = async () => {
if (!email || resendCooldown > 0) return;
setIsLoading(true);
try {
await requestSignup(email);
setResendCooldown(60); // 60 second cooldown
} catch {
// Silently fail - don't reveal if email exists
} finally {
setIsLoading(false);
}
};
const handleRetry = () => {
setStep("email");
setError(undefined);
setErrorCode(undefined);
setToken(null);
// Clear token from URL
window.history.replaceState({}, "", window.location.pathname);
};
const { t } = useLingui();
// Loading state for token verification
if (isLoading && token) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base">
<div className="text-center">
<Loader />
<p className="mt-4 text-kumo-subtle">{t`Verifying your link...`}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<LogoLockup className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">
{step === "email" && t`Create an account`}
{step === "check-email" && t`Check your email`}
{step === "verify" && t`Complete signup`}
{step === "error" && t`Oops!`}
</h1>
</div>
{/* Form Card */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{step === "email" && (
<EmailStep onSubmit={handleEmailSubmit} isLoading={isLoading} error={error} />
)}
{step === "check-email" && (
<CheckEmailStep
email={email}
onResend={handleResend}
isResending={isLoading}
resendCooldown={resendCooldown}
/>
)}
{step === "verify" && verifyResult && token && (
<VerifyStep verifyResult={verifyResult} token={token} onBack={handleRetry} />
)}
{step === "error" && (
<ErrorStep
message={error ?? "An unknown error occurred"}
code={errorCode}
onRetry={handleRetry}
/>
)}
</div>
{/* Login link */}
{step === "email" && (
<p className="text-center mt-6 text-sm text-kumo-subtle">
{t`Already have an account?`}{" "}
<Link to="/login" className="text-kumo-brand hover:underline font-medium">
{t`Sign in`}
</Link>
</p>
)}
</div>
</div>
);
}
export default SignupPage;