Phase 0 scaffold: SvelteKit 5 + Drizzle + auth + storage interface
Stack matches sibling buildfor_life_* apps: SvelteKit 5 with adapter-node, Svelte 5 runes, TypeScript, Tailwind v4 with @theme inline tokens, PostgreSQL via Drizzle ORM, Argon2id sessions via @node-rs/argon2 and @oslojs/crypto, EasyMDE ready for wiki/decision markdown, Sharp for thumbnails. Included in this commit: - Config: package.json, svelte.config.js, vite.config.ts, tsconfig.json, drizzle.config.ts, .gitignore, .env.example, .gitattributes, .npmrc - Tenancy schema: companies, users, company_users, sessions (10 enums pre-declared for the full domain so downstream migrations don't re-diff them; decision_scope widened to include asset + work_package per product decision) - Auth: password hashing + SHA-256-hashed session cookies, session lifetime 30d with sliding renewal at T-15d, login + logout + session refresh in hooks - Storage: StorageAdapter interface + LocalDiskStorage with HMAC-signed URLs served by /api/files, S3 drop-in with zero schema change - UI shell: dark-mode bootstrap in app.html identical to siblings, sidebar (w-64, h-14 header, amber attention band pattern from repair), topbar with breadcrumbs, theme toggle with cross-tab sync via storage event, blue-600 primary, responsive drawer - Routes: (app) authed group with auto-redirect to /login, (auth) login group, dashboard placeholder, error page, signed-file API - Scripts: create-user script for bootstrapping first admin user - Drizzle: initial migration generated (0000_init.sql) - Shared agents and skills committed under .claude/; per-user permissions gitignored Typecheck: 0 errors / 0 warnings across 555 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import type { SessionCompany } from '$lib/server/auth/types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.user) {
|
||||
const target = url.pathname + url.search;
|
||||
throw redirect(303, `/login?next=${encodeURIComponent(target)}`);
|
||||
}
|
||||
|
||||
// Load the user's companies so the sidebar switcher can render.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
slug: companies.slug,
|
||||
role: companyUsers.role
|
||||
})
|
||||
.from(companyUsers)
|
||||
.innerJoin(companies, eq(companies.id, companyUsers.companyId))
|
||||
.where(eq(companyUsers.userId, locals.user.id));
|
||||
|
||||
const userCompanies: SessionCompany[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
role: r.role
|
||||
}));
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
company: locals.company,
|
||||
companies: userCompanies
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/TopBar.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<Sidebar
|
||||
user={data.user}
|
||||
company={data.company}
|
||||
companies={data.companies}
|
||||
open={sidebarOpen}
|
||||
onclose={() => (sidebarOpen = false)}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar onmenu={() => (sidebarOpen = true)} />
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 lg:px-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Hi, {data.user.displayName.split(' ')[0]}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{#if data.company}
|
||||
Active company: <span class="font-medium">{data.company.name}</span>
|
||||
{:else}
|
||||
No active company — pick one from the sidebar.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Overdue maintenance
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">—</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once maintenance schedules exist.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
My open tasks
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">—</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once projects/tasks exist.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Recent decisions
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">—</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once decisions are logged.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 0 complete.</p>
|
||||
<p class="mt-1">Auth, layout shell, storage interface, and the tenancy schema are wired. The remaining schema modules (projects, properties, assets, maintenance, checklists, decisions, documents, wiki, audit) land in Phase 1.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-10 dark:bg-gray-900">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-6 flex items-center justify-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded bg-primary-600 text-white text-xs font-bold">B</span>
|
||||
buildfor_life · ops
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { users } from '$lib/server/db/schema/tenancy';
|
||||
import { verifyPassword } from '$lib/server/auth/password';
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
SESSION_COOKIE_MAX_AGE,
|
||||
createSession,
|
||||
generateSessionToken
|
||||
} from '$lib/server/auth/session';
|
||||
import { normalizeEmail } from '$lib/utils/email';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1).max(256),
|
||||
next: z.string().optional()
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (locals.user) {
|
||||
const next = url.searchParams.get('next') ?? '/';
|
||||
throw redirect(303, next);
|
||||
}
|
||||
return { next: url.searchParams.get('next') ?? '/' };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies, getClientAddress, url }) => {
|
||||
const form = await request.formData();
|
||||
const parsed = LoginSchema.safeParse({
|
||||
email: form.get('email'),
|
||||
password: form.get('password'),
|
||||
next: form.get('next') ?? undefined
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return fail(400, { error: 'Invalid email or password format.' });
|
||||
}
|
||||
|
||||
const emailNormalized = normalizeEmail(parsed.data.email);
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.emailNormalized, emailNormalized))
|
||||
.limit(1);
|
||||
|
||||
// Uniform error for unknown user vs. bad password to prevent enumeration.
|
||||
if (!user || !user.passwordHash || !user.isActive) {
|
||||
return fail(400, { error: 'Invalid credentials.' });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!ok) return fail(400, { error: 'Invalid credentials.' });
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, user.id, {
|
||||
ip: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !url.hostname.includes('localhost'),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE
|
||||
});
|
||||
|
||||
const next = parsed.data.next && parsed.data.next.startsWith('/') ? parsed.data.next : '/';
|
||||
throw redirect(303, next);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
use:enhance
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Log in</h1>
|
||||
|
||||
<input type="hidden" name="next" value={data.next} />
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if form?.error}
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full justify-center rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center p-6">
|
||||
<div class="max-w-md rounded-lg border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-sm font-semibold text-gray-400 dark:text-gray-500">Error {page.status}</p>
|
||||
<h1 class="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{page.error?.message ?? 'Something went wrong.'}
|
||||
</h1>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-6 inline-flex rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getStorage } from '$lib/server/storage';
|
||||
import { LocalDiskStorage } from '$lib/server/storage/local';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const storage = getStorage();
|
||||
if (!(storage instanceof LocalDiskStorage)) {
|
||||
throw error(501, 'Non-local storage adapter must use its own signed URL');
|
||||
}
|
||||
|
||||
let verified: { key: string; disposition: string; filename: string };
|
||||
try {
|
||||
verified = storage.verifySignedUrl(url.searchParams);
|
||||
} catch {
|
||||
throw error(403, 'Invalid or expired link');
|
||||
}
|
||||
|
||||
const obj = await storage.get(verified.key).catch(() => null);
|
||||
if (!obj) throw error(404, 'Not found');
|
||||
|
||||
const headers = new Headers({
|
||||
'content-type': obj.contentType,
|
||||
'content-length': String(obj.sizeBytes)
|
||||
});
|
||||
if (verified.filename) {
|
||||
const dispHeader =
|
||||
verified.disposition === 'attachment'
|
||||
? `attachment; filename="${verified.filename.replace(/"/g, '')}"`
|
||||
: `inline; filename="${verified.filename.replace(/"/g, '')}"`;
|
||||
headers.set('content-disposition', dispHeader);
|
||||
}
|
||||
|
||||
return new Response(obj.stream as unknown as ReadableStream, { headers });
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth/session';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
async function handleLogout(event: Parameters<RequestHandler>[0]): Promise<Response> {
|
||||
if (event.locals.sessionId) {
|
||||
await invalidateSession(event.locals.sessionId);
|
||||
}
|
||||
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = handleLogout;
|
||||
export const POST: RequestHandler = handleLogout;
|
||||
Reference in New Issue
Block a user