feat: add auth and database foundation
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { building } from '$app/environment';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
const session = await auth.api.getSession({ headers: event.request.headers });
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith('/dashboard') && !event.locals.user) {
|
||||
const redirectTo = event.url.pathname + event.url.search;
|
||||
redirect(303, `/login?redirectTo=${encodeURIComponent(redirectTo)}`);
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
};
|
||||
|
||||
export const handle: Handle = handleBetterAuth;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const passwordMessage = 'Password must be at least 8 characters.';
|
||||
const optionalPassword = z.string().refine((value) => value.length === 0 || value.length >= 8, {
|
||||
message: passwordMessage
|
||||
});
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Enter a name.'),
|
||||
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(8, passwordMessage)
|
||||
});
|
||||
|
||||
export const editAdminSchema = z.object({
|
||||
id: z.string().min(1, 'Admin id is required.'),
|
||||
name: z.string().trim().min(1, 'Enter a name.'),
|
||||
email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()),
|
||||
password: optionalPassword
|
||||
});
|
||||
|
||||
export const deleteAdminSchema = z.object({
|
||||
id: z.string().min(1, 'Admin id is required.')
|
||||
});
|
||||
|
||||
export type CreateAdminSchema = typeof createAdminSchema;
|
||||
export type EditAdminSchema = typeof editAdminSchema;
|
||||
export type DeleteAdminSchema = typeof deleteAdminSchema;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.email('Enter a valid email address.'),
|
||||
password: z.string().min(1, 'Enter your password.')
|
||||
});
|
||||
|
||||
export type LoginSchema = typeof loginSchema;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(db, { provider: 'sqlite' }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: true
|
||||
},
|
||||
plugins: [
|
||||
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { clients } from './clients.schema';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const addresses = sqliteTable(
|
||||
'addresses',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
label: text('label').notNull(),
|
||||
line1: text('line_1').notNull(),
|
||||
line2: text('line_2'),
|
||||
city: text('city').notNull(),
|
||||
region: text('region'),
|
||||
postcode: text('postcode').notNull(),
|
||||
country: text('country').notNull().default('United Kingdom'),
|
||||
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index('addresses_client_id_idx').on(table.clientId)]
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const user = sqliteTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
|
||||
image: text('image'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [index('session_userId_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const account = sqliteTable(
|
||||
'account',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: integer('access_token_expires_at', {
|
||||
mode: 'timestamp_ms'
|
||||
}),
|
||||
refreshTokenExpiresAt: integer('refresh_token_expires_at', {
|
||||
mode: 'timestamp_ms'
|
||||
}),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index('account_userId_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const verification = sqliteTable(
|
||||
'verification',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index('verification_identifier_idx').on(table.identifier)]
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account)
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { clients } from './clients.schema';
|
||||
import { rooms } from './rooms.schema';
|
||||
import { services } from './services.schema';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const bookings = sqliteTable(
|
||||
'bookings',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => rooms.id, { onDelete: 'restrict' }),
|
||||
serviceId: text('service_id').references(() => services.id, { onDelete: 'set null' }),
|
||||
startsAt: text('starts_at').notNull(),
|
||||
endsAt: text('ends_at').notNull(),
|
||||
status: text('status').notNull().default('booked'),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [
|
||||
index('bookings_client_id_idx').on(table.clientId),
|
||||
index('bookings_room_id_idx').on(table.roomId),
|
||||
index('bookings_service_id_idx').on(table.serviceId)
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { text, sqliteTable } from 'drizzle-orm/sqlite-core';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const clients = sqliteTable('clients', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull().default('business'),
|
||||
status: text('status').notNull().default('active'),
|
||||
website: text('website'),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { clients } from './clients.schema';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const contacts = sqliteTable(
|
||||
'contacts',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
name: text('name').notNull(),
|
||||
role: text('role'),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index('contacts_client_id_idx').on(table.clientId)]
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { clients } from './clients.schema';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const contracts = sqliteTable(
|
||||
'contracts',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
title: text('title').notNull(),
|
||||
startDate: text('start_date').notNull(),
|
||||
endDate: text('end_date'),
|
||||
status: text('status').notNull().default('draft'),
|
||||
valueGbp: real('value_gbp').notNull().default(0),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index('contracts_client_id_idx').on(table.clientId)]
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = createClient({ url: env.DATABASE_URL });
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
@@ -0,0 +1,25 @@
|
||||
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { clients } from './clients.schema';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const invoices = sqliteTable(
|
||||
'invoices',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
invoiceNumber: text('invoice_number').notNull().unique(),
|
||||
issueDate: text('issue_date').notNull(),
|
||||
dueDate: text('due_date').notNull(),
|
||||
status: text('status').notNull().default('draft'),
|
||||
subtotalGbp: real('subtotal_gbp').notNull().default(0),
|
||||
taxGbp: real('tax_gbp').notNull().default(0),
|
||||
totalGbp: real('total_gbp').notNull().default(0),
|
||||
notes: text('notes'),
|
||||
...timestamps
|
||||
},
|
||||
(table) => [index('invoices_client_id_idx').on(table.clientId)]
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const rooms = sqliteTable('rooms', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull().default('meeting_room'),
|
||||
sqFt: integer('sq_ft'),
|
||||
internalPricePerHourGbp: real('internal_price_per_hour_gbp'),
|
||||
externalPricePerHourGbp: real('external_price_per_hour_gbp'),
|
||||
internalPricePerDayGbp: real('internal_price_per_day_gbp'),
|
||||
externalPricePerDayGbp: real('external_price_per_day_gbp'),
|
||||
maxAttendees: integer('max_attendees'),
|
||||
workstations: integer('workstations'),
|
||||
pricePerMonthGbp: real('price_per_month_gbp'),
|
||||
...timestamps
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const task = sqliteTable('task', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
title: text('title').notNull(),
|
||||
priority: integer('priority').notNull().default(1)
|
||||
});
|
||||
|
||||
export * from './clients.schema';
|
||||
export * from './addresses.schema';
|
||||
export * from './contacts.schema';
|
||||
export * from './rooms.schema';
|
||||
export * from './services.schema';
|
||||
export * from './bookings.schema';
|
||||
export * from './invoices.schema';
|
||||
export * from './contracts.schema';
|
||||
export * from './auth.schema';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { timestamps } from './shared.schema';
|
||||
|
||||
export const services = sqliteTable('services', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
category: text('category'),
|
||||
unit: text('unit').notNull().default('each'),
|
||||
priceGbp: real('price_gbp').notNull().default(0),
|
||||
description: text('description'),
|
||||
...timestamps
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { integer } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const timestamps = {
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
archivedAt: integer('archived_at', { mode: 'timestamp_ms' })
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
<Toaster position="top-center" richColors />
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
redirect(303, '/dashboard');
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { APIError } from 'better-auth/api';
|
||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { loginSchema } from '$lib/schemas/auth';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
function getSafeRedirect(url: URL) {
|
||||
const redirectTo = url.searchParams.get('redirectTo');
|
||||
|
||||
if (!redirectTo || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
return redirectTo;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (locals.user) {
|
||||
redirect(303, getSafeRedirect(url));
|
||||
}
|
||||
|
||||
return {
|
||||
form: await superValidate(
|
||||
{
|
||||
email: url.searchParams.get('email') ?? '',
|
||||
password: ''
|
||||
},
|
||||
zod4(loginSchema),
|
||||
{ id: 'login' }
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const form = await superValidate(event, zod4(loginSchema), { id: 'login' });
|
||||
|
||||
if (!form.valid) {
|
||||
return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email: form.data.email,
|
||||
password: form.data.password,
|
||||
callbackURL: getSafeRedirect(event.url)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return message(form, error.message || 'The email or password you entered is incorrect.', {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
return message(form, 'Something went wrong while signing you in.', { status: 500 });
|
||||
}
|
||||
|
||||
redirect(303, getSafeRedirect(event.url));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import LoginForm from '$lib/components/login-form.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div class="w-full max-w-sm">
|
||||
<LoginForm form={data.form} />
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user