feat: add Clerk Auth

This commit is contained in:
piopi 2025-01-01 12:31:18 -05:00
parent 3f9d4f94ff
commit a80db3d80a
No known key found for this signature in database
GPG key ID: E305BD1ADD33F590
19 changed files with 146 additions and 225 deletions

2
.env
View file

@ -1,2 +1,4 @@
VITE_APP_VERSION=1.0.0-alpha
DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia"
PUBLIC_CLERK_PUBLISHABLE_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead
CLERK_SECRET_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ node_modules
# OS
.DS_Store
Thumbs.db
.idea
# Vite
vite.config.js.timestamp-*

BIN
bun.lockb

Binary file not shown.

View file

@ -3,7 +3,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun database:up && bun prisma:push && vite dev",
"dev": "bun database:up && bun prisma:generate && bun prisma:push && vite dev",
"build": "vite build",
"build-storybook": "storybook build",
"database:up": "docker compose -p hestia -f devops/docker-compose.dev.yml up -d && docker compose -p hestia -f devops/docker-compose.dev.yml -f devops/docker-compose.wait.yml run --rm wait -c hestia-database:5432",
@ -62,17 +62,19 @@
"vitest": "^2.0.4"
},
"dependencies": {
"@clerk/clerk-js": "^5.43.4",
"@clerk/express": "^1.3.31",
"@clerk/themes": "^2.2.3",
"@flaticon/flaticon-uicons": "^3.3.1",
"@inlang/paraglide-sveltekit": "^0.15.0",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@pothos/core": "^4.3.0",
"@pothos/plugin-prisma": "^4.4.0",
"@prisma/client": "6.0.1",
"@tailwindcss/typography": "^0.5.15",
"clerk-sveltekit": "https://pkg.pr.new/wobsoriano/clerk-sveltekit@ca15d4e",
"dayjs": "^1.11.13",
"graphql": "^16.9.0",
"graphql-yoga": "^5.10.4",
"lucia": "^3.2.2",
"oslo": "^1.2.1",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",

View file

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
-- DropIndex
DROP INDEX "User_email_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "password";
-- DropTable
DROP TABLE "Session";

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `clerkId` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "clerkId" TEXT NOT NULL;

View file

@ -16,23 +16,11 @@ datasource db {
model User {
id String @id @default(uuid())
clerkId String
email String? @unique
email String?
name String
password String
posts Post[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
model Session {
id String @id @default(uuid())
expiresAt DateTime
user User @relation(references: [id], fields: [userId])
userId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

7
src/app.d.ts vendored
View file

@ -4,8 +4,11 @@ declare global {
namespace App {
// interface Error {}
interface Locals {
user: import('lucia').User | null;
session: import('lucia').Session | null;
auth: {
userId?: string;
orgId?: string | null;
sessionId?: string;
};
}
// interface PageData {}
// interface PageState {}

3
src/hooks.server.ts Normal file
View file

@ -0,0 +1,3 @@
import { withClerkHandler } from 'clerk-sveltekit/server';
export const handle = withClerkHandler();

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { messages } from '$lib/i18n';
import SignOutButton from 'clerk-sveltekit/client/SignOutButton.svelte';
let { title, username }: { title: string; username: string } = $props();
@ -10,6 +11,7 @@
<h2 class="prose prose-xl">Hestia</h2>
<h1 class="prose prose-2xl">{title}</h1>
<p class="prose prose-lg">{message}</p>
<SignOutButton />
</header>
<style>

View file

@ -1,22 +1,43 @@
import { redirect, type ServerLoadEvent } from '@sveltejs/kit';
import dayjs from 'dayjs';
import { prisma } from '../prisma';
import { createClerkClient } from '@clerk/express';
import { CLERK_SECRET_KEY } from '$env/static/private';
import { clerkClient } from 'clerk-sveltekit/server';
export async function validateSession(event: ServerLoadEvent) {
const sessionId = event.cookies.get('auth_session');
if (!sessionId) {
redirect(302, '/login');
}
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
const clerkSessionClient = createClerkClient({
secretKey: CLERK_SECRET_KEY,
});
if (!session || !session.user) {
redirect(302, '/login');
export async function validateSession({ locals }: ServerLoadEvent) {
if (!locals.auth.userId) {
return redirect(307, '/login');
}
const expiry = session.expiresAt;
if (dayjs(expiry).isBefore(dayjs())) {
redirect(302, '/login');
if (!locals.auth.orgId && locals.auth.sessionId) {
// Sign out the user if they are not associated with an organization
await clerkSessionClient.sessions.revokeSession(locals.auth.sessionId);
return redirect(307, '/login');
}
return session;
const clerkUser = await clerkClient.users.getUser(locals.auth.userId);
let user = await prisma.user.findFirst({
where: {
clerkId: clerkUser.id,
},
});
if (!user) {
user = await prisma.user.create({
data: {
clerkId: clerkUser.id,
email: clerkUser.emailAddresses[0].emailAddress,
name: clerkUser.fullName ?? 'Ben the Man',
},
});
}
return {
user: { name: user.name },
};
}

View file

@ -1,31 +0,0 @@
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '$lib/server/prisma';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
// expect error (see next section)
export const auth = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => {
return {
email: attributes.email,
};
},
});
declare module 'lucia' {
interface Register {
Lucia: typeof Lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
email: string;
}
export type Auth = typeof auth;

View file

@ -1,12 +1,17 @@
<script lang="ts">
import ClerkProvider from 'clerk-sveltekit/client/ClerkProvider.svelte';
import { i18n } from '$lib/i18n';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import '../app.css';
import { PUBLIC_CLERK_PUBLISHABLE_KEY } from '$env/static/public';
let { children } = $props();
</script>
<div class="layout">
<ClerkProvider publishableKey={PUBLIC_CLERK_PUBLISHABLE_KEY}>
{@render children()}
</ClerkProvider>
</div>
<ParaglideJS {i18n}>

View file

@ -1,5 +1,3 @@
import { validateSession } from '$lib/server/auth';
export async function load(event) {
await validateSession(event);
}
export const load = async (event) => validateSession(event);

View file

@ -1,10 +1,3 @@
import { validateSession } from '$lib/server/auth';
export async function load(event) {
const {
user: { password: _, ...rest },
} = await validateSession(event);
return {
user: rest,
};
}
export const load = async (event) => validateSession(event);

View file

@ -2,6 +2,8 @@
import { Navbar } from '$lib/components/Navigation';
import type { User } from '@prisma/client';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import 'clerk-sveltekit/client';
type Props = {
children: Snippet;
@ -11,6 +13,32 @@
};
let { children, data }: Props = $props();
let clerk;
onMount(async () => {
clerk = window.Clerk;
if (clerk) {
if (!clerk.user || clerk.user?.organizationMemberships.length === 0) {
console.debug('No organizations found');
await clerk.signOut();
return;
}
try {
// Set the active organization to the first one in the list
await clerk.setActive({
organization: clerk.user?.organizationMemberships[0].organization.id,
});
console.debug(
`Active organization set to ${clerk.user?.organizationMemberships[0].organization.id}`
);
} catch (error) {
console.error('Error setting active organization:', error);
}
}
});
</script>
<Navbar title="Svelte" username={data.user.name} />

View file

@ -1,91 +0,0 @@
import { messages } from '$lib/i18n';
import { email_inuse } from '$lib/paraglide/messages';
import { logger } from '$lib/server/logger';
import { auth } from '$lib/server/lucia.js';
import { prisma } from '$lib/server/prisma';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { Argon2id } from 'oslo/password';
import { string } from 'zod';
export const actions = {
login: async (event) => {
const form = await event.request.formData();
const email = form.get('email');
if (typeof email !== 'string') {
return fail(400, { email: { error: messages.email_required() } });
}
const password = form.get('password') as string;
if (!password) {
return fail(400, { password: { error: messages.password_required() } });
}
const user = await prisma.user.findUnique({
where: {
email: email,
},
});
if (!user) {
logger.error(`User not found! ${email}`);
return fail(404, { email: { value: email, error: messages.user_not_found() } });
}
const validPassword = await new Argon2id().verify(user.password, password);
if (!validPassword) {
return fail(400, {
password: { error: messages.password_incorrect() },
});
}
const session = await auth.createSession(user.id, []);
const sessionCookie = auth.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '/',
maxAge: 120,
});
redirect(302, '/');
},
register: async (event) => {
const form = await event.request.formData();
if (!form.has('email')) {
return fail(400, { email: { error: messages.email_required() } });
}
const { success, data: email, error } = string().email().safeParse(form.get('email'));
if (!success) {
logger.error(error);
return fail(400, { email: { value: email, error: messages.email_incorrect() } });
}
const password = form.get('password');
if (typeof password !== 'string') {
return fail(400, { password: { error: messages.password_required() } });
}
const name = form.get('name');
if (typeof name !== 'string') {
return fail(400, { name: { error: messages.name_required() } });
}
const usersWithEmail = await prisma.user.count({ where: { email: email } });
if (usersWithEmail !== 0) {
return fail(409, { email: { value: email, error: email_inuse() } });
}
const hashedPassword = await new Argon2id().hash(password);
const user = await prisma.user.create({
data: {
email: form.get('email') as string,
name: form.get('name') as string,
password: hashedPassword,
},
});
const session = await auth.createSession(user.id.toString(), {});
const sessionCookie = auth.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '/',
maxAge: 120,
});
redirect(303, '/');
},
} satisfies Actions;

View file

@ -1,62 +1,23 @@
<script lang="ts">
import Button from '$lib/components/common/Button';
import TextInput from '$lib/components/common/TextInput';
import Tabs from '$lib/components/Navigation/Tabs';
import { fade } from 'svelte/transition';
let { form } = $props();
let tab: 0 | 1 = $state(0);
import SignIn from 'clerk-sveltekit/client/SignIn.svelte';
import { dark } from '@clerk/themes';
</script>
{#snippet userIcon()}
<i class="fi fi-br-envelope"></i>
{/snippet}
{#snippet passwordIcon()}
<i class="fi fi-br-key"></i>
{/snippet}
{#snippet nameIcon()}
<i class="fi fi-rr-user"></i>
{/snippet}
{#snippet alert(message: string)}
<div role="alert" class="alert alert-error absolute -top-20" transition:fade>
<i class="fi fi-bs-octagon-xmark h-6 w-6 shrink-0"></i>
<span>{message}</span>
</div>
{/snippet}
{#snippet Form(variant: 'login' | 'register')}
<form method="POST" action={`?/${variant}`}>
<div class="card-body gap-4">
<TextInput start={userIcon} placeholder="Email" name="email" type="email" />
<TextInput
start={passwordIcon}
placeholder="Password"
name="password"
type="password"
/>
{#if variant === 'register'}
<TextInput start={nameIcon} placeholder="Name" name="name" fade />
{/if}
</div>
<div class="card-actions px-4">
<Button block type="submit" label="Submit" outline />
</div>
</form>
{/snippet}
<div class="page" transition:fade>
<div class="card bg-base-200 py-4 shadow-xl">
<div class="card-title">
{#if form}
{@render alert(Object.values(form)[0].error)}
{/if}
<Tabs variant="bordered" bind:selected={tab} tabs={['Login', 'Register']} />
</div>
{@render Form(tab === 0 ? 'login' : 'register')}
<div>
<SignIn
appearance={{
baseTheme: dark,
variables: {
colorPrimary: '#FFF',
},
elements: {
// Remove the sign-up link
footerAction: { display: 'none' },
},
}}
/>
</div>
</div>

10
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CLERK_PUBLISHABLE_KEY: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}