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 { 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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getCurrentAuthRedirect,
|
getCurrentAuthRedirect,
|
||||||
@ -21,16 +31,9 @@ export function useAuthPageState(redirect: string | undefined) {
|
|||||||
? getOAuthSignedQuery(window.location.search)
|
? getOAuthSignedQuery(window.location.search)
|
||||||
: null;
|
: null;
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
// `team` mode: email/password sign-in is live, but Google social login and
|
|
||||||
// self-serve signup are not.
|
|
||||||
const isSessionMode = isSessionClientAuthMode();
|
const isSessionMode = isSessionClientAuthMode();
|
||||||
|
|
||||||
return {
|
return { redirectTo, oauthQuery, isHostedMode, isSessionMode };
|
||||||
redirectTo,
|
|
||||||
oauthQuery,
|
|
||||||
isHostedMode,
|
|
||||||
isSessionMode,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthMethodChooser({
|
export function AuthMethodChooser({
|
||||||
@ -52,21 +55,20 @@ export function AuthMethodChooser({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onContinueWithGoogle}
|
||||||
disabled={disabled || isBusy}
|
disabled={disabled || isBusy}
|
||||||
>
|
>
|
||||||
<GoogleLogo />
|
<GoogleLogo />
|
||||||
{isBusy ? "Opening Google..." : googleLabel}
|
<span>{isBusy ? "Opening Google..." : googleLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={onContinueWithEmail}
|
||||||
disabled={disabled || isBusy}
|
disabled={disabled || isBusy}
|
||||||
>
|
>
|
||||||
{emailLabel}
|
<span>{emailLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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({
|
export function AuthPageCard({
|
||||||
title,
|
title,
|
||||||
helperText,
|
helperText,
|
||||||
@ -107,30 +223,35 @@ export function AuthPageCard({
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xs space-y-6">
|
<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-3">
|
<div className="text-center space-y-1.5 mb-6">
|
||||||
<img
|
<img
|
||||||
src="/transparent-logo.png"
|
src="/transparent-logo.png"
|
||||||
alt="CrawlerX"
|
alt="Metatroncube"
|
||||||
className="mx-auto size-10 rounded-lg"
|
className="mx-auto size-11 object-contain drop-shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div>
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
<h1 className="text-xl font-semibold">{title}</h1>
|
{title}
|
||||||
|
</h2>
|
||||||
{helperText ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{footer ? <div className="text-center">{footer}</div> : null}
|
|
||||||
</div>
|
</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 }) {
|
export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
|
||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
@ -140,22 +261,147 @@ export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
|
|||||||
<Link
|
<Link
|
||||||
to="/sign-in"
|
to="/sign-in"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
</AuthPageCard>
|
</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 (
|
return (
|
||||||
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the
|
<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">
|
||||||
// auto-margin child centers when it fits but stays fully reachable (top and
|
<div
|
||||||
// bottom) when it's taller than the viewport. Plain `justify-center` clips
|
className="absolute inset-0 bg-cover bg-center pointer-events-none z-0 opacity-90 transition-opacity"
|
||||||
// the overflow with no way to scroll to it.
|
style={{ backgroundImage: `url('/auth-bg.jpg')` }}
|
||||||
<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="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>
|
</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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@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_PUBLIC_KEY?: string;
|
||||||
readonly POSTHOG_HOST?: string;
|
readonly POSTHOG_HOST?: string;
|
||||||
readonly TURNSTILE_SITE_KEY?: string;
|
readonly TURNSTILE_SITE_KEY?: string;
|
||||||
|
readonly GOOGLE_CLIENT_ID?: string;
|
||||||
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
readonly VITE_E2E_DOMAIN_FIXTURES?: string;
|
||||||
readonly VITE_E2E_KEYWORD_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
|
// Reset-by-email is hosted-only (Loops). In `team` mode the owner
|
||||||
// resets a member's password from the admin screen instead.
|
// resets a member's password from the admin screen instead.
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) return;
|
if (!isHostedAuthMode(env.AUTH_MODE)) return;
|
||||||
|
try {
|
||||||
await sendHostedPasswordResetEmail({
|
await sendHostedPasswordResetEmail({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
resetUrl: url,
|
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:
|
emailVerification:
|
||||||
@ -191,10 +196,15 @@ function createAuth() {
|
|||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
|
try {
|
||||||
await sendHostedVerificationEmail({
|
await sendHostedVerificationEmail({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
confirmationUrl: url,
|
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,
|
: undefined,
|
||||||
@ -310,6 +320,10 @@ function getTrustedOrigins(baseUrl: string) {
|
|||||||
"http://*.open-seo.localhost:1355",
|
"http://*.open-seo.localhost:1355",
|
||||||
"https://open-seo.localhost:1355",
|
"https://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() {
|
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)) {
|
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const google = getGoogleSocialProviderConfig();
|
||||||
google: getGoogleSocialProviderConfig(),
|
return google ? { google } : {};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGoogleSocialProviderConfig() {
|
function getGoogleSocialProviderConfig() {
|
||||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim();
|
const googleClientId = env.GOOGLE_CLIENT_ID?.trim();
|
||||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim();
|
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim();
|
||||||
|
|
||||||
if (!googleClientId) {
|
if (!googleClientId || !googleClientSecret) {
|
||||||
throw new Error("GOOGLE_CLIENT_ID is required in hosted mode");
|
return undefined;
|
||||||
}
|
|
||||||
|
|
||||||
if (!googleClientSecret) {
|
|
||||||
throw new Error("GOOGLE_CLIENT_SECRET is required in hosted mode");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -396,7 +400,6 @@ function hasHostedAuthConfig() {
|
|||||||
try {
|
try {
|
||||||
getHostedBaseUrl();
|
getHostedBaseUrl();
|
||||||
getHostedSecret();
|
getHostedSecret();
|
||||||
getGoogleSocialProviderConfig();
|
|
||||||
return (
|
return (
|
||||||
hasHostedTurnstileConfig(env) &&
|
hasHostedTurnstileConfig(env) &&
|
||||||
(Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
|
(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 VerifyEmailRouteImport } from './routes/verify-email'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-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 AuthErrorRouteImport } from './routes/auth-error'
|
||||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
@ -82,6 +83,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
|||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ChangePasswordRoute = ChangePasswordRouteImport.update({
|
||||||
|
id: '/change-password',
|
||||||
|
path: '/change-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthErrorRoute = AuthErrorRouteImport.update({
|
const AuthErrorRoute = AuthErrorRouteImport.update({
|
||||||
id: '/auth-error',
|
id: '/auth-error',
|
||||||
path: '/auth-error',
|
path: '/auth-error',
|
||||||
@ -370,6 +376,7 @@ const ProjectPProjectIdAuditIssuesResultIdRoute =
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -425,6 +432,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof AppIndexRoute
|
'/': typeof AppIndexRoute
|
||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -479,6 +487,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/auth-error': typeof AuthErrorRoute
|
'/auth-error': typeof AuthErrorRoute
|
||||||
|
'/change-password': typeof ChangePasswordRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
@ -537,6 +546,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -592,6 +602,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -645,6 +656,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
| '/auth-error'
|
| '/auth-error'
|
||||||
|
| '/change-password'
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
@ -705,6 +717,7 @@ export interface RootRouteChildren {
|
|||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||||
AuthErrorRoute: typeof AuthErrorRoute
|
AuthErrorRoute: typeof AuthErrorRoute
|
||||||
|
ChangePasswordRoute: typeof ChangePasswordRoute
|
||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
@ -741,6 +754,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ForgotPasswordRouteImport
|
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/change-password': {
|
||||||
|
id: '/change-password'
|
||||||
|
path: '/change-password'
|
||||||
|
fullPath: '/change-password'
|
||||||
|
preLoaderRoute: typeof ChangePasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/auth-error': {
|
'/auth-error': {
|
||||||
id: '/auth-error'
|
id: '/auth-error'
|
||||||
path: '/auth-error'
|
path: '/auth-error'
|
||||||
@ -1313,6 +1333,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||||
AuthErrorRoute: AuthErrorRoute,
|
AuthErrorRoute: AuthErrorRoute,
|
||||||
|
ChangePasswordRoute: ChangePasswordRoute,
|
||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
|
|||||||
@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<AutumnProvider>
|
<AutumnProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<>
|
<>
|
||||||
<PostHogBootstrap />
|
{isHostedClientAuthMode() ? <PostHogBootstrap /> : null}
|
||||||
{children}
|
{children}
|
||||||
<ExportToSheetsModal />
|
<ExportToSheetsModal />
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
||||||
|
import { ChangePasswordSection } from "@/client/features/settings/ChangePasswordSection";
|
||||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
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";
|
import { version } from "../../../../package.json";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/")({
|
export const Route = createFileRoute("/_app/settings/")({
|
||||||
@ -89,6 +90,10 @@ function PersonalSettings() {
|
|||||||
<>
|
<>
|
||||||
<ApiKeySettings />
|
<ApiKeySettings />
|
||||||
|
|
||||||
|
{isSessionClientAuthMode() && Boolean(session?.user) ? (
|
||||||
|
<ChangePasswordSection />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-sm font-medium text-base-content/50">
|
<h2 className="text-sm font-medium text-base-content/50">
|
||||||
Analytics
|
Analytics
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Mail, Lock, ArrowRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
|
AuthInputField,
|
||||||
|
AuthSubmitButton,
|
||||||
|
AuthDivider,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
@ -30,8 +34,10 @@ function SignInPage() {
|
|||||||
const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
|
const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
|
||||||
useAuthPageState(search.redirect);
|
useAuthPageState(search.redirect);
|
||||||
const authCallbackURL = redirectTo;
|
const authCallbackURL = redirectTo;
|
||||||
// `team` mode has no Google button, so go straight to the email/password form.
|
const hasGoogleAuth =
|
||||||
const [showEmailForm, setShowEmailForm] = useState(!isHostedMode);
|
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.
|
// `team` mode with no owner yet: send them to the one-time setup screen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -136,32 +142,23 @@ function SignInPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title="Sign in"
|
title="Welcome back"
|
||||||
|
helperText="Please enter your details to sign in"
|
||||||
footer={
|
footer={
|
||||||
isHostedMode ? (
|
isHostedMode ? (
|
||||||
<div
|
<div className="space-y-4">
|
||||||
className={
|
<AuthDivider text="or" />
|
||||||
showEmailForm
|
<p className="text-xs text-slate-500">
|
||||||
? "flex justify-between text-sm text-base-content/50"
|
Don't have an account?{" "}
|
||||||
: "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}
|
|
||||||
<Link
|
<Link
|
||||||
to="/sign-up"
|
to="/sign-up"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@ -181,12 +178,12 @@ function SignInPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{socialError ? (
|
{socialError ? (
|
||||||
<p className="text-sm text-error">{socialError}</p>
|
<p className="text-xs text-rose-500 text-center">{socialError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-3.5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void form.handleSubmit();
|
void form.handleSubmit();
|
||||||
@ -197,49 +194,56 @@ function SignInPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Mail}
|
||||||
type="email"
|
type="email"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Email address..."
|
placeholder="Email address..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isSessionMode}
|
disabled={!isSessionMode}
|
||||||
required
|
required
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => {
|
{(field) => {
|
||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Lock}
|
||||||
type="password"
|
type="password"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Password..."
|
placeholder="Password..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={!isSessionMode}
|
disabled={!isSessionMode}
|
||||||
required
|
required
|
||||||
|
showPasswordToggle
|
||||||
|
isPasswordVisible={showPassword}
|
||||||
|
onTogglePassword={() => setShowPassword(!showPassword)}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</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
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
submitError: state.errorMap.onSubmit,
|
submitError: state.errorMap.onSubmit,
|
||||||
@ -249,17 +253,28 @@ function SignInPage() {
|
|||||||
{({ submitError, isSubmitting }) => {
|
{({ submitError, isSubmitting }) => {
|
||||||
const errorMessage = getFormError(submitError);
|
const errorMessage = getFormError(submitError);
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-2 pt-1">
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="text-sm text-error">{errorMessage}</p>
|
<p className="text-xs text-rose-500 text-center">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<AuthSubmitButton
|
||||||
className="btn btn-soft w-full"
|
isSubmitting={isSubmitting}
|
||||||
disabled={!isSessionMode || 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>
|
</button>
|
||||||
</>
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { User, Mail, Lock, ArrowRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
|
AuthInputField,
|
||||||
|
AuthSubmitButton,
|
||||||
|
AuthDivider,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
InviteOnlyCard,
|
InviteOnlyCard,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
@ -21,6 +25,7 @@ import {
|
|||||||
HOSTED_PASSWORD_MAX_LENGTH,
|
HOSTED_PASSWORD_MAX_LENGTH,
|
||||||
HOSTED_PASSWORD_MIN_LENGTH,
|
HOSTED_PASSWORD_MIN_LENGTH,
|
||||||
} from "@/lib/auth-options";
|
} from "@/lib/auth-options";
|
||||||
|
import { isEmailVerificationBypassed } from "@/lib/auth-mode";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const signUpSchema = z
|
const signUpSchema = z
|
||||||
@ -29,17 +34,11 @@ const signUpSchema = z
|
|||||||
email: z.string().trim().email("Enter a valid email address."),
|
email: z.string().trim().email("Enter a valid email address."),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(
|
.min(HOSTED_PASSWORD_MIN_LENGTH, `Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`)
|
||||||
HOSTED_PASSWORD_MIN_LENGTH,
|
.max(HOSTED_PASSWORD_MAX_LENGTH, `Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`),
|
||||||
`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(),
|
confirmPassword: z.string(),
|
||||||
})
|
})
|
||||||
.refine((value) => value.password === value.confirmPassword, {
|
.refine((v) => v.password === v.confirmPassword, {
|
||||||
message: "Passwords do not match.",
|
message: "Passwords do not match.",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
});
|
});
|
||||||
@ -56,7 +55,11 @@ function SignUpPage() {
|
|||||||
search.redirect,
|
search.redirect,
|
||||||
);
|
);
|
||||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
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 });
|
const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
|
||||||
|
|
||||||
// Turnstile is active only in hosted mode with a configured site key.
|
// Turnstile is active only in hosted mode with a configured site key.
|
||||||
@ -134,6 +137,12 @@ function SignUpPage() {
|
|||||||
captureClientEvent("auth:sign_up_success", {
|
captureClientEvent("auth:sign_up_success", {
|
||||||
redirect_to: redirectTo,
|
redirect_to: redirectTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isEmailVerificationBypassed()) {
|
||||||
|
window.location.assign(postSignupRedirect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/verify-email",
|
to: "/verify-email",
|
||||||
search: getVerifyEmailSearch(email, postSignupRedirect),
|
search: getVerifyEmailSearch(email, postSignupRedirect),
|
||||||
@ -160,28 +169,17 @@ function SignUpPage() {
|
|||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title="Create your account"
|
title="Create your account"
|
||||||
|
helperText="Start your journey with us"
|
||||||
footer={
|
footer={
|
||||||
isHostedMode ? (
|
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">
|
<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{" "}
|
By signing up, you agree to our{" "}
|
||||||
<a
|
<a
|
||||||
href="https://openseo.so/terms-and-conditions"
|
href="https://openseo.so/terms-and-conditions"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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
|
Terms
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
@ -190,25 +188,27 @@ function SignUpPage() {
|
|||||||
href="https://openseo.so/privacy"
|
href="https://openseo.so/privacy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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
|
Privacy Policy
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-base-content/50">
|
<AuthDivider text="or" />
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/sign-in"
|
to="/sign-in"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -227,12 +227,12 @@ function SignUpPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{google.error ? (
|
{google.error ? (
|
||||||
<p className="text-sm text-error">{google.error}</p>
|
<p className="text-xs text-rose-500 text-center">{google.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-3.5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void form.handleSubmit();
|
void form.handleSubmit();
|
||||||
@ -243,20 +243,16 @@ function SignUpPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={User}
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Name (optional)..."
|
placeholder="Name (optional)..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
disabled={!isHostedMode}
|
disabled={!isHostedMode}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@ -266,21 +262,17 @@ function SignUpPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Mail}
|
||||||
type="email"
|
type="email"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Email address..."
|
placeholder="Email address..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isHostedMode}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@ -290,10 +282,9 @@ function SignUpPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Lock}
|
||||||
type="password"
|
type="password"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Password..."
|
placeholder="Password..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
@ -302,11 +293,11 @@ function SignUpPage() {
|
|||||||
required
|
required
|
||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_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>
|
</form.Field>
|
||||||
@ -316,10 +307,9 @@ function SignUpPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Lock}
|
||||||
type="password"
|
type="password"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Confirm password..."
|
placeholder="Confirm password..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
@ -328,11 +318,13 @@ function SignUpPage() {
|
|||||||
required
|
required
|
||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_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>
|
</form.Field>
|
||||||
@ -353,21 +345,32 @@ function SignUpPage() {
|
|||||||
{({ submitError, isSubmitting }) => {
|
{({ submitError, isSubmitting }) => {
|
||||||
const errorMessage = getFormError(submitError);
|
const errorMessage = getFormError(submitError);
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-2 pt-1">
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="text-sm text-error">{errorMessage}</p>
|
<p className="text-xs text-rose-500 text-center">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<AuthSubmitButton
|
||||||
className="btn btn-soft w-full"
|
isSubmitting={isSubmitting}
|
||||||
disabled={
|
disabled={
|
||||||
!isHostedMode ||
|
!isHostedMode ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
(isTurnstileEnabled && !captcha.hasToken)
|
(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>
|
</button>
|
||||||
</>
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
@ -377,7 +380,6 @@ function SignUpPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google sign-up: kicks off the social OAuth redirect and surfaces its error.
|
|
||||||
function useGoogleSignUp({
|
function useGoogleSignUp({
|
||||||
redirectTo,
|
redirectTo,
|
||||||
postSignupRedirect,
|
postSignupRedirect,
|
||||||
@ -391,22 +393,16 @@ function useGoogleSignUp({
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStarting(true);
|
setIsStarting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
captureClientEvent("auth:sign_up_google_start", {
|
captureClientEvent("auth:sign_up_google_start", { redirect_to: redirectTo });
|
||||||
redirect_to: redirectTo,
|
|
||||||
});
|
|
||||||
const result = await authClient.signIn.social({
|
const result = await authClient.signIn.social({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
callbackURL: redirectTo,
|
callbackURL: redirectTo,
|
||||||
newUserCallbackURL: postSignupRedirect,
|
newUserCallbackURL: postSignupRedirect,
|
||||||
requestSignUp: true,
|
requestSignUp: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(
|
setError(result.error.message || "Google sign up is not available right now.");
|
||||||
result.error.message || "Google sign up is not available right now.",
|
|
||||||
);
|
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -415,10 +411,5 @@ function useGoogleSignUp({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return { isStarting, error, start, clearError: () => setError(null) };
|
||||||
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 { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
import { getCurrentAuthRedirect, getSignInSearch } from "@/lib/auth-redirect";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth")({
|
export const Route = createFileRoute("/_auth")({
|
||||||
validateSearch: authRedirectSearchSchema,
|
validateSearch: authRedirectSearchSchema,
|
||||||
@ -16,6 +16,7 @@ export const Route = createFileRoute("/_auth")({
|
|||||||
function AuthPageLayout() {
|
function AuthPageLayout() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const isSessionMode = isSessionClientAuthMode();
|
const isSessionMode = isSessionClientAuthMode();
|
||||||
const redirectTo = getCurrentAuthRedirect(search.redirect);
|
const redirectTo = getCurrentAuthRedirect(search.redirect);
|
||||||
@ -35,8 +36,23 @@ function AuthPageLayout() {
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<AuthPageShell>
|
<AuthPageShell topLink={topLink}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AuthPageShell>
|
</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 { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Mail, ArrowRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
|
AuthInputField,
|
||||||
|
AuthSubmitButton,
|
||||||
|
AuthDivider,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
@ -63,7 +67,14 @@ function ForgotPasswordPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageShell>
|
<AuthPageShell
|
||||||
|
topLink={{
|
||||||
|
text: "Remember your password?",
|
||||||
|
linkText: "Sign in",
|
||||||
|
to: "/sign-in",
|
||||||
|
search: getSignInSearch(redirectTo),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
isSuccess: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
isSuccess: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
||||||
@ -77,36 +88,48 @@ function ForgotPasswordPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title={isSuccess ? "Check your email" : "Forgot password"}
|
title={isSuccess ? "Check your email" : "Reset your password"}
|
||||||
helperText={
|
helperText={
|
||||||
isSuccess
|
isSuccess
|
||||||
? `If an account exists for ${submittedEmail}, we sent a reset link.`
|
? `If an account exists for ${submittedEmail}, we sent a reset link.`
|
||||||
: isHostedMode
|
: 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."
|
: "Password reset isn't available right now."
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<p className="text-sm">
|
<div className="space-y-4">
|
||||||
|
<AuthDivider text="or" />
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
<Link
|
<Link
|
||||||
to="/sign-in"
|
to="/sign-in"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSuccess ? (
|
{isSuccess ? (
|
||||||
<div className="alert alert-success">
|
<div className="space-y-4">
|
||||||
<span>
|
<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
|
If an account exists for that email, you'll receive password
|
||||||
reset instructions shortly.
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-3.5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void form.handleSubmit();
|
void form.handleSubmit();
|
||||||
@ -117,10 +140,9 @@ function ForgotPasswordPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Mail}
|
||||||
type="email"
|
type="email"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Email address..."
|
placeholder="Email address..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -129,24 +151,24 @@ function ForgotPasswordPage() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isHostedMode}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="text-sm text-error">{errorMessage}</p>
|
<p className="text-xs text-rose-500 text-center">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
className="btn btn-soft w-full"
|
<AuthSubmitButton
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
disabled={!isHostedMode || isSubmitting}
|
disabled={!isHostedMode || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Sending reset link..." : "Send reset link"}
|
Send reset link
|
||||||
</button>
|
</AuthSubmitButton>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</AuthPageCard>
|
</AuthPageCard>
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Lock, ArrowRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
|
AuthInputField,
|
||||||
|
AuthSubmitButton,
|
||||||
|
AuthDivider,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { getFieldError, getFormError } from "@/client/lib/forms";
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
@ -104,6 +109,9 @@ function ResetPasswordPage() {
|
|||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const routeError = getResetPasswordErrorMessage(search.error);
|
const routeError = getResetPasswordErrorMessage(search.error);
|
||||||
const token = typeof search.token === "string" ? search.token : null;
|
const token = typeof search.token === "string" ? search.token : null;
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: "",
|
password: "",
|
||||||
@ -150,7 +158,14 @@ function ResetPasswordPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageShell>
|
<AuthPageShell
|
||||||
|
topLink={{
|
||||||
|
text: "Remember your password?",
|
||||||
|
linkText: "Sign in",
|
||||||
|
to: "/sign-in",
|
||||||
|
search: getSignInSearch(redirectTo),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
isComplete: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
isComplete: state.isSubmitSuccessful && !state.errorMap.onSubmit,
|
||||||
@ -172,39 +187,52 @@ function ResetPasswordPage() {
|
|||||||
title={pageCopy.title}
|
title={pageCopy.title}
|
||||||
helperText={pageCopy.helperText}
|
helperText={pageCopy.helperText}
|
||||||
footer={
|
footer={
|
||||||
<p className="text-sm">
|
<div className="space-y-4">
|
||||||
|
<AuthDivider text="or" />
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
<Link
|
<Link
|
||||||
to="/sign-in"
|
to="/sign-in"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isHostedMode ? null : isComplete ? (
|
{!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
|
<a
|
||||||
href={
|
href={
|
||||||
redirectTo === "/"
|
redirectTo === "/"
|
||||||
? "/sign-in"
|
? "/sign-in"
|
||||||
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`
|
: `/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>
|
</a>
|
||||||
|
</div>
|
||||||
) : routeError || !token ? (
|
) : routeError || !token ? (
|
||||||
|
<div className="space-y-4">
|
||||||
<Link
|
<Link
|
||||||
to="/forgot-password"
|
to="/forgot-password"
|
||||||
search={getSignInSearch(redirectTo)}
|
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>
|
</Link>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-3.5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void form.handleSubmit();
|
void form.handleSubmit();
|
||||||
@ -215,10 +243,9 @@ function ResetPasswordPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Lock}
|
||||||
type="password"
|
type="password"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="New password..."
|
placeholder="New password..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -228,11 +255,13 @@ function ResetPasswordPage() {
|
|||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||||
required
|
required
|
||||||
|
showPasswordToggle
|
||||||
|
isPasswordVisible={showPassword}
|
||||||
|
onTogglePassword={() =>
|
||||||
|
setShowPassword(!showPassword)
|
||||||
|
}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@ -242,10 +271,9 @@ function ResetPasswordPage() {
|
|||||||
const error = getFieldError(field.state.meta.errors);
|
const error = getFieldError(field.state.meta.errors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AuthInputField
|
||||||
<input
|
icon={Lock}
|
||||||
type="password"
|
type="password"
|
||||||
className="input input-bordered w-full"
|
|
||||||
placeholder="Confirm new password..."
|
placeholder="Confirm new password..."
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -255,24 +283,29 @@ function ResetPasswordPage() {
|
|||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||||
required
|
required
|
||||||
|
showPasswordToggle
|
||||||
|
isPasswordVisible={showConfirmPassword}
|
||||||
|
onTogglePassword={() =>
|
||||||
|
setShowConfirmPassword(!showConfirmPassword)
|
||||||
|
}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="text-sm text-error">{errorMessage}</p>
|
<p className="text-xs text-rose-500 text-center">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
className="btn btn-soft w-full"
|
<AuthSubmitButton
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Updating password..." : "Update password"}
|
Update password
|
||||||
</button>
|
</AuthSubmitButton>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</AuthPageCard>
|
</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 {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SHOW_DEVTOOLS?: string;
|
readonly VITE_SHOW_DEVTOOLS?: string;
|
||||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||||
|
readonly GOOGLE_CLIENT_ID?: string;
|
||||||
// more env variables...
|
// more env variables...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export default defineConfig(() => {
|
|||||||
"POSTHOG_PUBLIC_KEY",
|
"POSTHOG_PUBLIC_KEY",
|
||||||
"POSTHOG_HOST",
|
"POSTHOG_HOST",
|
||||||
"TURNSTILE_SITE_KEY",
|
"TURNSTILE_SITE_KEY",
|
||||||
|
"GOOGLE_CLIENT_ID",
|
||||||
],
|
],
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user