add authentication routes, components, and styling for CrawlerX app
This commit is contained in:
parent
a3b13a112a
commit
5ae39ebfd4
BIN
public/auth-bg.jpg
Normal file
BIN
public/auth-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 610 KiB |
@ -1,4 +1,14 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Zap,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getCurrentAuthRedirect,
|
||||
@ -21,16 +31,9 @@ export function useAuthPageState(redirect: string | undefined) {
|
||||
? getOAuthSignedQuery(window.location.search)
|
||||
: null;
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
// `team` mode: email/password sign-in is live, but Google social login and
|
||||
// self-serve signup are not.
|
||||
const isSessionMode = isSessionClientAuthMode();
|
||||
|
||||
return {
|
||||
redirectTo,
|
||||
oauthQuery,
|
||||
isHostedMode,
|
||||
isSessionMode,
|
||||
};
|
||||
return { redirectTo, oauthQuery, isHostedMode, isSessionMode };
|
||||
}
|
||||
|
||||
export function AuthMethodChooser({
|
||||
@ -52,21 +55,20 @@ export function AuthMethodChooser({
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full border border-black/10 bg-white text-neutral-900 hover:border-black/20 hover:bg-neutral-50 disabled:bg-white disabled:text-neutral-500 disabled:opacity-70"
|
||||
className="w-full h-11 sm:h-12 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 text-slate-800 font-medium text-sm transition-all flex items-center justify-center gap-2 shadow-sm disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer"
|
||||
onClick={onContinueWithGoogle}
|
||||
disabled={disabled || isBusy}
|
||||
>
|
||||
<GoogleLogo />
|
||||
{isBusy ? "Opening Google..." : googleLabel}
|
||||
<span>{isBusy ? "Opening Google..." : googleLabel}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full"
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-800 font-medium text-sm transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer"
|
||||
onClick={onContinueWithEmail}
|
||||
disabled={disabled || isBusy}
|
||||
>
|
||||
{emailLabel}
|
||||
<span>{emailLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -95,6 +97,120 @@ function GoogleLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInputField({
|
||||
icon: Icon,
|
||||
type = "text",
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
autoComplete,
|
||||
required,
|
||||
minLength,
|
||||
maxLength,
|
||||
disabled,
|
||||
showPasswordToggle,
|
||||
onTogglePassword,
|
||||
isPasswordVisible,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
error?: string | null;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
showPasswordToggle?: boolean;
|
||||
onTogglePassword?: () => void;
|
||||
isPasswordVisible?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-3.5 text-slate-400 pointer-events-none flex items-center justify-center">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<input
|
||||
type={
|
||||
showPasswordToggle ? (isPasswordVisible ? "text" : "password") : type
|
||||
}
|
||||
className="w-full h-11 sm:h-12 bg-slate-50/90 hover:bg-slate-50 focus:bg-white border border-slate-200 rounded-xl pl-10 pr-10 text-sm text-slate-900 placeholder:text-slate-400 focus:border-blue-600 focus:ring-4 focus:ring-blue-500/10 transition-all outline-none disabled:opacity-60"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 p-1 transition-colors cursor-pointer"
|
||||
onClick={onTogglePassword}
|
||||
tabIndex={-1}
|
||||
aria-label={isPasswordVisible ? "Hide password" : "Show password"}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-rose-500 font-medium pl-1">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthSubmitButton({
|
||||
children,
|
||||
isSubmitting,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isSubmitting?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isSubmitting}
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 active:scale-[0.99] text-white font-medium shadow-lg shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
) : (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthDivider({ text = "or" }: { text?: string }) {
|
||||
return (
|
||||
<div className="relative my-4 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-200" />
|
||||
</div>
|
||||
<span className="relative bg-white px-3 text-xs text-slate-400 font-medium uppercase tracking-wider">
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthPageCard({
|
||||
title,
|
||||
helperText,
|
||||
@ -107,30 +223,35 @@ export function AuthPageCard({
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-xs space-y-6">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-full max-w-[440px] bg-white/95 backdrop-blur-xl rounded-[28px] p-7 sm:p-9 shadow-[0_20px_60px_-15px_rgba(8,112,184,0.15),0_2px_20px_rgba(0,0,0,0.06)] border border-white/80 transition-all">
|
||||
<div className="text-center space-y-1.5 mb-6">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="CrawlerX"
|
||||
className="mx-auto size-10 rounded-lg"
|
||||
alt="Metatroncube"
|
||||
className="mx-auto size-11 object-contain drop-shadow-sm"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
{title}
|
||||
</h2>
|
||||
{helperText ? (
|
||||
<p className="text-sm text-base-content/60 mt-1">{helperText}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-normal">
|
||||
{helperText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">{children}</div>
|
||||
|
||||
{footer ? <div className="mt-5 space-y-3 text-center">{footer}</div> : null}
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-slate-400 pt-4 mt-2">
|
||||
<Lock className="size-3.5 text-slate-400 shrink-0" />
|
||||
<span>Your information is secure and encrypted.</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{footer ? <div className="text-center">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `team` mode has no self-serve signup — the workspace owner provisions
|
||||
// accounts. Shown in place of the sign-up form.
|
||||
export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
|
||||
return (
|
||||
<AuthPageCard
|
||||
@ -140,22 +261,147 @@ export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="btn btn-primary w-full"
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium shadow-md shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
Back to sign in
|
||||
<span>Back to sign in</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</AuthPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthPageShell({ children }: { children: React.ReactNode }) {
|
||||
const VALUE_PROPS = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Get Started Quickly",
|
||||
desc: "Set up your account in just a few simple steps.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Your Data is Safe",
|
||||
desc: "Enterprise-grade security to keep your information protected.",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Build for What's Next",
|
||||
desc: "Powerful tools and flexible features to help you grow.",
|
||||
},
|
||||
];
|
||||
|
||||
export function AuthPageShell({
|
||||
children,
|
||||
topLink,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
topLink?: {
|
||||
text: string;
|
||||
linkText: string;
|
||||
to: string;
|
||||
search?: Record<string, unknown>;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the
|
||||
// auto-margin child centers when it fits but stays fully reachable (top and
|
||||
// bottom) when it's taller than the viewport. Plain `justify-center` clips
|
||||
// the overflow with no way to scroll to it.
|
||||
<div className="h-[100dvh] flex flex-col items-center overflow-y-auto p-4 bg-base-200">
|
||||
<div className="m-auto flex w-full flex-col items-center">{children}</div>
|
||||
<div className="min-h-screen w-full relative flex flex-col lg:flex-row bg-[#020617] text-slate-900 selection:bg-blue-500 selection:text-white overflow-x-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center pointer-events-none z-0 opacity-90 transition-opacity"
|
||||
style={{ backgroundImage: `url('/auth-bg.jpg')` }}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 w-full lg:w-[50%] bg-gradient-to-r from-slate-950/95 via-slate-950/80 to-transparent pointer-events-none z-0" />
|
||||
|
||||
{/* Left Column: Brand & Hero Value Props */}
|
||||
<div className="lg:w-[46%] xl:w-[44%] p-6 sm:p-10 lg:p-14 xl:p-16 flex flex-col justify-between z-10 text-white min-h-[auto] lg:min-h-screen">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="Metatroncube"
|
||||
className="size-10 object-contain drop-shadow-md"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-bold text-lg tracking-tight text-white leading-none">
|
||||
Metatroncube
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-blue-200/70 tracking-wider uppercase mt-1">
|
||||
Solutions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-auto py-8 lg:py-0">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/15 border border-blue-400/30 text-blue-300 text-[11px] font-semibold tracking-wider uppercase mb-5 backdrop-blur-sm shadow-sm">
|
||||
<span className="size-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||
JOIN OUR PLATFORM
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl xl:text-5xl font-extrabold tracking-tight text-white leading-[1.15]">
|
||||
Build something
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-cyan-400 via-sky-300 to-blue-400 bg-clip-text text-transparent">
|
||||
exceptional.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 text-slate-300/85 text-sm xl:text-base leading-relaxed max-w-md font-normal">
|
||||
Create your account and get started in minutes. Join a growing
|
||||
community of creators, builders and innovators.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-4 max-w-md">
|
||||
{VALUE_PROPS.map(({ icon: Icon, title, desc }) => (
|
||||
<div key={title} className="flex items-start gap-3.5">
|
||||
<div className="size-10 rounded-xl bg-blue-500/15 border border-blue-400/25 flex items-center justify-center shrink-0 shadow-inner">
|
||||
<Icon className="size-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5 leading-relaxed">
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 font-['Caveat',cursive] text-2xl text-blue-200/90 leading-snug tracking-wide italic select-none">
|
||||
"More than software,
|
||||
<br />
|
||||
it's a better tomorrow."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block h-6" />
|
||||
</div>
|
||||
|
||||
{/* Right Column: Floating White Card */}
|
||||
<div className="lg:w-[54%] xl:w-[56%] p-4 sm:p-8 lg:p-12 flex flex-col justify-between items-center relative z-10 min-h-screen overflow-y-auto">
|
||||
<div className="w-full flex justify-end pt-2 sm:pt-4 pb-4 text-sm font-medium z-20">
|
||||
{topLink ? (
|
||||
<div className="text-slate-600 flex items-center gap-1.5 text-xs sm:text-sm">
|
||||
<span>{topLink.text}</span>
|
||||
<Link
|
||||
to={topLink.to}
|
||||
search={topLink.search}
|
||||
className="text-blue-600 hover:text-blue-700 font-semibold hover:underline flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>{topLink.linkText}</span>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="w-full my-auto flex justify-center py-4">{children}</div>
|
||||
|
||||
<div className="w-full flex justify-end pb-2 sm:pb-4 pt-4">
|
||||
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] font-semibold tracking-widest text-slate-400/80 uppercase select-none">
|
||||
<span>— • • •</span>
|
||||
<span>SIMPLE</span>
|
||||
<span>/</span>
|
||||
<span>SECURE</span>
|
||||
<span>/</span>
|
||||
<span>POWERFUL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
167
src/client/features/settings/ChangePasswordSection.tsx
Normal file
167
src/client/features/settings/ChangePasswordSection.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import * as React from "react";
|
||||
import { Lock, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { HOSTED_PASSWORD_MIN_LENGTH } from "@/lib/auth-options";
|
||||
import { AuthInputField, AuthSubmitButton } from "@/client/features/auth/AuthPage";
|
||||
|
||||
export function ChangePasswordSection() {
|
||||
const [currentPassword, setCurrentPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [showCurrentPassword, setShowCurrentPassword] = React.useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = React.useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = React.useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!currentPassword) {
|
||||
setError("Please enter your current password.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < HOSTED_PASSWORD_MIN_LENGTH) {
|
||||
setError(
|
||||
`New password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
setError("New password must be different from your current password.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const msg =
|
||||
result.error.message ||
|
||||
"Failed to update password. Please check your current password.";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else {
|
||||
toast.success("Password updated successfully.");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred while updating your password.");
|
||||
toast.error("Failed to update password.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4 pt-6 border-t border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 flex items-center gap-2">
|
||||
<Lock className="size-4 text-blue-600" />
|
||||
Change Password
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Update your password to keep your account secure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3.5 max-w-md">
|
||||
{error ? (
|
||||
<div className="p-3 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-xs font-medium">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">
|
||||
Current Password
|
||||
</label>
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="Enter current password..."
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showCurrentPassword}
|
||||
onTogglePassword={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">
|
||||
New Password
|
||||
</label>
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="At least 8 characters..."
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
autoComplete="new-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showNewPassword}
|
||||
onTogglePassword={() => setShowNewPassword(!showNewPassword)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="Confirm new password..."
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
autoComplete="new-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showConfirmPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting || !currentPassword || !newPassword || !confirmPassword
|
||||
}
|
||||
>
|
||||
Update password
|
||||
</AuthSubmitButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 pt-1">
|
||||
<ShieldCheck className="size-3.5 text-slate-400" />
|
||||
<span>Sessions on other devices will be securely signed out.</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Caveat:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -72,6 +72,7 @@ interface ImportMetaEnv {
|
||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||
readonly POSTHOG_HOST?: string;
|
||||
readonly TURNSTILE_SITE_KEY?: string;
|
||||
readonly GOOGLE_CLIENT_ID?: string;
|
||||
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
||||
readonly VITE_E2E_KEYWORD_FIXTURES?: string;
|
||||
}
|
||||
|
||||
@ -179,10 +179,15 @@ function createAuth() {
|
||||
// Reset-by-email is hosted-only (Loops). In `team` mode the owner
|
||||
// resets a member's password from the admin screen instead.
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) return;
|
||||
try {
|
||||
await sendHostedPasswordResetEmail({
|
||||
email: user.email,
|
||||
resetUrl: url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[auth] Failed to send password reset email via Loops:", error);
|
||||
console.log(`[auth] Password reset URL for ${user.email}: ${url}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
emailVerification:
|
||||
@ -191,10 +196,15 @@ function createAuth() {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
try {
|
||||
await sendHostedVerificationEmail({
|
||||
email: user.email,
|
||||
confirmationUrl: url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[auth] Failed to send verification email via Loops:", error);
|
||||
console.log(`[auth] Verification URL for ${user.email}: ${url}`);
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@ -310,6 +320,10 @@ function getTrustedOrigins(baseUrl: string) {
|
||||
"http://*.open-seo.localhost:1355",
|
||||
"https://open-seo.localhost:1355",
|
||||
"https://*.open-seo.localhost:1355",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
);
|
||||
}
|
||||
|
||||
@ -344,30 +358,20 @@ function getHostedSecret() {
|
||||
}
|
||||
|
||||
function getSocialProviders() {
|
||||
// Google social login is hosted-only. Self-hosted builds the auth instance
|
||||
// solely for Search Console token ops, which use the genericOAuth provider
|
||||
// (createBaseAuthConfig) with its own creds — so it must NOT require the
|
||||
// social-login config here, otherwise getAuth() construction would be coupled
|
||||
// to GSC creds rather than just BETTER_AUTH_SECRET.
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
google: getGoogleSocialProviderConfig(),
|
||||
};
|
||||
const google = getGoogleSocialProviderConfig();
|
||||
return google ? { google } : {};
|
||||
}
|
||||
|
||||
function getGoogleSocialProviderConfig() {
|
||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim();
|
||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim();
|
||||
|
||||
if (!googleClientId) {
|
||||
throw new Error("GOOGLE_CLIENT_ID is required in hosted mode");
|
||||
}
|
||||
|
||||
if (!googleClientSecret) {
|
||||
throw new Error("GOOGLE_CLIENT_SECRET is required in hosted mode");
|
||||
if (!googleClientId || !googleClientSecret) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@ -396,7 +400,6 @@ function hasHostedAuthConfig() {
|
||||
try {
|
||||
getHostedBaseUrl();
|
||||
getHostedSecret();
|
||||
getGoogleSocialProviderConfig();
|
||||
return (
|
||||
hasHostedTurnstileConfig(env) &&
|
||||
(Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
|
||||
|
||||
@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
||||
import { Route as ChangePasswordRouteImport } from './routes/change-password'
|
||||
import { Route as AuthErrorRouteImport } from './routes/auth-error'
|
||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
@ -82,6 +83,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
||||
path: '/forgot-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChangePasswordRoute = ChangePasswordRouteImport.update({
|
||||
id: '/change-password',
|
||||
path: '/change-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthErrorRoute = AuthErrorRouteImport.update({
|
||||
id: '/auth-error',
|
||||
path: '/auth-error',
|
||||
@ -370,6 +376,7 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AppIndexRoute
|
||||
'/auth-error': typeof AuthErrorRoute
|
||||
'/change-password': typeof ChangePasswordRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
@ -425,6 +432,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof AppIndexRoute
|
||||
'/auth-error': typeof AuthErrorRoute
|
||||
'/change-password': typeof ChangePasswordRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
@ -479,6 +487,7 @@ export interface FileRoutesById {
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/auth-error': typeof AuthErrorRoute
|
||||
'/change-password': typeof ChangePasswordRoute
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
@ -537,6 +546,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/auth-error'
|
||||
| '/change-password'
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
@ -592,6 +602,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/auth-error'
|
||||
| '/change-password'
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
@ -645,6 +656,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth'
|
||||
| '/_authenticated'
|
||||
| '/auth-error'
|
||||
| '/change-password'
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
@ -705,6 +717,7 @@ export interface RootRouteChildren {
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||
AuthErrorRoute: typeof AuthErrorRoute
|
||||
ChangePasswordRoute: typeof ChangePasswordRoute
|
||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||
@ -741,6 +754,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/change-password': {
|
||||
id: '/change-password'
|
||||
path: '/change-password'
|
||||
fullPath: '/change-password'
|
||||
preLoaderRoute: typeof ChangePasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/auth-error': {
|
||||
id: '/auth-error'
|
||||
path: '/auth-error'
|
||||
@ -1313,6 +1333,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||
AuthErrorRoute: AuthErrorRoute,
|
||||
ChangePasswordRoute: ChangePasswordRoute,
|
||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
VerifyEmailRoute: VerifyEmailRoute,
|
||||
|
||||
@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<AutumnProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<PostHogBootstrap />
|
||||
{isHostedClientAuthMode() ? <PostHogBootstrap /> : null}
|
||||
{children}
|
||||
<ExportToSheetsModal />
|
||||
<Toaster
|
||||
|
||||
@ -3,9 +3,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
||||
import { ChangePasswordSection } from "@/client/features/settings/ChangePasswordSection";
|
||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { isHostedClientAuthMode, isSessionClientAuthMode } from "@/lib/auth-mode";
|
||||
import { version } from "../../../../package.json";
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/")({
|
||||
@ -89,6 +90,10 @@ function PersonalSettings() {
|
||||
<>
|
||||
<ApiKeySettings />
|
||||
|
||||
{isSessionClientAuthMode() && Boolean(session?.user) ? (
|
||||
<ChangePasswordSection />
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Analytics
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Mail, Lock, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
AuthPageCard,
|
||||
AuthMethodChooser,
|
||||
AuthInputField,
|
||||
AuthSubmitButton,
|
||||
AuthDivider,
|
||||
authRedirectSearchSchema,
|
||||
useAuthPageState,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
@ -30,8 +34,10 @@ function SignInPage() {
|
||||
const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
|
||||
useAuthPageState(search.redirect);
|
||||
const authCallbackURL = redirectTo;
|
||||
// `team` mode has no Google button, so go straight to the email/password form.
|
||||
const [showEmailForm, setShowEmailForm] = useState(!isHostedMode);
|
||||
const hasGoogleAuth =
|
||||
isHostedMode && Boolean(import.meta.env.GOOGLE_CLIENT_ID?.trim());
|
||||
const [showEmailForm, setShowEmailForm] = useState(!hasGoogleAuth);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// `team` mode with no owner yet: send them to the one-time setup screen.
|
||||
useEffect(() => {
|
||||
@ -136,32 +142,23 @@ function SignInPage() {
|
||||
|
||||
return (
|
||||
<AuthPageCard
|
||||
title="Sign in"
|
||||
title="Welcome back"
|
||||
helperText="Please enter your details to sign in"
|
||||
footer={
|
||||
isHostedMode ? (
|
||||
<div
|
||||
className={
|
||||
showEmailForm
|
||||
? "flex justify-between text-sm text-base-content/50"
|
||||
: "text-sm text-base-content/50"
|
||||
}
|
||||
>
|
||||
{showEmailForm ? (
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
<AuthDivider text="or" />
|
||||
<p className="text-xs text-slate-500">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/sign-up"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Create account
|
||||
<span>Create account</span>
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
@ -181,12 +178,12 @@ function SignInPage() {
|
||||
}}
|
||||
/>
|
||||
{socialError ? (
|
||||
<p className="text-sm text-error">{socialError}</p>
|
||||
<p className="text-xs text-rose-500 text-center">{socialError}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
className="space-y-3.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
@ -197,49 +194,56 @@ function SignInPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Mail}
|
||||
type="email"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Email address..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={!isSessionMode}
|
||||
required
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
<div className="space-y-1">
|
||||
<form.Field name="password">
|
||||
{(field) => {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Password..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={!isSessionMode}
|
||||
required
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showPassword}
|
||||
onTogglePassword={() => setShowPassword(!showPassword)}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex justify-end pt-0.5">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
submitError: state.errorMap.onSubmit,
|
||||
@ -249,17 +253,28 @@ function SignInPage() {
|
||||
{({ submitError, isSubmitting }) => {
|
||||
const errorMessage = getFormError(submitError);
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2 pt-1">
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
<p className="text-xs text-rose-500 text-center">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!isSessionMode || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
Sign in
|
||||
</AuthSubmitButton>
|
||||
{hasGoogleAuth ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-slate-500 hover:text-slate-800 transition-colors pt-1 cursor-pointer"
|
||||
onClick={() => setShowEmailForm(false)}
|
||||
>
|
||||
Sign in with Google instead
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { User, Mail, Lock, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
AuthPageCard,
|
||||
AuthMethodChooser,
|
||||
AuthInputField,
|
||||
AuthSubmitButton,
|
||||
AuthDivider,
|
||||
authRedirectSearchSchema,
|
||||
InviteOnlyCard,
|
||||
useAuthPageState,
|
||||
@ -21,6 +25,7 @@ import {
|
||||
HOSTED_PASSWORD_MAX_LENGTH,
|
||||
HOSTED_PASSWORD_MIN_LENGTH,
|
||||
} from "@/lib/auth-options";
|
||||
import { isEmailVerificationBypassed } from "@/lib/auth-mode";
|
||||
import { z } from "zod";
|
||||
|
||||
const signUpSchema = z
|
||||
@ -29,17 +34,11 @@ const signUpSchema = z
|
||||
email: z.string().trim().email("Enter a valid email address."),
|
||||
password: z
|
||||
.string()
|
||||
.min(
|
||||
HOSTED_PASSWORD_MIN_LENGTH,
|
||||
`Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
|
||||
)
|
||||
.max(
|
||||
HOSTED_PASSWORD_MAX_LENGTH,
|
||||
`Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`,
|
||||
),
|
||||
.min(HOSTED_PASSWORD_MIN_LENGTH, `Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`)
|
||||
.max(HOSTED_PASSWORD_MAX_LENGTH, `Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((value) => value.password === value.confirmPassword, {
|
||||
.refine((v) => v.password === v.confirmPassword, {
|
||||
message: "Passwords do not match.",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
@ -56,7 +55,11 @@ function SignUpPage() {
|
||||
search.redirect,
|
||||
);
|
||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const hasGoogleAuth =
|
||||
isHostedMode && Boolean(import.meta.env.GOOGLE_CLIENT_ID?.trim());
|
||||
const [showEmailForm, setShowEmailForm] = useState(!hasGoogleAuth);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
|
||||
|
||||
// Turnstile is active only in hosted mode with a configured site key.
|
||||
@ -134,6 +137,12 @@ function SignUpPage() {
|
||||
captureClientEvent("auth:sign_up_success", {
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
|
||||
if (isEmailVerificationBypassed()) {
|
||||
window.location.assign(postSignupRedirect);
|
||||
return;
|
||||
}
|
||||
|
||||
void navigate({
|
||||
to: "/verify-email",
|
||||
search: getVerifyEmailSearch(email, postSignupRedirect),
|
||||
@ -160,28 +169,17 @@ function SignUpPage() {
|
||||
return (
|
||||
<AuthPageCard
|
||||
title="Create your account"
|
||||
helperText="Start your journey with us"
|
||||
footer={
|
||||
isHostedMode ? (
|
||||
showEmailForm ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
onClick={() => {
|
||||
setShowEmailForm(false);
|
||||
google.clearError();
|
||||
}}
|
||||
>
|
||||
Back to signup
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-base-content/60">
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
By signing up, you agree to our{" "}
|
||||
<a
|
||||
href="https://openseo.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
className="text-slate-700 font-medium underline underline-offset-2 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
@ -190,25 +188,27 @@ function SignUpPage() {
|
||||
href="https://openseo.so/privacy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
className="text-slate-700 font-medium underline underline-offset-2 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-base-content/50">
|
||||
<AuthDivider text="or" />
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
|
||||
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Sign in
|
||||
<span>Sign in</span>
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@ -227,12 +227,12 @@ function SignUpPage() {
|
||||
}}
|
||||
/>
|
||||
{google.error ? (
|
||||
<p className="text-sm text-error">{google.error}</p>
|
||||
<p className="text-xs text-rose-500 text-center">{google.error}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
className="space-y-3.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
@ -243,20 +243,16 @@ function SignUpPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={User}
|
||||
type="text"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Name (optional)..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!isHostedMode}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
@ -266,21 +262,17 @@ function SignUpPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Mail}
|
||||
type="email"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Email address..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
autoComplete="email"
|
||||
disabled={!isHostedMode}
|
||||
required
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
@ -290,10 +282,9 @@ function SignUpPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Password..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
@ -302,11 +293,11 @@ function SignUpPage() {
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showPassword}
|
||||
onTogglePassword={() => setShowPassword(!showPassword)}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
@ -316,10 +307,9 @@ function SignUpPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Confirm password..."
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
@ -328,11 +318,13 @@ function SignUpPage() {
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showConfirmPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
@ -353,21 +345,32 @@ function SignUpPage() {
|
||||
{({ submitError, isSubmitting }) => {
|
||||
const errorMessage = getFormError(submitError);
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2 pt-1">
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
<p className="text-xs text-rose-500 text-center">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={
|
||||
!isHostedMode ||
|
||||
isSubmitting ||
|
||||
(isTurnstileEnabled && !captcha.hasToken)
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Creating account..." : "Create account"}
|
||||
Create account
|
||||
</AuthSubmitButton>
|
||||
{hasGoogleAuth ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-slate-500 hover:text-slate-800 transition-colors pt-1 cursor-pointer"
|
||||
onClick={() => setShowEmailForm(false)}
|
||||
>
|
||||
Sign up with Google instead
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
@ -377,7 +380,6 @@ function SignUpPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Google sign-up: kicks off the social OAuth redirect and surfaces its error.
|
||||
function useGoogleSignUp({
|
||||
redirectTo,
|
||||
postSignupRedirect,
|
||||
@ -391,22 +393,16 @@ function useGoogleSignUp({
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
captureClientEvent("auth:sign_up_google_start", {
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
captureClientEvent("auth:sign_up_google_start", { redirect_to: redirectTo });
|
||||
const result = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: redirectTo,
|
||||
newUserCallbackURL: postSignupRedirect,
|
||||
requestSignUp: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(
|
||||
result.error.message || "Google sign up is not available right now.",
|
||||
);
|
||||
setError(result.error.message || "Google sign up is not available right now.");
|
||||
setIsStarting(false);
|
||||
}
|
||||
} catch {
|
||||
@ -415,10 +411,5 @@ function useGoogleSignUp({
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isStarting,
|
||||
error,
|
||||
start,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
return { isStarting, error, start, clearError: () => setError(null) };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute, useNavigate, useLocation } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
AuthPageShell,
|
||||
@ -6,7 +6,7 @@ import {
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
||||
import { getCurrentAuthRedirect, getSignInSearch } from "@/lib/auth-redirect";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
validateSearch: authRedirectSearchSchema,
|
||||
@ -16,6 +16,7 @@ export const Route = createFileRoute("/_auth")({
|
||||
function AuthPageLayout() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { data: session, isPending } = useSession();
|
||||
const isSessionMode = isSessionClientAuthMode();
|
||||
const redirectTo = getCurrentAuthRedirect(search.redirect);
|
||||
@ -35,8 +36,23 @@ function AuthPageLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSignUp = location.pathname.includes("sign-up");
|
||||
const topLink = isSignUp
|
||||
? {
|
||||
text: "Already have an account?",
|
||||
linkText: "Sign in",
|
||||
to: "/sign-in",
|
||||
search: getSignInSearch(redirectTo),
|
||||
}
|
||||
: {
|
||||
text: "Don't have an account?",
|
||||
linkText: "Sign up",
|
||||
to: "/sign-up",
|
||||
search: getSignInSearch(redirectTo),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthPageShell topLink={topLink}>
|
||||
<Outlet />
|
||||
</AuthPageShell>
|
||||
);
|
||||
|
||||
196
src/routes/change-password.tsx
Normal file
196
src/routes/change-password.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import { Lock, ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AuthPageCard,
|
||||
AuthPageShell,
|
||||
AuthInputField,
|
||||
AuthSubmitButton,
|
||||
AuthDivider,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { HOSTED_PASSWORD_MIN_LENGTH } from "@/lib/auth-options";
|
||||
import { getSignInSearch } from "@/lib/auth-redirect";
|
||||
|
||||
export const Route = createFileRoute("/change-password")({
|
||||
component: ChangePasswordPage,
|
||||
});
|
||||
|
||||
function ChangePasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = useSession();
|
||||
const [currentPassword, setCurrentPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [showCurrentPassword, setShowCurrentPassword] = React.useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = React.useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = React.useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!currentPassword) {
|
||||
setError("Please enter your current password.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < HOSTED_PASSWORD_MIN_LENGTH) {
|
||||
setError(
|
||||
`New password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
setError("New password must be different from your current password.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const msg =
|
||||
result.error.message ||
|
||||
"Failed to update password. Please check your current password.";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else {
|
||||
toast.success("Password updated successfully.");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
void navigate({ to: "/settings" });
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred while updating your password.");
|
||||
toast.error("Failed to update password.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPageShell
|
||||
topLink={{
|
||||
text: "Back to app?",
|
||||
linkText: "Dashboard",
|
||||
to: "/",
|
||||
}}
|
||||
>
|
||||
<AuthPageCard
|
||||
title="Change password"
|
||||
helperText="Choose a new secure password for your account."
|
||||
footer={
|
||||
<div className="space-y-4">
|
||||
<AuthDivider text="or" />
|
||||
<p className="text-xs text-slate-500">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span>Back to Settings</span>
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!isPending && !session?.user?.id ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800 text-xs leading-relaxed text-center font-medium">
|
||||
Please sign in to change your account password.
|
||||
</div>
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch("/change-password")}
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium shadow-md shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<span>Sign in to continue</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-3.5">
|
||||
{error ? (
|
||||
<p className="text-xs text-rose-500 text-center">{error}</p>
|
||||
) : null}
|
||||
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="Enter current password..."
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showCurrentPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowCurrentPassword(!showCurrentPassword)
|
||||
}
|
||||
/>
|
||||
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="New password (min 8 chars)..."
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
autoComplete="new-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showNewPassword}
|
||||
onTogglePassword={() => setShowNewPassword(!showNewPassword)}
|
||||
/>
|
||||
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
placeholder="Confirm new password..."
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
autoComplete="new-password"
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showConfirmPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pt-1">
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
>
|
||||
Update password
|
||||
</AuthSubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AuthPageCard>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Mail, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
AuthPageCard,
|
||||
AuthPageShell,
|
||||
AuthInputField,
|
||||
AuthSubmitButton,
|
||||
AuthDivider,
|
||||
authRedirectSearchSchema,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||
@ -63,7 +67,14 @@ function ForgotPasswordPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthPageShell
|
||||
topLink={{
|
||||
text: "Remember your password?",
|
||||
linkText: "Sign in",
|
||||
to: "/sign-in",
|
||||
search: getSignInSearch(redirectTo),
|
||||
}}
|
||||
>
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSuccess: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
||||
@ -77,36 +88,48 @@ function ForgotPasswordPage() {
|
||||
|
||||
return (
|
||||
<AuthPageCard
|
||||
title={isSuccess ? "Check your email" : "Forgot password"}
|
||||
title={isSuccess ? "Check your email" : "Reset your password"}
|
||||
helperText={
|
||||
isSuccess
|
||||
? `If an account exists for ${submittedEmail}, we sent a reset link.`
|
||||
: isHostedMode
|
||||
? "Enter your email and we'll send you a password reset link."
|
||||
? "Enter your email address and we'll send you a password reset link."
|
||||
: "Password reset isn't available right now."
|
||||
}
|
||||
footer={
|
||||
<p className="text-sm">
|
||||
<div className="space-y-4">
|
||||
<AuthDivider text="or" />
|
||||
<p className="text-xs text-slate-500">
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-base-content/50 hover:text-base-content transition-colors"
|
||||
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Back to sign in
|
||||
<span>Back to sign in</span>
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<div className="alert alert-success">
|
||||
<span>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs leading-relaxed text-center font-medium">
|
||||
If an account exists for that email, you'll receive password
|
||||
reset instructions shortly.
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-800 font-medium transition-all flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<span>Back to sign in</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
className="space-y-3.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
@ -117,10 +140,9 @@ function ForgotPasswordPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Mail}
|
||||
type="email"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Email address..."
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
@ -129,24 +151,24 @@ function ForgotPasswordPage() {
|
||||
autoComplete="email"
|
||||
disabled={!isHostedMode}
|
||||
required
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
<p className="text-xs text-rose-500 text-center">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!isHostedMode || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Sending reset link..." : "Send reset link"}
|
||||
</button>
|
||||
Send reset link
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
)}
|
||||
</AuthPageCard>
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { Lock, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
AuthPageCard,
|
||||
AuthPageShell,
|
||||
AuthInputField,
|
||||
AuthSubmitButton,
|
||||
AuthDivider,
|
||||
authRedirectSearchSchema,
|
||||
} from "@/client/features/auth/AuthPage";
|
||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||
@ -104,6 +109,9 @@ function ResetPasswordPage() {
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const routeError = getResetPasswordErrorMessage(search.error);
|
||||
const token = typeof search.token === "string" ? search.token : null;
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
@ -150,7 +158,14 @@ function ResetPasswordPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthPageShell
|
||||
topLink={{
|
||||
text: "Remember your password?",
|
||||
linkText: "Sign in",
|
||||
to: "/sign-in",
|
||||
search: getSignInSearch(redirectTo),
|
||||
}}
|
||||
>
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isComplete: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
||||
@ -172,39 +187,52 @@ function ResetPasswordPage() {
|
||||
title={pageCopy.title}
|
||||
helperText={pageCopy.helperText}
|
||||
footer={
|
||||
<p className="text-sm">
|
||||
<div className="space-y-4">
|
||||
<AuthDivider text="or" />
|
||||
<p className="text-xs text-slate-500">
|
||||
<Link
|
||||
to="/sign-in"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="text-base-content/50 hover:text-base-content transition-colors"
|
||||
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Sign in
|
||||
<span>Sign in</span>
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!isHostedMode ? null : isComplete ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs leading-relaxed text-center font-medium">
|
||||
Your password has been changed successfully.
|
||||
</div>
|
||||
<a
|
||||
href={
|
||||
redirectTo === "/"
|
||||
? "/sign-in"
|
||||
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`
|
||||
}
|
||||
className="btn btn-soft w-full"
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium shadow-md shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
Continue to sign in
|
||||
<span>Continue to sign in</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
) : routeError || !token ? (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
className="btn btn-soft w-full"
|
||||
className="w-full h-11 sm:h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-800 font-medium transition-all flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
Request a new reset link
|
||||
<span>Request a new reset link</span>
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
className="space-y-3.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
@ -215,10 +243,9 @@ function ResetPasswordPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="New password..."
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
@ -228,11 +255,13 @@ function ResetPasswordPage() {
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
required
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowPassword(!showPassword)
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
@ -242,10 +271,9 @@ function ResetPasswordPage() {
|
||||
const error = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
<AuthInputField
|
||||
icon={Lock}
|
||||
type="password"
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Confirm new password..."
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
@ -255,24 +283,29 @@ function ResetPasswordPage() {
|
||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||
required
|
||||
showPasswordToggle
|
||||
isPasswordVisible={showConfirmPassword}
|
||||
onTogglePassword={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-error">{errorMessage}</p>
|
||||
<p className="text-xs text-rose-500 text-center">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
|
||||
<AuthSubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating password..." : "Update password"}
|
||||
</button>
|
||||
Update password
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
)}
|
||||
</AuthPageCard>
|
||||
|
||||
1
src/types/vite-env.d.ts
vendored
1
src/types/vite-env.d.ts
vendored
@ -8,6 +8,7 @@ interface ViteTypeOptions {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SHOW_DEVTOOLS?: string;
|
||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||
readonly GOOGLE_CLIENT_ID?: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ export default defineConfig(() => {
|
||||
"POSTHOG_PUBLIC_KEY",
|
||||
"POSTHOG_HOST",
|
||||
"TURNSTILE_SITE_KEY",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
],
|
||||
|
||||
server: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user