Autogen SolidJS template with auth
This commit is contained in:
1
ui/.data/users/counter
Normal file
1
ui/.data/users/counter
Normal file
@@ -0,0 +1 @@
|
||||
2
|
||||
1
ui/.data/users/data
Normal file
1
ui/.data/users/data
Normal file
@@ -0,0 +1 @@
|
||||
[{"id":1,"email":"john@doe.com","password":"c88c563ad2e948afe20cee0e5c317de6:2b919c93359eec851eba1ea617843202f8cf967dc9a5a1daa19d409f80673eaf43202f5dc6186be2a8aedd14f05a79fb604590c9a8346dc20e17968164d07b80"}]
|
||||
28
ui/.gitignore
vendored
Normal file
28
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
dist
|
||||
.wrangler
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.vinxi
|
||||
app.config.timestamp_*.js
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# Temp
|
||||
gitignore
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
20
ui/README.md
Normal file
20
ui/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Klapi Käyttöliittymä
|
||||
|
||||
Freimis: SolidJS
|
||||
|
||||
## Käynnistys
|
||||
|
||||
Luo `.env` projektin juureen ja lisää siihen
|
||||
|
||||
```
|
||||
SESSION_SECRET = myverylongsessionsecretkeythatishouldchange
|
||||
```
|
||||
|
||||
`bun install`
|
||||
`bun dev`
|
||||
|
||||
## Sisäänkirjautuminen
|
||||
|
||||
Käyttäjä: john@doe.com
|
||||
|
||||
Salasana: <ym. SESSION_SECRET>
|
||||
8
ui/app.config.ts
Normal file
8
ui/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@solidjs/start/config";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
ssr: true, // false for client-side rendering only
|
||||
server: { preset: "" }, // your deployment
|
||||
vite: { plugins: [tailwindcss()] }
|
||||
});
|
||||
1352
ui/bun.lock
Normal file
1352
ui/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
26
ui/package.json
Normal file
26
ui/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "example-with-auth",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@solidjs/start": "^1.1.7",
|
||||
"@types/node": "^25.2.0",
|
||||
"solid-js": "^1.9.9",
|
||||
"start-oauth": "^1.3.0",
|
||||
"unstorage": "1.17.1",
|
||||
"vinxi": "^0.5.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"tailwindcss": "^4.1.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
92
ui/public/favicon.svg
Normal file
92
ui/public/favicon.svg
Normal file
@@ -0,0 +1,92 @@
|
||||
<svg viewBox="0 0 463 383" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="347.737" y1="-11.816" x2="248.4" y2="478.227" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1593F5"/>
|
||||
<stop offset="1" stop-color="#0084CE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x1="338.003" y1="518.992" x2="-78.452" y2="-148.902" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1593F5"/>
|
||||
<stop offset="1" stop-color="#0084CE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="c" x1="611.99" y1="530.235" x2="361.19" y2="80.271" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#15ABFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="d" x1="167.455" y1="-262.399" x2="380.368" y2="225.387" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#79CFFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="e" x1="406.927" y1="-76.821" x2="68.518" y2="392.18" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0057E5"/>
|
||||
<stop offset="1" stop-color="#0084CE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f" x1="330.245" y1="-94.545" x2="223.798" y2="199.049" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#15ABFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="g" x1="463.1" y1="491.08" x2="-71.75" y2="-214.82" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#79CFFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="h" x1="369.836" y1="-165.821" x2="95.393" y2="376.47" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#79CFFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<mask id="m1" maskUnits="userSpaceOnUse" x="214" y="334" width="117" height="49" style="mask-type:luminance">
|
||||
<path d="M214.285 382.848L289.742 376.963C289.742 376.963 315.548 372.759 330.696 347.817L277.399 334.926L214.285 382.848Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m1)">
|
||||
<path d="M339.952 336.888L326.208 405.548L204.748 380.886L218.773 312.226L339.952 336.888Z" fill="url(#a)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m2" maskUnits="userSpaceOnUse" x="45" y="206" width="286" height="147" style="mask-type:luminance">
|
||||
<path d="M109.937 206.012C87.496 207.133 45.981 211.057 45.981 211.057L226.908 342.493L268.143 352.862L330.696 348.098L148.647 216.381C148.647 216.381 134.06 206.012 113.022 206.012C111.9 206.012 111.059 206.012 109.937 206.012Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m2)">
|
||||
<path d="M125.645 480.654L-20.219 247.209L250.751 78.22L396.895 311.665L125.645 480.654Z" fill="url(#b)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m3" maskUnits="userSpaceOnUse" x="343" y="132" width="120" height="48" style="mask-type:luminance">
|
||||
<path d="M343.319 179.949L421.019 175.185C421.019 175.185 447.667 171.262 462.815 146.6L407.555 132.868L343.319 179.949Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m3)">
|
||||
<path d="M371.65 230.674L323.122 143.798L434.483 81.863L483.011 168.74L371.65 230.674Z" fill="url(#c)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m4" maskUnits="userSpaceOnUse" x="167" y="0" width="297" height="151" style="mask-type:luminance">
|
||||
<path d="M233.36 0.591C210.078 1.432 167.441 4.795 167.441 4.795L355.941 139.314L398.578 150.243L463.095 146.32L273.472 11.521C273.472 11.521 257.764 0.591 235.604 0.591C235.043 0.591 234.201 0.591 233.36 0.591Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m4)">
|
||||
<path d="M415.689 -107.584L518.074 126.422L214.846 258.699L112.461 24.693L415.689 -107.584Z" fill="url(#d)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m5" maskUnits="userSpaceOnUse" x="0" y="210" width="267" height="173" style="mask-type:luminance">
|
||||
<path d="M1.1 240.202C0.819 240.483 0.819 240.763 0.819 241.043L50.469 276.915L99.558 312.506L182.027 372.199C209.797 392.096 247.385 383.689 266.46 352.862L216.249 316.71L166.038 280.558L84.41 221.146C74.312 213.859 63.092 210.496 51.872 210.496C32.236 210.496 12.881 220.865 1.1 240.202Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m5)">
|
||||
<path d="M352.856 272.711L175.295 518.207L-85.577 329.881L91.704 84.385L352.856 272.711Z" fill="url(#e)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m6" maskUnits="userSpaceOnUse" x="122" y="4" width="277" height="177" style="mask-type:luminance">
|
||||
<path d="M123.401 33.661C123.401 33.941 123.12 34.221 122.84 34.501L174.453 71.214L226.067 107.646L311.902 168.74C340.794 189.198 379.504 181.07 398.578 150.523L346.404 113.251L294.23 76.258L208.956 15.444C198.296 7.878 186.235 4.235 174.453 4.235C154.537 4.515 135.182 14.604 123.401 33.661Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m6)">
|
||||
<path d="M457.765 25.814L366.601 277.475L63.653 167.619L155.098 -84.043L457.765 25.814Z" fill="url(#f)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m7" maskUnits="userSpaceOnUse" x="86" y="71" width="283" height="231" style="mask-type:luminance">
|
||||
<path d="M86.935 75.137L153.415 148.001C156.501 152.205 159.867 156.128 163.794 159.491L293.388 301.857L357.905 297.933C376.979 267.386 369.125 225.91 340.233 205.452L254.398 144.358L203.065 107.926L151.452 71.214L86.935 75.137Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m7)">
|
||||
<path d="M192.967 441.7L-24.426 155.568L271.228 -68.349L488.341 217.783L192.967 441.7Z" fill="url(#g)"/>
|
||||
</g>
|
||||
|
||||
<mask id="m8" maskUnits="userSpaceOnUse" x="76" y="75" width="228" height="227" style="mask-type:luminance">
|
||||
<path d="M86.374 75.978C67.58 106.244 75.434 147.16 103.765 167.338L188.759 227.872L241.214 264.864L293.388 301.857C312.463 271.31 304.608 229.833 275.716 209.375L190.162 148.562L138.548 111.849L86.935 75.137C86.935 75.417 86.654 75.698 86.374 75.978Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#m8)">
|
||||
<path d="M404.189 121.658L262.532 400.784L-23.865 255.896L117.791 -23.51L404.189 121.658Z" fill="url(#h)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
17
ui/src/app.css
Normal file
17
ui/src/app.css
Normal 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
33
ui/src/app.tsx
Normal 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
28
ui/src/auth/db.ts
Normal 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
37
ui/src/auth/index.ts
Normal 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
85
ui/src/auth/server.ts
Normal 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);
|
||||
}
|
||||
28
ui/src/components/Context.tsx
Normal file
28
ui/src/components/Context.tsx
Normal 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;
|
||||
}
|
||||
14
ui/src/components/Counter.tsx
Normal file
14
ui/src/components/Counter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
ui/src/components/Error.tsx
Normal file
33
ui/src/components/Error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
ui/src/components/Icons.tsx
Normal file
22
ui/src/components/Icons.tsx
Normal 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
50
ui/src/components/Nav.tsx
Normal 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
3
ui/src/entry-client.tsx
Normal 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
21
ui/src/entry-server.tsx
Normal 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
1
ui/src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
17
ui/src/routes/[...404].tsx
Normal file
17
ui/src/routes/[...404].tsx
Normal 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 you’re 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
19
ui/src/routes/about.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
ui/src/routes/api/oauth/[...oauth].ts
Normal file
16
ui/src/routes/api/oauth/[...oauth].ts
Normal 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
15
ui/src/routes/index.tsx
Normal 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
77
ui/src/routes/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
ui/tsconfig.json
Normal file
19
ui/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vinxi/types/client"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user