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:
+53
@@ -0,0 +1,53 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled),
|
||||
summary,
|
||||
label[for],
|
||||
select:not(:disabled),
|
||||
input[type='submit']:not(:disabled),
|
||||
input[type='reset']:not(:disabled),
|
||||
input[type='button']:not(:disabled),
|
||||
input[type='checkbox']:not(:disabled),
|
||||
input[type='radio']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role='button'][aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import type { SessionUser, SessionCompany } from '$lib/server/auth/types';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: SessionUser | null;
|
||||
company: SessionCompany | null;
|
||||
sessionId: string | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
interface Error {
|
||||
code?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
SESSION_COOKIE_MAX_AGE,
|
||||
refreshSession,
|
||||
validateSession
|
||||
} from '$lib/server/auth/session';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = null;
|
||||
event.locals.company = null;
|
||||
event.locals.sessionId = null;
|
||||
|
||||
const token = event.cookies.get(SESSION_COOKIE);
|
||||
if (token) {
|
||||
const lookup = await validateSession(token);
|
||||
if (lookup) {
|
||||
event.locals.user = lookup.user;
|
||||
event.locals.company = lookup.company;
|
||||
event.locals.sessionId = lookup.sessionId;
|
||||
|
||||
if (lookup.shouldRefresh) {
|
||||
await refreshSession(lookup.sessionId);
|
||||
event.cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !event.url.hostname.includes('localhost'),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE
|
||||
});
|
||||
}
|
||||
} else {
|
||||
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
import type { SessionCompany, SessionUser } from '$lib/server/auth/types';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser;
|
||||
company: SessionCompany | null;
|
||||
companies: SessionCompany[];
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { user, company, companies, open, onclose }: Props = $props();
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: 'home' | 'briefcase' | 'building' | 'cube' | 'check' | 'book';
|
||||
}
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: 'home' },
|
||||
{ href: '/projects', label: 'Projects', icon: 'briefcase' },
|
||||
{ href: '/properties', label: 'Properties', icon: 'building' },
|
||||
{ href: '/assets', label: 'Assets', icon: 'cube' },
|
||||
{ href: '/checklists', label: 'Checklists', icon: 'check' },
|
||||
{ href: '/wiki', label: 'Wiki', icon: 'book' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
|
||||
onclick={onclose}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-30 flex w-64 transform flex-col border-r border-gray-200 bg-white transition-transform duration-200 lg:relative lg:translate-x-0 dark:border-gray-700 dark:bg-gray-800 {open
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
|
||||
<a href="/" class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span class="inline-flex h-6 w-6 items-center justify-center rounded bg-primary-600 text-white text-xs font-bold">B</span>
|
||||
<span>buildfor_life · ops</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4 text-sm">
|
||||
<ul>
|
||||
{#each mainNav as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={onclose}
|
||||
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 font-medium {isActive(item.href)
|
||||
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'}"
|
||||
>
|
||||
<span aria-hidden="true" class="inline-flex h-4 w-4 items-center justify-center">
|
||||
{#if item.icon === 'home'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>
|
||||
{:else if item.icon === 'briefcase'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.16 2.16 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
|
||||
{:else if item.icon === 'building'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" /></svg>
|
||||
{:else if item.icon === 'cube'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
|
||||
{:else if item.icon === 'check'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
|
||||
{:else if item.icon === 'book'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if companies.length > 0}
|
||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Companies
|
||||
</div>
|
||||
<ul>
|
||||
{#each companies as c}
|
||||
<li>
|
||||
<div
|
||||
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium {company?.id === c.id
|
||||
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-[10px] font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200"
|
||||
>
|
||||
{c.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span class="flex-1 truncate">{c.name}</span>
|
||||
<span class="text-xs text-gray-400 capitalize dark:text-gray-500">{c.role}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<!-- TODO: company switcher action is wired in Phase 1 (/switch-company form action). -->
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Footer: user menu + theme toggle -->
|
||||
<div class="flex h-14 items-center justify-between border-t border-gray-200 px-3 dark:border-gray-700">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200">
|
||||
{user.displayName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-gray-900 dark:text-gray-100">{user.displayName}</div>
|
||||
<a href="/logout" data-sveltekit-preload-data="off" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isDark = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
isDark = document.documentElement.classList.contains('dark');
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'theme') {
|
||||
isDark = e.newValue === 'dark';
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
isDark = !isDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{#if isDark}
|
||||
<!-- sun -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- moon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface Props {
|
||||
onmenu: () => void;
|
||||
}
|
||||
let { onmenu }: Props = $props();
|
||||
|
||||
// Simple breadcrumbs derived from pathname segments.
|
||||
// Individual routes can override with page data later.
|
||||
let crumbs = $derived.by(() => {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
const out: { label: string; href: string }[] = [];
|
||||
let acc = '';
|
||||
for (const p of parts) {
|
||||
acc += '/' + p;
|
||||
out.push({ label: p.replace(/-/g, ' '), href: acc });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="flex h-14 items-center gap-3 border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
class="-ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 lg:hidden dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
aria-label="Open menu"
|
||||
onclick={onmenu}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
|
||||
</button>
|
||||
|
||||
<nav aria-label="Breadcrumb" class="flex min-w-0 flex-1 items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{#if crumbs.length === 0}
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">Dashboard</span>
|
||||
{:else}
|
||||
<a href="/" class="hover:text-gray-700 dark:hover:text-gray-200">Home</a>
|
||||
{#each crumbs as c, i}
|
||||
<span aria-hidden="true" class="text-gray-300 dark:text-gray-600">›</span>
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="truncate font-medium text-gray-900 capitalize dark:text-gray-100">{c.label}</span>
|
||||
{:else}
|
||||
<a href={c.href} class="capitalize hover:text-gray-700 dark:hover:text-gray-200">{c.label}</a>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
// OWASP-recommended Argon2id parameters (moderate server, 2024 guidance).
|
||||
// Match sibling buildfor_life_budget exactly so a user can be migrated across apps later.
|
||||
const ARGON2_OPTS = {
|
||||
memoryCost: 19_456, // 19 MB
|
||||
timeCost: 2,
|
||||
parallelism: 1
|
||||
} as const;
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, ARGON2_OPTS);
|
||||
}
|
||||
|
||||
export function verifyPassword(storedHash: string, password: string): Promise<boolean> {
|
||||
return verify(storedHash, password);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '../db/client';
|
||||
import { sessions, users, companies, companyUsers } from '../db/schema/tenancy';
|
||||
import type { SessionCompany, SessionUser } from './types';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const SESSION_LIFETIME_MS = 30 * DAY_MS;
|
||||
const SESSION_RENEWAL_THRESHOLD_MS = 15 * DAY_MS;
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeHexLowerCase(bytes);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
token: string,
|
||||
userId: string,
|
||||
meta: { ip?: string; userAgent?: string } = {}
|
||||
): Promise<void> {
|
||||
const id = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
|
||||
await db.insert(sessions).values({
|
||||
id,
|
||||
userId,
|
||||
expiresAt,
|
||||
ip: meta.ip,
|
||||
userAgent: meta.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
export interface SessionLookup {
|
||||
user: SessionUser;
|
||||
company: SessionCompany | null;
|
||||
sessionId: string;
|
||||
shouldRefresh: boolean;
|
||||
}
|
||||
|
||||
export async function validateSession(token: string): Promise<SessionLookup | null> {
|
||||
const id = hashToken(token);
|
||||
const row = await db
|
||||
.select({
|
||||
sessionId: sessions.id,
|
||||
expiresAt: sessions.expiresAt,
|
||||
activeCompanyId: sessions.activeCompanyId,
|
||||
userId: users.id,
|
||||
userEmail: users.email,
|
||||
userDisplayName: users.displayName,
|
||||
userIsActive: users.isActive
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
|
||||
if (row.length === 0) return null;
|
||||
const r = row[0];
|
||||
if (!r.userIsActive) return null;
|
||||
|
||||
let company: SessionCompany | null = null;
|
||||
if (r.activeCompanyId) {
|
||||
const c = 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(and(eq(companyUsers.userId, r.userId), eq(companies.id, r.activeCompanyId)))
|
||||
.limit(1);
|
||||
if (c.length > 0) company = c[0];
|
||||
}
|
||||
|
||||
const msLeft = r.expiresAt.getTime() - Date.now();
|
||||
const shouldRefresh = msLeft < SESSION_RENEWAL_THRESHOLD_MS;
|
||||
|
||||
return {
|
||||
sessionId: r.sessionId,
|
||||
user: {
|
||||
id: r.userId,
|
||||
email: r.userEmail,
|
||||
displayName: r.userDisplayName,
|
||||
isActive: r.userIsActive
|
||||
},
|
||||
company,
|
||||
shouldRefresh
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshSession(sessionId: string): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ expiresAt, lastSeenAt: new Date() })
|
||||
.where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export async function setActiveCompany(sessionId: string, companyId: string | null): Promise<void> {
|
||||
await db.update(sessions).set({ activeCompanyId: companyId }).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE = 'ops_session';
|
||||
export const SESSION_COOKIE_MAX_AGE = SESSION_LIFETIME_MS / 1000;
|
||||
@@ -0,0 +1,15 @@
|
||||
export type Role = 'admin' | 'manager' | 'user' | 'viewer';
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SessionCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role: Role;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import { env } from '../env';
|
||||
import * as schema from './schema';
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('pg pool error', err);
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
export type DB = typeof db;
|
||||
export { pool };
|
||||
@@ -0,0 +1,70 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgEnum, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const roleEnum = pgEnum('role', ['admin', 'manager', 'user', 'viewer']);
|
||||
export const taskStatusEnum = pgEnum('task_status', ['todo', 'doing', 'done', 'blocked']);
|
||||
export const containerKindEnum = pgEnum('container_kind', ['project', 'property']);
|
||||
export const scheduleKindEnum = pgEnum('schedule_kind', ['time', 'usage']);
|
||||
export const intervalUnitEnum = pgEnum('interval_unit', [
|
||||
'days',
|
||||
'months',
|
||||
'years',
|
||||
'hours',
|
||||
'cycles',
|
||||
'km'
|
||||
]);
|
||||
export const fieldTypeEnum = pgEnum('field_type', [
|
||||
'text',
|
||||
'textarea',
|
||||
'int',
|
||||
'float',
|
||||
'bool',
|
||||
'date',
|
||||
'ip',
|
||||
'cidr',
|
||||
'mac',
|
||||
'enum',
|
||||
'multi_enum',
|
||||
'url',
|
||||
'email',
|
||||
'asset_ref'
|
||||
]);
|
||||
export const checklistScopeEnum = pgEnum('checklist_scope', [
|
||||
'task',
|
||||
'subtask',
|
||||
'maintenance_event',
|
||||
'ad_hoc'
|
||||
]);
|
||||
export const docScopeEnum = pgEnum('doc_scope', [
|
||||
'project',
|
||||
'property',
|
||||
'asset',
|
||||
'work_package',
|
||||
'decision_event'
|
||||
]);
|
||||
export const wikiScopeEnum = pgEnum('wiki_scope', ['global', 'project', 'property']);
|
||||
export const decisionScopeEnum = pgEnum('decision_scope', [
|
||||
'project',
|
||||
'property',
|
||||
'asset',
|
||||
'work_package'
|
||||
]);
|
||||
export const auditActionEnum = pgEnum('audit_action', [
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'move',
|
||||
'assign',
|
||||
'complete',
|
||||
'login',
|
||||
'logout'
|
||||
]);
|
||||
|
||||
export const pk = () => uuid('id').primaryKey().default(sql`gen_random_uuid()`);
|
||||
export const fk = (name: string) => uuid(name);
|
||||
export const createdAt = () =>
|
||||
timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
|
||||
export const updatedAt = () =>
|
||||
timestamp('updated_at', { withTimezone: true }).notNull().defaultNow();
|
||||
export const deletedAt = () => timestamp('deleted_at', { withTimezone: true });
|
||||
export const slugCol = (name = 'slug') => varchar(name, { length: 128 }).notNull();
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './_shared';
|
||||
export * from './tenancy';
|
||||
// Additional schema modules (projects, properties, assets, maintenance,
|
||||
// checklists, decisions, documents, wiki, audit) are added in phases as
|
||||
// laid out in project_buildfor_life_ops.md.
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
roleEnum,
|
||||
pk,
|
||||
fk,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
slugCol
|
||||
} from './_shared';
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: pk(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
slug: slugCol().unique(),
|
||||
// Company-level config (e.g. default_currency: 'THB'). Kept as text + JSON.parse
|
||||
// so we don't need a migration every time a setting is added.
|
||||
settings: text('settings_json'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
deletedAt: deletedAt()
|
||||
});
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: pk(),
|
||||
email: varchar('email', { length: 320 }).notNull(),
|
||||
emailNormalized: varchar('email_normalized', { length: 320 }).notNull(),
|
||||
displayName: varchar('display_name', { length: 255 }).notNull(),
|
||||
// Argon2id hash; nullable for OIDC-only accounts
|
||||
passwordHash: text('password_hash'),
|
||||
oidcSubject: varchar('oidc_subject', { length: 255 }),
|
||||
oidcIssuer: varchar('oidc_issuer', { length: 255 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
deletedAt: deletedAt()
|
||||
},
|
||||
(t) => ({
|
||||
emailNormUq: uniqueIndex('users_email_norm_uq').on(t.emailNormalized),
|
||||
oidcUq: uniqueIndex('users_oidc_uq').on(t.oidcIssuer, t.oidcSubject)
|
||||
})
|
||||
);
|
||||
|
||||
export const companyUsers = pgTable(
|
||||
'company_users',
|
||||
{
|
||||
id: pk(),
|
||||
companyId: fk('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
userId: fk('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
role: roleEnum('role').notNull().default('user'),
|
||||
joinedAt: createdAt()
|
||||
},
|
||||
(t) => ({
|
||||
uq: uniqueIndex('company_users_uq').on(t.companyId, t.userId),
|
||||
byUser: index('company_users_by_user').on(t.userId)
|
||||
})
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
// Opaque token stored directly (hex from CSPRNG, not UUID).
|
||||
id: varchar('id', { length: 128 }).primaryKey(),
|
||||
userId: fk('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
// Currently-active company context for this session.
|
||||
activeCompanyId: fk('active_company_id').references(() => companies.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
userAgent: text('user_agent'),
|
||||
ip: varchar('ip', { length: 64 }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(t) => ({
|
||||
byUser: index('sessions_by_user').on(t.userId),
|
||||
byExpiry: index('sessions_by_expiry').on(t.expiresAt)
|
||||
})
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Company = typeof companies.$inferSelect;
|
||||
export type CompanyUser = typeof companyUsers.$inferSelect;
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
|
||||
PUBLIC_BASE_URL: z.string().url(),
|
||||
|
||||
STORAGE_BACKEND: z.enum(['local', 's3']).default('local'),
|
||||
STORAGE_LOCAL_ROOT: z.string().default('./storage'),
|
||||
STORAGE_SIGNING_SECRET: z.string().min(32, 'STORAGE_SIGNING_SECRET must be at least 32 characters'),
|
||||
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === 'true')
|
||||
.default('false'),
|
||||
OIDC_ISSUER: z.string().url().optional().or(z.literal('')),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Environment validation failed:');
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment. See errors above and .env.example for the expected shape.');
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export type AppEnv = typeof env;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { env } from '../env';
|
||||
import { LocalDiskStorage } from './local';
|
||||
import type { StorageAdapter } from './types';
|
||||
|
||||
let instance: StorageAdapter | null = null;
|
||||
|
||||
export function getStorage(): StorageAdapter {
|
||||
if (instance) return instance;
|
||||
if (env.STORAGE_BACKEND === 'local') {
|
||||
instance = new LocalDiskStorage(
|
||||
env.STORAGE_LOCAL_ROOT,
|
||||
env.STORAGE_SIGNING_SECRET,
|
||||
env.PUBLIC_BASE_URL
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Storage backend '${env.STORAGE_BACKEND}' is not wired yet`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Exported for tests / explicit swaps
|
||||
export function _setStorageForTesting(s: StorageAdapter | null) {
|
||||
instance = s;
|
||||
}
|
||||
|
||||
export * from './types';
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, stat, unlink } from 'node:fs/promises';
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
generateStorageKey,
|
||||
type GetObjectResult,
|
||||
type PutObjectInput,
|
||||
type PutObjectResult,
|
||||
type SignedUrlOptions,
|
||||
type StorageAdapter
|
||||
} from './types';
|
||||
|
||||
export class LocalDiskStorage implements StorageAdapter {
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly signingSecret: string,
|
||||
private readonly publicBaseUrl: string
|
||||
) {}
|
||||
|
||||
private resolve(key: string): string {
|
||||
if (key.includes('..') || path.isAbsolute(key)) {
|
||||
throw new Error('invalid storage key');
|
||||
}
|
||||
return path.join(this.root, key);
|
||||
}
|
||||
|
||||
async put(input: PutObjectInput): Promise<PutObjectResult> {
|
||||
const full = this.resolve(input.key);
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
const hash = createHash('sha256');
|
||||
let size = 0;
|
||||
const out = createWriteStream(full);
|
||||
|
||||
if (Buffer.isBuffer(input.body)) {
|
||||
hash.update(input.body);
|
||||
size = input.body.length;
|
||||
out.end(input.body);
|
||||
await new Promise<void>((res, rej) => {
|
||||
out.on('finish', () => res());
|
||||
out.on('error', rej);
|
||||
});
|
||||
} else {
|
||||
await pipeline(
|
||||
input.body,
|
||||
async function* (src) {
|
||||
for await (const chunk of src) {
|
||||
const buf = chunk as Buffer;
|
||||
hash.update(buf);
|
||||
size += buf.length;
|
||||
yield buf;
|
||||
}
|
||||
},
|
||||
out
|
||||
);
|
||||
}
|
||||
|
||||
const sha256 = hash.digest('hex');
|
||||
if (input.sha256 && input.sha256 !== sha256) {
|
||||
await unlink(full).catch(() => {});
|
||||
throw new Error('sha256 mismatch on upload');
|
||||
}
|
||||
return { key: input.key, sha256, sizeBytes: size };
|
||||
}
|
||||
|
||||
async get(key: string): Promise<GetObjectResult> {
|
||||
const full = this.resolve(key);
|
||||
const st = await stat(full);
|
||||
return {
|
||||
stream: createReadStream(full),
|
||||
contentType: 'application/octet-stream',
|
||||
sizeBytes: st.size
|
||||
};
|
||||
}
|
||||
|
||||
async head(key: string) {
|
||||
const st = await stat(this.resolve(key));
|
||||
return { sizeBytes: st.size, contentType: 'application/octet-stream' };
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await unlink(this.resolve(key)).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
|
||||
generateKey(filename: string): string {
|
||||
return generateStorageKey(filename);
|
||||
}
|
||||
|
||||
async getSignedUrl(key: string, opts: SignedUrlOptions): Promise<string> {
|
||||
const exp = Math.floor(Date.now() / 1000) + opts.expiresInSeconds;
|
||||
const disposition = opts.disposition ?? 'inline';
|
||||
const filename = opts.filename ?? '';
|
||||
const payload = `${key}|${exp}|${disposition}|${filename}`;
|
||||
const sig = createHmac('sha256', this.signingSecret).update(payload).digest('hex');
|
||||
const q = new URLSearchParams({
|
||||
k: key,
|
||||
e: String(exp),
|
||||
d: disposition,
|
||||
n: filename,
|
||||
s: sig
|
||||
});
|
||||
return `${this.publicBaseUrl}/api/files?${q.toString()}`;
|
||||
}
|
||||
|
||||
verifySignedUrl(params: URLSearchParams): { key: string; disposition: string; filename: string } {
|
||||
const key = params.get('k');
|
||||
const expStr = params.get('e');
|
||||
const disposition = params.get('d') ?? 'inline';
|
||||
const filename = params.get('n') ?? '';
|
||||
const sig = params.get('s');
|
||||
if (!key || !expStr || !sig) throw new Error('missing signed-url params');
|
||||
const exp = Number.parseInt(expStr, 10);
|
||||
if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new Error('signed url expired');
|
||||
}
|
||||
const payload = `${key}|${exp}|${disposition}|${filename}`;
|
||||
const expected = createHmac('sha256', this.signingSecret).update(payload).digest('hex');
|
||||
// timing-safe compare
|
||||
if (sig.length !== expected.length) throw new Error('bad signature');
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
if (diff !== 0) throw new Error('bad signature');
|
||||
return { key, disposition, filename };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export interface PutObjectInput {
|
||||
key: string;
|
||||
body: Readable | Buffer;
|
||||
contentType: string;
|
||||
contentLength?: number;
|
||||
sha256?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PutObjectResult {
|
||||
key: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface GetObjectResult {
|
||||
stream: Readable;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface SignedUrlOptions {
|
||||
expiresInSeconds: number;
|
||||
disposition?: 'inline' | 'attachment';
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface StorageAdapter {
|
||||
put(input: PutObjectInput): Promise<PutObjectResult>;
|
||||
get(key: string): Promise<GetObjectResult>;
|
||||
head(key: string): Promise<{ sizeBytes: number; contentType: string; sha256?: string }>;
|
||||
delete(key: string): Promise<void>;
|
||||
getSignedUrl(key: string, opts: SignedUrlOptions): Promise<string>;
|
||||
generateKey(filename: string): string;
|
||||
}
|
||||
|
||||
export function generateStorageKey(filename: string): string {
|
||||
const now = new Date();
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const uuid = crypto.randomUUID();
|
||||
const safe = filename
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w.-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 120);
|
||||
return `${yyyy}/${mm}/${uuid}__${safe}`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.normalize('NFKC').trim().toLowerCase();
|
||||
}
|
||||
@@ -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