feat: authentication and invite flow

This commit is contained in:
Jonathan 2026-04-18 22:45:25 +02:00
parent 0dc2dbd849
commit 9de5602d2d
6 changed files with 709 additions and 75 deletions

184
src/layouts/AppLayout.astro Normal file
View file

@ -0,0 +1,184 @@
---
import BaseLayout from './BaseLayout.astro';
import type { UserPublic } from '../lib/db';
interface Props {
title: string;
user: UserPublic;
}
const { title, user } = Astro.props;
const navLinks = [
{ href: '/', label: 'Home' },
{ href: '/updates', label: 'Updates' },
{ href: '/roadmap', label: 'Roadmap' },
{ href: '/calendar', label: 'Calendar' },
{ href: '/contribute', label: 'Contribute' },
{ href: '/preview', label: 'Preview' },
];
const currentPath = Astro.url.pathname;
---
<BaseLayout title={title}>
<div class="app">
<header class="nav" role="banner">
<div class="nav-inner">
<a href="/" class="wordmark-link" aria-label="Project Bifrost — home">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
</a>
<nav class="nav-links" aria-label="Main navigation">
{navLinks.map(({ href, label }) => (
<a
href={href}
class:list={['nav-link', { active: currentPath === href || (href !== '/' && currentPath.startsWith(href)) }]}
>
{label}
</a>
))}
{user.role === 'fenja' && (
<a
href="/admin"
class:list={['nav-link', { active: currentPath.startsWith('/admin') }]}
>
Admin
</a>
)}
</nav>
<div class="nav-user">
<a href="/account" class="nav-user-name body-sm">{user.name}</a>
<form method="POST" action="/api/logout">
<button type="submit" class="logout-btn label-sm">Sign out</button>
</form>
</div>
</div>
</header>
<main class="main-content">
<slot />
</main>
</div>
</BaseLayout>
<style>
.app {
min-height: 100vh;
background: var(--background);
display: flex;
flex-direction: column;
}
/* ── Nav ────────────────────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
background: var(--glass-surface);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: var(--ghost-border);
}
.nav-inner {
display: flex;
align-items: center;
gap: var(--space-6);
padding: 0 var(--space-10);
height: 56px;
max-width: var(--content-max);
margin: 0 auto;
width: 100%;
}
.wordmark-link {
flex-shrink: 0;
display: flex;
align-items: center;
border-bottom: none;
}
.wordmark-link:hover {
border-bottom: none;
}
.wordmark {
height: 22px;
width: auto;
display: block;
}
/* ── Nav links ──────────────────────────────────────────────────── */
.nav-links {
display: flex;
align-items: center;
gap: var(--space-1);
flex: 1;
}
.nav-link {
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
text-decoration: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.nav-link:hover {
color: var(--on-surface);
background: var(--surface-container-low);
border-bottom: none;
}
.nav-link.active {
color: var(--on-surface);
background: var(--surface-container);
}
/* ── User zone ──────────────────────────────────────────────────── */
.nav-user {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.nav-user-name {
color: var(--on-surface-variant);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.nav-user-name:hover {
color: var(--on-surface);
border-bottom: none;
}
.logout-btn {
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.logout-btn:hover {
color: var(--on-surface);
background: var(--surface-container-low);
}
/* ── Content ────────────────────────────────────────────────────── */
.main-content {
flex: 1;
}
</style>

View file

@ -171,10 +171,25 @@ export function createInvite(data: {
export function getInviteByTokenHash(tokenHash: string): Invite | null { export function getInviteByTokenHash(tokenHash: string): Invite | null {
return db.prepare( return db.prepare(
"SELECT * FROM invites WHERE token_hash = ? AND used_at IS NULL AND expires_at > datetime('now')" 'SELECT * FROM invites WHERE token_hash = ?'
).get(tokenHash) as Invite | null; ).get(tokenHash) as Invite | null;
} }
export function redeemInvite(inviteId: number): void {
db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(inviteId);
}
export function createUserFromInvite(invite: Invite, passwordHash: string): UserPublic {
const id = createUser({
email: invite.email,
password_hash: passwordHash,
name: invite.name,
organisation: invite.organisation,
role: invite.role,
});
return getUserPublicById(id) as UserPublic;
}
export function markInviteUsed(id: number): void { export function markInviteUsed(id: number): void {
db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(id); db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(id);
} }

10
src/pages/api/logout.ts Normal file
View file

@ -0,0 +1,10 @@
import type { APIRoute } from 'astro';
import { clearSession } from '../../lib/auth';
export const POST: APIRoute = ({ cookies }) => {
clearSession(cookies);
return new Response(null, {
status: 302,
headers: { Location: '/login' },
});
};

View file

@ -1,35 +1,28 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
// Placeholder — will be replaced with real session data once auth is built. const user = Astro.locals.user;
const user = { name: 'Mette' };
--- ---
<BaseLayout title="Welcome"> <AppLayout title="Welcome" user={user}>
<div class="page"> <div class="page">
<!-- Wordmark -->
<header class="wordmark-bar">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
</header>
<!-- Greeting --> <!-- Greeting -->
<main class="main"> <section class="greeting-zone">
<div class="greeting-zone">
<p class="label-md eyebrow">Project Bifrost</p> <p class="label-md eyebrow">Project Bifrost</p>
<h1 class="display-lg greeting"> <h1 class="display-lg greeting">
Welcome, <em class="name">{user.name}.</em> Welcome, <em class="name">{user.name.split(' ')[0]}.</em>
</h1> </h1>
<p class="lead framing"> <p class="lead framing">
This is the private working space for the Bifrost pilot — where you follow what we are building, This is the private working space for the Bifrost pilot — where you follow what we are building,
shape it with your experience, and meet the people building it with you. shape it with your experience, and meet the people building it with you.
</p> </p>
</div> </section>
<!-- Navigation cards --> <!-- Navigation cards -->
<nav class="cards" aria-label="Quick navigation"> <nav class="cards" aria-label="Quick navigation">
<a href="/updates" class="card"> <a href="/updates" class="card">
<span class="label-sm card-label">Latest update</span> <span class="label-sm card-label">Latest update</span>
<h2 class="headline-sm card-title"> <h2 class="card-title">
See what <em class="card-em">changed.</em> See what <em class="card-em">changed.</em>
</h2> </h2>
<p class="body-sm card-body"> <p class="body-sm card-body">
@ -40,7 +33,7 @@ const user = { name: 'Mette' };
<a href="/calendar" class="card"> <a href="/calendar" class="card">
<span class="label-sm card-label">Next meeting</span> <span class="label-sm card-label">Next meeting</span>
<h2 class="headline-sm card-title"> <h2 class="card-title">
The CAB <em class="card-em">calendar.</em> The CAB <em class="card-em">calendar.</em>
</h2> </h2>
<p class="body-sm card-body"> <p class="body-sm card-body">
@ -51,7 +44,7 @@ const user = { name: 'Mette' };
<a href="/contribute" class="card"> <a href="/contribute" class="card">
<span class="label-sm card-label">Contribute</span> <span class="label-sm card-label">Contribute</span>
<h2 class="headline-sm card-title"> <h2 class="card-title">
Share an <em class="card-em">idea.</em> Share an <em class="card-em">idea.</em>
</h2> </h2>
<p class="body-sm card-body"> <p class="body-sm card-body">
@ -59,43 +52,61 @@ const user = { name: 'Mette' };
</p> </p>
<span class="card-arrow" aria-hidden="true">↗</span> <span class="card-arrow" aria-hidden="true">↗</span>
</a> </a>
<a href="/roadmap" class="card">
<span class="label-sm card-label">Roadmap</span>
<h2 class="card-title">
What is <em class="card-em">next.</em>
</h2>
<p class="body-sm card-body">
In progress, coming soon, and further out — the full picture of where we are going.
</p>
<span class="card-arrow" aria-hidden="true">↗</span>
</a>
<a href="/preview" class="card">
<span class="label-sm card-label">Product preview</span>
<h2 class="card-title">
See the <em class="card-em">platform.</em>
</h2>
<p class="body-sm card-body">
Screenshots and walkthroughs of the sovereign AI platform you are piloting.
</p>
<span class="card-arrow" aria-hidden="true">↗</span>
</a>
<a href="/participants" class="card">
<span class="label-sm card-label">Participants</span>
<h2 class="card-title">
The <em class="card-em">people.</em>
</h2>
<p class="body-sm card-body">
Everyone in the pilot — who they are, where they are from, and what they bring.
</p>
<span class="card-arrow" aria-hidden="true">↗</span>
</a>
</nav> </nav>
</main>
</div> </div>
</BaseLayout> </AppLayout>
<style> <style>
.page { .page {
min-height: 100vh; padding: var(--space-12) var(--space-20) var(--space-16);
background: var(--background); max-width: var(--content-max);
display: flex; margin: 0 auto;
flex-direction: column;
}
/* ── Wordmark bar ─────────────────────────────────────────────── */
.wordmark-bar {
padding: var(--space-8) var(--space-20);
}
.wordmark {
height: 28px;
width: auto;
display: block;
} }
/* ── Greeting ─────────────────────────────────────────────────── */ /* ── Greeting ─────────────────────────────────────────────────── */
.main {
flex: 1;
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
}
.greeting-zone { .greeting-zone {
max-width: 44rem; max-width: 44rem;
margin-bottom: var(--space-12); margin-bottom: var(--space-12);
} }
.eyebrow { .eyebrow {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
@ -110,6 +121,7 @@ const user = { name: 'Mette' };
.framing { .framing {
max-width: var(--reading-max); max-width: var(--reading-max);
color: var(--on-surface-variant);
} }
/* ── Navigation cards ─────────────────────────────────────────── */ /* ── Navigation cards ─────────────────────────────────────────── */
@ -140,6 +152,8 @@ const user = { name: 'Mette' };
.card-label { .card-label {
color: var(--on-surface-muted); color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
} }
.card-title { .card-title {

View file

@ -0,0 +1,223 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import {
verifyInviteTokenFormat, hashToken, createSession, hashPassword,
} from '../../lib/auth';
import { getInviteByTokenHash, redeemInvite, createUserFromInvite } from '../../lib/db';
const { token } = Astro.params;
// Validate token format before any DB work
if (!token || !verifyInviteTokenFormat(token)) {
return Astro.redirect('/login');
}
const tokenHash = hashToken(token);
const invite = getInviteByTokenHash(tokenHash);
const isExpired = invite ? new Date(invite.expires_at) < new Date() : false;
const isUsed = invite ? invite.used_at !== null : false;
const isInvalid = !invite || isExpired || isUsed;
let error: string | null = null;
if (!isInvalid && Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const password = String(data.get('password') ?? '');
const password2 = String(data.get('password2') ?? '');
if (password.length < 8) {
error = 'Password must be at least 8 characters.';
} else if (password !== password2) {
error = 'Passwords do not match.';
} else {
const hash = hashPassword(password);
const user = createUserFromInvite(invite!, hash);
redeemInvite(invite!.id);
createSession(user.id, Astro.cookies);
return Astro.redirect('/');
}
}
---
<BaseLayout title="Accept invitation">
<div class="page">
<div class="card">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
<h1 class="headline-md title">You have been invited.</h1>
{isInvalid ? (
<div>
<p class="body-md error-msg">
{!invite
? 'This invitation link is not valid.'
: isUsed
? 'This invitation has already been used.'
: 'This invitation has expired.'
}
</p>
<a href="/login" class="body-sm back-link">Go to sign in</a>
</div>
) : (
<div class="invite-body">
<p class="body-md welcome">
Welcome, <strong>{invite!.name}</strong> from {invite!.organisation}.
Set a password to activate your account.
</p>
{error && (
<p class="error body-sm" role="alert">{error}</p>
)}
<form method="POST" class="form" novalidate>
<div class="field">
<label for="password" class="label-sm field-label">Password</label>
<input
type="password"
id="password"
name="password"
class="input body-md"
autocomplete="new-password"
required
minlength="8"
autofocus
/>
</div>
<div class="field">
<label for="password2" class="label-sm field-label">Confirm password</label>
<input
type="password"
id="password2"
name="password2"
class="input body-md"
autocomplete="new-password"
required
/>
</div>
<button type="submit" class="btn-primary body-md">Activate account</button>
</form>
</div>
)}
</div>
</div>
</BaseLayout>
<style>
.page {
min-height: 100vh;
background: var(--background);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.card {
width: 100%;
max-width: 28rem;
background: var(--surface-container-lowest);
border-radius: var(--radius-lg);
padding: var(--space-10) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.wordmark {
height: 22px;
width: auto;
display: block;
margin-bottom: var(--space-2);
}
.title {
font-family: var(--font-serif);
letter-spacing: var(--tracking-snug);
margin: 0;
}
.error-msg {
color: var(--pigment-terracotta);
margin: 0 0 var(--space-4);
}
.back-link {
color: var(--on-surface-variant);
}
.invite-body {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.welcome {
color: var(--on-surface-variant);
margin: 0;
}
.error {
padding: var(--space-3) var(--space-4);
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-sm);
color: var(--pigment-terracotta);
margin: 0;
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field-label {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
outline: none;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
box-sizing: border-box;
}
.input:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.btn-primary {
display: block;
width: 100%;
padding: var(--space-3) var(--space-5);
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 600;
letter-spacing: var(--tracking-snug);
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover {
opacity: 0.9;
}
</style>

188
src/pages/login.astro Normal file
View file

@ -0,0 +1,188 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getSessionUser } from '../lib/auth';
import { getUserByEmail } from '../lib/db';
import { verifyPassword, createSession } from '../lib/auth';
// Already logged in → home
const existing = getSessionUser(Astro.cookies);
if (existing) return Astro.redirect('/');
let error: string | null = null;
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const email = String(data.get('email') ?? '').trim().toLowerCase();
const password = String(data.get('password') ?? '');
const user = getUserByEmail(email);
if (!user || !user.active || !verifyPassword(password, user.password_hash)) {
error = 'Email or password is incorrect.';
} else {
createSession(user.id, Astro.cookies);
return Astro.redirect('/');
}
}
---
<BaseLayout title="Sign in">
<div class="page">
<div class="card">
<a href="/" class="wordmark-link" aria-label="Fenja AI home">
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
</a>
<h1 class="headline-md title">Project Bifrost</h1>
<p class="body-md subtitle">Sign in to continue.</p>
{error && (
<p class="error body-sm" role="alert">{error}</p>
)}
<form method="POST" class="form" novalidate>
<div class="field">
<label for="email" class="label-sm field-label">Email</label>
<input
type="email"
id="email"
name="email"
class="input body-md"
autocomplete="email"
required
autofocus
/>
</div>
<div class="field">
<label for="password" class="label-sm field-label">Password</label>
<input
type="password"
id="password"
name="password"
class="input body-md"
autocomplete="current-password"
required
/>
</div>
<button type="submit" class="btn-primary body-md">Sign in</button>
</form>
</div>
</div>
</BaseLayout>
<style>
.page {
min-height: 100vh;
background: var(--background);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.card {
width: 100%;
max-width: 26rem;
background: var(--surface-container-lowest);
border-radius: var(--radius-lg);
padding: var(--space-10) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.wordmark-link {
display: inline-block;
margin-bottom: var(--space-2);
border-bottom: none;
}
.wordmark-link:hover {
border-bottom: none;
}
.wordmark {
height: 22px;
width: auto;
}
.title {
font-family: var(--font-serif);
letter-spacing: var(--tracking-snug);
margin: 0;
}
.subtitle {
color: var(--on-surface-variant);
margin: 0 0 var(--space-2);
}
/* ── Error ──────────────────────────────────────────────────────── */
.error {
padding: var(--space-3) var(--space-4);
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-sm);
color: var(--pigment-terracotta);
margin: 0;
}
/* ── Form ───────────────────────────────────────────────────────── */
.form {
display: flex;
flex-direction: column;
gap: var(--space-5);
margin-top: var(--space-2);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field-label {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
outline: none;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
box-sizing: border-box;
}
.input:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.btn-primary {
display: block;
width: 100%;
padding: var(--space-3) var(--space-5);
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 600;
letter-spacing: var(--tracking-snug);
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
margin-top: var(--space-2);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:active {
opacity: 0.8;
}
</style>