good enough

This commit is contained in:
Michael Freno
2025-12-22 16:44:43 -05:00
parent ad2cde6afc
commit 11fad35288
9 changed files with 332 additions and 208 deletions

View File

@@ -1,6 +1,6 @@
import { createSignal, Show, onMount } from "solid-js";
import { createSignal, Show, createEffect } from "solid-js";
import { Title, Meta } from "@solidjs/meta";
import { useNavigate, redirect, query } from "@solidjs/router";
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
import { getEvent } from "vinxi/http";
import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash";
@@ -23,29 +23,56 @@ type UserProfile = {
hasPassword: boolean;
};
const checkAuth = query(async () => {
const getUserProfile = query(async (): Promise<UserProfile | null> => {
"use server";
const { checkAuthStatus } = await import("~/server/utils");
const { getUserID, ConnectionFactory } = await import("~/server/utils");
const event = getEvent()!;
const { isAuthenticated } = await checkAuthStatus(event);
if (!isAuthenticated) {
const userId = await getUserID(event);
if (!userId) {
throw redirect("/login");
}
return { isAuthenticated };
}, "accountAuthCheck");
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;
// Transform to UserProfile type
return {
id: user.id,
email: user.email || null,
emailVerified: Boolean(user.emailVerified),
displayName: user.displayName || null,
image: user.image || null,
provider: user.provider || null,
hasPassword: Boolean(user.password)
};
} catch (err) {
console.error("Failed to fetch user profile:", err);
throw redirect("/login");
}
}, "accountUserProfile");
export const route = {
load: () => checkAuth()
load: () => getUserProfile()
};
export default function AccountPage() {
const navigate = useNavigate();
// User data
const userData = createAsync(() => getUserProfile(), { deferStream: true });
// Local user state for client-side updates
const [user, setUser] = createSignal<UserProfile | null>(null);
const [loading, setLoading] = createSignal(true);
// Form loading states
const [emailButtonLoading, setEmailButtonLoading] = createSignal(false);
@@ -98,27 +125,14 @@ export default function AccountPage() {
let displayNameRef: HTMLInputElement | undefined;
let deleteAccountPasswordRef: HTMLInputElement | undefined;
// Fetch user profile on mount
onMount(async () => {
try {
const response = await fetch("/api/trpc/user.getProfile", {
method: "GET"
});
// Helper to get current user (from SSR data or local state)
const currentUser = () => user() || userData();
if (response.ok) {
const result = await response.json();
if (result.result?.data) {
setUser(result.result.data);
// Set preset holder if user has existing image
if (result.result.data.image) {
setPreSetHolder(result.result.data.image);
}
}
}
} catch (err) {
console.error("Failed to fetch user profile:", err);
} finally {
setLoading(false);
// Initialize preSetHolder when userData loads
createEffect(() => {
const userProfile = userData();
if (userProfile?.image && !preSetHolder()) {
setPreSetHolder(userProfile.image);
}
});
@@ -152,8 +166,8 @@ export default function AccountPage() {
setProfileImageSetLoading(true);
setShowImageSuccess(false);
const currentUser = user();
if (!currentUser) {
const userProfile = currentUser();
if (!userProfile) {
setProfileImageSetLoading(false);
return;
}
@@ -161,17 +175,15 @@ export default function AccountPage() {
try {
let imageUrl = "";
// Upload new image if one was selected
if (profileImage()) {
const imageKey = await AddImageToS3(
profileImage()!,
currentUser.id,
userProfile.id,
"user"
);
imageUrl = imageKey || "";
}
// Update user profile image
const response = await fetch("/api/trpc/user.updateProfileImage", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -264,10 +276,10 @@ export default function AccountPage() {
// Password change/set handler
const handlePasswordSubmit = async (e: Event) => {
e.preventDefault();
const currentUser = user();
if (!currentUser) return;
const userProfile = currentUser();
if (!userProfile) return;
if (currentUser.hasPassword) {
if (userProfile.hasPassword) {
// Change password (requires old password)
if (!oldPasswordRef || !newPasswordRef || !newPasswordConfRef) return;
@@ -399,14 +411,14 @@ export default function AccountPage() {
// Resend email verification
const sendEmailVerification = async () => {
const currentUser = user();
if (!currentUser?.email) return;
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: currentUser.email })
body: JSON.stringify({ email: userProfile.email })
});
alert("Verification email sent!");
} catch (err) {
@@ -496,9 +508,20 @@ export default function AccountPage() {
/>
<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={!loading() && user()} fallback={<TerminalSplash />}>
{(currentUser) => (
<Show when={currentUser()} fallback={<TerminalSplash />}>
{(userProfile) => (
<>
<div class="text-text mb-8 text-center text-3xl font-bold">
Account Settings
@@ -512,18 +535,18 @@ export default function AccountPage() {
</div>
<div class="flex items-center justify-center gap-3">
<span
class={getProviderInfo(currentUser().provider).color}
class={getProviderInfo(userProfile().provider).color}
>
{getProviderInfo(currentUser().provider).icon}
{getProviderInfo(userProfile().provider).icon}
</span>
<span class="text-lg font-semibold">
{getProviderInfo(currentUser().provider).name} Account
{getProviderInfo(userProfile().provider).name} Account
</span>
</div>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().email
userProfile().provider !== "email" &&
!userProfile().email
}
>
<div class="mt-3 rounded bg-yellow-500/10 px-3 py-2 text-center text-sm text-yellow-600 dark:text-yellow-400">
@@ -532,8 +555,8 @@ export default function AccountPage() {
</Show>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().hasPassword
userProfile().provider !== "email" &&
!userProfile().hasPassword
}
>
<div class="mt-3 rounded bg-blue-500/10 px-3 py-2 text-center text-sm text-blue-600 dark:text-blue-400">
@@ -551,12 +574,17 @@ export default function AccountPage() {
<div class="mb-2 text-center text-lg font-semibold">
Profile Image
</div>
<div class="flex items-start">
<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() || currentUser().image || null}
preSet={preSetHolder() || userProfile().image || null}
/>
<button
type="button"
@@ -603,22 +631,22 @@ export default function AccountPage() {
<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">
{currentUser().provider === "email"
{userProfile().provider === "email"
? "Email:"
: "Linked Email:"}
</div>
{currentUser().email ? (
<span>{currentUser().email}</span>
{userProfile().email ? (
<span>{userProfile().email}</span>
) : (
<span class="font-light italic underline underline-offset-4">
{currentUser().provider === "email"
{userProfile().provider === "email"
? "None Set"
: "Not Linked"}
</span>
)}
</div>
<Show
when={currentUser().email && !currentUser().emailVerified}
when={userProfile().email && !userProfile().emailVerified}
>
<button
onClick={sendEmailVerification}
@@ -630,6 +658,11 @@ export default function AccountPage() {
</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>
<div class="input-group mx-4">
<input
ref={emailRef}
@@ -637,21 +670,22 @@ export default function AccountPage() {
required
disabled={
emailButtonLoading() ||
(currentUser().email !== null &&
!currentUser().emailVerified)
(userProfile().email !== null &&
!userProfile().emailVerified)
}
placeholder=" "
title="Please enter a valid email address"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">
{currentUser().email ? "Update Email" : "Add Email"}
{userProfile().email ? "Update Email" : "Add Email"}
</label>
</div>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().email
userProfile().provider !== "email" &&
!userProfile().email
}
>
<div class="text-subtext0 mt-1 px-4 text-xs">
@@ -663,13 +697,13 @@ export default function AccountPage() {
type="submit"
disabled={
emailButtonLoading() ||
(currentUser().email !== null &&
!currentUser().emailVerified)
(userProfile().email !== null &&
!userProfile().emailVerified)
}
class={`${
emailButtonLoading() ||
(currentUser().email !== null &&
!currentUser().emailVerified)
(userProfile().email !== null &&
!userProfile().emailVerified)
? "bg-blue cursor-not-allowed brightness-75"
: "bg-blue hover:brightness-125 active:scale-90"
} mt-2 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
@@ -690,8 +724,8 @@ export default function AccountPage() {
<div class="pr-1 font-semibold whitespace-nowrap">
Display Name:
</div>
{currentUser().displayName ? (
<span>{currentUser().displayName}</span>
{userProfile().displayName ? (
<span>{userProfile().displayName}</span>
) : (
<span class="font-light italic underline underline-offset-4">
None Set
@@ -701,6 +735,11 @@ export default function AccountPage() {
</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>
<div class="input-group mx-4">
<input
ref={displayNameRef}
@@ -708,11 +747,12 @@ export default function AccountPage() {
required
disabled={displayNameButtonLoading()}
placeholder=" "
title="Please enter your display name"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">
Set {currentUser().displayName ? "New " : ""}Display
Set {userProfile().displayName ? "New " : ""}Display
Name
</label>
</div>
@@ -746,28 +786,36 @@ export default function AccountPage() {
>
<div class="flex w-full max-w-md flex-col justify-center">
<div class="mb-2 text-center text-xl font-semibold">
{currentUser().hasPassword
{userProfile().hasPassword
? "Change Password"
: "Add Password"}
</div>
<Show when={!currentUser().hasPassword}>
<noscript>
<div class="text-subtext0 mb-4 text-center text-sm">
{currentUser().provider === "email"
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 " +
getProviderInfo(currentUser().provider).name +
getProviderInfo(userProfile().provider).name +
" login"}
</div>
</Show>
<Show when={currentUser().hasPassword}>
<Show when={userProfile().hasPassword}>
<div class="input-group relative mx-4 mb-6">
<input
ref={oldPasswordRef}
type={showOldPasswordInput() ? "text" : "password"}
required
minlength="8"
disabled={passwordChangeLoading()}
placeholder=" "
title="Password must be at least 8 characters"
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
@@ -794,10 +842,12 @@ export default function AccountPage() {
ref={newPasswordRef}
type={showPasswordInput() ? "text" : "password"}
required
minlength="8"
onInput={handleNewPasswordChange}
onBlur={handlePasswordBlur}
disabled={passwordChangeLoading()}
placeholder=" "
title="Password must be at least 8 characters"
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
@@ -826,9 +876,11 @@ export default function AccountPage() {
ref={newPasswordConfRef}
type={showPasswordConfInput() ? "text" : "password"}
required
minlength="8"
onInput={handlePasswordConfChange}
disabled={passwordChangeLoading()}
placeholder=" "
title="Password must be at least 8 characters"
class="underlinedInput w-full bg-transparent pr-10"
/>
<span class="bar"></span>
@@ -875,7 +927,7 @@ export default function AccountPage() {
<Show when={passwordError()}>
<div class="text-red text-center text-sm">
{currentUser().hasPassword
{userProfile().hasPassword
? "Password did not match record"
: "Error setting password"}
</div>
@@ -883,7 +935,7 @@ export default function AccountPage() {
<Show when={showPasswordSuccess()}>
<div class="text-green text-center text-sm">
Password {currentUser().hasPassword ? "changed" : "set"}{" "}
Password {userProfile().hasPassword ? "changed" : "set"}{" "}
successfully!
</div>
</Show>
@@ -892,6 +944,20 @@ export default function AccountPage() {
<hr class="mt-8 mb-8" />
{/* Sign Out Section */}
<div class="mx-auto max-w-md py-4">
<form method="post" action="/api/auth/signout">
<button
type="submit"
class="bg-overlay0 hover:bg-overlay1 w-full rounded px-4 py-2 transition-all"
>
Sign Out
</button>
</form>
</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">
@@ -903,12 +969,18 @@ export default function AccountPage() {
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={currentUser().hasPassword}
when={userProfile().hasPassword}
fallback={
<div class="flex flex-col items-center">
<div class="text-crust mb-4 text-center text-sm">
Your {getProviderInfo(currentUser().provider).name}{" "}
Your {getProviderInfo(userProfile().provider).name}{" "}
account doesn't have a password. To delete your
account, please set a password first, then return
here to proceed with deletion.
@@ -931,8 +1003,10 @@ export default function AccountPage() {
ref={deleteAccountPasswordRef}
type="password"
required
minlength="8"
disabled={deleteAccountButtonLoading()}
placeholder=" "
title="Enter your password to confirm account deletion"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>