add authentication routes, components, and styling for CrawlerX app
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

This commit is contained in:
Alaguraj0361 2026-10-17 15:02:39 +05:30
parent a3b13a112a
commit 5ae39ebfd4
17 changed files with 1093 additions and 375 deletions

BIN
public/auth-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

View File

@ -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>
{helperText ? (
<p className="text-sm text-base-content/60 mt-1">{helperText}</p>
) : null}
</div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
{title}
</h2>
{helperText ? (
<p className="text-sm text-slate-500 leading-relaxed font-normal">
{helperText}
</p>
) : null}
</div>
{children}
<div className="space-y-3.5">{children}</div>
{footer ? <div className="text-center">{footer}</div> : null}
{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>
);
}
// `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>
);
}

View 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>
);
}

View File

@ -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
View File

@ -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;
}

View File

@ -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;
await sendHostedPasswordResetEmail({
email: user.email,
resetUrl: url,
});
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 }) => {
await sendHostedVerificationEmail({
email: user.email,
confirmationUrl: 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" ||

View File

@ -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,

View File

@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<AutumnProvider>
<QueryClientProvider client={queryClient}>
<>
<PostHogBootstrap />
{isHostedClientAuthMode() ? <PostHogBootstrap /> : null}
{children}
<ExportToSheetsModal />
<Toaster

View File

@ -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

View File

@ -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 ? (
<div className="space-y-4">
<AuthDivider text="or" />
<p className="text-xs text-slate-500">
Don't have an account?{" "}
<Link
to="/forgot-password"
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"
>
Forgot password?
<span>Create account</span>
<ArrowRight className="size-3" />
</Link>
) : null}
<Link
to="/sign-up"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Create account
</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,48 +194,55 @@ function SignInPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
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 ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Mail}
type="email"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isSessionMode}
required
error={error}
/>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
<div className="space-y-1">
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
return (
<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>
);
}}
</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) => ({
@ -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"}
</button>
</>
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>

View File

@ -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,55 +169,46 @@ 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">
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"
>
Terms
</a>{" "}
and{" "}
<a
href="https://openseo.so/privacy"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Privacy Policy
</a>
.
</p>
<div className="space-y-4">
<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-slate-700 font-medium underline underline-offset-2 hover:text-blue-600 transition-colors"
>
Terms
</a>{" "}
and{" "}
<a
href="https://openseo.so/privacy"
target="_blank"
rel="noreferrer"
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">
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"
>
Sign in
</Link>
</p>
</div>
)
<AuthDivider text="or" />
<p className="text-xs text-slate-500">
Already have an account?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
>
<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
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 ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={User}
type="text"
placeholder="Name (optional)..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode}
error={error}
/>
);
}}
</form.Field>
@ -266,21 +262,17 @@ function SignUpPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
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 ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Mail}
type="email"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode}
required
error={error}
/>
);
}}
</form.Field>
@ -290,23 +282,22 @@ function SignUpPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Lock}
type="password"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
showPasswordToggle
isPasswordVisible={showPassword}
onTogglePassword={() => setShowPassword(!showPassword)}
error={error}
/>
);
}}
</form.Field>
@ -316,23 +307,24 @@ function SignUpPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Confirm password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Lock}
type="password"
placeholder="Confirm password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
showPasswordToggle
isPasswordVisible={showConfirmPassword}
onTogglePassword={() =>
setShowConfirmPassword(!showConfirmPassword)
}
error={error}
/>
);
}}
</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"}
</button>
</>
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) };
}

View File

@ -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>
);

View 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>
);
}

View File

@ -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">
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-base-content/50 hover:text-base-content transition-colors"
>
Back to sign in
</Link>
</p>
<div className="space-y-4">
<AuthDivider text="or" />
<p className="text-xs text-slate-500">
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
>
<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,36 +140,35 @@ function ForgotPasswordPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
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 ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Mail}
type="email"
placeholder="Email address..."
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="email"
disabled={!isHostedMode}
required
error={error}
/>
);
}}
</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>

View File

@ -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">
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-base-content/50 hover:text-base-content transition-colors"
>
Sign in
</Link>
</p>
<div className="space-y-4">
<AuthDivider text="or" />
<p className="text-xs text-slate-500">
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-blue-600 font-semibold hover:underline inline-flex items-center gap-1"
>
<span>Sign in</span>
<ArrowRight className="size-3" />
</Link>
</p>
</div>
}
>
{!isHostedMode ? null : isComplete ? (
<a
href={
redirectTo === "/"
? "/sign-in"
: `/sign-in?redirect=${encodeURIComponent(redirectTo)}`
}
className="btn btn-soft w-full"
>
Continue to sign in
</a>
<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="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>Continue to sign in</span>
<ArrowRight className="size-4" />
</a>
</div>
) : routeError || !token ? (
<Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="btn btn-soft w-full"
>
Request a new reset link
</Link>
<div className="space-y-4">
<Link
to="/forgot-password"
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>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,24 +243,25 @@ function ResetPasswordPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="New password..."
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Lock}
type="password"
placeholder="New password..."
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
showPasswordToggle
isPasswordVisible={showPassword}
onTogglePassword={() =>
setShowPassword(!showPassword)
}
error={error}
/>
);
}}
</form.Field>
@ -242,37 +271,41 @@ function ResetPasswordPage() {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Confirm new password..."
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
<AuthInputField
icon={Lock}
type="password"
placeholder="Confirm new password..."
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
autoComplete="new-password"
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
required
showPasswordToggle
isPasswordVisible={showConfirmPassword}
onTogglePassword={() =>
setShowConfirmPassword(!showConfirmPassword)
}
error={error}
/>
);
}}
</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>

View File

@ -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...
}

View File

@ -24,6 +24,7 @@ export default defineConfig(() => {
"POSTHOG_PUBLIC_KEY",
"POSTHOG_HOST",
"TURNSTILE_SITE_KEY",
"GOOGLE_CLIENT_ID",
],
server: {