diff --git a/public/auth-bg.jpg b/public/auth-bg.jpg new file mode 100644 index 0000000..1ea03b1 Binary files /dev/null and b/public/auth-bg.jpg differ diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx index 95e6a0b..821e0d2 100644 --- a/src/client/features/auth/AuthPage.tsx +++ b/src/client/features/auth/AuthPage.tsx @@ -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({
-
); @@ -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) => void; + error?: string | null; + autoComplete?: string; + required?: boolean; + minLength?: number; + maxLength?: number; + disabled?: boolean; + showPasswordToggle?: boolean; + onTogglePassword?: () => void; + isPasswordVisible?: boolean; +}) { + return ( +
+
+
+ +
+ + {showPasswordToggle ? ( + + ) : null} +
+ {error ? ( +

{error}

+ ) : null} +
+ ); +} + +export function AuthSubmitButton({ + children, + isSubmitting, + disabled, +}: { + children: React.ReactNode; + isSubmitting?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +export function AuthDivider({ text = "or" }: { text?: string }) { + return ( +
+
+
+
+ + {text} + +
+ ); +} + export function AuthPageCard({ title, helperText, @@ -107,30 +223,35 @@ export function AuthPageCard({ footer?: React.ReactNode; }) { return ( -
-
+
+
CrawlerX -
-

{title}

- {helperText ? ( -

{helperText}

- ) : null} -
+

+ {title} +

+ {helperText ? ( +

+ {helperText} +

+ ) : null}
- {children} +
{children}
- {footer ?
{footer}
: null} + {footer ?
{footer}
: null} + +
+ + Your information is secure and encrypted. +
); } -// `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 ( - Back to sign in + Back to sign in + ); } -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; + }; +}) { 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. -
-
{children}
+
+
+
+ + {/* Left Column: Brand & Hero Value Props */} +
+
+ Metatroncube +
+
+ Metatroncube +
+
+ Solutions +
+
+
+ +
+
+ + JOIN OUR PLATFORM +
+ +

+ Build something +
+ + exceptional. + +

+ +

+ Create your account and get started in minutes. Join a growing + community of creators, builders and innovators. +

+ +
+ {VALUE_PROPS.map(({ icon: Icon, title, desc }) => ( +
+
+ +
+
+
{title}
+
+ {desc} +
+
+
+ ))} +
+ +
+ "More than software, +
+ it's a better tomorrow." +
+
+ +
+
+ + {/* Right Column: Floating White Card */} +
+
+ {topLink ? ( +
+ {topLink.text} + + {topLink.linkText} + + +
+ ) : null} +
+ +
{children}
+ +
+
+ — • • • + SIMPLE + / + SECURE + / + POWERFUL +
+
+
); } diff --git a/src/client/features/settings/ChangePasswordSection.tsx b/src/client/features/settings/ChangePasswordSection.tsx new file mode 100644 index 0000000..ed74985 --- /dev/null +++ b/src/client/features/settings/ChangePasswordSection.tsx @@ -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(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 ( +
+
+

+ + Change Password +

+

+ Update your password to keep your account secure. +

+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + showPasswordToggle + isPasswordVisible={showCurrentPassword} + onTogglePassword={() => setShowCurrentPassword(!showCurrentPassword)} + /> +
+ +
+ + setNewPassword(e.target.value)} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + autoComplete="new-password" + showPasswordToggle + isPasswordVisible={showNewPassword} + onTogglePassword={() => setShowNewPassword(!showNewPassword)} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + autoComplete="new-password" + showPasswordToggle + isPasswordVisible={showConfirmPassword} + onTogglePassword={() => + setShowConfirmPassword(!showConfirmPassword) + } + /> +
+ +
+ + Update password + +
+ +
+ + Sessions on other devices will be securely signed out. +
+
+
+ ); +} diff --git a/src/client/styles/app.css b/src/client/styles/app.css index 7cb240f..9658c1b 100644 --- a/src/client/styles/app.css +++ b/src/client/styles/app.css @@ -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 { diff --git a/src/env.d.ts b/src/env.d.ts index 788c269..f1ca843 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -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; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bd2a495..84dda5a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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" || diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f891dd3..0ad1c37 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 0d7b772..94ffc70 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { <> - + {isHostedClientAuthMode() ? : null} {children} + {isSessionClientAuthMode() && Boolean(session?.user) ? ( + + ) : null} +

Analytics diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index e3008b1..7aaeea1 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -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 ( - {showEmailForm ? ( +
+ +

+ Don't have an account?{" "} - Forgot password? + Create account + - ) : null} - - Create account - +

) : null } @@ -181,12 +178,12 @@ function SignInPage() { }} /> {socialError ? ( -

{socialError}

+

{socialError}

) : null} ) : (
{ event.preventDefault(); void form.handleSubmit(); @@ -197,48 +194,55 @@ function SignInPage() { const error = getFieldError(field.state.meta.errors); return ( -
- field.handleChange(event.target.value)} - autoComplete="email" - disabled={!isSessionMode} - required - /> - {error ? ( -

{error}

- ) : null} -
+ field.handleChange(event.target.value)} + autoComplete="email" + disabled={!isSessionMode} + required + error={error} + /> ); }} - - {(field) => { - const error = getFieldError(field.state.meta.errors); +
+ + {(field) => { + const error = getFieldError(field.state.meta.errors); - return ( -
- field.handleChange(event.target.value)} autoComplete="current-password" disabled={!isSessionMode} required + showPasswordToggle + isPasswordVisible={showPassword} + onTogglePassword={() => setShowPassword(!showPassword)} + error={error} /> - {error ? ( -

{error}

- ) : null} -
- ); - }} -
+ ); + }} + + +
+ + Forgot password? + +
+
({ @@ -249,17 +253,28 @@ function SignInPage() { {({ submitError, isSubmitting }) => { const errorMessage = getFormError(submitError); return ( - <> +
{errorMessage ? ( -

{errorMessage}

+

+ {errorMessage} +

) : null} - - + Sign in + + {hasGoogleAuth ? ( + + ) : null} +
); }}
diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index 1915dd4..e980418 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -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 ( { - setShowEmailForm(false); - google.clearError(); - }} - > - Back to signup - - ) : ( -
-

- By signing up, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - - . -

+
+

+ By signing up, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + + . +

-

- Already have an account?{" "} - - Sign in - -

-
- ) + + +

+ Already have an account?{" "} + + Sign in + + +

+
) : null } > @@ -227,12 +227,12 @@ function SignUpPage() { }} /> {google.error ? ( -

{google.error}

+

{google.error}

) : null} ) : ( { event.preventDefault(); void form.handleSubmit(); @@ -243,20 +243,16 @@ function SignUpPage() { const error = getFieldError(field.state.meta.errors); return ( -
- field.handleChange(event.target.value)} - autoComplete="name" - disabled={!isHostedMode} - /> - {error ? ( -

{error}

- ) : null} -
+ field.handleChange(event.target.value)} + autoComplete="name" + disabled={!isHostedMode} + error={error} + /> ); }}
@@ -266,21 +262,17 @@ function SignUpPage() { const error = getFieldError(field.state.meta.errors); return ( -
- field.handleChange(event.target.value)} - autoComplete="email" - disabled={!isHostedMode} - required - /> - {error ? ( -

{error}

- ) : null} -
+ field.handleChange(event.target.value)} + autoComplete="email" + disabled={!isHostedMode} + required + error={error} + /> ); }} @@ -290,23 +282,22 @@ function SignUpPage() { const error = getFieldError(field.state.meta.errors); return ( -
- field.handleChange(event.target.value)} - autoComplete="new-password" - disabled={!isHostedMode} - required - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - /> - {error ? ( -

{error}

- ) : null} -
+ 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} + /> ); }} @@ -316,23 +307,24 @@ function SignUpPage() { const error = getFieldError(field.state.meta.errors); return ( -
- field.handleChange(event.target.value)} - autoComplete="new-password" - disabled={!isHostedMode} - required - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - /> - {error ? ( -

{error}

- ) : null} -
+ 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} + /> ); }} @@ -353,21 +345,32 @@ function SignUpPage() { {({ submitError, isSubmitting }) => { const errorMessage = getFormError(submitError); return ( - <> +
{errorMessage ? ( -

{errorMessage}

+

+ {errorMessage} +

) : null} - - + Create account + + {hasGoogleAuth ? ( + + ) : null} +
); }} @@ -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) }; } diff --git a/src/routes/_auth.tsx b/src/routes/_auth.tsx index 261863b..ec13033 100644 --- a/src/routes/_auth.tsx +++ b/src/routes/_auth.tsx @@ -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 ( - + ); diff --git a/src/routes/change-password.tsx b/src/routes/change-password.tsx new file mode 100644 index 0000000..416420a --- /dev/null +++ b/src/routes/change-password.tsx @@ -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(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 ( + + + +

+ + Back to Settings + + +

+

+ } + > + {!isPending && !session?.user?.id ? ( +
+
+ Please sign in to change your account password. +
+ + Sign in to continue + + +
+ ) : ( + + {error ? ( +

{error}

+ ) : null} + + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + showPasswordToggle + isPasswordVisible={showCurrentPassword} + onTogglePassword={() => + setShowCurrentPassword(!showCurrentPassword) + } + /> + + setNewPassword(e.target.value)} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + autoComplete="new-password" + showPasswordToggle + isPasswordVisible={showNewPassword} + onTogglePassword={() => setShowNewPassword(!showNewPassword)} + /> + + setConfirmPassword(e.target.value)} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + autoComplete="new-password" + showPasswordToggle + isPasswordVisible={showConfirmPassword} + onTogglePassword={() => + setShowConfirmPassword(!showConfirmPassword) + } + /> + +
+ + Update password + +
+ + )} + + + ); +} diff --git a/src/routes/forgot-password.tsx b/src/routes/forgot-password.tsx index e16e351..3e02550 100644 --- a/src/routes/forgot-password.tsx +++ b/src/routes/forgot-password.tsx @@ -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 ( - + ({ isSuccess: state.isSubmitSuccessful && !state.errorMap.onSubmit, @@ -77,36 +88,48 @@ function ForgotPasswordPage() { return ( - - Back to sign in - -

+
+ +

+ + Back to sign in + + +

+
} > {isSuccess ? ( -
- +
+
If an account exists for that email, you'll receive password reset instructions shortly. - +
+ + Back to sign in + +
) : (
{ event.preventDefault(); void form.handleSubmit(); @@ -117,36 +140,35 @@ function ForgotPasswordPage() { const error = getFieldError(field.state.meta.errors); return ( -
- - field.handleChange(event.target.value) - } - autoComplete="email" - disabled={!isHostedMode} - required - /> - {error ? ( -

{error}

- ) : null} -
+ + field.handleChange(event.target.value) + } + autoComplete="email" + disabled={!isHostedMode} + required + error={error} + /> ); }} {errorMessage ? ( -

{errorMessage}

+

+ {errorMessage} +

) : null} - + Send reset link + )} diff --git a/src/routes/reset-password.tsx b/src/routes/reset-password.tsx index d098e7b..df591c5 100644 --- a/src/routes/reset-password.tsx +++ b/src/routes/reset-password.tsx @@ -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 ( - + ({ isComplete: state.isSubmitSuccessful && !state.errorMap.onSubmit, @@ -172,39 +187,52 @@ function ResetPasswordPage() { title={pageCopy.title} helperText={pageCopy.helperText} footer={ -

- - Sign in - -

+
+ +

+ + Sign in + + +

+
} > {!isHostedMode ? null : isComplete ? ( - - Continue to sign in - +
+
+ Your password has been changed successfully. +
+ + Continue to sign in + + +
) : routeError || !token ? ( - - Request a new reset link - +
+ + Request a new reset link + + +
) : (
{ event.preventDefault(); void form.handleSubmit(); @@ -215,24 +243,25 @@ function ResetPasswordPage() { const error = getFieldError(field.state.meta.errors); return ( -
- - field.handleChange(event.target.value) - } - autoComplete="new-password" - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - required - /> - {error ? ( -

{error}

- ) : null} -
+ + 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} + /> ); }} @@ -242,37 +271,41 @@ function ResetPasswordPage() { const error = getFieldError(field.state.meta.errors); return ( -
- - field.handleChange(event.target.value) - } - autoComplete="new-password" - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - required - /> - {error ? ( -

{error}

- ) : null} -
+ + 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} + /> ); }} {errorMessage ? ( -

{errorMessage}

+

+ {errorMessage} +

) : null} - + Update password + )} diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts index 51380db..4a12148 100644 --- a/src/types/vite-env.d.ts +++ b/src/types/vite-env.d.ts @@ -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... } diff --git a/vite.config.ts b/vite.config.ts index f329d97..1f317a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig(() => { "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST", "TURNSTILE_SITE_KEY", + "GOOGLE_CLIENT_ID", ], server: {