forked from Mike/freno-dev
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars, cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and 12 unused dependencies removed Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose blocks tightened; dead commented-out config removed Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10); login formHandler split into 3 submitters (CCN 63->~5); account page render split into 8 section components (CCN 40); updatePost SQL builder rebuilt; assert*Owned consolidated behind generic assertOwnedBy Defensive guards: 4 redundant rethrow/nullish guards removed
1361 lines
42 KiB
TypeScript
1361 lines
42 KiB
TypeScript
import { createSignal, Show, createEffect, For } from "solid-js";
|
||
import { PageHead } from "~/components/PageHead";
|
||
import { useNavigate, redirect, query, createAsync } from "@solidjs/router";
|
||
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 { useAuth } from "~/context/auth";
|
||
|
||
const getUserProfile = query(async (): Promise<UserProfile | null> => {
|
||
"use server";
|
||
const { getUserState } = await import("~/lib/auth-query");
|
||
const { ConnectionFactory } = await import("~/server/utils");
|
||
|
||
const userState = await getUserState();
|
||
if (!userState.isAuthenticated || !userState.userId) {
|
||
throw redirect("/login");
|
||
}
|
||
|
||
const conn = ConnectionFactory();
|
||
try {
|
||
const res = await conn.execute({
|
||
sql: "SELECT provider, image, password_hash FROM User WHERE id = ?",
|
||
args: [userState.userId]
|
||
});
|
||
|
||
if (res.rows.length === 0) {
|
||
throw redirect("/login");
|
||
}
|
||
|
||
const user = res.rows[0] as any;
|
||
|
||
return {
|
||
id: userState.userId,
|
||
email: userState.email ?? undefined,
|
||
emailVerified: userState.emailVerified,
|
||
displayName: userState.displayName ?? 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 { refreshAuth } = useAuth();
|
||
|
||
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();
|
||
refreshAuth();
|
||
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>
|
||
|
||
<AccountTypeSection
|
||
profile={userProfile}
|
||
getProviderColor={getProviderColor}
|
||
getProviderName={getProviderName}
|
||
/>
|
||
|
||
<hr class="mx-auto mb-8 max-w-4xl" />
|
||
|
||
<ProfileImageSection
|
||
profile={userProfile}
|
||
handleImageDrop={handleImageDrop}
|
||
profileImageHolder={profileImageHolder}
|
||
preSetHolder={preSetHolder}
|
||
removeImage={removeImage}
|
||
setUserImage={setUserImage}
|
||
profileImageSetLoading={profileImageSetLoading}
|
||
profileImageStateChange={profileImageStateChange}
|
||
showImageSuccess={showImageSuccess}
|
||
/>
|
||
|
||
<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">
|
||
<EmailSection
|
||
profile={userProfile}
|
||
emailRef={(el) => (emailRef = el)}
|
||
emailButtonLoading={emailButtonLoading}
|
||
setEmailTrigger={setEmailTrigger}
|
||
showEmailSuccess={showEmailSuccess}
|
||
sendEmailVerification={sendEmailVerification}
|
||
/>
|
||
|
||
{/* Display Name Section */}
|
||
<DisplayNameSection
|
||
profile={userProfile}
|
||
displayNameRef={(el) => (displayNameRef = el)}
|
||
displayNameButtonLoading={displayNameButtonLoading}
|
||
setDisplayNameTrigger={setDisplayNameTrigger}
|
||
showDisplayNameSuccess={showDisplayNameSuccess}
|
||
/>
|
||
</div>
|
||
|
||
<PasswordSection
|
||
profile={userProfile}
|
||
handlePasswordSubmit={handlePasswordSubmit}
|
||
oldPasswordRef={(el) => (oldPasswordRef = el)}
|
||
newPasswordRef={(el) => (newPasswordRef = el)}
|
||
newPasswordConfRef={(el) =>
|
||
el !== undefined
|
||
? (newPasswordConfRef = el)
|
||
: newPasswordConfRef
|
||
}
|
||
handleNewPasswordChange={handleNewPasswordChange}
|
||
handlePasswordConfChange={handlePasswordConfChange}
|
||
handlePasswordBlur={handlePasswordBlur}
|
||
sendEmailVerification={sendEmailVerification}
|
||
getProviderName={getProviderName}
|
||
passwordChangeLoading={passwordChangeLoading}
|
||
newPassword={newPassword}
|
||
passwordsMatch={passwordsMatch}
|
||
passwordLengthSufficient={passwordLengthSufficient}
|
||
passwordError={passwordError}
|
||
showPasswordSuccess={showPasswordSuccess}
|
||
/>
|
||
|
||
<hr class="mt-8 mb-8" />
|
||
|
||
<LinkedProvidersSection profile={userProfile} />
|
||
|
||
<hr class="mt-8 mb-8" />
|
||
|
||
<SignOutSection
|
||
handleSignOut={handleSignOut}
|
||
signOutLoading={signOutLoading}
|
||
/>
|
||
|
||
<hr class="mt-8 mb-8" />
|
||
|
||
<DeleteAccountSection
|
||
profile={userProfile}
|
||
getProviderName={getProviderName}
|
||
deleteAccountPasswordRef={(el) =>
|
||
(deleteAccountPasswordRef = el)
|
||
}
|
||
deleteAccountButtonLoading={deleteAccountButtonLoading}
|
||
deleteAccountTrigger={deleteAccountTrigger}
|
||
passwordDeletionError={passwordDeletionError}
|
||
/>
|
||
</>
|
||
)}
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AccountTypeSection(props: {
|
||
profile: () => UserProfile;
|
||
getProviderColor: (provider: UserProfile["provider"]) => string;
|
||
getProviderName: (provider: UserProfile["provider"]) => string;
|
||
}) {
|
||
const { profile, getProviderColor, getProviderName } = props;
|
||
|
||
return (
|
||
<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(profile().provider)}>
|
||
<Show when={profile().provider === "google"}>
|
||
<GoogleLogo height={24} width={24} />
|
||
</Show>
|
||
<Show when={profile().provider === "github"}>
|
||
<GitHub height={24} width={24} fill="currentColor" />
|
||
</Show>
|
||
<Show
|
||
when={
|
||
profile().provider === "email" ||
|
||
!profile().provider
|
||
}
|
||
>
|
||
<EmailIcon height={24} width={24} />
|
||
</Show>
|
||
</span>
|
||
<span class="text-lg font-semibold">
|
||
{getProviderName(profile().provider)} Account
|
||
</span>
|
||
</div>
|
||
<Show
|
||
when={
|
||
profile().provider !== "email" &&
|
||
!profile().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={
|
||
profile().provider !== "email" &&
|
||
!profile().hasPassword
|
||
}
|
||
>
|
||
<div class="bg-blue mt-3 rounded px-3 py-2 text-center text-base text-sm">
|
||
{!profile().email
|
||
? "💡 Add and verify an email to enable email/password login"
|
||
: !profile().emailVerified
|
||
? "💡 Verify your email to enable password setup"
|
||
: "💡 Add a password to enable email/password login"}
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProfileImageSection(props: {
|
||
profile: () => UserProfile;
|
||
handleImageDrop: (acceptedFiles: File[]) => void;
|
||
profileImageHolder: () => string | null;
|
||
preSetHolder: () => string | null;
|
||
removeImage: () => void;
|
||
setUserImage: (e: Event) => void;
|
||
profileImageSetLoading: () => boolean;
|
||
profileImageStateChange: () => boolean;
|
||
showImageSuccess: () => boolean;
|
||
}) {
|
||
const {
|
||
profile,
|
||
handleImageDrop,
|
||
profileImageHolder,
|
||
preSetHolder,
|
||
removeImage,
|
||
setUserImage,
|
||
profileImageSetLoading,
|
||
profileImageStateChange,
|
||
showImageSuccess
|
||
} = props;
|
||
|
||
return (
|
||
<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() || profile().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>
|
||
);
|
||
}
|
||
|
||
function EmailSection(props: {
|
||
profile: () => UserProfile;
|
||
emailRef: (el: HTMLInputElement) => void;
|
||
emailButtonLoading: () => boolean;
|
||
setEmailTrigger: (e: Event) => void;
|
||
showEmailSuccess: () => boolean;
|
||
sendEmailVerification: () => void;
|
||
}) {
|
||
const {
|
||
profile,
|
||
emailRef,
|
||
emailButtonLoading,
|
||
setEmailTrigger,
|
||
showEmailSuccess,
|
||
sendEmailVerification
|
||
} = props;
|
||
|
||
return (
|
||
<>
|
||
<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">
|
||
{profile().provider === "email"
|
||
? "Email:"
|
||
: "Linked Email:"}
|
||
</div>
|
||
{profile().email ? (
|
||
<span>{profile().email}</span>
|
||
) : (
|
||
<span class="font-light italic underline underline-offset-4">
|
||
{profile().provider === "email"
|
||
? "None Set"
|
||
: "Not Linked"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Show when={profile().email && !profile().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()}
|
||
title="Please enter a valid email address"
|
||
label={profile().email ? "Update Email" : "Add Email"}
|
||
/>
|
||
<Show
|
||
when={
|
||
profile().provider !== "email" &&
|
||
!profile().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={
|
||
profile().email !== null &&
|
||
!profile().emailVerified
|
||
}
|
||
loading={emailButtonLoading()}
|
||
class="mt-2"
|
||
>
|
||
Submit
|
||
</Button>
|
||
</div>
|
||
<FormFeedback
|
||
type="success"
|
||
message="Email updated!"
|
||
show={showEmailSuccess()}
|
||
class="mt-2"
|
||
/>
|
||
</form>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DisplayNameSection(props: {
|
||
profile: () => UserProfile;
|
||
displayNameRef: (el: HTMLInputElement) => void;
|
||
displayNameButtonLoading: () => boolean;
|
||
setDisplayNameTrigger: (e: Event) => void;
|
||
showDisplayNameSuccess: () => boolean;
|
||
}) {
|
||
const {
|
||
profile,
|
||
displayNameRef,
|
||
displayNameButtonLoading,
|
||
setDisplayNameTrigger,
|
||
showDisplayNameSuccess
|
||
} = props;
|
||
|
||
return (
|
||
<>
|
||
<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>
|
||
{profile().displayName ? (
|
||
<span>{profile().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 ${profile().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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function PasswordSection(props: {
|
||
profile: () => UserProfile;
|
||
handlePasswordSubmit: (e: Event) => void;
|
||
oldPasswordRef: (el: HTMLInputElement) => void;
|
||
newPasswordRef: (el: HTMLInputElement) => void;
|
||
newPasswordConfRef: (
|
||
el?: HTMLInputElement
|
||
) => HTMLInputElement | undefined;
|
||
handleNewPasswordChange: (e: Event) => void;
|
||
handlePasswordConfChange: (e: Event) => void;
|
||
handlePasswordBlur: () => void;
|
||
sendEmailVerification: () => void;
|
||
getProviderName: (provider: UserProfile["provider"]) => string;
|
||
passwordChangeLoading: () => boolean;
|
||
newPassword: () => string;
|
||
passwordsMatch: () => boolean;
|
||
passwordLengthSufficient: () => boolean;
|
||
passwordError: () => boolean;
|
||
showPasswordSuccess: () => boolean;
|
||
}) {
|
||
const {
|
||
profile,
|
||
handlePasswordSubmit,
|
||
oldPasswordRef,
|
||
newPasswordRef,
|
||
newPasswordConfRef,
|
||
handleNewPasswordChange,
|
||
handlePasswordConfChange,
|
||
handlePasswordBlur,
|
||
sendEmailVerification,
|
||
getProviderName,
|
||
passwordChangeLoading,
|
||
newPassword,
|
||
passwordsMatch,
|
||
passwordLengthSufficient,
|
||
passwordError,
|
||
showPasswordSuccess
|
||
} = props;
|
||
|
||
return (
|
||
<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">
|
||
{profile().hasPassword
|
||
? "Change Password"
|
||
: "Add Password"}
|
||
</div>
|
||
<noscript>
|
||
<div class="text-subtext0 mb-4 text-center text-sm">
|
||
JavaScript required to{" "}
|
||
{profile().hasPassword ? "change" : "add"} password
|
||
</div>
|
||
</noscript>
|
||
<Show when={!profile().hasPassword}>
|
||
<Show
|
||
when={
|
||
profile().provider !== "email" &&
|
||
(!profile().email || !profile().emailVerified)
|
||
}
|
||
>
|
||
<div class="bg-yellow mb-4 rounded px-4 py-3 text-center text-base text-sm">
|
||
<div class="mb-1 font-semibold">
|
||
⚠️ Email Verification Required
|
||
</div>
|
||
<div>
|
||
{!profile().email
|
||
? "Please add and verify an email address before setting a password."
|
||
: "Please verify your email address before setting a password."}
|
||
</div>
|
||
<Show
|
||
when={
|
||
profile().email &&
|
||
!profile().emailVerified
|
||
}
|
||
>
|
||
<button
|
||
onClick={sendEmailVerification}
|
||
class="mt-2 font-semibold text-blue-700 underline transition-all hover:brightness-125"
|
||
>
|
||
Resend Verification Email
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
<Show
|
||
when={
|
||
profile().provider === "email" ||
|
||
(profile().email && profile().emailVerified)
|
||
}
|
||
>
|
||
<div class="text-subtext0 mb-4 text-center text-sm">
|
||
{profile().provider === "email"
|
||
? "Set a password to enable password login"
|
||
: "Add a password to enable email/password login alongside your " +
|
||
getProviderName(profile().provider) +
|
||
" login"}
|
||
</div>
|
||
</Show>
|
||
</Show>
|
||
|
||
<Show when={profile().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() ||
|
||
(!profile().hasPassword &&
|
||
profile().provider !== "email" &&
|
||
(!profile().email ||
|
||
!profile().emailVerified))
|
||
}
|
||
title="Password must be at least 8 characters"
|
||
label="New Password"
|
||
showStrength
|
||
passwordValue={newPassword()}
|
||
/>
|
||
<PasswordInput
|
||
ref={newPasswordConfRef}
|
||
required
|
||
minlength="8"
|
||
onInput={handlePasswordConfChange}
|
||
disabled={
|
||
passwordChangeLoading() ||
|
||
(!profile().hasPassword &&
|
||
profile().provider !== "email" &&
|
||
(!profile().email ||
|
||
!profile().emailVerified))
|
||
}
|
||
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() ||
|
||
(!profile().hasPassword &&
|
||
profile().provider !== "email" &&
|
||
(!profile().email ||
|
||
!profile().emailVerified))
|
||
}
|
||
loading={passwordChangeLoading()}
|
||
class="my-6"
|
||
>
|
||
Set
|
||
</Button>
|
||
|
||
<FormFeedback
|
||
type="error"
|
||
message={
|
||
profile().hasPassword
|
||
? "Password did not match record"
|
||
: "Must have email & password provider linked or set password first"
|
||
}
|
||
show={passwordError()}
|
||
/>
|
||
|
||
<FormFeedback
|
||
type="success"
|
||
message={`Password ${profile().hasPassword ? "changed" : "set"} successfully!`}
|
||
show={showPasswordSuccess()}
|
||
/>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function LinkedProvidersSection(props: { profile: () => UserProfile }) {
|
||
const { profile } = props;
|
||
|
||
return (
|
||
<div class="mx-auto max-w-2xl py-8">
|
||
<div class="mb-6 text-center text-2xl font-semibold">
|
||
Linked Authentication Methods
|
||
</div>
|
||
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||
<LinkedProviders userId={profile().id} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SignOutSection(props: {
|
||
handleSignOut: () => void;
|
||
signOutLoading: () => boolean;
|
||
}) {
|
||
const { handleSignOut, signOutLoading } = props;
|
||
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function DeleteAccountSection(props: {
|
||
profile: () => UserProfile;
|
||
getProviderName: (provider: UserProfile["provider"]) => string;
|
||
deleteAccountPasswordRef: (el: HTMLInputElement) => void;
|
||
deleteAccountButtonLoading: () => boolean;
|
||
deleteAccountTrigger: (e: Event) => void;
|
||
passwordDeletionError: () => boolean;
|
||
}) {
|
||
const {
|
||
profile,
|
||
getProviderName,
|
||
deleteAccountPasswordRef,
|
||
deleteAccountButtonLoading,
|
||
deleteAccountTrigger,
|
||
passwordDeletionError
|
||
} = props;
|
||
|
||
return (
|
||
<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={profile().hasPassword}
|
||
fallback={
|
||
<div class="flex flex-col items-center">
|
||
<div class="text-crust mb-4 text-center text-sm">
|
||
Your {getProviderName(profile().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>
|
||
);
|
||
}
|
||
|
||
function LinkedProviders(props: { userId: string }) {
|
||
const [providers, setProviders] = createSignal<any[]>([]);
|
||
const [loading, setLoading] = createSignal(true);
|
||
const [unlinkLoading, setUnlinkLoading] = createSignal<string | null>(null);
|
||
|
||
const loadProviders = async () => {
|
||
try {
|
||
const response = await fetch("/api/trpc/user.getProviders");
|
||
const result = await response.json();
|
||
if (response.ok && result.result?.data) {
|
||
setProviders(result.result.data);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to load providers:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
createEffect(() => {
|
||
loadProviders();
|
||
});
|
||
|
||
const handleUnlink = async (provider: string) => {
|
||
if (!confirm(`Are you sure you want to unlink your ${provider} account?`)) {
|
||
return;
|
||
}
|
||
|
||
setUnlinkLoading(provider);
|
||
try {
|
||
const response = await fetch("/api/trpc/user.unlinkProvider", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ provider })
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (response.ok && result.result?.data?.success) {
|
||
await loadProviders();
|
||
alert(`${provider} account unlinked successfully`);
|
||
} else {
|
||
alert(result.error?.message || "Failed to unlink provider");
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to unlink provider:", err);
|
||
alert("Failed to unlink provider");
|
||
} finally {
|
||
setUnlinkLoading(null);
|
||
}
|
||
};
|
||
|
||
const getProviderIcon = (provider: string) => {
|
||
switch (provider) {
|
||
case "google":
|
||
return <GoogleLogo height={20} width={20} />;
|
||
case "github":
|
||
return <GitHub height={20} width={20} fill="currentColor" />;
|
||
case "email":
|
||
return <EmailIcon height={20} width={20} />;
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric"
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div class="space-y-4">
|
||
<Show when={loading()}>
|
||
<div class="text-center text-sm">Loading providers...</div>
|
||
</Show>
|
||
<Show when={!loading() && providers().length === 0}>
|
||
<div class="text-center text-sm">No linked providers found</div>
|
||
</Show>
|
||
<For each={providers()}>
|
||
{(provider) => (
|
||
<div class="bg-surface1 flex items-center justify-between rounded-lg p-4">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-blue">
|
||
{getProviderIcon(provider.provider)}
|
||
</span>
|
||
<div>
|
||
<div class="font-semibold capitalize">{provider.provider}</div>
|
||
<Show when={provider.email}>
|
||
<div class="text-subtext0 text-sm">{provider.email}</div>
|
||
</Show>
|
||
<Show when={provider.lastUsedAt}>
|
||
<div class="text-subtext0 text-xs">
|
||
Last used: {formatDate(provider.lastUsedAt)}
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
<Show when={providers().length > 1}>
|
||
<button
|
||
onClick={() => handleUnlink(provider.provider)}
|
||
disabled={unlinkLoading() === provider.provider}
|
||
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
|
||
>
|
||
{unlinkLoading() === provider.provider
|
||
? "Unlinking..."
|
||
: "Unlink"}
|
||
</button>
|
||
</Show>
|
||
<Show when={providers().length === 1}>
|
||
<div class="text-subtext0 text-xs italic">Primary method</div>
|
||
</Show>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
);
|
||
}
|