project-bifrost-platform/src/pages/admin/index.astro
2026-04-18 22:52:29 +02:00

519 lines
18 KiB
Text

---
import AppLayout from '../../layouts/AppLayout.astro';
import {
getAllInvites, getAllUsersPublic, revokeInvite,
createInvite, updateUserRole, deactivateUser,
} from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown';
import type { Role } from '../../lib/db';
const user = Astro.locals.user;
// Guard: fenja only
if (user.role !== 'fenja') {
return Astro.redirect('/');
}
const tab = Astro.url.searchParams.get('tab') ?? 'invitations';
let newInviteToken: string | null = null;
let formError: string | null = null;
let actionMsg: string | null = null;
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'create_invite') {
const name = String(data.get('name') ?? '').trim();
const email = String(data.get('email') ?? '').trim().toLowerCase();
const organisation = String(data.get('organisation') ?? '').trim();
const role = String(data.get('role') ?? '') as Role;
if (!name || !email || !organisation || !['pilot','cab','fenja'].includes(role)) {
formError = 'All fields are required.';
} else {
const { token, tokenHash } = generateInviteToken();
createInvite({
token_hash: tokenHash,
email,
name,
organisation,
role,
expires_at: inviteExpiresAt(),
created_by_user_id: user.id,
});
newInviteToken = `${Astro.url.origin}/invite/${token}`;
}
} else if (action === 'revoke_invite') {
const id = Number(data.get('invite_id'));
if (id) revokeInvite(id);
return Astro.redirect('/admin?tab=invitations&msg=revoked');
} else if (action === 'change_role') {
const userId = Number(data.get('user_id'));
const newRole = String(data.get('role')) as Role;
if (userId && ['pilot','cab','fenja'].includes(newRole)) {
updateUserRole(userId, newRole);
}
return Astro.redirect('/admin?tab=participants&msg=updated');
} else if (action === 'deactivate_user') {
const userId = Number(data.get('user_id'));
if (userId && userId !== user.id) deactivateUser(userId);
return Astro.redirect('/admin?tab=participants&msg=deactivated');
}
}
const invites = getAllInvites();
const users = getAllUsersPublic();
actionMsg = Astro.url.searchParams.get('msg');
---
<AppLayout title="Admin" user={user}>
<div class="page">
<header class="page-header">
<p class="label-sm eyebrow">Admin</p>
<h1 class="display-md page-title">Control panel.</h1>
</header>
<!-- Tabs -->
<div class="tabs">
<a
href="/admin?tab=invitations"
class:list={['tab label-sm', { active: tab === 'invitations' }]}
>Invitations</a>
<a
href="/admin?tab=participants"
class:list={['tab label-sm', { active: tab === 'participants' }]}
>Participants</a>
</div>
{actionMsg && (
<p class="action-msg body-sm" role="status">
{actionMsg === 'revoked' ? 'Invite revoked.' :
actionMsg === 'updated' ? 'Role updated.' :
actionMsg === 'deactivated' ? 'User deactivated.' : ''}
</p>
)}
<!-- Invitations tab -->
{tab === 'invitations' && (
<div class="tab-content">
{/* New invite form */}
<section class="section">
<h2 class="label-sm section-heading">Generate invite link</h2>
{formError && (
<p class="form-error body-sm" role="alert">{formError}</p>
)}
{newInviteToken && (
<div class="invite-result">
<p class="label-sm invite-result-label">Copy this link and send it personally. It expires in 14 days and is single-use.</p>
<div class="invite-link-row">
<code class="invite-link body-sm">{newInviteToken}</code>
<button
type="button"
class="copy-btn label-sm"
data-copy={newInviteToken}
onclick="navigator.clipboard.writeText(this.dataset.copy);this.textContent='Copied'"
>
Copy
</button>
</div>
</div>
)}
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="create_invite" />
<div class="form-grid">
<div class="field">
<label for="name" class="label-sm field-label">Name</label>
<input type="text" id="name" name="name" class="input body-md" required />
</div>
<div class="field">
<label for="email" class="label-sm field-label">Email</label>
<input type="email" id="email" name="email" class="input body-md" required />
</div>
<div class="field">
<label for="organisation" class="label-sm field-label">Organisation</label>
<input type="text" id="organisation" name="organisation" class="input body-md" required />
</div>
<div class="field">
<label for="role" class="label-sm field-label">Role</label>
<select id="role" name="role" class="select body-md" required>
<option value="pilot">Pilot</option>
<option value="cab">CAB</option>
<option value="fenja">Fenja</option>
</select>
</div>
</div>
<button type="submit" class="btn-primary label-sm">Generate link</button>
</form>
</section>
{/* Invite table */}
<section class="section">
<h2 class="label-sm section-heading">Outstanding invites</h2>
{invites.filter((i) => !i.used_at).length === 0 ? (
<p class="body-sm empty-msg">No outstanding invites.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Role</th>
<th class="label-sm">Expires</th>
<th class="label-sm">Action</th>
</tr>
</thead>
<tbody>
{invites.filter((i) => !i.used_at).map((invite) => (
<tr>
<td class="body-sm">{invite.name}</td>
<td class="body-sm">{invite.email}</td>
<td class="body-sm">{invite.organisation}</td>
<td class="body-sm" style="text-transform:capitalize">{invite.role}</td>
<td class="body-sm">{fmtDate(invite.expires_at)}</td>
<td>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="revoke_invite" />
<input type="hidden" name="invite_id" value={invite.id} />
<button type="submit" class="danger-btn label-sm">Revoke</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)}
<!-- Participants tab -->
{tab === 'participants' && (
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">All participants</h2>
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Role</th>
<th class="label-sm">Last seen</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr class:list={[{ self: u.id === user.id }]}>
<td class="body-sm">{u.name}</td>
<td class="body-sm">{u.email}</td>
<td class="body-sm">{u.organisation}</td>
<td>
{u.id !== user.id ? (
<form method="POST" class="inline-form role-form">
<input type="hidden" name="action" value="change_role" />
<input type="hidden" name="user_id" value={u.id} />
<select name="role" class="select-inline label-sm" onchange="this.form.submit()">
<option value="pilot" selected={u.role === 'pilot'}>Pilot</option>
<option value="cab" selected={u.role === 'cab'}>CAB</option>
<option value="fenja" selected={u.role === 'fenja'}>Fenja</option>
</select>
</form>
) : (
<span class="body-sm" style="text-transform:capitalize">{u.role}</span>
)}
</td>
<td class="body-sm muted">
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
</td>
<td>
{u.id !== user.id && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="deactivate_user" />
<input type="hidden" name="user_id" value={u.id} />
<button type="submit" class="danger-btn label-sm"
onclick="return confirm('Deactivate this user?')">
Deactivate
</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-8);
}
.eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.page-title { margin: 0; }
/* ── Tabs ────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: var(--space-1);
margin-bottom: var(--space-8);
border-bottom: var(--ghost-border);
padding-bottom: var(--space-2);
}
.tab {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
.tab.active { color: var(--on-surface); background: var(--surface-container); }
/* ── Messages ────────────────────────────────────────────────────── */
.action-msg {
padding: var(--space-3) var(--space-4);
background: rgba(109, 140, 124, 0.1);
border-radius: var(--radius-sm);
color: var(--pigment-copper);
margin-bottom: var(--space-6);
}
.form-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-bottom: var(--space-4);
}
/* ── Tab content ─────────────────────────────────────────────────── */
.tab-content {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
/* ── Section ─────────────────────────────────────────────────────── */
.section {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.section-heading {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.empty-msg {
color: var(--on-surface-muted);
margin: 0;
}
/* ── Invite result ───────────────────────────────────────────────── */
.invite-result {
background: rgba(109, 140, 124, 0.08);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.invite-result-label {
color: var(--pigment-copper);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.invite-link-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.invite-link {
font-family: var(--font-mono);
background: var(--background);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
color: var(--on-surface);
word-break: break-all;
flex: 1;
}
.copy-btn {
padding: var(--space-2) var(--space-3);
background: var(--secondary);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-sans);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Invite form ─────────────────────────────────────────────────── */
.invite-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 48rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.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,
.select {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--surface-container-lowest);
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,
.select:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.btn-primary {
align-self: flex-start;
padding: var(--space-2) var(--space-6);
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-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover { opacity: 0.9; }
/* ── Data table ──────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
padding: var(--space-2) var(--space-3) var(--space-2) 0;
border-bottom: var(--ghost-border);
font-weight: 500;
}
.data-table td {
padding: var(--space-3) var(--space-3) var(--space-3) 0;
border-bottom: var(--ghost-border);
color: var(--on-surface-variant);
vertical-align: middle;
}
.data-table tr.self td {
color: var(--on-surface-muted);
}
.muted { color: var(--on-surface-muted) !important; }
/* ── Inline elements ─────────────────────────────────────────────── */
.inline-form { display: inline; }
.select-inline {
background: none;
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
padding: 0.2em var(--space-3);
cursor: pointer;
outline: none;
}
.danger-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(--pigment-terracotta);
padding: 0.2em var(--space-2);
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-standard);
}
.danger-btn:hover {
background: rgba(185, 107, 88, 0.08);
}
</style>