webapp: login signup improvements + Google SSO (#1567)

## Login & Signup improvements + Google sign-in

- **Refreshed login & signup pages** - cleaner two-panel design with a
looping
Seedance 2.0 showcase video alongside the form, the ArtCraft logo, and a
back
button. Tidied up the related password-reset and welcome screens to
match.
- **Sign in with Google** - added a "Continue with Google" button to
both the
  login and signup pages.
- **Set a password after Google sign-up** - first-time Google users are
guided to
create a password (so they can also sign in on the desktop app), then
continue
  to the welcome/pricing page. Returning users go straight in.
- **Account settings** - the password option now says "Set" for accounts
that
don't have a password yet (and skips asking for a current one), and
"Change"
  otherwise.
This commit is contained in:
BFlat authored and GitHub committed 2026-05-26 01:57:05 +07:00
1 parent 06cc380d74
commit 19d0813f02
20 files changed
+659 -260

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+10 -3
View File
@@ -20,6 +20,8 @@ import Pricing from "../pages/pricing";
import Support from "../pages/support/support";
import Login from "../pages/login";
import Signup from "../pages/signup";
import SetPassword from "../pages/set-password";
import { AuthLayout } from "../components/auth";
import ForgotPassword, { VerifyReset } from "../pages/forgot-password";
import Welcome from "../pages/welcome";
import Onboarding from "../pages/onboarding";
@@ -118,9 +120,14 @@ export function App() {
<>
<ScrollToTop />
<Routes>
{/* Public — no chrome, no auth gate */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
{/* Public — no chrome, no auth gate. login/signup share a layout route
so the showcase pane (and its video) stays mounted when toggling
between them. */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
</Route>
<Route path="/set-password" element={<SetPassword />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/forgot-password/verify" element={<VerifyReset />} />
@@ -57,9 +57,11 @@ export function GoogleLoginButton({
};
return (
<div className={`relative w-full h-12 ${className}`}>
<div
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
>
{/* Custom visual button - shown behind */}
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-lg font-medium border border-gray-200 hover:bg-gray-50 transition-colors">
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-full font-medium border border-gray-200 group-hover:border-white/80 group-hover:bg-white/80 transition-colors">
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
@@ -68,7 +70,7 @@ export function GoogleLoginButton({
) : (
<>
<GoogleIcon />
<span>Continue with Google</span>
<span className="text-sm font-semibold">Continue with Google</span>
</>
)}
</div>
@@ -0,0 +1,109 @@
import { ReactNode } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { faArrowLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TruchetPattern } from "@storyteller/ui-vfx";
import { AuthShowcase } from "./auth-showcase";
import { useMediaQuery } from "../ui/use-media-query";
/**
* Persistent shell for the auth pages. Rendered as a layout route so the
* showcase pane (and its video) stays mounted while the form pane swaps
* between login and signup via <Outlet> — toggling between the two no longer
* reloads the showcase video from the start. Each page renders its own header
* (AuthHeader) and footer (AuthFooter) into the outlet.
*/
export const AuthLayout = () => {
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
// mobile never downloads the demo videos.
const showShowcase = useMediaQuery("(min-width: 1024px)");
const navigate = useNavigate();
const handleBack = () => {
navigate("/");
};
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#101014] p-4 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black 25%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 40%, black 25%, transparent 80%)",
}}
>
<TruchetPattern
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] shadow-2xl lg:min-h-[640px]">
{/* Form pane — only the inner content swaps per route (via Outlet), so
this pane and the showcase beside it stay mounted across the
login/signup toggle. */}
<div className="relative flex w-full flex-col lg:w-1/2">
<button
type="button"
onClick={handleBack}
aria-label="Go back"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
<div className="mx-auto w-full max-w-sm">
<Outlet />
</div>
</div>
<div className="px-8 pb-8 text-center text-xs text-white/20">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
{/* Showcase pane (desktop only) */}
{showShowcase && (
<div className="relative lg:w-1/2">
<AuthShowcase />
</div>
)}
</div>
</div>
);
};
interface AuthHeaderProps {
title: string;
subtitle: string;
}
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
<div className="mb-8 text-center">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="text-sm text-white/60">{subtitle}</p>
</div>
);
export const AuthFooter = ({ children }: { children: ReactNode }) => (
<div className="mt-8 text-center text-sm text-white/60">{children}</div>
);
@@ -0,0 +1,45 @@
const FEATURED_VIDEO_URL =
"https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1";
export const AuthShowcase = () => {
return (
<div
className="absolute inset-2 overflow-hidden bg-black rounded-2xl"
style={{ containerType: "size" }}
>
{/* Vimeo background embed (no controls) scaled to cover the pane. The
iframe is forced to 16:9 and sized to the larger cover dimension via
container-query units, then centered so the overflow crops evenly. */}
<iframe
src={FEATURED_VIDEO_URL}
title="Cheap Seedance 2.0"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{
width: "max(100cqw, calc(100cqh * 16 / 9))",
height: "max(100cqh, calc(100cqw * 9 / 16))",
}}
/>
{/* Legibility gradient behind the caption */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/10 to-black/25"
/>
{/* Caption */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-300">
One of the cheapest
</p>
<h2 className="text-2xl font-bold leading-tight">
Seedance 2.0 Video Generation
</h2>
<p className="mt-1 max-w-sm text-sm text-white/70">
Generate jaw-dropping AI videos with Seedance 2.0.
</p>
</div>
</div>
);
};
@@ -1,2 +1,4 @@
export * from "./signup-form";
export * from "./GoogleLoginButton";
export * from "./auth-layout";
export * from "./auth-showcase";
@@ -7,6 +7,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import {
getLandingUrl,
@@ -14,6 +15,8 @@ import {
getReferralUsername,
getReferrer,
} from "@storyteller/common";
import { refreshSession, useSessionStore } from "../../lib/session";
import { GoogleLoginButton } from "./GoogleLoginButton";
interface SignupFormProps {
onSuccess: (isNewUser?: boolean) => void;
@@ -28,6 +31,7 @@ export const SignupForm = ({
className = "",
autoFocus = false,
}: SignupFormProps) => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -81,36 +85,24 @@ export const SignupForm = ({
}
};
//const handleGoogleSuccess = (isNewUser: boolean) => {
// onSuccess(isNewUser);
//};
const handleGoogleSuccess = async () => {
// Refresh so the session reflects the new cookie (and whether the account
// still needs a password), then route accordingly. New SSO users with no
// password go set one; everyone else goes home.
await refreshSession(true);
if (useSessionStore.getState().passwordNotSet) {
navigate("/set-password");
} else {
navigate("/");
}
};
//const handleGoogleError = (errorMessage: string) => {
// setError(errorMessage);
//};
const handleGoogleError = (message: string) => {
setError(message);
};
return (
<div className={`space-y-4 ${className}`}>
{/* {showGoogleButton && (
<>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
or
</span>
</div>
</>
)}
*/}
<form
className="space-y-4"
onSubmit={(e) => {
@@ -134,21 +126,23 @@ export const SignupForm = ({
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Password
</label>
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -170,11 +164,26 @@ export const SignupForm = ({
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create Account"
"Create account"
)}
</Button>
</div>
</form>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</div>
);
};
@@ -18,9 +18,10 @@ type EditingSection = "username" | "email" | "password" | null;
interface AccountSectionProps {
user: UserInfo;
passwordNotSet: boolean;
}
export function AccountSection({ user }: AccountSectionProps) {
export function AccountSection({ user, passwordNotSet }: AccountSectionProps) {
const [editing, setEditing] = useState<EditingSection>(null);
const requestOpen = (section: EditingSection) => setEditing(section);
@@ -44,6 +45,7 @@ export function AccountSection({ user }: AccountSectionProps) {
<hr className="border-ui-panel-border" />
<PasswordForm
user={user}
passwordNotSet={passwordNotSet}
isEditing={editing === "password"}
onOpen={() => requestOpen("password")}
onClose={close}
@@ -130,7 +132,9 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
await refreshSession(true);
onClose();
} else {
setError(humanizeError(response.errorMessage, "Could not update username."));
setError(
humanizeError(response.errorMessage, "Could not update username."),
);
}
};
@@ -280,15 +284,22 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
);
}
function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
function PasswordForm({
user,
passwordNotSet,
isEditing,
onOpen,
onClose,
}: SectionFormProps & { passwordNotSet: boolean }) {
const [oldPassword, setOldPassword] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const canSubmit =
oldPassword.length > 0 && password.length > 0 && confirm.length > 0;
const canSubmit = passwordNotSet
? password.length > 0 && confirm.length > 0
: oldPassword.length > 0 && password.length > 0 && confirm.length > 0;
const reset = () => {
setOldPassword("");
@@ -311,32 +322,32 @@ function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
e.preventDefault();
setError(null);
if (oldPassword.length === 0) {
setError("Enter your current password.");
return;
}
const reason = validatePassword(password, confirm);
if (reason) {
setError(reason);
return;
}
if (oldPassword === password) {
setError("New password must be different from the current password.");
return;
}
setSubmitting(true);
const usersApi = new UsersApi();
const verify = await usersApi.Login({
usernameOrEmail: user.username,
password: oldPassword,
});
if (!verify.success) {
setSubmitting(false);
setError("Current password is incorrect.");
return;
// When the account already has a password, confirm the current one before
// changing it. A new SSO account with no password set skips this step.
if (!passwordNotSet) {
if (oldPassword === password) {
setSubmitting(false);
setError("New password must be different from the current password.");
return;
}
const verify = await usersApi.Login({
usernameOrEmail: user.username,
password: oldPassword,
});
if (!verify.success) {
setSubmitting(false);
setError("Current password is incorrect.");
return;
}
}
const response = await usersApi.ChangePassword({
@@ -346,46 +357,59 @@ function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
setSubmitting(false);
if (response.success) {
toast.success("Password updated.");
toast.success(passwordNotSet ? "Password set." : "Password updated.");
await refreshSession(true);
handleClose();
} else {
setError(humanizeError(response.errorMessage, "Could not update password."));
setError(
humanizeError(response.errorMessage, "Could not update password."),
);
}
};
return (
<SectionShell
title="Password"
collapsedDescription="Used to sign in to your account."
editingDescription={`Choose a new password of at least ${PASSWORD_MIN} characters.`}
collapsedDescription={
passwordNotSet
? "Set a password so you can also sign into the desktop app."
: "Used to sign in to your account."
}
editingDescription={`Choose a password of at least ${PASSWORD_MIN} characters.`}
actionLabel={passwordNotSet ? "Set" : "Change"}
isEditing={isEditing}
onOpen={handleOpen}
>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Current password"
autoComplete="current-password"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
{!passwordNotSet && (
<Input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Current password"
autoComplete="current-password"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
)}
<div className="grid grid-cols-2 gap-2">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="New password"
placeholder={passwordNotSet ? "Password" : "New password"}
minLength={PASSWORD_MIN}
autoComplete="new-password"
autoFocus={passwordNotSet}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Confirm new password"
placeholder={
passwordNotSet ? "Confirm password" : "Confirm new password"
}
minLength={PASSWORD_MIN}
autoComplete="new-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
@@ -408,6 +432,7 @@ function SectionShell({
editingDescription,
currentLabel,
currentValue,
actionLabel = "Change",
isEditing,
onOpen,
children,
@@ -417,6 +442,7 @@ function SectionShell({
editingDescription: string;
currentLabel?: string;
currentValue?: string;
actionLabel?: string;
isEditing: boolean;
onOpen: () => void;
children: React.ReactNode;
@@ -437,7 +463,7 @@ function SectionShell({
className="h-9 px-3 shrink-0"
onClick={onOpen}
>
Change
{actionLabel}
</Button>
)}
</div>
@@ -500,15 +526,15 @@ function FormActions({
}
function FormError({ message }: { message: string }) {
return (
<p className="text-xs text-red-400 leading-tight">{message}</p>
);
return <p className="text-xs text-red-400 leading-tight">{message}</p>;
}
function validateUsername(username: string): string | null {
if (username.length === 0) return "Username cannot be empty.";
if (username.length < USERNAME_MIN) return `Username must be at least ${USERNAME_MIN} characters.`;
if (username.length > USERNAME_MAX) return `Username must be at most ${USERNAME_MAX} characters.`;
if (username.length < USERNAME_MIN)
return `Username must be at least ${USERNAME_MIN} characters.`;
if (username.length > USERNAME_MAX)
return `Username must be at most ${USERNAME_MAX} characters.`;
if (!USERNAME_REGEX.test(username)) {
return "Username may only contain letters, numbers, underscores, and hyphens.";
}
@@ -523,7 +549,8 @@ function validateEmail(email: string): string | null {
}
function validatePassword(password: string, confirm: string): string | null {
if (password.length < PASSWORD_MIN) return `Password must be at least ${PASSWORD_MIN} characters.`;
if (password.length < PASSWORD_MIN)
return `Password must be at least ${PASSWORD_MIN} characters.`;
if (password !== confirm) return "Passwords do not match.";
return null;
}
@@ -94,7 +94,7 @@ function GeneralPanel() {
}
function AccountPanel() {
const { user, authChecked } = useSession();
const { user, authChecked, passwordNotSet } = useSession();
if (!authChecked) {
return (
@@ -112,7 +112,7 @@ function AccountPanel() {
return (
<div className="pt-3">
<AccountSection user={user} />
<AccountSection user={user} passwordNotSet={passwordNotSet} />
</div>
);
}
@@ -8,11 +8,11 @@ export function TopBar() {
const showTopbarLogo = isMobile || state === "collapsed";
return (
<header className="sticky top-0 z-20 relative flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
<div className="flex items-center gap-2 min-w-0 shrink-0">
<SidebarTrigger className="md:hidden" />
<div className="flex gap-6">
<div className="flex gap-6 h-8">
{showTopbarLogo && (
<Link to="/" className="flex items-center shrink-0">
<img
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}
@@ -6,6 +6,9 @@ interface SessionState {
user: UserInfo | undefined;
loggedIn: boolean;
authChecked: boolean;
// True when the signed-in user has no password yet (e.g. signed up via
// Google SSO and hasn't set one). Drives the set-password flow.
passwordNotSet: boolean;
setSession: (next: Partial<Omit<SessionState, "setSession">>) => void;
}
@@ -13,6 +16,7 @@ export const useSessionStore = create<SessionState>((set) => ({
user: undefined,
loggedIn: false,
authChecked: false,
passwordNotSet: false,
setSession: (next) => set(next),
}));
@@ -36,6 +40,9 @@ async function fetchAndStoreSession(): Promise<void> {
user: loggedIn ? response.data!.user : undefined,
loggedIn,
authChecked: true,
passwordNotSet: loggedIn
? !!response.data!.onboarding?.password_not_set
: false,
});
} catch {
// Backend unreachable or timed out — clear cache so next call retries.
@@ -45,6 +52,7 @@ async function fetchAndStoreSession(): Promise<void> {
user: undefined,
loggedIn: false,
authChecked: true,
passwordNotSet: false,
});
}
}
@@ -92,6 +100,7 @@ export interface UseSessionResult {
user: UserInfo | undefined;
loggedIn: boolean;
authChecked: boolean;
passwordNotSet: boolean;
}
/** Subscribe to session state. The first caller per page-load triggers the fetch. */
@@ -99,6 +108,7 @@ export function useSession(): UseSessionResult {
const user = useSessionStore((s) => s.user);
const loggedIn = useSessionStore((s) => s.loggedIn);
const authChecked = useSessionStore((s) => s.authChecked);
const passwordNotSet = useSessionStore((s) => s.passwordNotSet);
useEffect(() => {
ensureAuthChangeListener();
@@ -108,7 +118,7 @@ export function useSession(): UseSessionResult {
}
}, []);
return { user, loggedIn, authChecked };
return { user, loggedIn, authChecked, passwordNotSet };
}
// Backwards-compatible response shape for legacy `await getSession()` callers.
@@ -61,10 +61,16 @@ const ForgotPassword = () => {
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
{!submitted ? (
<>
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
@@ -94,7 +100,7 @@ const ForgotPassword = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
@@ -117,10 +117,16 @@ const VerifyReset = () => {
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
{!success ? (
<>
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">
Password Reset Verification
</h1>
@@ -154,7 +160,7 @@ const VerifyReset = () => {
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
isError={!!fieldErrors.verificationCode}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1">
@@ -176,7 +182,7 @@ const VerifyReset = () => {
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
isError={!!fieldErrors.newPassword}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -208,7 +214,7 @@ const VerifyReset = () => {
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Enter new password again"
isError={!!fieldErrors.confirmPassword}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -9,8 +9,9 @@ import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import Seo from "../../components/seo";
import { refreshSession } from "../../lib/session";
import { refreshSession, useSessionStore } from "../../lib/session";
const Login = () => {
const navigate = useNavigate();
@@ -47,148 +48,125 @@ const Login = () => {
}
};
//const handleGoogleSuccess = (isNewUser: boolean) => {
// if (isNewUser) {
// // Optionally redirect to set username for new users
// navigate("/welcome");
// } else {
// navigate("/");
// }
//};
const handleGoogleSuccess = async () => {
// Refresh so the session store reflects the new cookie (and whether the
// account still needs a password) before deciding where to send them.
await refreshSession(true);
if (useSessionStore.getState().passwordNotSet) {
navigate("/set-password");
} else {
navigate(redirectTo);
}
};
//const handleGoogleError = (errorMessage: string) => {
// setError(errorMessage);
//};
const handleGoogleError = (message: string) => {
setError(message);
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<>
<Seo
title="Login - ArtCraft"
description="Login to your ArtCraft account."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-4">
{/*<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
or
</span>
</div>
*/}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="pt-2">
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Log in"
)}
</Button>
</div>
</form>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<div className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
Email or Username
</label>
<Input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
Password
</label>
<Link
to="/signup"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Sign up
Forgot password?
</Link>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</div>
</form>
<div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
</div>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</>
);
};
@@ -0,0 +1,2 @@
import SetPassword from "./set-password";
export default SetPassword;
@@ -0,0 +1,194 @@
import {
faEye,
faEyeSlash,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { useNavigate, Navigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { useSession, refreshSession } from "../../lib/session";
import Seo from "../../components/seo";
import { toast } from "../../components/toast/toast";
const SetPassword = () => {
const navigate = useNavigate();
const { loggedIn, authChecked, passwordNotSet } = useSession();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Only meant for a signed-in user who has no password yet (the Google SSO
// sign-up case). Wait for the session check, then send everyone else home —
// this covers direct navigation and stops an existing user (who already has
// a password) from being misrouted here.
if (!authChecked) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#101014]">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-4xl text-primary/80"
/>
</div>
);
}
if (!loggedIn || !passwordNotSet) {
return <Navigate to="/" replace />;
}
const handleSetPassword = async () => {
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setIsLoading(true);
const api = new UsersApi();
const response = await api.ChangePassword({
password,
passwordConfirmation: confirmPassword,
});
setIsLoading(false);
if (response.success) {
toast.success("Password has been set");
await refreshSession(true);
navigate("/welcome");
} else {
setError(response.errorMessage || "Failed to set password");
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<Seo
title="Set Password - ArtCraft"
description="Set a password for your ArtCraft account."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="text-2xl font-semibold mb-2">Set a password</h1>
<p className="text-white/60 text-sm">
Create a password so you can also log into the desktop app
</p>
</div>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleSetPassword();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
autoFocus
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Confirm password
</label>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon
icon={showConfirmPassword ? faEyeSlash : faEye}
/>
</button>
</div>
</div>
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Set password"
)}
</Button>
</div>
</form>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
};
export default SetPassword;
@@ -1,53 +1,30 @@
import { Link, useNavigate } from "react-router-dom";
import { SignupForm } from "../../components/auth";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
const Signup = () => {
const navigate = useNavigate();
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<>
<Seo
title="Sign Up - ArtCraft"
description="Create your ArtCraft account."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
</div>
<SignupForm onSuccess={() => navigate("/welcome")} signupSource="artcraft" />
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link
to="/login"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Log in
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</>
);
};
@@ -32,7 +32,7 @@ const Welcome = () => {
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-28 pb-20 px-4 sm:px-6 lg:px-8">
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8">
{/* Success Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
@@ -48,8 +48,9 @@ const Welcome = () => {
<h1 className="text-4xl sm:text-5xl md:text-6xl font-medium mb-4 tracking-tight">
Welcome to <span className="text-primary">ArtCraft</span>
</h1>
<p className="text-xl text-white/70 max-w-2xl mx-auto">
<p className="text-lg sm:text-xl text-white/70 max-w-2xl mx-auto px-2.5">
Support open-source development and unlock premium AI art features.
<br className="hidden sm:block" />
Your subscription keeps ArtCraft free for everyone.
</p>
</div>
@@ -163,7 +164,7 @@ const Welcome = () => {
<Button
as="link"
href={downloadUrl}
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold rounded-xl"
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold"
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
Download for {isMacOs ? "Mac" : "Windows"}
+7 -1
View File
@@ -319,7 +319,13 @@ export class UsersApi extends ApiManager {
try {
const response = await this.authFetch<
{ google_credential: string },
{
google_credential: string;
maybe_referral_url?: string;
maybe_landing_url?: string;
maybe_referral_username?: string;
maybe_referral_code?: string;
},
{
success: boolean;
username_not_yet_customized?: boolean;