mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-08 02:52:12 +00:00
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:
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 |
@@ -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">
|
||||
© {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">
|
||||
© {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">
|
||||
© {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">
|
||||
© {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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user