From 43fcccce65aa525237ec55419c6a7b2e9da2005c Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 16 May 2026 15:12:59 +0700 Subject: [PATCH] web/webapp: username, email, password change --- .../settings-modal/AccountSection.tsx | 564 ++++++++++++++++++ .../settings-modal/SettingsModal.tsx | 105 +++- .../src/components/topbar/topbar.tsx | 67 ++- .../apps/artcraft-webapp/src/lib/session.ts | 9 + .../src/components/navbar/navbar.tsx | 38 +- .../settings-modal/AccountSection.tsx | 564 ++++++++++++++++++ .../settings-modal/SettingsModal.tsx | 105 +++- .../apps/artcraft-website/src/lib/session.ts | 9 + 8 files changed, 1376 insertions(+), 85 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/components/settings-modal/AccountSection.tsx create mode 100644 frontend/apps/artcraft-website/src/components/settings-modal/AccountSection.tsx diff --git a/frontend/apps/artcraft-webapp/src/components/settings-modal/AccountSection.tsx b/frontend/apps/artcraft-webapp/src/components/settings-modal/AccountSection.tsx new file mode 100644 index 0000000000..f30411e906 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/settings-modal/AccountSection.tsx @@ -0,0 +1,564 @@ +import { useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { Input } from "@storyteller/ui-input"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { UsersApi, type UserInfo } from "@storyteller/api"; +import { refreshSession, updateSessionUser } from "../../lib/session"; +import { toast } from "../toast/toast"; + +const USERNAME_REGEX = /^[A-Za-z0-9_-]+$/; +const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; +const USERNAME_MIN = 3; +const USERNAME_MAX = 16; +const EMAIL_MAX = 255; +const PASSWORD_MIN = 6; + +type EditingSection = "username" | "email" | "password" | null; + +interface AccountSectionProps { + user: UserInfo; +} + +export function AccountSection({ user }: AccountSectionProps) { + const [editing, setEditing] = useState(null); + + const requestOpen = (section: EditingSection) => setEditing(section); + const close = () => setEditing(null); + + return ( +
+ requestOpen("username")} + onClose={close} + /> +
+ requestOpen("email")} + onClose={close} + /> +
+ requestOpen("password")} + onClose={close} + /> +
+ ); +} + +interface SectionFormProps { + user: UserInfo; + isEditing: boolean; + onOpen: () => void; + onClose: () => void; +} + +function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const initial = user.display_name || user.username || ""; + const [value, setValue] = useState(initial); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmed = value.trim(); + const isDirty = trimmed !== initial.trim(); + const canSubmit = isDirty && trimmed.length > 0 && currentPassword.length > 0; + + const reset = () => { + setValue(initial); + setCurrentPassword(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const reason = validateUsername(trimmed); + if (reason) { + setError(reason); + return; + } + if (!isDirty) { + handleClose(); + return; + } + if (currentPassword.length === 0) { + setError("Enter your current password."); + return; + } + + setSubmitting(true); + const usersApi = new UsersApi(); + + const verify = await usersApi.Login({ + usernameOrEmail: user.username, + password: currentPassword, + }); + + if (!verify.success) { + setSubmitting(false); + setError("Current password is incorrect."); + return; + } + + const response = await usersApi.EditUsername({ displayName: trimmed }); + setSubmitting(false); + + if (response.success) { + toast.success("Username updated."); + updateSessionUser({ + username: trimmed.toLowerCase(), + display_name: trimmed, + }); + await refreshSession(true); + onClose(); + } else { + setError(humanizeError(response.errorMessage, "Could not update username.")); + } + }; + + return ( + +
+ setValue(e.target.value)} + placeholder="Your username" + maxLength={USERNAME_MAX} + autoComplete="username" + 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" + /> + setCurrentPassword(e.target.value)} + placeholder="Confirm password" + autoComplete="current-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" + /> + {error && } + + +
+ ); +} + +function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const [value, setValue] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0 && currentPassword.length > 0; + + const reset = () => { + setValue(""); + setCurrentPassword(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const reason = validateEmail(trimmed); + if (reason) { + setError(reason); + return; + } + if (currentPassword.length === 0) { + setError("Enter your current password."); + return; + } + + setSubmitting(true); + const usersApi = new UsersApi(); + + const verify = await usersApi.Login({ + usernameOrEmail: user.username, + password: currentPassword, + }); + + if (!verify.success) { + setSubmitting(false); + setError("Current password is incorrect."); + return; + } + + const response = await usersApi.EditEmail({ emailAddress: trimmed }); + setSubmitting(false); + + if (response.success) { + toast.success("Email updated."); + await refreshSession(true); + onClose(); + reset(); + } else { + setError(humanizeError(response.errorMessage, "Could not update email.")); + } + }; + + return ( + +
+ setValue(e.target.value)} + placeholder="you@example.com" + maxLength={EMAIL_MAX} + autoComplete="email" + 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" + /> + setCurrentPassword(e.target.value)} + placeholder="Confirm password" + autoComplete="current-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" + /> + {error && } + + +
+ ); +} + +function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const [oldPassword, setOldPassword] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const canSubmit = + oldPassword.length > 0 && password.length > 0 && confirm.length > 0; + + const reset = () => { + setOldPassword(""); + setPassword(""); + setConfirm(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + 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; + } + + const response = await usersApi.ChangePassword({ + password, + passwordConfirmation: confirm, + }); + setSubmitting(false); + + if (response.success) { + toast.success("Password updated."); + handleClose(); + } else { + setError(humanizeError(response.errorMessage, "Could not update password.")); + } + }; + + return ( + +
+ 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" + /> +
+ setPassword(e.target.value)} + placeholder="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" + /> + setConfirm(e.target.value)} + placeholder="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" + /> +
+ {error && } + + +
+ ); +} + +function SectionShell({ + title, + collapsedDescription, + editingDescription, + currentLabel, + currentValue, + isEditing, + onOpen, + children, +}: { + title: string; + collapsedDescription: string; + editingDescription: string; + currentLabel: string; + currentValue: string; + isEditing: boolean; + onOpen: () => void; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+

+ {isEditing ? editingDescription : collapsedDescription} +

+
+ {!isEditing && ( + + )} +
+ {!isEditing ? ( + + ) : ( + children + )} +
+ ); +} + +function CurrentValue({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function FormActions({ + onCancel, + submitting, + disabled, +}: { + onCancel: () => void; + submitting: boolean; + disabled: boolean; +}) { + return ( +
+ + +
+ ); +} + +function FormError({ message }: { message: string }) { + return ( +

{message}

+ ); +} + +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_REGEX.test(username)) { + return "Username may only contain letters, numbers, underscores, and hyphens."; + } + return null; +} + +function validateEmail(email: string): string | null { + if (email.length === 0) return "Email address cannot be empty."; + if (email.length > EMAIL_MAX) return "Email address is too long."; + if (!EMAIL_REGEX.test(email)) return "Enter a valid email address."; + return 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 !== confirm) return "Passwords do not match."; + return null; +} + +function humanizeError(raw: string | undefined, fallback: string): string { + if (!raw) return fallback; + const lower = raw.toLowerCase(); + if (lower.includes("unauthorized")) { + return "You are signed out. Please sign in again."; + } + if (lower.includes("server error")) { + return "Something went wrong on our end. Please try again in a moment."; + } + if (lower.includes("username is taken")) { + return "That username is already taken."; + } + if (lower.includes("email address is already in use")) { + return "That email is already in use."; + } + if (lower.includes("username contains slurs")) { + return "That username is not allowed."; + } + if (lower.includes("username is reserved")) { + return "That username is reserved."; + } + if (lower.startsWith("bad username:")) { + return capitalize(raw.slice("bad username:".length).trim()) + "."; + } + if (lower.startsWith("bad email:")) { + return capitalize(raw.slice("bad email:".length).trim()) + "."; + } + return capitalize(raw.replace(/\.$/, "")) + "."; +} + +function capitalize(s: string): string { + if (s.length === 0) return s; + return s[0].toUpperCase() + s.slice(1); +} diff --git a/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx b/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx index b714f0ec82..89f10f8417 100644 --- a/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/settings-modal/SettingsModal.tsx @@ -1,17 +1,33 @@ +import { useEffect, useState } from "react"; import { Modal } from "@storyteller/ui-modal"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCog } from "@fortawesome/pro-solid-svg-icons"; +import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons"; import { Switch } from "@storyteller/ui-switch"; +import { twMerge } from "tailwind-merge"; import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store"; +import { useSession } from "../../lib/session"; +import { AccountSection } from "./AccountSection"; + +type Tab = "general" | "account"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; } +const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [ + { id: "general", label: "General", icon: faCog }, + { id: "account", label: "Account", icon: faUser }, +]; + export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); - const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); + const [tab, setTab] = useState("general"); + + useEffect(() => { + if (isOpen) setTab("general"); + }, [isOpen]); + + const activeLabel = TABS.find((t) => t.id === tab)?.label ?? ""; return ( @@ -23,33 +39,31 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
- + {TABS.map((t) => ( + + ))}
-

General

+

{activeLabel}

-
-
-
-

Enter to generate

-

- When on, pressing Enter submits the prompt and Shift+Enter - adds a new line. When off (default), both Enter and - Shift+Enter add a new line — use the button to submit. -

-
- -
-
+ {tab === "general" && } + {tab === "account" && }
@@ -57,3 +71,48 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
); } + +function GeneralPanel() { + const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); + + return ( +
+
+
+

Enter to generate

+

+ When on, pressing Enter submits the prompt and Shift+Enter + adds a new line. When off (default), both Enter and + Shift+Enter add a new line, use the button to submit. +

+
+ +
+
+ ); +} + +function AccountPanel() { + const { user, authChecked } = useSession(); + + if (!authChecked) { + return ( +
Loading account details...
+ ); + } + + if (!user) { + return ( +
+ You need to be signed in to manage account settings. +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx index 683cbbc367..0564ee4912 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx @@ -14,11 +14,17 @@ import { faGem, faCog, faLifeRing, + faGift, } from "@fortawesome/pro-solid-svg-icons"; import { faDiscord } from "@fortawesome/free-brands-svg-icons"; import { Button } from "@storyteller/ui-button"; import { PopoverMenu } from "@storyteller/ui-popover"; -import { BillingApi, CreditsApi, UsersApi } from "@storyteller/api"; +import { + BillingApi, + CreditsApi, + USER_FEATURE_FLAGS, + UsersApi, +} from "@storyteller/api"; import { invalidateSession, useSession } from "../../lib/session"; import { SOCIAL_LINKS } from "../../config/links"; import { CreditsModal } from "../credits-modal"; @@ -229,23 +235,6 @@ export function TopBar() { {user.display_name || user.username}

- - {({ active }) => ( - - )} - {({ active }) => ( + )} + + {user.maybe_feature_flags?.includes( + USER_FEATURE_FLAGS.REFERRALS, + ) && ( + + {({ active }) => ( + + )} + + )} {({ active }) => ( Sign out diff --git a/frontend/apps/artcraft-webapp/src/lib/session.ts b/frontend/apps/artcraft-webapp/src/lib/session.ts index 95ebb39a71..eb7b1075ba 100644 --- a/frontend/apps/artcraft-webapp/src/lib/session.ts +++ b/frontend/apps/artcraft-webapp/src/lib/session.ts @@ -64,6 +64,15 @@ export function invalidateSession(): void { cachedAt = 0; } +export function updateSessionUser(partial: Partial): void { + const current = useSessionStore.getState().user; + if (!current) return; + useSessionStore.getState().setSession({ + user: { ...current, ...partial }, + }); + invalidateSession(); +} + // Attach the auth-change listener exactly once per page load. Login/logout/ // password-reset flows dispatch this event; every consumer shares the resulting // store update instead of each re-running its own effect. diff --git a/frontend/apps/artcraft-website/src/components/navbar/navbar.tsx b/frontend/apps/artcraft-website/src/components/navbar/navbar.tsx index d8a95a4d37..9f65fb0224 100644 --- a/frontend/apps/artcraft-website/src/components/navbar/navbar.tsx +++ b/frontend/apps/artcraft-website/src/components/navbar/navbar.tsx @@ -412,6 +412,23 @@ export default function Navbar() { {user.display_name || user.username}

+ + {({ active }) => ( + + )} + {({ active }) => ( - )} - {({ active }) => (
Sign out diff --git a/frontend/apps/artcraft-website/src/components/settings-modal/AccountSection.tsx b/frontend/apps/artcraft-website/src/components/settings-modal/AccountSection.tsx new file mode 100644 index 0000000000..f30411e906 --- /dev/null +++ b/frontend/apps/artcraft-website/src/components/settings-modal/AccountSection.tsx @@ -0,0 +1,564 @@ +import { useState } from "react"; +import { Button } from "@storyteller/ui-button"; +import { Input } from "@storyteller/ui-input"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons"; +import { UsersApi, type UserInfo } from "@storyteller/api"; +import { refreshSession, updateSessionUser } from "../../lib/session"; +import { toast } from "../toast/toast"; + +const USERNAME_REGEX = /^[A-Za-z0-9_-]+$/; +const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; +const USERNAME_MIN = 3; +const USERNAME_MAX = 16; +const EMAIL_MAX = 255; +const PASSWORD_MIN = 6; + +type EditingSection = "username" | "email" | "password" | null; + +interface AccountSectionProps { + user: UserInfo; +} + +export function AccountSection({ user }: AccountSectionProps) { + const [editing, setEditing] = useState(null); + + const requestOpen = (section: EditingSection) => setEditing(section); + const close = () => setEditing(null); + + return ( +
+ requestOpen("username")} + onClose={close} + /> +
+ requestOpen("email")} + onClose={close} + /> +
+ requestOpen("password")} + onClose={close} + /> +
+ ); +} + +interface SectionFormProps { + user: UserInfo; + isEditing: boolean; + onOpen: () => void; + onClose: () => void; +} + +function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const initial = user.display_name || user.username || ""; + const [value, setValue] = useState(initial); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmed = value.trim(); + const isDirty = trimmed !== initial.trim(); + const canSubmit = isDirty && trimmed.length > 0 && currentPassword.length > 0; + + const reset = () => { + setValue(initial); + setCurrentPassword(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const reason = validateUsername(trimmed); + if (reason) { + setError(reason); + return; + } + if (!isDirty) { + handleClose(); + return; + } + if (currentPassword.length === 0) { + setError("Enter your current password."); + return; + } + + setSubmitting(true); + const usersApi = new UsersApi(); + + const verify = await usersApi.Login({ + usernameOrEmail: user.username, + password: currentPassword, + }); + + if (!verify.success) { + setSubmitting(false); + setError("Current password is incorrect."); + return; + } + + const response = await usersApi.EditUsername({ displayName: trimmed }); + setSubmitting(false); + + if (response.success) { + toast.success("Username updated."); + updateSessionUser({ + username: trimmed.toLowerCase(), + display_name: trimmed, + }); + await refreshSession(true); + onClose(); + } else { + setError(humanizeError(response.errorMessage, "Could not update username.")); + } + }; + + return ( + +
+ setValue(e.target.value)} + placeholder="Your username" + maxLength={USERNAME_MAX} + autoComplete="username" + 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" + /> + setCurrentPassword(e.target.value)} + placeholder="Confirm password" + autoComplete="current-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" + /> + {error && } + + +
+ ); +} + +function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const [value, setValue] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0 && currentPassword.length > 0; + + const reset = () => { + setValue(""); + setCurrentPassword(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const reason = validateEmail(trimmed); + if (reason) { + setError(reason); + return; + } + if (currentPassword.length === 0) { + setError("Enter your current password."); + return; + } + + setSubmitting(true); + const usersApi = new UsersApi(); + + const verify = await usersApi.Login({ + usernameOrEmail: user.username, + password: currentPassword, + }); + + if (!verify.success) { + setSubmitting(false); + setError("Current password is incorrect."); + return; + } + + const response = await usersApi.EditEmail({ emailAddress: trimmed }); + setSubmitting(false); + + if (response.success) { + toast.success("Email updated."); + await refreshSession(true); + onClose(); + reset(); + } else { + setError(humanizeError(response.errorMessage, "Could not update email.")); + } + }; + + return ( + +
+ setValue(e.target.value)} + placeholder="you@example.com" + maxLength={EMAIL_MAX} + autoComplete="email" + 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" + /> + setCurrentPassword(e.target.value)} + placeholder="Confirm password" + autoComplete="current-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" + /> + {error && } + + +
+ ); +} + +function PasswordForm({ user, isEditing, onOpen, onClose }: SectionFormProps) { + const [oldPassword, setOldPassword] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const canSubmit = + oldPassword.length > 0 && password.length > 0 && confirm.length > 0; + + const reset = () => { + setOldPassword(""); + setPassword(""); + setConfirm(""); + setError(null); + }; + + const handleOpen = () => { + reset(); + onOpen(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + 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; + } + + const response = await usersApi.ChangePassword({ + password, + passwordConfirmation: confirm, + }); + setSubmitting(false); + + if (response.success) { + toast.success("Password updated."); + handleClose(); + } else { + setError(humanizeError(response.errorMessage, "Could not update password.")); + } + }; + + return ( + +
+ 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" + /> +
+ setPassword(e.target.value)} + placeholder="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" + /> + setConfirm(e.target.value)} + placeholder="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" + /> +
+ {error && } + + +
+ ); +} + +function SectionShell({ + title, + collapsedDescription, + editingDescription, + currentLabel, + currentValue, + isEditing, + onOpen, + children, +}: { + title: string; + collapsedDescription: string; + editingDescription: string; + currentLabel: string; + currentValue: string; + isEditing: boolean; + onOpen: () => void; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+

+ {isEditing ? editingDescription : collapsedDescription} +

+
+ {!isEditing && ( + + )} +
+ {!isEditing ? ( + + ) : ( + children + )} +
+ ); +} + +function CurrentValue({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function FormActions({ + onCancel, + submitting, + disabled, +}: { + onCancel: () => void; + submitting: boolean; + disabled: boolean; +}) { + return ( +
+ + +
+ ); +} + +function FormError({ message }: { message: string }) { + return ( +

{message}

+ ); +} + +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_REGEX.test(username)) { + return "Username may only contain letters, numbers, underscores, and hyphens."; + } + return null; +} + +function validateEmail(email: string): string | null { + if (email.length === 0) return "Email address cannot be empty."; + if (email.length > EMAIL_MAX) return "Email address is too long."; + if (!EMAIL_REGEX.test(email)) return "Enter a valid email address."; + return 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 !== confirm) return "Passwords do not match."; + return null; +} + +function humanizeError(raw: string | undefined, fallback: string): string { + if (!raw) return fallback; + const lower = raw.toLowerCase(); + if (lower.includes("unauthorized")) { + return "You are signed out. Please sign in again."; + } + if (lower.includes("server error")) { + return "Something went wrong on our end. Please try again in a moment."; + } + if (lower.includes("username is taken")) { + return "That username is already taken."; + } + if (lower.includes("email address is already in use")) { + return "That email is already in use."; + } + if (lower.includes("username contains slurs")) { + return "That username is not allowed."; + } + if (lower.includes("username is reserved")) { + return "That username is reserved."; + } + if (lower.startsWith("bad username:")) { + return capitalize(raw.slice("bad username:".length).trim()) + "."; + } + if (lower.startsWith("bad email:")) { + return capitalize(raw.slice("bad email:".length).trim()) + "."; + } + return capitalize(raw.replace(/\.$/, "")) + "."; +} + +function capitalize(s: string): string { + if (s.length === 0) return s; + return s[0].toUpperCase() + s.slice(1); +} diff --git a/frontend/apps/artcraft-website/src/components/settings-modal/SettingsModal.tsx b/frontend/apps/artcraft-website/src/components/settings-modal/SettingsModal.tsx index b714f0ec82..89f10f8417 100644 --- a/frontend/apps/artcraft-website/src/components/settings-modal/SettingsModal.tsx +++ b/frontend/apps/artcraft-website/src/components/settings-modal/SettingsModal.tsx @@ -1,17 +1,33 @@ +import { useEffect, useState } from "react"; import { Modal } from "@storyteller/ui-modal"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCog } from "@fortawesome/pro-solid-svg-icons"; +import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons"; import { Switch } from "@storyteller/ui-switch"; +import { twMerge } from "tailwind-merge"; import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store"; +import { useSession } from "../../lib/session"; +import { AccountSection } from "./AccountSection"; + +type Tab = "general" | "account"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; } +const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [ + { id: "general", label: "General", icon: faCog }, + { id: "account", label: "Account", icon: faUser }, +]; + export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); - const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); + const [tab, setTab] = useState("general"); + + useEffect(() => { + if (isOpen) setTab("general"); + }, [isOpen]); + + const activeLabel = TABS.find((t) => t.id === tab)?.label ?? ""; return ( @@ -23,33 +39,31 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
- + {TABS.map((t) => ( + + ))}
-

General

+

{activeLabel}

-
-
-
-

Enter to generate

-

- When on, pressing Enter submits the prompt and Shift+Enter - adds a new line. When off (default), both Enter and - Shift+Enter add a new line — use the button to submit. -

-
- -
-
+ {tab === "general" && } + {tab === "account" && }
@@ -57,3 +71,48 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
); } + +function GeneralPanel() { + const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); + + return ( +
+
+
+

Enter to generate

+

+ When on, pressing Enter submits the prompt and Shift+Enter + adds a new line. When off (default), both Enter and + Shift+Enter add a new line, use the button to submit. +

+
+ +
+
+ ); +} + +function AccountPanel() { + const { user, authChecked } = useSession(); + + if (!authChecked) { + return ( +
Loading account details...
+ ); + } + + if (!user) { + return ( +
+ You need to be signed in to manage account settings. +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/apps/artcraft-website/src/lib/session.ts b/frontend/apps/artcraft-website/src/lib/session.ts index 95ebb39a71..eb7b1075ba 100644 --- a/frontend/apps/artcraft-website/src/lib/session.ts +++ b/frontend/apps/artcraft-website/src/lib/session.ts @@ -64,6 +64,15 @@ export function invalidateSession(): void { cachedAt = 0; } +export function updateSessionUser(partial: Partial): void { + const current = useSessionStore.getState().user; + if (!current) return; + useSessionStore.getState().setSession({ + user: { ...current, ...partial }, + }); + invalidateSession(); +} + // Attach the auth-change listener exactly once per page load. Login/logout/ // password-reset flows dispatch this event; every consumer shares the resulting // store update instead of each re-running its own effect.