flow update

This commit is contained in:
Michael Freno
2025-12-21 13:10:58 -05:00
parent 76a7dc5e65
commit f22e9c925b
4 changed files with 235 additions and 52 deletions

View File

@@ -0,0 +1,23 @@
import { Component } from "solid-js";
interface EmailIconProps {
height?: string | number;
width?: string | number;
fill?: string;
}
const EmailIcon: Component<EmailIconProps> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
height={props.height}
width={props.width}
fill={props.fill || "currentColor"}
>
<path d="M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48H48zM0 176V384c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V176L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z" />
</svg>
);
};
export default EmailIcon;

View File

@@ -5,6 +5,9 @@ import { getEvent } from "vinxi/http";
import Eye from "~/components/icons/Eye"; import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash"; import EyeSlash from "~/components/icons/EyeSlash";
import XCircle from "~/components/icons/XCircle"; 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 Dropzone from "~/components/blog/Dropzone";
import AddImageToS3 from "~/lib/s3upload"; import AddImageToS3 from "~/lib/s3upload";
import { validatePassword, isValidEmail } from "~/lib/validation"; import { validatePassword, isValidEmail } from "~/lib/validation";
@@ -16,7 +19,7 @@ type UserProfile = {
emailVerified: boolean; emailVerified: boolean;
displayName: string | null; displayName: string | null;
image: string | null; image: string | null;
provider: string; provider: "email" | "google" | "github" | null;
hasPassword: boolean; hasPassword: boolean;
}; };
@@ -454,6 +457,36 @@ export default function AccountPage() {
setPasswordBlurred(true); setPasswordBlurred(true);
}; };
// Helper to get provider display info
const getProviderInfo = (provider: UserProfile["provider"]) => {
switch (provider) {
case "google":
return {
name: "Google",
icon: <GoogleLogo height={24} width={24} />,
color: "text-blue-500"
};
case "github":
return {
name: "GitHub",
icon: <GitHub height={24} width={24} fill="currentColor" />,
color: "text-gray-700 dark:text-gray-300"
};
case "email":
return {
name: "Email",
icon: <EmailIcon height={24} width={24} />,
color: "text-green-500"
};
default:
return {
name: "Unknown",
icon: <EmailIcon height={24} width={24} />,
color: "text-gray-500"
};
}
};
return ( return (
<> <>
<Title>Account | Michael Freno</Title> <Title>Account | Michael Freno</Title>
@@ -471,6 +504,47 @@ export default function AccountPage() {
Account Settings Account Settings
</div> </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={getProviderInfo(currentUser().provider).color}
>
{getProviderInfo(currentUser().provider).icon}
</span>
<span class="text-lg font-semibold">
{getProviderInfo(currentUser().provider).name} Account
</span>
</div>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().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">
Add an email address for account recovery
</div>
</Show>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().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">
💡 Add a password to enable email/password login
</div>
</Show>
</div>
</div>
<hr class="mx-auto mb-8 max-w-4xl" />
{/* Profile Image Section */} {/* Profile Image Section */}
<div class="mx-auto mb-8 flex max-w-md justify-center"> <div class="mx-auto mb-8 flex max-w-md justify-center">
<div class="flex flex-col py-4"> <div class="flex flex-col py-4">
@@ -529,13 +603,17 @@ export default function AccountPage() {
<div class="flex items-center justify-center text-lg md:justify-normal"> <div class="flex items-center justify-center text-lg md:justify-normal">
<div class="flex flex-col lg:flex-row"> <div class="flex flex-col lg:flex-row">
<div class="pr-1 font-semibold whitespace-nowrap"> <div class="pr-1 font-semibold whitespace-nowrap">
Current email: {currentUser().provider === "email"
? "Email:"
: "Linked Email:"}
</div> </div>
{currentUser().email ? ( {currentUser().email ? (
<span>{currentUser().email}</span> <span>{currentUser().email}</span>
) : ( ) : (
<span class="font-light italic underline underline-offset-4"> <span class="font-light italic underline underline-offset-4">
None Set {currentUser().provider === "email"
? "None Set"
: "Not Linked"}
</span> </span>
)} )}
</div> </div>
@@ -566,8 +644,20 @@ export default function AccountPage() {
class="underlinedInput bg-transparent" class="underlinedInput bg-transparent"
/> />
<span class="bar"></span> <span class="bar"></span>
<label class="underlinedInputLabel">Set New Email</label> <label class="underlinedInputLabel">
{currentUser().email ? "Update Email" : "Add Email"}
</label>
</div> </div>
<Show
when={
currentUser().provider !== "email" &&
!currentUser().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"> <div class="flex justify-end">
<button <button
type="submit" type="submit"
@@ -655,11 +745,20 @@ export default function AccountPage() {
class="mt-8 flex w-full justify-center" class="mt-8 flex w-full justify-center"
> >
<div class="flex w-full max-w-md flex-col justify-center"> <div class="flex w-full max-w-md flex-col justify-center">
<div class="mb-4 text-center text-xl font-semibold"> <div class="mb-2 text-center text-xl font-semibold">
{currentUser().hasPassword {currentUser().hasPassword
? "Change Password" ? "Change Password"
: "Set Password"} : "Add Password"}
</div> </div>
<Show when={!currentUser().hasPassword}>
<div class="text-subtext0 mb-4 text-center text-sm">
{currentUser().provider === "email"
? "Set a password to enable password login"
: "Add a password to enable email/password login alongside your " +
getProviderInfo(currentUser().provider).name +
" login"}
</div>
</Show>
<Show when={currentUser().hasPassword}> <Show when={currentUser().hasPassword}>
<div class="input-group relative mx-4 mb-6"> <div class="input-group relative mx-4 mb-6">
@@ -804,44 +903,66 @@ export default function AccountPage() {
irreversible irreversible
</div> </div>
<form onSubmit={deleteAccountTrigger}> <Show
<div class="flex w-full justify-center"> when={currentUser().hasPassword}
<div class="input-group delete mx-4"> fallback={
<input <div class="flex flex-col items-center">
ref={deleteAccountPasswordRef} <div class="text-crust mb-4 text-center text-sm">
type="password" Your {getProviderInfo(currentUser().provider).name}{" "}
required account doesn't have a password. To delete your
disabled={deleteAccountButtonLoading()} account, please set a password first, then return
placeholder=" " here to proceed with deletion.
class="underlinedInput bg-transparent" </div>
/> <button
<span class="bar"></span> onClick={() => {
<label class="underlinedInputLabel"> window.scrollTo({ top: 0, behavior: "smooth" });
Enter Password }}
</label> class="bg-surface0 hover:bg-surface1 rounded px-4 py-2 transition-all"
>
Go to Add Password Section
</button>
</div> </div>
</div> }
>
<button <form onSubmit={deleteAccountTrigger}>
type="submit" <div class="flex w-full justify-center">
disabled={deleteAccountButtonLoading()} <div class="input-group delete mx-4">
class={`${ <input
deleteAccountButtonLoading() ref={deleteAccountPasswordRef}
? "bg-red cursor-not-allowed brightness-75" type="password"
: "bg-red hover:brightness-125 active:scale-90" required
} mx-auto mt-4 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`} disabled={deleteAccountButtonLoading()}
> placeholder=" "
{deleteAccountButtonLoading() class="underlinedInput bg-transparent"
? "Deleting..." />
: "Delete Account"} <span class="bar"></span>
</button> <label class="underlinedInputLabel">
Enter Password
<Show when={passwordDeletionError()}> </label>
<div class="text-red mt-2 text-center text-sm"> </div>
Password did not match record
</div> </div>
</Show>
</form> <button
type="submit"
disabled={deleteAccountButtonLoading()}
class={`${
deleteAccountButtonLoading()
? "bg-red cursor-not-allowed brightness-75"
: "bg-red hover:brightness-125 active:scale-90"
} mx-auto mt-4 flex justify-center rounded px-4 py-2 text-base transition-all duration-300 ease-out`}
>
{deleteAccountButtonLoading()
? "Deleting..."
: "Delete Account"}
</button>
<Show when={passwordDeletionError()}>
<div class="text-red mt-2 text-center text-sm">
Password did not match record
</div>
</Show>
</form>
</Show>
</div> </div>
</div> </div>
</> </>

View File

@@ -117,6 +117,30 @@ export const authRouter = createTRPCRouter({
await checkResponse(userResponse); await checkResponse(userResponse);
const user = await userResponse.json(); const user = await userResponse.json();
const login = user.login; const login = user.login;
const icon = user.avatar_url;
// Fetch primary email from GitHub emails endpoint
const emailsResponse = await fetchWithTimeout(
"https://api.github.com/user/emails",
{
headers: {
Authorization: `token ${access_token}`
},
timeout: 15000
}
);
await checkResponse(emailsResponse);
const emails = await emailsResponse.json();
// Find primary verified email
const primaryEmail = emails.find(
(e: { primary: boolean; verified: boolean; email: string }) =>
e.primary && e.verified
);
const email = primaryEmail?.email || null;
const emailVerified = primaryEmail?.verified || false;
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check if user exists // Check if user exists
@@ -127,16 +151,26 @@ export const authRouter = createTRPCRouter({
let userId: string; let userId: string;
if (res.rows[0]) { if (res.rows[0]) {
// User exists // User exists - update email and image if changed
userId = (res.rows[0] as unknown as User).id; userId = (res.rows[0] as unknown as User).id;
await conn.execute({
sql: `UPDATE User SET email = ?, email_verified = ?, image = ? WHERE id = ?`,
args: [email, emailVerified ? 1 : 0, icon, userId]
});
} else { } else {
// Create new user // Create new user
const icon = user.avatar_url;
const email = user.email;
userId = uuidV4(); userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, display_name, provider, image) VALUES (?, ?, ?, ?, ?)`; const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
const insertParams = [userId, email, login, "github", icon]; const insertParams = [
userId,
email,
emailVerified ? 1 : 0,
login,
"github",
icon
];
await conn.execute({ sql: insertQuery, args: insertParams }); await conn.execute({ sql: insertQuery, args: insertParams });
} }
@@ -254,8 +288,13 @@ export const authRouter = createTRPCRouter({
let userId: string; let userId: string;
if (res.rows[0]) { if (res.rows[0]) {
// User exists // User exists - update email, email_verified, display_name, and image if changed
userId = (res.rows[0] as unknown as User).id; userId = (res.rows[0] as unknown as User).id;
await conn.execute({
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
args: [email, email_verified ? 1 : 0, name, image, userId]
});
} else { } else {
// Create new user // Create new user
userId = uuidV4(); userId = uuidV4();
@@ -264,7 +303,7 @@ export const authRouter = createTRPCRouter({
const insertParams = [ const insertParams = [
userId, userId,
email, email,
email_verified, email_verified ? 1 : 0,
name, name,
"google", "google",
image image

View File

@@ -8,7 +8,7 @@ export interface User {
email_verified: number; // SQLite boolean (0 or 1) email_verified: number; // SQLite boolean (0 or 1)
password_hash: string | null; password_hash: string | null;
display_name: string | null; display_name: string | null;
provider: "email" | "google" | "apple" | null; provider: "email" | "google" | "github" | null;
image: string | null; image: string | null;
apple_user_string: string | null; apple_user_string: string | null;
database_name: string | null; database_name: string | null;
@@ -27,7 +27,7 @@ export interface UserProfile {
email?: string; email?: string;
emailVerified: boolean; emailVerified: boolean;
displayName?: string; displayName?: string;
provider?: "email" | "google" | "apple"; provider?: "email" | "google" | "github";
image?: string; image?: string;
hasPassword: boolean; hasPassword: boolean;
} }
@@ -43,7 +43,7 @@ export function toUserProfile(user: User): UserProfile {
displayName: user.display_name ?? undefined, displayName: user.display_name ?? undefined,
provider: user.provider ?? undefined, provider: user.provider ?? undefined,
image: user.image ?? undefined, image: user.image ?? undefined,
hasPassword: !!user.password_hash, hasPassword: !!user.password_hash
}; };
} }