Files
freno-dev/src/routes/account.tsx
2026-01-06 14:00:22 -05:00

952 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSignal, Show, createEffect } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
import { getEvent } from "vinxi/http";
import XCircle from "~/components/icons/XCircle";
import GoogleLogo from "~/components/icons/GoogleLogo";
import GitHub from "~/components/icons/GitHub";
import EmailIcon from "~/components/icons/EmailIcon";
import Dropzone from "~/components/blog/Dropzone";
import AddImageToS3 from "~/lib/s3upload";
import { validatePassword, isValidEmail } from "~/lib/validation";
import { TerminalSplash } from "~/components/TerminalSplash";
import { VALIDATION_CONFIG } from "~/config";
import { api } from "~/lib/api";
import Input from "~/components/ui/Input";
import PasswordInput from "~/components/ui/PasswordInput";
import Button from "~/components/ui/Button";
import FormFeedback from "~/components/ui/FormFeedback";
import type { UserProfile } from "~/types/user";
import PasswordStrengthMeter from "~/components/PasswordStrengthMeter";
const getUserProfile = query(async (): Promise<UserProfile | null> => {
"use server";
const { getUserID, ConnectionFactory } = await import("~/server/utils");
const event = getEvent()!;
const userId = await getUserID(event);
if (!userId) {
throw redirect("/login");
}
const conn = ConnectionFactory();
try {
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId]
});
if (res.rows.length === 0) {
throw redirect("/login");
}
const user = res.rows[0] as any;
return {
id: user.id,
email: user.email ?? undefined,
emailVerified: user.email_verified === 1,
displayName: user.display_name ?? undefined,
provider: user.provider ?? undefined,
image: user.image ?? undefined,
hasPassword: !!user.password_hash
};
} catch (err) {
console.error("Failed to fetch user profile:", err);
throw redirect("/login");
}
}, "accountUserProfile");
export const route = {
load: () => getUserProfile()
};
export default function AccountPage() {
const navigate = useNavigate();
const userData = createAsync(() => getUserProfile(), { deferStream: true });
const [user, setUser] = createSignal<UserProfile | null>(null);
const [emailButtonLoading, setEmailButtonLoading] = createSignal(false);
const [displayNameButtonLoading, setDisplayNameButtonLoading] =
createSignal(false);
const [passwordChangeLoading, setPasswordChangeLoading] = createSignal(false);
const [deleteAccountButtonLoading, setDeleteAccountButtonLoading] =
createSignal(false);
const [profileImageSetLoading, setProfileImageSetLoading] =
createSignal(false);
const [signOutLoading, setSignOutLoading] = createSignal(false);
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] =
createSignal(false);
const [passwordLengthSufficient, setPasswordLengthSufficient] =
createSignal(false);
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
const [passwordError, setPasswordError] = createSignal(false);
const [passwordDeletionError, setPasswordDeletionError] = createSignal(false);
const [newPassword, setNewPassword] = createSignal("");
const [showImageSuccess, setShowImageSuccess] = createSignal(false);
const [showEmailSuccess, setShowEmailSuccess] = createSignal(false);
const [showDisplayNameSuccess, setShowDisplayNameSuccess] =
createSignal(false);
const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false);
const [profileImage, setProfileImage] = createSignal<Blob | undefined>(
undefined
);
const [profileImageHolder, setProfileImageHolder] = createSignal<
string | null
>(null);
const [profileImageStateChange, setProfileImageStateChange] =
createSignal(false);
const [preSetHolder, setPreSetHolder] = createSignal<string | null>(null);
let oldPasswordRef: HTMLInputElement | undefined;
let newPasswordRef: HTMLInputElement | undefined;
let newPasswordConfRef: HTMLInputElement | undefined;
let emailRef: HTMLInputElement | undefined;
let displayNameRef: HTMLInputElement | undefined;
let deleteAccountPasswordRef: HTMLInputElement | undefined;
const currentUser = () => user() || userData();
createEffect(() => {
const userProfile = userData();
if (userProfile?.image && !preSetHolder()) {
setPreSetHolder(userProfile.image);
}
});
const handleImageDrop = (acceptedFiles: File[]) => {
acceptedFiles.forEach((file: File) => {
setProfileImage(file);
const reader = new FileReader();
reader.onload = () => {
const str = reader.result as string;
setProfileImageHolder(str);
setProfileImageStateChange(true);
};
reader.readAsDataURL(file);
});
};
const removeImage = () => {
setProfileImage(undefined);
setProfileImageHolder(null);
if (preSetHolder()) {
setProfileImageStateChange(true);
setPreSetHolder(null);
} else {
setProfileImageStateChange(false);
}
};
const setUserImage = async (e: Event) => {
e.preventDefault();
setProfileImageSetLoading(true);
setShowImageSuccess(false);
const userProfile = currentUser();
if (!userProfile) {
setProfileImageSetLoading(false);
return;
}
try {
let imageUrl = "";
if (profileImage()) {
const imageKey = await AddImageToS3(
profileImage()!,
userProfile.id,
"user"
);
imageUrl = imageKey || "";
}
const response = await fetch("/api/trpc/user.updateProfileImage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl })
});
const result = await response.json();
if (response.ok && result.result?.data) {
setUser(result.result.data);
setShowImageSuccess(true);
setProfileImageStateChange(false);
setTimeout(() => setShowImageSuccess(false), 3000);
setPreSetHolder(imageUrl || null);
} else {
alert("Error updating profile image!");
}
} catch (err) {
console.error("Profile image update error:", err);
alert("Error updating profile image! Check console.");
} finally {
setProfileImageSetLoading(false);
}
};
const setEmailTrigger = async (e: Event) => {
e.preventDefault();
if (!emailRef) return;
const email = emailRef.value;
if (!isValidEmail(email)) {
alert("Invalid email address");
return;
}
setEmailButtonLoading(true);
setShowEmailSuccess(false);
try {
const response = await fetch("/api/trpc/user.updateEmail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
});
const result = await response.json();
if (response.ok && result.result?.data) {
setUser(result.result.data);
setShowEmailSuccess(true);
setTimeout(() => setShowEmailSuccess(false), 3000);
}
} catch (err) {
console.error("Email update error:", err);
} finally {
setEmailButtonLoading(false);
}
};
const setDisplayNameTrigger = async (e: Event) => {
e.preventDefault();
if (!displayNameRef) return;
const displayName = displayNameRef.value;
setDisplayNameButtonLoading(true);
setShowDisplayNameSuccess(false);
try {
const response = await fetch("/api/trpc/user.updateDisplayName", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName })
});
const result = await response.json();
if (response.ok && result.result?.data) {
setUser(result.result.data);
setShowDisplayNameSuccess(true);
setTimeout(() => setShowDisplayNameSuccess(false), 3000);
}
} catch (err) {
console.error("Display name update error:", err);
} finally {
setDisplayNameButtonLoading(false);
}
};
const handlePasswordSubmit = async (e: Event) => {
e.preventDefault();
const userProfile = currentUser();
if (!userProfile) return;
if (userProfile.hasPassword) {
if (!oldPasswordRef || !newPasswordRef || !newPasswordConfRef) return;
const oldPassword = oldPasswordRef.value;
const newPassword = newPasswordRef.value;
const newPasswordConf = newPasswordConfRef.value;
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.isValid) {
setPasswordError(true);
return;
}
if (newPassword !== newPasswordConf) {
setPasswordError(true);
return;
}
setPasswordChangeLoading(true);
setPasswordError(false);
try {
const response = await fetch("/api/trpc/user.changePassword", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldPassword,
newPassword,
newPasswordConfirmation: newPasswordConf
})
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
setShowPasswordSuccess(true);
setTimeout(() => setShowPasswordSuccess(false), 3000);
if (oldPasswordRef) oldPasswordRef.value = "";
if (newPasswordRef) newPasswordRef.value = "";
if (newPasswordConfRef) newPasswordConfRef.value = "";
} else {
setPasswordError(true);
}
} catch (err) {
console.error("Password change error:", err);
setPasswordError(true);
} finally {
setPasswordChangeLoading(false);
}
} else {
if (!newPasswordRef || !newPasswordConfRef) return;
const newPassword = newPasswordRef.value;
const newPasswordConf = newPasswordConfRef.value;
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.isValid) {
setPasswordError(true);
return;
}
if (newPassword !== newPasswordConf) {
setPasswordError(true);
return;
}
setPasswordChangeLoading(true);
setPasswordError(false);
try {
const response = await fetch("/api/trpc/user.setPassword", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
newPassword,
newPasswordConfirmation: newPasswordConf
})
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
const profileResponse = await fetch("/api/trpc/user.getProfile");
const profileResult = await profileResponse.json();
if (profileResult.result?.data) {
setUser(profileResult.result.data);
}
setShowPasswordSuccess(true);
setTimeout(() => setShowPasswordSuccess(false), 3000);
if (newPasswordRef) newPasswordRef.value = "";
if (newPasswordConfRef) newPasswordConfRef.value = "";
} else {
setPasswordError(true);
}
} catch (err) {
console.error("Password set error:", err);
setPasswordError(true);
} finally {
setPasswordChangeLoading(false);
}
}
};
const deleteAccountTrigger = async (e: Event) => {
e.preventDefault();
if (!deleteAccountPasswordRef) return;
const password = deleteAccountPasswordRef.value;
setDeleteAccountButtonLoading(true);
setPasswordDeletionError(false);
try {
const response = await fetch("/api/trpc/user.deleteAccount", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password })
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
navigate("/login");
} else {
setPasswordDeletionError(true);
}
} catch (err) {
console.error("Delete account error:", err);
setPasswordDeletionError(true);
} finally {
setDeleteAccountButtonLoading(false);
}
};
const sendEmailVerification = async () => {
const userProfile = currentUser();
if (!userProfile?.email) return;
try {
await fetch("/api/trpc/auth.resendEmailVerification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: userProfile.email })
});
alert("Verification email sent!");
} catch (err) {
console.error("Email verification error:", err);
}
};
const checkPasswordLength = (password: string) => {
if (password.length >= 8) {
setPasswordLengthSufficient(true);
setShowPasswordLengthWarning(false);
} else {
setPasswordLengthSufficient(false);
if (passwordBlurred()) {
setShowPasswordLengthWarning(true);
}
}
};
const checkForMatch = (newPassword: string, newPasswordConf: string) => {
setPasswordsMatch(newPassword === newPasswordConf);
};
const handleNewPasswordChange = (e: Event) => {
const target = e.target as HTMLInputElement;
setNewPassword(target.value);
checkPasswordLength(target.value);
if (newPasswordConfRef) {
checkForMatch(target.value, newPasswordConfRef.value);
}
};
const handlePasswordConfChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (newPasswordRef) {
checkForMatch(newPasswordRef.value, target.value);
}
};
const handlePasswordBlur = () => {
if (
!passwordLengthSufficient() &&
newPasswordRef &&
newPasswordRef.value !== ""
) {
setShowPasswordLengthWarning(true);
}
setPasswordBlurred(true);
};
const handleSignOut = async () => {
setSignOutLoading(true);
try {
await api.auth.signOut.mutate();
navigate("/");
} catch (error) {
console.error("Sign out failed:", error);
setSignOutLoading(false);
}
};
const getProviderName = (provider: UserProfile["provider"]) => {
switch (provider) {
case "google":
return "Google";
case "github":
return "GitHub";
case "email":
return "Email";
default:
return "Unknown";
}
};
const getProviderColor = (provider: UserProfile["provider"]) => {
switch (provider) {
case "google":
return "text-blue-500";
case "github":
return "text-gray-700 dark:text-gray-300";
case "email":
return "text-green-500";
default:
return "text-gray-500";
}
};
return (
<>
<PageHead
title="Account"
description="Manage your account settings, update profile information, and configure preferences."
/>
<div class="bg-base mx-8 min-h-screen md:mx-24 lg:mx-36">
<noscript>
<div class="bg-yellow mx-auto mt-8 max-w-2xl rounded-lg px-6 py-4 text-center text-black">
<strong> JavaScript Required for Account Updates</strong>
<p class="mt-2 text-sm">
You can view your account information below, but JavaScript is
required to update your profile, change settings, or delete your
account.
</p>
</div>
</noscript>
<div class="pt-24">
<Show when={currentUser()} fallback={<TerminalSplash />}>
{(userProfile) => (
<>
<div class="text-text mb-8 text-center text-3xl font-bold">
Account Settings
</div>
{/* Account Type Section */}
<div class="mx-auto mb-8 max-w-md">
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
<div class="text-subtext0 mb-2 text-center text-sm font-semibold tracking-wide uppercase">
Account Type
</div>
<div class="flex items-center justify-center gap-3">
<span class={getProviderColor(userProfile().provider)}>
<Show when={userProfile().provider === "google"}>
<GoogleLogo height={24} width={24} />
</Show>
<Show when={userProfile().provider === "github"}>
<GitHub height={24} width={24} fill="currentColor" />
</Show>
<Show
when={
userProfile().provider === "email" ||
!userProfile().provider
}
>
<EmailIcon height={24} width={24} />
</Show>
</span>
<span class="text-lg font-semibold">
{getProviderName(userProfile().provider)} Account
</span>
</div>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().email
}
>
<div class="bg-yellow mt-3 rounded px-3 py-2 text-center text-base text-sm">
Add an email address for account recovery
</div>
</Show>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().hasPassword
}
>
<div class="bg-blue mt-3 rounded px-3 py-2 text-center text-base text-sm">
💡 Add a password to enable email/password login
</div>
</Show>
</div>
</div>
<hr class="mx-auto mb-8 max-w-4xl" />
{/* Profile Image Section */}
<div class="mx-auto mb-8 flex max-w-md justify-center">
<div class="flex flex-col py-4">
<div class="mb-2 text-center text-lg font-semibold">
Profile Image
</div>
<noscript>
<div class="text-subtext0 mb-4 text-center text-sm">
JavaScript is required to update profile images
</div>
</noscript>
<div class="flex items-start justify-center">
<Dropzone
onDrop={handleImageDrop}
acceptedFiles="image/jpg, image/jpeg, image/png"
fileHolder={profileImageHolder()}
preSet={preSetHolder() || userProfile().image || null}
/>
<button
type="button"
onClick={removeImage}
class="z-20 -ml-6 h-fit rounded-full transition-all hover:brightness-125"
>
<XCircle
height={36}
width={36}
stroke="currentColor"
strokeWidth={1}
/>
</button>
</div>
<form onSubmit={setUserImage}>
<button
type="submit"
disabled={
profileImageSetLoading() || !profileImageStateChange()
}
class={`${
profileImageSetLoading() || !profileImageStateChange()
? "bg-blue cursor-not-allowed brightness-75"
: "bg-blue hover:brightness-125 active:scale-90"
} mt-2 flex w-full justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
>
{profileImageSetLoading()
? "Uploading..."
: "Set Image"}
</button>
</form>
<FormFeedback
type="success"
message="Profile image updated!"
show={showImageSuccess()}
class="mt-2"
/>
</div>
</div>
<hr class="mx-auto mb-8 max-w-4xl" />
{/* Email Section */}
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
<div class="flex items-center justify-center text-lg md:justify-normal">
<div class="flex flex-col lg:flex-row">
<div class="pr-1 font-semibold whitespace-nowrap">
{userProfile().provider === "email"
? "Email:"
: "Linked Email:"}
</div>
{userProfile().email ? (
<span>{userProfile().email}</span>
) : (
<span class="font-light italic underline underline-offset-4">
{userProfile().provider === "email"
? "None Set"
: "Not Linked"}
</span>
)}
</div>
<Show
when={userProfile().email && !userProfile().emailVerified}
>
<button
onClick={sendEmailVerification}
class="text-red ml-2 text-sm underline transition-all hover:brightness-125"
>
Verify Email
</button>
</Show>
</div>
<form onSubmit={setEmailTrigger} class="mx-auto">
<noscript>
<div class="text-subtext0 mb-2 px-4 text-center text-xs">
JavaScript required to update email
</div>
</noscript>
<Input
ref={emailRef}
type="email"
required
disabled={
emailButtonLoading() ||
(userProfile().email !== null &&
!userProfile().emailVerified)
}
title="Please enter a valid email address"
label={userProfile().email ? "Update Email" : "Add Email"}
containerClass="input-group mx-4"
/>
<Show
when={
userProfile().provider !== "email" &&
!userProfile().email
}
>
<div class="text-subtext0 mt-1 px-4 text-xs">
Add an email for account recovery and notifications
</div>
</Show>
<div class="flex justify-end">
<Button
type="submit"
disabled={
userProfile().email !== null &&
!userProfile().emailVerified
}
loading={emailButtonLoading()}
class="mt-2"
>
Submit
</Button>
</div>
<FormFeedback
type="success"
message="Email updated!"
show={showEmailSuccess()}
class="mt-2"
/>
</form>
{/* Display Name Section */}
<div class="flex items-center justify-center text-lg md:justify-normal">
<div class="flex flex-col lg:flex-row">
<div class="pr-1 font-semibold whitespace-nowrap">
Display Name:
</div>
{userProfile().displayName ? (
<span>{userProfile().displayName}</span>
) : (
<span class="font-light italic underline underline-offset-4">
None Set
</span>
)}
</div>
</div>
<form onSubmit={setDisplayNameTrigger} class="mx-auto">
<noscript>
<div class="text-subtext0 mb-2 px-4 text-center text-xs">
JavaScript required to update display name
</div>
</noscript>
<Input
ref={displayNameRef}
type="text"
required
disabled={displayNameButtonLoading()}
title="Please enter your display name"
label={`Set ${userProfile().displayName ? "New " : ""}Display Name`}
containerClass="input-group mx-4"
/>
<div class="flex justify-end">
<Button
type="submit"
loading={displayNameButtonLoading()}
class="mt-2"
>
Submit
</Button>
</div>
<FormFeedback
type="success"
message="Display name updated!"
show={showDisplayNameSuccess()}
class="mt-2"
/>
</form>
</div>
{/* Password Change/Set Section */}
<form
onSubmit={handlePasswordSubmit}
class="mt-8 flex w-full justify-center"
>
<div class="flex w-full max-w-md flex-col justify-center">
<div class="mb-2 text-center text-xl font-semibold">
{userProfile().hasPassword
? "Change Password"
: "Add Password"}
</div>
<noscript>
<div class="text-subtext0 mb-4 text-center text-sm">
JavaScript required to{" "}
{userProfile().hasPassword ? "change" : "add"} password
</div>
</noscript>
<Show when={!userProfile().hasPassword}>
<div class="text-subtext0 mb-4 text-center text-sm">
{userProfile().provider === "email"
? "Set a password to enable password login"
: "Add a password to enable email/password login alongside your " +
getProviderName(userProfile().provider) +
" login"}
</div>
</Show>
<Show when={userProfile().hasPassword}>
<PasswordInput
ref={oldPasswordRef}
required
minlength={VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
disabled={passwordChangeLoading()}
title="Password must be at least 8 characters"
label="Old Password"
/>
</Show>
<PasswordInput
ref={newPasswordRef}
required
minlength="8"
onInput={handleNewPasswordChange}
onBlur={handlePasswordBlur}
disabled={passwordChangeLoading()}
title="Password must be at least 8 characters"
label="New Password"
showStrength
passwordValue={newPassword()}
/>
<PasswordInput
ref={newPasswordConfRef}
required
minlength="8"
onInput={handlePasswordConfChange}
disabled={passwordChangeLoading()}
title="Password must be at least 8 characters"
label="New Password Conf."
/>
<Show
when={
!passwordsMatch() &&
passwordLengthSufficient() &&
newPasswordConfRef &&
newPasswordConfRef.value.length >= 6
}
>
<FormFeedback
type="error"
message="Passwords do not match!"
class="mb-4"
/>
</Show>
<Button
type="submit"
disabled={!passwordsMatch()}
loading={passwordChangeLoading()}
class="my-6"
>
Set
</Button>
<FormFeedback
type="error"
message={
userProfile().hasPassword
? "Password did not match record"
: "Must have email & password provider linked or set password first"
}
show={passwordError()}
/>
<FormFeedback
type="success"
message={`Password ${userProfile().hasPassword ? "changed" : "set"} successfully!`}
show={showPasswordSuccess()}
/>
</div>
</form>
<hr class="mt-8 mb-8" />
{/* Sign Out Section */}
<div class="mx-auto max-w-md py-4">
<Button
type="button"
onClick={handleSignOut}
loading={signOutLoading()}
variant="secondary"
class="w-full"
>
Sign Out
</Button>
</div>
<hr class="mt-8 mb-8" />
{/* Delete Account Section */}
<div class="mx-auto max-w-2xl py-8">
<div class="bg-red w-full rounded-md px-6 pt-8 pb-4 shadow-md brightness-75">
<div class="pb-4 text-center text-xl font-semibold">
Delete Account
</div>
<div class="text-crust mb-4 text-center text-sm">
Warning: This will delete all account information and is
irreversible
</div>
<noscript>
<div class="text-crust mb-4 text-center text-sm font-semibold">
JavaScript is required to delete your account
</div>
</noscript>
<Show
when={userProfile().hasPassword}
fallback={
<div class="flex flex-col items-center">
<div class="text-crust mb-4 text-center text-sm">
Your {getProviderName(userProfile().provider)}{" "}
account doesn't have a password. To delete your
account, please set a password first, then return
here to proceed with deletion.
</div>
<button
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}}
class="bg-surface0 hover:bg-surface1 rounded px-4 py-2 transition-all"
>
Go to Add Password Section
</button>
</div>
}
>
<form onSubmit={deleteAccountTrigger}>
<div class="delete flex w-full justify-center">
<PasswordInput
ref={deleteAccountPasswordRef}
required
minlength={VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
disabled={deleteAccountButtonLoading()}
title="Enter your password to confirm account deletion"
label="Enter Password"
/>
</div>
<Button
type="submit"
loading={deleteAccountButtonLoading()}
variant="danger"
class="border-text mx-auto mt-4 border"
>
Delete Account
</Button>
<FormFeedback
type="error"
message="Password did not match record"
show={passwordDeletionError()}
class="mt-2"
/>
</form>
</Show>
</div>
</div>
</>
)}
</Show>
</div>
</div>
</>
);
}