migrating
2
.gitignore
vendored
@@ -19,9 +19,11 @@ app.config.timestamp_*.js
|
|||||||
.classpath
|
.classpath
|
||||||
*.launch
|
*.launch
|
||||||
.settings/
|
.settings/
|
||||||
|
tasks
|
||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
gitignore
|
gitignore
|
||||||
|
#*_migration_source
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
BIN
public/BlackLogo.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/Cave/0.png
Executable file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/Cave/1.png
Executable file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/Cave/2.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/Cave/3.png
Executable file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/Cave/4.png
Executable file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/Cave/5.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/Cave/6.png
Executable file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/Cave/7.png
Executable file
|
After Width: | Height: | Size: 860 B |
BIN
public/LineageIcon.png
Normal file
|
After Width: | Height: | Size: 644 KiB |
BIN
public/Mirror.png
Normal file
|
After Width: | Height: | Size: 8.9 MiB |
BIN
public/StormyMountain/0.png
Executable file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/StormyMountain/1.png
Executable file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/StormyMountain/10.png
Executable file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/StormyMountain/11.png
Executable file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/StormyMountain/2.png
Executable file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/StormyMountain/3.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/StormyMountain/4.png
Executable file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/StormyMountain/5.png
Executable file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/StormyMountain/6.png
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/StormyMountain/7.png
Executable file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/StormyMountain/8.png
Executable file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/StormyMountain/9.png
Executable file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/StormyMountain/Stormy_Mountains_Rain.gif
Executable file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/WhiteLogo.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
public/bitcoin.jpg
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/blueprint.jpg
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
public/blur_SH_water.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/blur_SH_water_uncompressed.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 176 KiB |
BIN
public/google-play-badge.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/just box.png
Normal file
|
After Width: | Height: | Size: 9.7 MiB |
BIN
public/manhattan-night-skyline.jpg
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
public/me_in_flannel.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/pic01.jpg
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public/pic02.jpg
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/pic03.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/shapes-app-store-qr.jpg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/textures/grass/Grass_001_COLOR.JPG
Executable file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/textures/grass/Grass_001_DISP.JPG
Executable file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/textures/grass/Grass_001_NRM.JPG
Executable file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/textures/grass/Grass_001_OCC.JPG
Executable file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/textures/rocky_terrain/rocky_terrain_02_ao_1k.jpg
Executable file
|
After Width: | Height: | Size: 432 KiB |
BIN
public/textures/rocky_terrain/rocky_terrain_02_arm_1k.jpg
Executable file
|
After Width: | Height: | Size: 513 KiB |
BIN
public/textures/rocky_terrain/rocky_terrain_02_diff_1k.jpg
Executable file
|
After Width: | Height: | Size: 823 KiB |
BIN
public/textures/rocky_terrain/rocky_terrain_02_disp_1k.png
Executable file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/textures/rocky_terrain/rocky_terrain_02_nor_gl_1k.jpg
Executable file
|
After Width: | Height: | Size: 970 KiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_ao_2k.jpg
Executable file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_arm_2k.jpg
Executable file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_diff_2k.jpg
Executable file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_diff_2k.png
Executable file
|
After Width: | Height: | Size: 24 MiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_disp_2k.jpg
Executable file
|
After Width: | Height: | Size: 824 KiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_disp_2k.png
Executable file
|
After Width: | Height: | Size: 5.9 MiB |
BIN
public/textures/rocky_terrain_2/rocky_terrain_nor_dx_2k.exr
Executable file
BIN
public/textures/rocky_terrain_2/rocky_terrain_nor_gl_2k.exr
Executable file
BIN
public/textures/rocky_terrain_2/rocky_terrain_rough_2k.exr
Executable file
73
src/components/CountdownCircleTimer.tsx
Normal 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;
|
||||||
36
src/components/icons/Eye.tsx
Normal 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;
|
||||||
31
src/components/icons/EyeSlash.tsx
Normal 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;
|
||||||
24
src/components/icons/GitHub.tsx
Normal 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;
|
||||||
37
src/components/icons/GoogleLogo.tsx
Normal 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;
|
||||||
90
src/components/ui/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/ui/Input.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\//g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
54
src/routes/api/auth/callback/github.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/routes/api/auth/callback/google.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/routes/api/auth/email-login-callback.ts
Normal 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"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/routes/api/auth/email-verification-callback.ts
Normal 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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1235
src/routes/test.tsx
@@ -3,6 +3,7 @@ import { authRouter } from "./routers/auth";
|
|||||||
import { databaseRouter } from "./routers/database";
|
import { databaseRouter } from "./routers/database";
|
||||||
import { lineageRouter } from "./routers/lineage";
|
import { lineageRouter } from "./routers/lineage";
|
||||||
import { miscRouter } from "./routers/misc";
|
import { miscRouter } from "./routers/misc";
|
||||||
|
import { userRouter } from "./routers/user";
|
||||||
import { createTRPCRouter } from "./utils";
|
import { createTRPCRouter } from "./utils";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -10,7 +11,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
database: databaseRouter,
|
database: databaseRouter,
|
||||||
lineage: lineageRouter,
|
lineage: lineageRouter,
|
||||||
misc: miscRouter
|
misc: miscRouter,
|
||||||
|
user: userRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -3,28 +3,51 @@ import { z } from "zod";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { v4 as uuidV4 } from "uuid";
|
import { v4 as uuidV4 } from "uuid";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { ConnectionFactory } from "~/server/utils";
|
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
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
|
// 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 secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const token = await new SignJWT({ id: userId })
|
const token = await new SignJWT({ id: userId })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setExpirationTime("14d") // 14 days
|
.setExpirationTime(expiresIn)
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User type for database rows
|
// Helper to send email via Brevo/SendInBlue
|
||||||
interface User {
|
async function sendEmail(to: string, subject: string, htmlContent: string) {
|
||||||
id: string;
|
const apiKey = env.SENDINBLUE_KEY;
|
||||||
email?: string;
|
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||||
display_name?: string;
|
|
||||||
provider?: string;
|
const sendinblueData = {
|
||||||
image?: string;
|
sender: {
|
||||||
email_verified?: boolean;
|
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({
|
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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
357
src/server/api/routers/user.ts
Normal 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
@@ -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;
|
||||||
|
}
|
||||||