Autogen SolidJS template with auth

This commit is contained in:
2026-02-04 21:37:21 +02:00
parent b2d2409357
commit 5b544f5818
27 changed files with 2063 additions and 0 deletions

17
ui/src/app.css Normal file
View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
#app {
user-select: none;
}
main {
@apply flex flex-col items-center justify-center min-h-screen bg-gray-50 gap-8 px-4;
}
h1 {
@apply uppercase text-6xl text-sky-700 font-thin;
}
button {
cursor: pointer;
}

33
ui/src/app.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { type RouteDefinition, Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { MetaProvider } from "@solidjs/meta";
import { Suspense } from "solid-js";
import { querySession } from "./auth";
import Auth from "./components/Context";
import Nav from "./components/Nav";
import ErrorNotification from "./components/Error";
import "./app.css";
export const route: RouteDefinition = {
preload: ({ location }) => querySession(location.pathname)
};
export default function App() {
return (
<Router
root={props => (
<MetaProvider>
<Auth>
<Suspense>
<Nav />
{props.children}
<ErrorNotification />
</Suspense>
</Auth>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
);
}

28
ui/src/auth/db.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createStorage } from "unstorage";
import fsLiteDriver from "unstorage/drivers/fs-lite";
interface User {
id: number;
email: string;
password?: string;
}
const storage = createStorage({ driver: fsLiteDriver({ base: "./.data" }) });
export async function createUser(data: Pick<User, "email" | "password">) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
const counter = (await storage.getItem<number>("users:counter")) ?? 1;
const user: User = { id: counter, ...data };
await Promise.all([
storage.setItem("users:data", [...users, user]),
storage.setItem("users:counter", counter + 1)
]);
return user;
}
export async function findUser({ email, id }: { email?: string; id?: number }) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
if (id) return users.find(u => u.id === id);
if (email) return users.find(u => u.email === email);
return undefined;
}

37
ui/src/auth/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { action, query, redirect } from "@solidjs/router";
import { getSession, passwordLogin } from "./server";
// Define routes that require being logged in
const PROTECTED_ROUTES = ["/"];
const isProtected = (path: string) =>
PROTECTED_ROUTES.some(route =>
route.endsWith("/*")
? path.startsWith(route.slice(0, -2))
: path === route || path.startsWith(route + "/")
);
export const querySession = query(async (path: string) => {
"use server";
const { data } = await getSession();
if (path === "/login" && data.id) return redirect("/");
if (data.id) return data;
if (isProtected(path)) throw redirect(`/login?redirect=${path}`);
return null;
}, "session");
export const formLogin = action(async (formData: FormData) => {
"use server";
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string")
return new Error("Email and password are required");
return await passwordLogin(email.trim().toLowerCase(), password);
});
export const logout = action(async () => {
"use server";
const session = await getSession();
await session.update({ id: undefined });
throw redirect("/login", { revalidate: "session" });
});

85
ui/src/auth/server.ts Normal file
View File

@@ -0,0 +1,85 @@
import { redirect } from "@solidjs/router";
import { useSession } from "vinxi/http";
import { getRandomValues, subtle, timingSafeEqual } from "crypto";
import { createUser, findUser } from "./db";
export interface Session {
id: number;
email: string;
}
export const getSession = () =>
useSession<Session>({
password: process.env.SESSION_SECRET!,
});
export async function createSession(user: Session, redirectTo?: string) {
const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/";
const session = await getSession();
await session.update(user);
return redirect(validDest ? redirectTo : "/");
}
async function createHash(password: string) {
const salt = getRandomValues(new Uint8Array(16));
const saltHex = Buffer.from(salt).toString("hex");
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key).toString("hex");
return `${saltHex}:${hash}`;
}
async function checkPassword(storedPassword: string, providedPassword: string) {
const [storedSalt, storedHash] = storedPassword.split(":");
if (!storedSalt || !storedHash)
throw new Error("Invalid stored password format");
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt: Buffer.from(storedSalt, "hex"),
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(providedPassword),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key);
const stored = Buffer.from(storedHash, "hex");
if (stored.length !== hash.length || !timingSafeEqual(stored, hash))
throw new Error("Invalid email or password");
}
export async function passwordLogin(email: string, password: string) {
let user = await findUser({ email });
if (!user)
user = await createUser({
email,
password: await createHash(password),
});
else if (!user.password)
throw new Error(
"Account exists via OAuth. Sign in with your OAuth provider",
);
else await checkPassword(user.password, password);
return createSession(user);
}

View File

@@ -0,0 +1,28 @@
import { createAsync, useLocation, type AccessorWithLatest } from "@solidjs/router";
import { createContext, useContext, type ParentProps } from "solid-js";
import { logout, querySession } from "../auth";
import type { Session } from "../auth/server";
const Context = createContext<{
session: AccessorWithLatest<Session | null | undefined>;
signedIn: () => boolean;
logout: typeof logout;
}>();
export default function Auth(props: ParentProps) {
const location = useLocation();
const session = createAsync(() => querySession(location.pathname), {
deferStream: true
});
const signedIn = () => Boolean(session()?.id);
return (
<Context.Provider value={{ session, signedIn, logout }}>{props.children}</Context.Provider>
);
}
export function useAuth() {
const context = useContext(Context);
if (!context) throw new Error("useAuth must be used within Auth context");
return context;
}

View File

@@ -0,0 +1,14 @@
import { createSignal } from "solid-js";
export default function Counter() {
const [count, setCount] = createSignal(0);
return (
<button
class="w-52 rounded-full bg-gray-100 border-2 border-gray-300 focus:border-gray-400 py-4"
onclick={() => setCount(prev => prev + 1)}
>
Clicks: {count()}
</button>
);
}

View File

@@ -0,0 +1,33 @@
import { useSearchParams } from "@solidjs/router";
import { createEffect, onCleanup, Show } from "solid-js";
import { X } from "./Icons";
export default function ErrorNotification() {
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
if (searchParams.error) {
const timer = setTimeout(() => setSearchParams({ error: "" }), 5000);
onCleanup(() => clearTimeout(timer));
}
});
return (
<Show when={typeof searchParams.error === "string" && searchParams.error} keyed>
{msg => (
<aside class="flex items-start gap-3 fixed bottom-4 left-4 max-w-sm bg-red-50 border border-red-200 rounded-xl p-4 shadow-lg z-50 transition-all duration-300 text-sm">
<div>
<strong class="font-medium text-red-800">Error</strong>
<p class="text-red-700 mt-1 select-text">{msg}</p>
</div>
<button
onclick={() => setSearchParams({ error: "" })}
class="text-red-400 hover:text-red-600 transition-colors"
>
<X class="w-4 h-4" />
</button>
</aside>
)}
</Show>
);
}

View File

@@ -0,0 +1,22 @@
type Icon = { class: string };
export const Discord = (props: Icon) => (
<svg viewBox="0 0 48 48" class={props.class}>
<path d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z" />
</svg>
);
export const X = (props: Icon) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={props.class}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);

50
ui/src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useMatch } from "@solidjs/router";
import { Show } from "solid-js";
import { useAuth } from "~/components/Context";
export default function Nav() {
const { signedIn, logout } = useAuth();
const isHome = useMatch(() => "/");
const isAbout = useMatch(() => "/about");
return (
<nav class="fixed top-0 left-0 w-full bg-sky-800 shadow-sm z-50 flex items-center justify-between py-3 px-4 font-medium text-sm">
<a
href="/"
class={`px-3 py-2 text-sky-100 uppercase transition-colors duration-200 border-b-2 ${
isHome() ? "border-sky-300 text-white" : "border-transparent hover:text-white"
}`}
>
Home
</a>
<a
href="/about"
class={`px-3 py-2 text-sky-100 uppercase transition-colors duration-200 border-b-2 ${
isAbout() ? "border-sky-300 text-white" : "border-transparent hover:text-white"
}`}
>
About
</a>
<Show
when={signedIn()}
fallback={
<a
href="/login"
class="ml-auto px-4 py-2 text-sky-100 bg-sky-700 border border-sky-600 rounded-md hover:bg-sky-600 hover:text-white focus:outline-none transition-colors duration-200"
>
Login
</a>
}
>
<form action={logout} method="post" class="ml-auto">
<button
type="submit"
class="px-4 py-2 text-sky-100 bg-sky-700 border border-sky-600 rounded-md hover:bg-sky-600 hover:text-white focus:outline-none transition-colors duration-200"
>
Sign Out
</button>
</form>
</Show>
</nav>
);
}

3
ui/src/entry-client.tsx Normal file
View File

@@ -0,0 +1,3 @@
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);

21
ui/src/entry-server.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="SolidStart with-auth example" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg" href="favicon.svg" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

1
ui/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />

View File

@@ -0,0 +1,17 @@
import { Title } from "@solidjs/meta";
export default function NotFound() {
return (
<main class="text-center">
<Title>Page Not Found</Title>
<h1>Not Found</h1>
Sorry, the page youre looking for doesn't exist
<a
href="/"
class="px-4 py-2 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-100 transition-colors duration-200"
>
Go Home
</a>
</main>
);
}

19
ui/src/routes/about.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Title } from "@solidjs/meta";
import Counter from "~/components/Counter";
export default function About() {
return (
<main>
<Title>About Page</Title>
<h1 class="text-center">About Page</h1>
<Counter />
<p class="text-gray-700 text-center">
Visit{" "}
<a href="https://start.solidjs.com" target="_blank" class="text-sky-600 hover:underline">
start.solidjs.com
</a>{" "}
to learn more on SolidStart
</p>
</main>
);
}

View File

@@ -0,0 +1,16 @@
import OAuth from "start-oauth";
import { createUser, findUser } from "~/auth/db";
import { createSession } from "~/auth/server";
export const GET = OAuth({
password: process.env.SESSION_SECRET!,
discord: {
id: process.env.DISCORD_ID!,
secret: process.env.DISCORD_SECRET!
},
async handler({ email }, redirectTo) {
let user = await findUser({ email });
if (!user) user = await createUser({ email });
return createSession(user, redirectTo);
}
});

15
ui/src/routes/index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Title } from "@solidjs/meta";
import { useAuth } from "~/components/Context";
export default function Home() {
const { session } = useAuth();
return (
<main>
<Title>Home</Title>
<h1 class="text-center">Hello World</h1>
<img src="/favicon.svg" alt="logo" class="w-28" />
You are signed in as <b class="font-medium">{session()?.email}</b>
</main>
);
}

77
ui/src/routes/login.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Title } from "@solidjs/meta";
import { useSubmission } from "@solidjs/router";
import { Show } from "solid-js";
import { useOAuthLogin } from "start-oauth";
import { formLogin } from "~/auth";
import { Discord } from "~/components/Icons";
export default function Login() {
const login = useOAuthLogin();
return (
<main>
<Title>Sign In</Title>
<h1>Sign in</h1>
<div class="space-y-6 font-medium">
<PasswordLogin />
<div class="flex items-center w-full text-xs">
<span class="flex-grow bg-gray-300 h-[1px]" />
<span class="flex-grow-0 mx-2 text-gray-500">Or continue with</span>
<span class="flex-grow bg-gray-300 h-[1px]" />
</div>
<a
href={login("discord")}
rel="external"
class="group w-full px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-[#5865F2] hover:border-gray-300 focus:outline-none transition-colors duration-300 flex items-center justify-center gap-2.5 text-gray-700 hover:text-white"
>
<Discord class="h-5 fill-[#5865F2] group-hover:fill-white duration-300" />
Sign in with Discord
</a>
</div>
</main>
);
}
function PasswordLogin() {
const submission = useSubmission(formLogin);
return (
<form action={formLogin} method="post" class="space-y-4 space-x-12">
<label for="email" class="block text-left w-full">
Email
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="john@doe.com"
required
class="bg-white mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label for="password" class="block text-left w-full">
Password
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
placeholder="••••••••"
minLength={6}
required
class="bg-white mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<button
type="submit"
disabled={submission.pending}
class="w-full px-4 py-2 bg-gradient-to-r from-sky-600 to-blue-600 text-white rounded-lg hover:from-sky-700 hover:to-blue-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300 shadow-lg shadow-sky-500/25"
>
Submit
</button>
<Show when={submission.error} keyed>
{({ message }) => <p class="text-red-600 mt-2 text-xs text-center">{message}</p>}
</Show>
</form>
);
}