From 7f02600c0515e500cefb751ea1db05b63fe22a70 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 18 Apr 2026 22:52:29 +0200 Subject: [PATCH] feat: admin panel --- src/pages/admin/index.astro | 519 ++++++++++++++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 src/pages/admin/index.astro diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..aa7906d --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,519 @@ +--- +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'); +--- + +
+ + + + + + + {actionMsg && ( +

+ {actionMsg === 'revoked' ? 'Invite revoked.' : + actionMsg === 'updated' ? 'Role updated.' : + actionMsg === 'deactivated' ? 'User deactivated.' : ''} +

+ )} + + + {tab === 'invitations' && ( +
+ + {/* New invite form */} +
+

Generate invite link

+ + {formError && ( + + )} + + {newInviteToken && ( +
+

Copy this link and send it personally. It expires in 14 days and is single-use.

+ +
+ )} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* Invite table */} +
+

Outstanding invites

+ {invites.filter((i) => !i.used_at).length === 0 ? ( +

No outstanding invites.

+ ) : ( + + + + + + + + + + + + + {invites.filter((i) => !i.used_at).map((invite) => ( + + + + + + + + + ))} + +
NameEmailOrganisationRoleExpiresAction
{invite.name}{invite.email}{invite.organisation}{invite.role}{fmtDate(invite.expires_at)} +
+ + + +
+
+ )} +
+ +
+ )} + + + {tab === 'participants' && ( +
+
+

All participants

+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
NameEmailOrganisationRoleLast seenActions
{u.name}{u.email}{u.organisation} + {u.id !== user.id ? ( +
+ + + +
+ ) : ( + {u.role} + )} +
+ {u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'} + + {u.id !== user.id && ( +
+ + + +
+ )} +
+
+
+ )} + +
+
+ +