migrating

This commit is contained in:
Michael Freno
2025-12-17 00:23:13 -05:00
parent b3df3eedd2
commit 81969ae907
79 changed files with 4187 additions and 172 deletions

View File

@@ -0,0 +1,54 @@
import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
// Handle OAuth error (user denied access, etc.)
if (error) {
return new Response(null, {
status: 302,
headers: { Location: `/login?error=${encodeURIComponent(error)}` },
});
}
// Missing authorization code
if (!code) {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_code" },
});
}
try {
// Create tRPC caller to invoke the githubCallback procedure
const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx);
// Call the GitHub callback handler
const result = await caller.auth.githubCallback({ code });
if (result.success) {
// Redirect to account page on success
return new Response(null, {
status: 302,
headers: { Location: result.redirectTo || "/account" },
});
} else {
// Redirect to login with error
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" },
});
}
} catch (error) {
console.error("GitHub OAuth callback error:", error);
return new Response(null, {
status: 302,
headers: { Location: "/login?error=server_error" },
});
}
}

View File

@@ -0,0 +1,54 @@
import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
// Handle OAuth error (user denied access, etc.)
if (error) {
return new Response(null, {
status: 302,
headers: { Location: `/login?error=${encodeURIComponent(error)}` },
});
}
// Missing authorization code
if (!code) {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_code" },
});
}
try {
// Create tRPC caller to invoke the googleCallback procedure
const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx);
// Call the Google callback handler
const result = await caller.auth.googleCallback({ code });
if (result.success) {
// Redirect to account page on success
return new Response(null, {
status: 302,
headers: { Location: result.redirectTo || "/account" },
});
} else {
// Redirect to login with error
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" },
});
}
} catch (error) {
console.error("Google OAuth callback error:", error);
return new Response(null, {
status: 302,
headers: { Location: "/login?error=server_error" },
});
}
}

View File

@@ -0,0 +1,63 @@
import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const email = url.searchParams.get("email");
const token = url.searchParams.get("token");
const rememberMeParam = url.searchParams.get("rememberMe");
// Parse rememberMe parameter
const rememberMe = rememberMeParam === "true";
// Missing required parameters
if (!email || !token) {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_params" },
});
}
try {
// Create tRPC caller to invoke the emailLogin procedure
const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx);
// Call the email login handler
const result = await caller.auth.emailLogin({
email,
token,
rememberMe,
});
if (result.success) {
// Redirect to account page on success
return new Response(null, {
status: 302,
headers: { Location: result.redirectTo || "/account" },
});
} else {
// Redirect to login with error
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" },
});
}
} catch (error) {
console.error("Email login callback error:", error);
// Check if it's a token expiration error
const errorMessage = error instanceof Error ? error.message : "server_error";
const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid");
return new Response(null, {
status: 302,
headers: {
Location: isTokenError
? "/login?error=link_expired"
: "/login?error=server_error"
},
});
}
}

View File

@@ -0,0 +1,200 @@
import type { APIEvent } from "@solidjs/start/server";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const email = url.searchParams.get("email");
const token = url.searchParams.get("token");
// Missing required parameters
if (!email || !token) {
return new Response(
`
<!DOCTYPE html>
<html>
<head>
<title>Email Verification Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
}
h1 { color: #e53e3e; margin-bottom: 1rem; }
p { color: #4a5568; margin-bottom: 1.5rem; }
a {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 0.5rem;
transition: background 0.3s;
}
a:hover { background: #5a67d8; }
</style>
</head>
<body>
<div class="container">
<h1>❌ Verification Failed</h1>
<p>Invalid verification link. Please check your email and try again.</p>
<a href="/login">Return to Login</a>
</div>
</body>
</html>
`,
{
status: 400,
headers: { "Content-Type": "text/html" },
}
);
}
try {
// Create tRPC caller to invoke the emailVerification procedure
const ctx = await createTRPCContext(event);
const caller = appRouter.createCaller(ctx);
// Call the email verification handler
const result = await caller.auth.emailVerification({
email,
token,
});
if (result.success) {
// Show success page
return new Response(
`
<!DOCTYPE html>
<html>
<head>
<title>Email Verified</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
}
h1 { color: #48bb78; margin-bottom: 1rem; }
p { color: #4a5568; margin-bottom: 1.5rem; }
.checkmark {
font-size: 4rem;
margin-bottom: 1rem;
}
a {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #48bb78;
color: white;
text-decoration: none;
border-radius: 0.5rem;
transition: background 0.3s;
}
a:hover { background: #38a169; }
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✓</div>
<h1>Email Verified!</h1>
<p>${result.message || "Your email has been successfully verified."}</p>
<a href="/login">Continue to Login</a>
</div>
</body>
</html>
`,
{
status: 200,
headers: { "Content-Type": "text/html" },
}
);
} else {
throw new Error("Verification failed");
}
} catch (error) {
console.error("Email verification callback error:", error);
// Check if it's a token expiration error
const errorMessage = error instanceof Error ? error.message : "server_error";
const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid");
return new Response(
`
<!DOCTYPE html>
<html>
<head>
<title>Email Verification Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
}
h1 { color: #e53e3e; margin-bottom: 1rem; }
p { color: #4a5568; margin-bottom: 1.5rem; }
a {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 0.5rem;
transition: background 0.3s;
margin: 0.5rem;
}
a:hover { background: #5a67d8; }
</style>
</head>
<body>
<div class="container">
<h1>❌ Verification Failed</h1>
<p>${isTokenError ? "This verification link has expired. Please request a new verification email." : "An error occurred during verification. Please try again."}</p>
<a href="/login">Return to Login</a>
</div>
</body>
</html>
`,
{
status: 400,
headers: { "Content-Type": "text/html" },
}
);
}
}

582
src/routes/login.tsx Normal file
View File

@@ -0,0 +1,582 @@
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
import { A, useNavigate, useSearchParams } from "@solidjs/router";
import GoogleLogo from "~/components/icons/GoogleLogo";
import GitHub from "~/components/icons/GitHub";
import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import { isValidEmail, validatePassword } from "~/lib/validation";
import { getClientCookie } from "~/lib/cookies.client";
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 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);
const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false);
const [showPasswordInput, setShowPasswordInput] = createSignal(false);
const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false);
const [passwordsMatch, setPasswordsMatch] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] = createSignal(false);
const [passwordLengthSufficient, setPasswordLengthSufficient] = createSignal(false);
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
// Form refs
let emailRef: HTMLInputElement | undefined;
let passwordRef: HTMLInputElement | undefined;
let passwordConfRef: HTMLInputElement | undefined;
let rememberMeRef: HTMLInputElement | undefined;
let timerInterval: number | undefined;
// Environment variables
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const githubClientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
const domain = import.meta.env.VITE_DOMAIN || "https://www.freno.me";
// Calculate remaining time from cookie
const calcRemainder = (timer: string) => {
const expires = new Date(timer);
const remaining = expires.getTime() - Date.now();
const remainingInSeconds = remaining / 1000;
if (remainingInSeconds <= 0) {
setCountDown(0);
if (timerInterval) {
clearInterval(timerInterval);
}
} else {
setCountDown(remainingInSeconds);
}
};
// Check for existing timer on mount
createEffect(() => {
const timer = getClientCookie("emailLoginLinkRequested");
if (timer) {
timerInterval = setInterval(() => calcRemainder(timer), 1000) as unknown as number;
onCleanup(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
});
}
});
// Check for OAuth/callback errors in URL
createEffect(() => {
const errorParam = searchParams.error;
if (errorParam) {
const errorMessages: Record<string, string> = {
missing_code: "OAuth authorization failed - missing code",
auth_failed: "Authentication failed - please try again",
server_error: "Server error - please try again later",
missing_params: "Invalid login link - missing parameters",
link_expired: "Login link has expired - please request a new one",
access_denied: "Access denied - you cancelled the login",
};
setError(errorMessages[errorParam] || "An error occurred during login");
}
});
// Form submission handler
const formHandler = async (e: Event) => {
e.preventDefault();
setLoading(true);
setError("");
setShowPasswordError(false);
setShowPasswordSuccess(false);
try {
if (register()) {
// Registration flow
if (!emailRef || !passwordRef || !passwordConfRef) {
setError("Please fill in all fields");
setLoading(false);
return;
}
const email = emailRef.value;
const password = passwordRef.value;
const passwordConf = passwordConfRef.value;
// Validate inputs
if (!isValidEmail(email)) {
setError("Invalid email address");
setLoading(false);
return;
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.isValid) {
setError(passwordValidation.errors[0] || "Invalid password");
setLoading(false);
return;
}
if (password !== passwordConf) {
setError("passwordMismatch");
setLoading(false);
return;
}
// Call registration endpoint
const response = await fetch("/api/trpc/auth.emailRegistration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirmation: passwordConf }),
});
const result = await response.json();
if (response.ok && result.result?.data) {
navigate("/account");
} else {
const errorMsg = result.error?.message || result.result?.data?.message || "Registration failed";
if (errorMsg.includes("duplicate") || errorMsg.includes("already exists")) {
setError("duplicate");
} else {
setError(errorMsg);
}
}
} else if (usePassword()) {
// Password login flow
if (!emailRef || !passwordRef || !rememberMeRef) {
setError("Please fill in all fields");
setLoading(false);
return;
}
const email = emailRef.value;
const password = passwordRef.value;
const rememberMe = rememberMeRef.checked;
const response = await fetch("/api/trpc/auth.emailPasswordLogin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, rememberMe }),
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
setShowPasswordSuccess(true);
setTimeout(() => {
navigate(-1); // Go back
window.location.reload(); // Refresh to update session
}, 500);
} else {
setShowPasswordError(true);
}
} else {
// Email link login flow
if (!emailRef || !rememberMeRef) {
setError("Please enter your email");
setLoading(false);
return;
}
const email = emailRef.value;
const rememberMe = rememberMeRef.checked;
if (!isValidEmail(email)) {
setError("Invalid email address");
setLoading(false);
return;
}
const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, rememberMe }),
});
const result = await response.json();
if (response.ok && result.result?.data?.success) {
setEmailSent(true);
const timer = getClientCookie("emailLoginLinkRequested");
if (timer) {
if (timerInterval) {
clearInterval(timerInterval);
}
timerInterval = setInterval(() => calcRemainder(timer), 1000) as unknown as number;
}
} else {
const errorMsg = result.error?.message || result.result?.data?.message || "Failed to send email";
setError(errorMsg);
}
}
} catch (err: any) {
console.error("Login error:", err);
setError(err.message || "An error occurred");
} finally {
setLoading(false);
}
};
// Countdown timer render function
const renderTime = () => {
return (
<div class="timer">
<div class="value">{countDown().toFixed(0)}</div>
</div>
);
};
// Password validation helpers
const checkForMatch = (newPassword: string, newPasswordConf: string) => {
setPasswordsMatch(newPassword === newPasswordConf);
};
const checkPasswordLength = (password: string) => {
if (password.length >= 8) {
setPasswordLengthSufficient(true);
setShowPasswordLengthWarning(false);
} else {
setPasswordLengthSufficient(false);
if (passwordBlurred()) {
setShowPasswordLengthWarning(true);
}
}
};
const passwordLengthBlurCheck = () => {
if (!passwordLengthSufficient() && passwordRef && passwordRef.value !== "") {
setShowPasswordLengthWarning(true);
}
setPasswordBlurred(true);
};
const handleNewPasswordChange = (e: Event) => {
const target = e.target as HTMLInputElement;
checkPasswordLength(target.value);
};
const handlePasswordConfChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (passwordRef) {
checkForMatch(passwordRef.value, target.value);
}
};
const handlePasswordBlur = () => {
passwordLengthBlurCheck();
};
return (
<div class="flex h-[100dvh] flex-row justify-evenly">
{/* Logo section - hidden on mobile */}
<div class="hidden md:flex">
<div class="vertical-rule-around z-0 flex justify-center">
<picture class="-mr-8">
<source srcSet="/WhiteLogo.png" media="(prefers-color-scheme: dark)" />
<img src="/BlackLogo.png" alt="logo" width={64} height={64} />
</picture>
</div>
</div>
{/* Main content */}
<div class="pt-24 md:pt-48">
{/* Error message */}
<div class="absolute -mt-12 text-center text-3xl italic text-red-400">
<Show when={error() === "passwordMismatch"}>Passwords did not match!</Show>
<Show when={error() === "duplicate"}>Email Already Exists!</Show>
</div>
{/* Title */}
<div class="py-2 pl-6 text-2xl md:pl-0">{register() ? "Register" : "Login"}</div>
{/* Toggle Register/Login */}
<Show
when={!register()}
fallback={
<div class="py-4 text-center md:min-w-[475px]">
Already have an account?
<button
onClick={() => {
setRegister(false);
setUsePassword(false);
}}
class="pl-1 text-blue-400 underline dark:text-blue-600 hover:text-blue-300 dark:hover:text-blue-500"
>
Click here to Login
</button>
</div>
}
>
<div class="py-4 text-center md:min-w-[475px]">
Don't have an account yet?
<button
onClick={() => {
setRegister(true);
setUsePassword(false);
}}
class="pl-1 text-blue-400 underline dark:text-blue-600 hover:text-blue-300 dark:hover:text-blue-500"
>
Click here to Register
</button>
</div>
</Show>
{/* Form */}
<form onSubmit={formHandler} class="flex flex-col px-2 py-4">
{/* Email input */}
<div class="flex justify-center">
<div class="input-group mx-4">
<input
type="text"
required
ref={emailRef}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Email</label>
</div>
</div>
{/* Password input - shown for login with password or registration */}
<Show when={usePassword() || register()}>
<div class="-mt-4 flex justify-center">
<div class="input-group mx-4 flex">
<input
type={showPasswordInput() ? "text" : "password"}
required
minLength={8}
ref={passwordRef}
onInput={register() ? handleNewPasswordChange : undefined}
onBlur={register() ? handlePasswordBlur : undefined}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Password</label>
</div>
<button
onClick={() => {
setShowPasswordInput(!showPasswordInput());
passwordRef?.focus();
}}
class="absolute ml-60 mt-14"
type="button"
>
<Show
when={showPasswordInput()}
fallback={
<EyeSlash
height={24}
width={24}
strokeWidth={1}
class="stroke-zinc-900 dark:stroke-white"
/>
}
>
<Eye
height={24}
width={24}
strokeWidth={1}
class="stroke-zinc-900 dark:stroke-white"
/>
</Show>
</button>
</div>
<div
class={`${
showPasswordLengthWarning() ? "" : "select-none opacity-0"
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
>
Password too short! Min Length: 8
</div>
</Show>
{/* Password confirmation - shown only for registration */}
<Show when={register()}>
<div class="-mt-4 flex justify-center">
<div class="input-group mx-4">
<input
type={showPasswordConfInput() ? "text" : "password"}
required
minLength={8}
ref={passwordConfRef}
onInput={handlePasswordConfChange}
placeholder=" "
class="underlinedInput bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Confirm Password</label>
</div>
<button
onClick={() => {
setShowPasswordConfInput(!showPasswordConfInput());
passwordConfRef?.focus();
}}
class="absolute ml-60 mt-14"
type="button"
>
<Show
when={showPasswordConfInput()}
fallback={
<EyeSlash
height={24}
width={24}
strokeWidth={1}
class="stroke-zinc-900 dark:stroke-white"
/>
}
>
<Eye
height={24}
width={24}
strokeWidth={1}
class="stroke-zinc-900 dark:stroke-white"
/>
</Show>
</button>
</div>
<div
class={`${
!passwordsMatch() &&
passwordLengthSufficient() &&
passwordConfRef &&
passwordConfRef.value.length >= 6
? ""
: "select-none opacity-0"
} text-center text-red-500 transition-opacity duration-200 ease-in-out`}
>
Passwords do not match!
</div>
</Show>
{/* Remember Me checkbox */}
<div class="mx-auto flex pt-4">
<input type="checkbox" class="my-auto" ref={rememberMeRef} />
<div class="my-auto px-2 text-sm font-normal">Remember Me</div>
</div>
{/* Error/Success messages */}
<div
class={`${
showPasswordError()
? "text-red-500"
: showPasswordSuccess()
? "text-green-500"
: "select-none opacity-0"
} flex min-h-[16px] justify-center italic transition-opacity duration-300 ease-in-out`}
>
<Show when={showPasswordError()}>Credentials did not match any record</Show>
<Show when={showPasswordSuccess()}>Login Success! Redirecting...</Show>
</div>
{/* Submit button or countdown timer */}
<div class="flex justify-center py-4">
<Show
when={!register() && !usePassword() && countDown() > 0}
fallback={
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-zinc-400"
: "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700"
} flex w-36 justify-center rounded py-3 text-white shadow-lg shadow-blue-300 transition-all duration-300 ease-out dark:shadow-blue-700`}
>
{register() ? "Sign Up" : usePassword() ? "Sign In" : "Get Link"}
</button>
}
>
<CountdownCircleTimer
duration={120}
initialRemainingTime={countDown()}
size={48}
strokeWidth={6}
colors="#60a5fa"
>
{renderTime}
</CountdownCircleTimer>
</Show>
{/* Toggle password/email link */}
<Show when={!register() && !usePassword()}>
<button
type="button"
onClick={() => setUsePassword(true)}
class="hover-underline-animation my-auto ml-2 px-2 text-sm"
>
Use Password
</button>
</Show>
<Show when={usePassword()}>
<button
type="button"
onClick={() => setUsePassword(false)}
class="hover-underline-animation my-auto ml-2 px-2 text-sm"
>
Use Email Link
</button>
</Show>
</div>
</form>
{/* Password reset link */}
<Show when={usePassword()}>
<div class="pb-4 text-center text-sm">
Trouble Logging In?{" "}
<A
class="text-blue-500 underline underline-offset-4 hover:text-blue-400"
href="/login/request-password-reset"
>
Reset Password
</A>
</div>
</Show>
{/* Email sent confirmation */}
<div
class={`${
emailSent() ? "" : "user-select opacity-0"
} flex min-h-[16px] justify-center text-center italic text-green-400 transition-opacity duration-300 ease-in-out`}
>
<Show when={emailSent()}>Email Sent!</Show>
</div>
{/* Or divider */}
<div class="rule-around text-center">Or</div>
{/* OAuth buttons */}
<div class="my-2 flex justify-center">
<div class="mx-auto mb-4 flex flex-col">
{/* Google OAuth */}
<A
href={`https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${domain}/api/auth/callback/google&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email`}
class="my-4 flex w-80 flex-row justify-between rounded border border-zinc-800 bg-white px-4 py-2 text-black shadow-md transition-all duration-300 ease-out hover:bg-zinc-100 active:scale-95 dark:border dark:border-zinc-50 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700"
>
{register() ? "Register " : "Sign in "} with Google
<span class="my-auto">
<GoogleLogo height={24} width={24} />
</span>
</A>
{/* GitHub OAuth */}
<A
href={`https://github.com/login/oauth/authorize?client_id=${githubClientId}&redirect_uri=${domain}/api/auth/callback/github&scope=user`}
class="my-4 flex w-80 flex-row justify-between rounded bg-zinc-600 px-4 py-2 text-white shadow-md transition-all duration-300 ease-out hover:bg-zinc-700 active:scale-95"
>
{register() ? "Register " : "Sign in "} with Github
<span class="my-auto">
<GitHub height={24} width={24} fill="white" />
</span>
</A>
</div>
</div>
</div>
</div>
);
}

169
src/routes/test-utils.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { createSignal } from "solid-js";
import Input from "~/components/ui/Input";
import Button from "~/components/ui/Button";
import { isValidEmail, validatePassword, passwordsMatch } from "~/lib/validation";
/**
* Test page to validate Task 01 components and utilities
* Navigate to /test-utils to view
*/
export default function TestUtilsPage() {
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [passwordConf, setPasswordConf] = createSignal("");
const [loading, setLoading] = createSignal(false);
const emailError = () => {
if (!email()) return undefined;
return isValidEmail(email()) ? undefined : "Invalid email format";
};
const passwordError = () => {
if (!password()) return undefined;
const validation = validatePassword(password());
return validation.isValid ? undefined : validation.errors.join(", ");
};
const passwordMatchError = () => {
if (!passwordConf()) return undefined;
return passwordsMatch(password(), passwordConf())
? undefined
: "Passwords do not match";
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
alert(`Form submitted!\nEmail: ${email()}\nPassword: ${password()}`);
setLoading(false);
};
return (
<main class="min-h-screen bg-gray-100 p-8">
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
<h1 class="text-3xl font-bold mb-2">Task 01 - Utility Testing</h1>
<p class="text-gray-600 mb-4">
Testing shared utilities, types, and UI components
</p>
</div>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Form Components & Validation</h2>
<form onSubmit={handleSubmit} class="space-y-4">
<Input
type="email"
label="Email Address"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
error={emailError()}
helperText="Enter a valid email address"
required
/>
<Input
type="password"
label="Password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={passwordError()}
helperText="Minimum 8 characters"
required
/>
<Input
type="password"
label="Confirm Password"
value={passwordConf()}
onInput={(e) => setPasswordConf(e.currentTarget.value)}
error={passwordMatchError()}
required
/>
<div class="flex gap-2">
<Button
type="submit"
variant="primary"
loading={loading()}
disabled={
!isValidEmail(email()) ||
!validatePassword(password()).isValid ||
!passwordsMatch(password(), passwordConf())
}
>
Submit
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setEmail("");
setPassword("");
setPasswordConf("");
}}
>
Reset
</Button>
<Button
type="button"
variant="danger"
onClick={() => alert("Danger action!")}
>
Delete
</Button>
<Button
type="button"
variant="ghost"
onClick={() => alert("Ghost action!")}
>
Cancel
</Button>
</div>
</form>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-4">Validation Status</h2>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span class={`w-3 h-3 rounded-full ${isValidEmail(email()) ? "bg-green-500" : "bg-gray-300"}`} />
<span>Email Valid: {isValidEmail(email()) ? "✓" : "✗"}</span>
</div>
<div class="flex items-center gap-2">
<span class={`w-3 h-3 rounded-full ${validatePassword(password()).isValid ? "bg-green-500" : "bg-gray-300"}`} />
<span>Password Valid: {validatePassword(password()).isValid ? "✓" : "✗"}</span>
</div>
<div class="flex items-center gap-2">
<span class={`w-3 h-3 rounded-full ${passwordsMatch(password(), passwordConf()) ? "bg-green-500" : "bg-gray-300"}`} />
<span>Passwords Match: {passwordsMatch(password(), passwordConf()) ? "✓" : "✗"}</span>
</div>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded p-4 mt-6">
<h3 class="font-bold text-blue-800 mb-2"> Task 01 Complete</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li> User types created</li>
<li> Cookie utilities created</li>
<li> Validation helpers created</li>
<li> Input component created</li>
<li> Button component created</li>
<li> Conversion patterns documented</li>
<li> Build successful</li>
</ul>
</div>
</div>
</main>
);
}

File diff suppressed because it is too large Load Diff