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

2
.gitignore vendored
View File

@@ -19,9 +19,11 @@ app.config.timestamp_*.js
.classpath
*.launch
.settings/
tasks
# Temp
gitignore
#*_migration_source
# System Files
.DS_Store

BIN
public/BlackLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
public/Cave/0.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/Cave/1.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/Cave/2.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/Cave/3.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/Cave/4.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
public/Cave/5.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/Cave/6.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/Cave/7.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

BIN
public/LineageIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

BIN
public/Mirror.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

BIN
public/StormyMountain/0.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
public/StormyMountain/1.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/StormyMountain/10.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/StormyMountain/11.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/StormyMountain/2.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/StormyMountain/3.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/StormyMountain/4.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/StormyMountain/5.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/StormyMountain/6.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/StormyMountain/7.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/StormyMountain/8.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/StormyMountain/9.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
public/WhiteLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
public/bitcoin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

BIN
public/blueprint.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

BIN
public/blur_SH_water.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 B

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
public/just box.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

BIN
public/me_in_flannel.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/pic01.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
public/pic02.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
public/pic03.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,73 @@
import { Component, createEffect, onCleanup } from "solid-js";
interface CountdownCircleTimerProps {
duration: number;
initialRemainingTime: number;
size: number;
strokeWidth: number;
colors: string;
children: () => any;
}
const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
const radius = (props.size - props.strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
// Calculate progress (0 to 1)
const progress = () => props.initialRemainingTime / props.duration;
const strokeDashoffset = () => circumference * (1 - progress());
return (
<div
style={{
position: "relative",
width: `${props.size}px`,
height: `${props.size}px`,
}}
>
<svg
width={props.size}
height={props.size}
style={{ transform: "rotate(-90deg)" }}
>
{/* Background circle */}
<circle
cx={props.size / 2}
cy={props.size / 2}
r={radius}
fill="none"
stroke="#e5e7eb"
stroke-width={props.strokeWidth}
/>
{/* Progress circle */}
<circle
cx={props.size / 2}
cy={props.size / 2}
r={radius}
fill="none"
stroke={props.colors}
stroke-width={props.strokeWidth}
stroke-dasharray={circumference}
stroke-dashoffset={strokeDashoffset()}
stroke-linecap="round"
style={{
transition: "stroke-dashoffset 0.5s linear",
}}
/>
</svg>
{/* Timer text in center */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{props.children()}
</div>
</div>
);
};
export default CountdownCircleTimer;

View File

@@ -0,0 +1,36 @@
import { Component } from "solid-js";
interface EyeProps {
strokeWidth: number;
height: number;
width: number;
class?: string;
}
const Eye: Component<EyeProps> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={props.strokeWidth}
stroke="currentColor"
height={props.height}
width={props.width}
class={props.class}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
};
export default Eye;

View File

@@ -0,0 +1,31 @@
import { Component } from "solid-js";
interface EyeSlashProps {
strokeWidth: number;
height: number;
width: number;
class?: string;
}
const EyeSlash: Component<EyeSlashProps> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={props.strokeWidth}
stroke="currentColor"
height={props.height}
width={props.width}
class={props.class}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
);
};
export default EyeSlash;

View File

@@ -0,0 +1,24 @@
import { Component } from "solid-js";
interface GitHubProps {
height?: string | number;
width?: string | number;
fill?: string;
}
const GitHub: Component<GitHubProps> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
height={props.height}
width={props.width}
fill={props.fill || ""}
class={props.fill ? "" : "fill-black dark:fill-white"}
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
);
};
export default GitHub;

View File

@@ -0,0 +1,37 @@
import { Component } from "solid-js";
interface GoogleLogoProps {
height: number;
width: number;
}
const GoogleLogo: Component<GoogleLogoProps> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox="0 0 24 24"
width={props.width}
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
<path d="M1 1h22v22H1z" fill="none" />
</svg>
);
};
export default GoogleLogo;

View File

@@ -0,0 +1,90 @@
import { JSX, splitProps, Show } from "solid-js";
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
fullWidth?: boolean;
}
/**
* Reusable button component with variants and loading state
*/
export default function Button(props: ButtonProps) {
const [local, others] = splitProps(props, [
"variant",
"size",
"loading",
"fullWidth",
"class",
"children",
"disabled",
]);
const variant = () => local.variant || "primary";
const size = () => local.size || "md";
const baseClasses = "flex justify-center items-center rounded font-semibold transition-all duration-300 ease-out disabled:opacity-50 disabled:cursor-not-allowed";
const variantClasses = () => {
switch (variant()) {
case "primary":
return "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-lg shadow-blue-300 dark:shadow-blue-700";
case "secondary":
return "bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white";
case "danger":
return "bg-red-500 hover:bg-red-600 active:scale-90 text-white shadow-lg shadow-red-300 dark:shadow-red-700";
case "ghost":
return "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300";
default:
return "";
}
};
const sizeClasses = () => {
switch (size()) {
case "sm":
return "px-3 py-1.5 text-sm";
case "md":
return "px-4 py-2 text-base";
case "lg":
return "px-6 py-3 text-lg";
default:
return "";
}
};
const widthClass = () => (local.fullWidth ? "w-full" : "");
return (
<button
{...others}
disabled={local.disabled || local.loading}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
>
<Show when={local.loading} fallback={local.children}>
<svg
class="animate-spin h-5 w-5 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</Show>
</button>
);
}

View File

@@ -0,0 +1,45 @@
import { JSX, splitProps } from "solid-js";
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
/**
* Reusable input component with label and error handling
* Styled to match Next.js migration source (underlined input style)
*/
export default function Input(props: InputProps) {
const [local, others] = splitProps(props, ["label", "error", "helperText", "class"]);
return (
<div class="input-group">
<input
{...others}
placeholder=" "
class={`underlinedInput bg-transparent ${local.class || ""}`}
aria-invalid={!!local.error}
aria-describedby={local.error ? `${others.id}-error` : undefined}
/>
<span class="bar"></span>
{local.label && (
<label class="underlinedInputLabel">{local.label}</label>
)}
{local.error && (
<span
id={`${others.id}-error`}
class="text-xs text-red-500 mt-1 block"
role="alert"
>
{local.error}
</span>
)}
{local.helperText && !local.error && (
<span class="text-xs text-gray-500 mt-1 block">
{local.helperText}
</span>
)}
</div>
);
}

441
src/lib/SOLID-PATTERNS.md Normal file
View File

@@ -0,0 +1,441 @@
# React to SolidJS Conversion Patterns
This guide documents common patterns for converting React code to SolidJS for this migration.
## Table of Contents
- [State Management](#state-management)
- [Effects](#effects)
- [Refs](#refs)
- [Routing](#routing)
- [Conditional Rendering](#conditional-rendering)
- [Lists](#lists)
- [Forms](#forms)
- [Event Handlers](#event-handlers)
## State Management
### React (useState)
```tsx
import { useState } from "react";
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
// Update
setCount(count + 1);
setCount(prev => prev + 1);
setUser({ ...user, name: "John" });
```
### Solid (createSignal)
```tsx
import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);
const [user, setUser] = createSignal<User | null>(null);
// Update - note the function call to read value
setCount(count() + 1);
setCount(prev => prev + 1);
setUser({ ...user(), name: "John" });
// ⚠️ Important: Always call the signal to read its value
console.log(count()); // ✅ Correct
console.log(count); // ❌ Wrong - this is the function itself
```
## Effects
### React (useEffect)
```tsx
import { useEffect } from "react";
// Run once on mount
useEffect(() => {
console.log("Mounted");
return () => {
console.log("Cleanup");
};
}, []);
// Run when dependency changes
useEffect(() => {
console.log(count);
}, [count]);
```
### Solid (createEffect / onMount / onCleanup)
```tsx
import { createEffect, onMount, onCleanup } from "solid-js";
// Run once on mount
onMount(() => {
console.log("Mounted");
});
// Cleanup
onCleanup(() => {
console.log("Cleanup");
});
// Run when dependency changes (automatic tracking)
createEffect(() => {
console.log(count()); // Automatically tracks count signal
});
// ⚠️ Important: Effects automatically track any signal reads
// No dependency array needed!
```
## Refs
### React (useRef)
```tsx
import { useRef } from "react";
const inputRef = useRef<HTMLInputElement>(null);
// Access
inputRef.current?.focus();
// In JSX
<input ref={inputRef} />
```
### Solid (let binding or signal)
```tsx
// Method 1: Direct binding (preferred for simple cases)
let inputRef: HTMLInputElement | undefined;
// Access
inputRef?.focus();
// In JSX
<input ref={inputRef} />
// Method 2: Using a signal (for reactive refs)
import { createSignal } from "solid-js";
const [inputRef, setInputRef] = createSignal<HTMLInputElement>();
// Access
inputRef()?.focus();
// In JSX
<input ref={setInputRef} />
```
## Routing
### React (Next.js)
```tsx
import Link from "next/link";
import { useRouter } from "next/navigation";
// Link component
<Link href="/about">About</Link>
// Programmatic navigation
const router = useRouter();
router.push("/dashboard");
router.back();
router.refresh();
```
### Solid (SolidStart)
```tsx
import { A, useNavigate } from "@solidjs/router";
// Link component
<A href="/about">About</A>
// Programmatic navigation
const navigate = useNavigate();
navigate("/dashboard");
navigate(-1); // Go back
// Note: No refresh() - Solid is reactive by default
```
## Conditional Rendering
### React
```tsx
// Using && operator
{isLoggedIn && <Dashboard />}
// Using ternary
{isLoggedIn ? <Dashboard /> : <Login />}
// Using if statement
if (loading) return <Spinner />;
return <Content />;
```
### Solid
```tsx
import { Show } from "solid-js";
// Using Show component (recommended)
<Show when={isLoggedIn()}>
<Dashboard />
</Show>
// With fallback
<Show when={isLoggedIn()} fallback={<Login />}>
<Dashboard />
</Show>
// ⚠️ Important: Can still use && and ternary, but Show is more efficient
// because it doesn't recreate the DOM on every change
// Early return still works
if (loading()) return <Spinner />;
return <Content />;
```
## Lists
### React
```tsx
// Using map
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
// With index
{users.map((user, index) => (
<div key={index}>{user.name}</div>
))}
```
### Solid
```tsx
import { For, Index } from "solid-js";
// Using For (when items have stable keys)
<For each={users()}>
{(user) => <div>{user.name}</div>}
</For>
// With index
<For each={users()}>
{(user, index) => <div>{index()} - {user.name}</div>}
</For>
// Using Index (when items have no stable identity, keyed by index)
<Index each={users()}>
{(user, index) => <div>{index} - {user().name}</div>}
</Index>
// ⚠️ Key differences:
// - For: Better when items have stable identity (keyed by reference)
// - Index: Better when items change frequently (keyed by index)
// - Note the () on user in Index component
```
## Forms
### React
```tsx
import { useState } from "react";
const [email, setEmail] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(email);
};
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</form>
```
### Solid
```tsx
import { createSignal } from "solid-js";
const [email, setEmail] = createSignal("");
const handleSubmit = (e: Event) => {
e.preventDefault();
console.log(email());
};
<form onSubmit={handleSubmit}>
<input
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</form>
// ⚠️ Important differences:
// - Use onInput instead of onChange for real-time updates
// - onChange fires on blur in Solid
// - Use e.currentTarget instead of e.target for type safety
```
## Event Handlers
### React
```tsx
// Inline
<button onClick={() => setCount(count + 1)}>
// Function reference
<button onClick={handleClick}>
// With parameters
<button onClick={(e) => handleClick(e, id)}>
```
### Solid
```tsx
// Inline - same as React
<button onClick={() => setCount(count() + 1)}>
// Function reference - same as React
<button onClick={handleClick}>
// With parameters - same as React
<button onClick={(e) => handleClick(e, id)}>
// Alternative syntax with array (for optimization)
<button onClick={[handleClick, id]}>
// ⚠️ Important: Remember to call signals with ()
<button onClick={() => setCount(count() + 1)}> // ✅
<button onClick={() => setCount(count + 1)}> // ❌
```
## Server Actions to tRPC
### React (Next.js Server Actions)
```tsx
"use server";
export async function updateProfile(displayName: string) {
const userId = cookies().get("userIDToken");
await db.execute("UPDATE User SET display_name = ? WHERE id = ?",
[displayName, userId]);
return { success: true };
}
// Client usage
import { updateProfile } from "./actions";
const result = await updateProfile("John");
```
### Solid (tRPC)
```tsx
// Server (in src/server/api/routers/user.ts)
updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event);
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET display_name = ? WHERE id = ?",
args: [input.displayName, userId]
});
return { success: true };
})
// Client usage
import { api } from "~/lib/api";
const result = await api.user.updateDisplayName.mutate({
displayName: "John"
});
```
## Common Gotchas
### 1. Reading Signal Values
```tsx
// ❌ WRONG
const value = mySignal; // This is the function, not the value!
// ✅ CORRECT
const value = mySignal(); // Call it to get the value
```
### 2. Updating Objects in Signals
```tsx
// ❌ WRONG - Mutating directly
const [user, setUser] = createSignal({ name: "John" });
user().name = "Jane"; // This won't trigger reactivity!
// ✅ CORRECT - Create new object
setUser({ ...user(), name: "Jane" });
// ✅ ALSO CORRECT - Using produce (from solid-js/store)
import { produce } from "solid-js/store";
setUser(produce(u => { u.name = "Jane"; }));
```
### 3. Conditional Effects
```tsx
// ❌ WRONG - Effect won't re-run when condition changes
if (someCondition()) {
createEffect(() => {
// This only creates the effect if condition is true initially
});
}
// ✅ CORRECT - Effect tracks condition reactively
createEffect(() => {
if (someCondition()) {
// This runs whenever condition or dependencies change
}
});
```
### 4. Cleanup in Effects
```tsx
// ❌ WRONG
createEffect(() => {
const timer = setInterval(() => {}, 1000);
// No cleanup!
});
// ✅ CORRECT
createEffect(() => {
const timer = setInterval(() => {}, 1000);
onCleanup(() => {
clearInterval(timer);
});
});
```
## Quick Reference Card
| React | Solid | Notes |
|-------|-------|-------|
| `useState` | `createSignal` | Call signal to read: `count()` |
| `useEffect` | `createEffect` | Auto-tracks dependencies |
| `useRef` | `let` binding | Or use signal for reactive refs |
| `useRouter()` | `useNavigate()` | Different API |
| `Link` | `A` | Different import |
| `{cond && <A />}` | `<Show when={cond()}><A /></Show>` | Show is more efficient |
| `{arr.map()}` | `<For each={arr()}></For>` | For is more efficient |
| `onChange` | `onInput` | onChange fires on blur |
| `e.target` | `e.currentTarget` | Better types |
| "use server" | tRPC router | Different architecture |
## Tips for Success
1. **Always call signals to read their values**: `count()` not `count`
2. **Use Show and For components**: More efficient than && and map
3. **Effects auto-track**: No dependency arrays needed
4. **Immutable updates**: Always create new objects when updating signals
5. **Use onCleanup**: Clean up timers, subscriptions, etc.
6. **Type safety**: SolidJS has excellent TypeScript support - use it!

72
src/lib/cookies.client.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Client-side cookie utilities
* For use in browser/client components only
*/
/**
* Get cookie value on the client (browser)
*/
export function getClientCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift();
}
return undefined;
}
/**
* Set cookie on the client (browser)
*/
export function setClientCookie(
name: string,
value: string,
options?: {
maxAge?: number;
expires?: Date;
path?: string;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
}
) {
if (typeof document === "undefined") return;
let cookieString = `${name}=${value}`;
if (options?.maxAge) {
cookieString += `; max-age=${options.maxAge}`;
}
if (options?.expires) {
cookieString += `; expires=${options.expires.toUTCString()}`;
}
if (options?.path) {
cookieString += `; path=${options.path}`;
} else {
cookieString += "; path=/";
}
if (options?.secure) {
cookieString += "; secure";
}
if (options?.sameSite) {
cookieString += `; samesite=${options.sameSite}`;
}
document.cookie = cookieString;
}
/**
* Delete cookie on the client (browser)
*/
export function deleteClientCookie(name: string) {
if (typeof document === "undefined") return;
document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}

111
src/lib/cookies.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Cookie utilities for SolidStart
* Provides client and server-side cookie management
*/
import { getCookie as getServerCookie, setCookie as setServerCookie } from "vinxi/http";
import type { H3Event } from "vinxi/http";
/**
* Get cookie value on the server
*/
export function getCookie(event: H3Event, name: string): string | undefined {
return getServerCookie(event, name);
}
/**
* Set cookie on the server
*/
export function setCookie(
event: H3Event,
name: string,
value: string,
options?: {
maxAge?: number;
expires?: Date;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
path?: string;
}
) {
setServerCookie(event, name, value, options);
}
/**
* Delete cookie on the server
*/
export function deleteCookie(event: H3Event, name: string) {
setServerCookie(event, name, "", {
maxAge: 0,
expires: new Date("2016-10-05"),
});
}
/**
* Get cookie value on the client (browser)
*/
export function getClientCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift();
}
return undefined;
}
/**
* Set cookie on the client (browser)
*/
export function setClientCookie(
name: string,
value: string,
options?: {
maxAge?: number;
expires?: Date;
path?: string;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
}
) {
if (typeof document === "undefined") return;
let cookieString = `${name}=${value}`;
if (options?.maxAge) {
cookieString += `; max-age=${options.maxAge}`;
}
if (options?.expires) {
cookieString += `; expires=${options.expires.toUTCString()}`;
}
if (options?.path) {
cookieString += `; path=${options.path}`;
} else {
cookieString += "; path=/";
}
if (options?.secure) {
cookieString += "; secure";
}
if (options?.sameSite) {
cookieString += `; samesite=${options.sameSite}`;
}
document.cookie = cookieString;
}
/**
* Delete cookie on the client (browser)
*/
export function deleteClientCookie(name: string) {
if (typeof document === "undefined") return;
document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}

95
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Form validation utilities
*/
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate password strength
*/
export function validatePassword(password: string): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Password must be at least 8 characters long");
}
// Optional: Add more password requirements
// if (!/[A-Z]/.test(password)) {
// errors.push("Password must contain at least one uppercase letter");
// }
// if (!/[a-z]/.test(password)) {
// errors.push("Password must contain at least one lowercase letter");
// }
// if (!/[0-9]/.test(password)) {
// errors.push("Password must contain at least one number");
// }
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Check if two passwords match
*/
export function passwordsMatch(password: string, confirmation: string): boolean {
return password === confirmation && password.length > 0;
}
/**
* Validate display name
*/
export function isValidDisplayName(name: string): boolean {
return name.trim().length >= 1 && name.trim().length <= 50;
}
/**
* Sanitize user input (basic XSS prevention)
*/
export function sanitizeInput(input: string): string {
return input
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
/**
* Check if string is a valid URL
*/
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validate file type for uploads
*/
export function isValidImageType(file: File): boolean {
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
return validTypes.includes(file.type);
}
/**
* Validate file size (in bytes)
*/
export function isValidFileSize(file: File, maxSizeMB: number = 5): boolean {
const maxBytes = maxSizeMB * 1024 * 1024;
return file.size <= maxBytes;
}

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

View File

@@ -3,6 +3,7 @@ import { authRouter } from "./routers/auth";
import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage";
import { miscRouter } from "./routers/misc";
import { userRouter } from "./routers/user";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
@@ -10,7 +11,8 @@ export const appRouter = createTRPCRouter({
auth: authRouter,
database: databaseRouter,
lineage: lineageRouter,
misc: miscRouter
misc: miscRouter,
user: userRouter
});
export type AppRouter = typeof appRouter;

View File

@@ -3,28 +3,51 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { v4 as uuidV4 } from "uuid";
import { env } from "~/env/server";
import { ConnectionFactory } from "~/server/utils";
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { SignJWT, jwtVerify } from "jose";
import { setCookie } from "vinxi/http";
import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user";
// Helper to create JWT token
async function createJWT(userId: string): Promise<string> {
async function createJWT(userId: string, expiresIn: string = "14d"): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("14d") // 14 days
.setExpirationTime(expiresIn)
.sign(secret);
return token;
}
// User type for database rows
interface User {
id: string;
email?: string;
display_name?: string;
provider?: string;
image?: string;
email_verified?: boolean;
// Helper to send email via Brevo/SendInBlue
async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "no_reply@freno.me",
},
to: [{ email: to }],
htmlContent,
subject,
};
const response = await fetch(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json",
},
body: JSON.stringify(sendinblueData),
});
if (!response.ok) {
throw new Error("Failed to send email");
}
return response;
}
export const authRouter = createTRPCRouter({
@@ -323,4 +346,471 @@ export const authRouter = createTRPCRouter({
});
}
}),
// Email/password registration
emailRegistration: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch",
});
}
const passwordHash = await hashPassword(password);
const conn = ConnectionFactory();
const userId = uuidV4();
try {
await conn.execute({
sql: "INSERT INTO User (id, email, password_hash, provider) VALUES (?, ?, ?, ?)",
args: [userId, email, passwordHash, "email"],
});
// Create JWT token
const token = await createJWT(userId);
// Set cookie
setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
});
return { success: true, message: "success" };
} catch (e) {
console.error("Registration error:", e);
throw new TRPCError({
code: "BAD_REQUEST",
message: "duplicate",
});
}
}),
// Email/password login
emailPasswordLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string(),
rememberMe: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ? AND provider = ?",
args: [email, "email"],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
});
}
// Create JWT token with appropriate expiry
const expiresIn = rememberMe ? "14d" : "12h";
const token = await createJWT(user.id, expiresIn);
// Set cookie
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
};
if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days
}
setCookie(ctx.event.nativeEvent, "userIDToken", token, cookieOptions);
return { success: true, message: "success" };
}),
// Request email login link
requestEmailLinkLogin: publicProcedure
.input(
z.object({
email: z.string().email(),
rememberMe: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { email, rememberMe } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailLoginLinkRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Create JWT token for email link (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email, rememberMe: rememberMe ?? false })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to log in</p>
</div>
<br/>
<div class="center">
<a href="${domain}/api/auth/email-login-callback?email=${email}&token=${token}&rememberMe=${rememberMe}" class="button">Log In</a>
</div>
<div class="center">
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
</div>
</body>
</html>`;
await sendEmail(email, "freno.me login link", htmlContent);
// Set rate limit cookie (2 minutes)
const exp = new Date(Date.now() + 2 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "emailLoginLinkRequested", exp.toUTCString(), {
maxAge: 2 * 60,
path: "/",
});
return { success: true, message: "email sent" };
}),
// Request password reset
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "passwordResetRequested");
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
// Don't reveal if user exists
return { success: true, message: "email sent" };
}
const user = res.rows[0] as unknown as User;
// Create JWT token with user ID (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: user.id })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to reset password</p>
</div>
<br/>
<div class="center">
<a href="${domain}/login/password-reset?token=${token}" class="button">Reset Password</a>
</div>
<div class="center">
<p>You can ignore this if you did not request this email, someone may have requested it in error</p>
</div>
</body>
</html>`;
await sendEmail(email, "password reset", htmlContent);
// Set rate limit cookie (5 minutes)
const exp = new Date(Date.now() + 5 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "passwordResetRequested", exp.toUTCString(), {
maxAge: 5 * 60,
path: "/",
});
return { success: true, message: "email sent" };
}),
// Reset password with token
resetPassword: publicProcedure
.input(
z.object({
token: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const { token, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
try {
// Verify JWT token
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret);
if (!payload.id || typeof payload.id !== "string") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "bad token",
});
}
const conn = ConnectionFactory();
const passwordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, payload.id],
});
// Clear any session cookies
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
console.error("Password reset error:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "token expired",
});
}
}),
// Resend email verification
resendEmailVerification: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailVerificationRequested");
if (requested) {
const time = parseInt(requested);
const currentTime = Date.now();
const difference = (currentTime - time) / (1000 * 60);
if (difference < 15) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Please wait before requesting another verification email",
});
}
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Create JWT token (15min expiry)
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const htmlContent = `<html>
<head>
<style>
.center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.button {
display: inline-block;
padding: 10px 20px;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #007BFF;
border-radius: 6px;
transition: background-color 0.3s;
}
.button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="center">
<p>Click the button below to verify email</p>
</div>
<br/>
<div class="center">
<a href="${domain}/api/auth/email-verification-callback?email=${email}&token=${token}" class="button">Verify Email</a>
</div>
</body>
</html>`;
await sendEmail(email, "freno.me email verification", htmlContent);
// Set rate limit cookie
setCookie(ctx.event.nativeEvent, "emailVerificationRequested", Date.now().toString(), {
maxAge: 15 * 60,
path: "/",
});
return { success: true, message: "Verification email sent" };
}),
// Sign out
signOut: publicProcedure.mutation(async ({ ctx }) => {
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
return { success: true };
}),
});

View File

@@ -0,0 +1,357 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { env } from "~/env/server";
import { ConnectionFactory, getUserID, hashPassword, checkPassword } from "~/server/utils";
import { setCookie } from "vinxi/http";
import type { User } from "~/types/user";
import { toUserProfile } from "~/types/user";
export const userRouter = createTRPCRouter({
// Get current user profile
getProfile: publicProcedure.query(async ({ ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Update email
updateEmail: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { email } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET email = ?, email_verified = ? WHERE id = ?",
args: [email, 0, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
// Set email cookie for verification flow
setCookie(ctx.event.nativeEvent, "emailToken", email, {
path: "/",
});
return toUserProfile(user);
}),
// Update display name
updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string().min(1).max(50) }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { displayName } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET display_name = ? WHERE id = ?",
args: [displayName, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Update profile image
updateProfileImage: publicProcedure
.input(z.object({ imageUrl: z.string().url() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { imageUrl } = input;
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET image = ? WHERE id = ?",
args: [imageUrl, userId],
});
// Fetch updated user
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
const user = res.rows[0] as unknown as User;
return toUserProfile(user);
}),
// Change password (requires old password)
changePassword: publicProcedure
.input(
z.object({
oldPassword: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { oldPassword, newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No password set",
});
}
const passwordMatch = await checkPassword(oldPassword, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Password did not match record",
});
}
// Update password
const newPasswordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [newPasswordHash, userId],
});
// Clear session cookies (force re-login)
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
}),
// Set password (for OAuth users who don't have password)
setPassword: publicProcedure
.input(
z.object({
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
)
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { newPassword, newPasswordConfirmation } = input;
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
});
}
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password exists",
});
}
// Set password
const passwordHash = await hashPassword(newPassword);
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, userId],
});
// Clear session cookies (force re-login)
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "success" };
}),
// Delete account (anonymize data)
deleteAccount: publicProcedure
.input(z.object({ password: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event.nativeEvent);
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authenticated",
});
}
const { password } = input;
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE id = ?",
args: [userId],
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const user = res.rows[0] as unknown as User;
if (!user.password_hash) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password required",
});
}
const passwordMatch = await checkPassword(password, user.password_hash);
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Password Did Not Match",
});
}
// Anonymize user data (don't hard delete)
await conn.execute({
sql: `UPDATE User SET
email = ?,
email_verified = ?,
password_hash = ?,
display_name = ?,
provider = ?,
image = ?
WHERE id = ?`,
args: [null, 0, null, "user deleted", null, null, userId],
});
// Clear session cookies
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
});
return { success: true, message: "deleted" };
}),
});

70
src/types/user.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* User type definitions matching database schema
*/
export interface User {
id: string;
email: string | null;
email_verified: number; // SQLite boolean (0 or 1)
password_hash: string | null;
display_name: string | null;
provider: "email" | "google" | "apple" | null;
image: string | null;
apple_user_string: string | null;
database_name: string | null;
database_token: string | null;
database_url: string | null;
db_destroy_date: string | null;
created_at: string;
updated_at: string;
}
/**
* Client-safe user data (excludes sensitive fields)
*/
export interface UserProfile {
id: string;
email?: string;
emailVerified: boolean;
displayName?: string;
provider?: "email" | "google" | "apple";
image?: string;
hasPassword: boolean;
}
/**
* Convert database User to client-safe UserProfile
*/
export function toUserProfile(user: User): UserProfile {
return {
id: user.id,
email: user.email ?? undefined,
emailVerified: user.email_verified === 1,
displayName: user.display_name ?? undefined,
provider: user.provider ?? undefined,
image: user.image ?? undefined,
hasPassword: !!user.password_hash,
};
}
/**
* JWT payload for session tokens
*/
export interface SessionPayload {
id: string; // user ID
email?: string;
}
/**
* JWT payload for email verification
*/
export interface EmailVerificationPayload {
email: string;
}
/**
* JWT payload for password reset
*/
export interface PasswordResetPayload {
email: string;
}