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>

View File

@@ -0,0 +1,25 @@
import type { APIEvent } from "@solidjs/start/server";
import { getCookie, getEvent, setCookie } from "vinxi/http";
export async function POST() {
"use server";
const event = getEvent()!;
// Clear the userIDToken cookie (the actual session cookie)
setCookie(event, "userIDToken", "", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 0, // Expire immediately
expires: new Date(0) // Set expiry to past date
});
// Redirect to home page
return new Response(null, {
status: 302,
headers: {
Location: "/"
}
});
}

View File

@@ -312,93 +312,93 @@ export default function PostPage() {
</div>
{/* Spacer to push content down */}
<div class="-mt-[10vh] h-80 sm:h-96 md:h-[50vh]" />
{/* Content that slides over the fixed image */}
<div class="bg-base relative z-40 pb-24">
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
<div class="">
<div class="flex justify-center italic md:justify-start md:pl-24">
<div>
Written {new Date(p().date).toDateString()}
<br />
By Michael Freno
<div class="z-10: pt-80 sm:pt-96 md:pt-[50vh]">
{/* Content that slides over the fixed image */}
<div class="bg-base relative pb-24">
<div class="top-4 flex w-full flex-col justify-center md:absolute md:flex-row md:justify-between">
<div class="">
<div class="flex justify-center italic md:justify-start md:pl-24">
<div>
Written {new Date(p().date).toDateString()}
<br />
By Michael Freno
</div>
</div>
<div class="flex max-w-105 flex-wrap justify-center italic md:justify-start md:pl-24">
<For each={postData.tags as any[]}>
{(tag) => (
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
<div class="text-white">{tag.value}</div>
</div>
)}
</For>
</div>
</div>
<div class="flex max-w-105 flex-wrap justify-center italic md:justify-start md:pl-24">
<For each={postData.tags as any[]}>
{(tag) => (
<div class="group relative m-1 h-fit w-fit rounded-xl bg-purple-600 px-2 py-1 text-sm">
<div class="text-white">{tag.value}</div>
<div class="flex flex-row justify-center pt-4 md:pt-0 md:pr-8">
<a href="#comments" class="mx-2">
<div class="tooltip flex flex-col">
<div class="mx-auto hover:brightness-125">
<CommentIcon
strokeWidth={1}
height={32}
width={32}
/>
</div>
)}
</For>
</div>
</div>
<div class="text-text my-auto pt-0.5 pl-2 text-sm">
{postData.comments.length}{" "}
{postData.comments.length === 1
? "Comment"
: "Comments"}
</div>
</div>
</a>
<div class="flex flex-row justify-center pt-4 md:pt-0 md:pr-8">
<a href="#comments" class="mx-2">
<div class="tooltip flex flex-col">
<div class="mx-auto hover:brightness-125">
<CommentIcon
strokeWidth={1}
height={32}
width={32}
/>
</div>
<div class="text-text my-auto pt-0.5 pl-2 text-sm">
{postData.comments.length}{" "}
{postData.comments.length === 1
? "Comment"
: "Comments"}
</div>
<div class="mx-2">
<SessionDependantLike
currentUserID={postData.userID}
privilegeLevel={postData.privilegeLevel}
likes={postData.likes as any[]}
projectID={p().id}
/>
</div>
</a>
<div class="mx-2">
<SessionDependantLike
currentUserID={postData.userID}
privilegeLevel={postData.privilegeLevel}
likes={postData.likes as any[]}
projectID={p().id}
/>
</div>
</div>
</div>
{/* Post body */}
<PostBodyClient
body={p().body}
hasCodeBlock={hasCodeBlock(p().body)}
/>
<Show when={postData.privilegeLevel === "admin"}>
<div class="flex justify-center">
<A
class="border-blue bg-blue z-100 h-fit rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
href={`/blog/edit/${p().id}`}
>
Edit
</A>
</div>
</Show>
{/* Comments section */}
<div
id="comments"
class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12"
>
<CommentSectionWrapper
privilegeLevel={postData.privilegeLevel}
allComments={postData.comments as Comment[]}
topLevelComments={
postData.topLevelComments as Comment[]
}
id={p().id}
reactionMap={reactionMap}
currentUserID={postData.userID || ""}
userCommentMap={userCommentMap}
{/* Post body */}
<PostBodyClient
body={p().body}
hasCodeBlock={hasCodeBlock(p().body)}
/>
<Show when={postData.privilegeLevel === "admin"}>
<div class="flex justify-center">
<A
class="border-blue bg-blue z-100 h-fit rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
href={`/blog/edit/${p().id}`}
>
Edit
</A>
</div>
</Show>
{/* Comments section */}
<div
id="comments"
class="mx-4 pt-12 pb-12 md:mx-8 lg:mx-12"
>
<CommentSectionWrapper
privilegeLevel={postData.privilegeLevel}
allComments={postData.comments as Comment[]}
topLevelComments={
postData.topLevelComments as Comment[]
}
id={p().id}
reactionMap={reactionMap}
currentUserID={postData.userID || ""}
userCommentMap={userCommentMap}
/>
</div>
</div>
</div>
</div>

View File

@@ -215,6 +215,7 @@ export default function ContactPage() {
name="name"
value={user()?.displayName ?? ""}
placeholder=" "
title="Please enter your name"
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
@@ -227,6 +228,7 @@ export default function ContactPage() {
name="email"
value={user()?.email ?? ""}
placeholder=" "
title="Please enter a valid email address"
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
@@ -239,6 +241,7 @@ export default function ContactPage() {
required
name="message"
placeholder=" "
title="Please enter your message"
class="underlinedInput w-full bg-transparent"
rows={4}
/>

View File

@@ -38,11 +38,13 @@ export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Derive state directly from URL parameters (no signals needed)
const register = () => searchParams.mode === "register";
const usePassword = () => searchParams.auth === "password";
// State management
const [register, setRegister] = createSignal(false);
const [error, setError] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [usePassword, setUsePassword] = createSignal(false);
const [countDown, setCountDown] = createSignal(0);
const [emailSent, setEmailSent] = createSignal(false);
const [showPasswordError, setShowPasswordError] = createSignal(false);
@@ -370,29 +372,23 @@ export default function LoginPage() {
fallback={
<div class="py-4 text-center md:min-w-118.75">
Already have an account?
<button
onClick={() => {
setRegister(false);
setUsePassword(false);
}}
<A
href="/login"
class="text-blue pl-1 underline hover:brightness-125"
>
Click here to Login
</button>
</A>
</div>
}
>
<div class="py-4 text-center md:min-w-118.75">
Don't have an account yet?
<button
onClick={() => {
setRegister(true);
setUsePassword(false);
}}
<A
href="/login?mode=register"
class="text-blue pl-1 underline hover:brightness-125"
>
Click here to Register
</button>
</A>
</div>
</Show>
@@ -406,6 +402,7 @@ export default function LoginPage() {
required
ref={emailRef}
placeholder=" "
title="Please enter a valid email address"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
@@ -425,6 +422,7 @@ export default function LoginPage() {
onInput={register() ? handleNewPasswordChange : undefined}
onBlur={register() ? handlePasswordBlur : undefined}
placeholder=" "
title="Password must be at least 8 characters"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
@@ -478,6 +476,7 @@ export default function LoginPage() {
ref={passwordConfRef}
onInput={handlePasswordConfChange}
placeholder=" "
title="Password must be at least 8 characters and match the password above"
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
@@ -584,22 +583,20 @@ export default function LoginPage() {
{/* Toggle password/email link */}
<Show when={!register() && !usePassword()}>
<button
type="button"
onClick={() => setUsePassword(true)}
<A
href="/login?auth=password"
class="hover-underline-animation my-auto ml-2 px-2 text-sm"
>
Use Password
</button>
</A>
</Show>
<Show when={usePassword()}>
<button
type="button"
onClick={() => setUsePassword(false)}
<A
href="/login"
class="hover-underline-animation my-auto ml-2 px-2 text-sm"
>
Use Email Link
</button>
</A>
</Show>
</div>
</form>

View File

@@ -144,6 +144,7 @@ export default function RequestPasswordResetPage() {
required
disabled={loading()}
placeholder=" "
title="Please enter a valid email address"
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>