Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/design-system/components/flex-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ interface LayoutProps {
contentWidth?: 'centered' | 'full'
}

function isAdminUser(login: string): boolean {
const admins = (process.env.ADMIN_USERS ?? 'danielnaab')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
return admins.includes(login)
}

export const Layout: FC<PropsWithChildren<LayoutProps>> = (props) => {
const title = props.title ? `${props.title} | Forms Lab` : 'Forms Lab'

Expand Down Expand Up @@ -101,6 +109,13 @@ export const Layout: FC<PropsWithChildren<LayoutProps>> = (props) => {
label="Catalog"
current={props.currentPath?.startsWith('/catalog') ?? false}
/>
{isAdminUser(props.user.login) && (
<HeaderNavItem
href={resolveUrl('/admin/users')}
label="Admin"
current={props.currentPath?.startsWith('/admin') ?? false}
/>
)}
</>
) : (
<>
Expand Down
37 changes: 35 additions & 2 deletions src/entrypoints/app/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getCookie } from 'hono/cookie'
import { deleteCookie, getCookie } from 'hono/cookie'
import { createMiddleware } from 'hono/factory'
import {
type AccessStore,
COOKIE_NAME,
decryptSession,
type SessionUser,
Expand All @@ -13,6 +14,13 @@ declare module 'hono' {
}
}

function parseAdminUsers(): string[] {
return (process.env.ADMIN_USERS ?? 'danielnaab')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
}

export function sessionReader() {
return createMiddleware(async (c, next) => {
const secret = process.env.SESSION_SECRET
Expand All @@ -35,7 +43,7 @@ export function sessionReader() {
})
}

export function requireAuth() {
export function requireAuth(accessStore?: AccessStore) {
return createMiddleware(async (c, next) => {
const user = c.get('user')
if (!user) {
Expand All @@ -45,6 +53,31 @@ export function requireAuth() {
const returnTo = encodeURIComponent(fullPath)
return c.redirect(`${resolveUrl('/auth/signin')}?returnTo=${returnTo}`)
}

if (accessStore) {
const entry = accessStore.get(user.login)
if (entry && entry.status !== 'approved') {
deleteCookie(c, COOKIE_NAME)
const returnTo = encodeURIComponent(c.req.path)
return c.redirect(`${resolveUrl('/auth/signin')}?returnTo=${returnTo}`)
}
}

await next()
})
}

export function requireAdmin() {
return createMiddleware(async (c, next) => {
const user = c.get('user')
if (!user) {
return c.text('Forbidden', 403)
}

if (!parseAdminUsers().includes(user.login)) {
return c.text('Forbidden', 403)
}

await next()
})
}
212 changes: 212 additions & 0 deletions src/entrypoints/app/routes/admin/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import type { FC } from 'hono/jsx'
import type { AccessEntry } from '../../../../services/auth'
import { resolveUrl } from '../../../../shared/base-path'

interface EnrichedEntry extends AccessEntry {
name: string
avatarUrl: string | null
}

export const AdminUsersPage: FC<{
pending: EnrichedEntry[]
approved: EnrichedEntry[]
revoked: EnrichedEntry[]
}> = ({ pending, approved, revoked }) => (
<div class="l-stack" data-space="lg">
<h1>User Management</h1>

<section class="l-stack">
<h2>Pending Requests ({pending.length})</h2>
{pending.length === 0 ? (
<p class="text-muted">No pending requests.</p>
) : (
<table class="flex-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Requested</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{pending.map((u) => (
<tr>
<td>
<div
class="l-cluster"
style="gap: var(--flex-space-xs); align-items: center;"
>
{u.avatarUrl && (
<img
src={u.avatarUrl}
alt=""
width="24"
height="24"
style="border-radius: 50%;"
/>
)}
<span>
{u.name} (@{u.login})
</span>
</div>
</td>
<td>
{u.requestedAt
? new Date(u.requestedAt * 1000).toLocaleDateString()
: '\u2014'}
</td>
<td>
<div class="l-cluster" style="gap: var(--flex-space-xs);">
<form
method="post"
action={resolveUrl('/admin/users/approve')}
>
<input type="hidden" name="login" value={u.login} />
<button type="submit" class="flex-button flex-button--sm">
Approve
</button>
</form>
<form
method="post"
action={resolveUrl('/admin/users/deny')}
>
<input type="hidden" name="login" value={u.login} />
<button
type="submit"
class="flex-button flex-button--sm flex-button--outline"
>
Deny
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>

<section class="l-stack">
<h2>Approved Users ({approved.length})</h2>
{approved.length === 0 ? (
<p class="text-muted">No approved users.</p>
) : (
<table class="flex-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Source</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{approved.map((u) => (
<tr>
<td>
<div
class="l-cluster"
style="gap: var(--flex-space-xs); align-items: center;"
>
{u.avatarUrl && (
<img
src={u.avatarUrl}
alt=""
width="24"
height="24"
style="border-radius: 50%;"
/>
)}
<span>
{u.name} (@{u.login})
</span>
</div>
</td>
<td>{u.source}</td>
<td>
{u.source === 'env' ? (
<em class="text-muted">env-managed</em>
) : (
<form
method="post"
action={resolveUrl('/admin/users/revoke')}
>
<input type="hidden" name="login" value={u.login} />
<button
type="submit"
class="flex-button flex-button--sm flex-button--outline"
>
Revoke
</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>

<section class="l-stack">
<h2>Add User</h2>
<form
method="post"
action={resolveUrl('/admin/users/add')}
class="l-cluster"
style="gap: var(--flex-space-sm);"
>
<input
type="text"
name="login"
placeholder="GitHub username"
required
class="flex-input"
/>
<button type="submit" class="flex-button">
Add
</button>
</form>
</section>

{revoked.length > 0 && (
<section class="l-stack">
<h2>Revoked ({revoked.length})</h2>
<table class="flex-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Revoked by</th>
</tr>
</thead>
<tbody>
{revoked.map((u) => (
<tr>
<td>
<div
class="l-cluster"
style="gap: var(--flex-space-xs); align-items: center;"
>
{u.avatarUrl && (
<img
src={u.avatarUrl}
alt=""
width="24"
height="24"
style="border-radius: 50%;"
/>
)}
<span>
{u.name} (@{u.login})
</span>
</div>
</td>
<td>{u.decidedBy ?? '\u2014'}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
)
92 changes: 92 additions & 0 deletions src/entrypoints/app/routes/admin/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Hono } from 'hono'
import { Layout } from '../../../../design-system/components/flex-layout'
import type { AccessStore, UserStore } from '../../../../services/auth'
import { resolveUrl } from '../../../../shared/base-path'
import { requireAdmin } from '../../middleware/auth'
import { AdminUsersPage } from './components'

export function createAdminRoutes(
accessStore: AccessStore,
userStore: UserStore,
): Hono {
const admin = new Hono()

// All admin routes require admin
admin.use('*', requireAdmin())

// GET /admin/users — admin dashboard
admin.get('/users', (c) => {
const pending = accessStore.listByStatus('pending')
const approved = accessStore.listByStatus('approved')
const revoked = accessStore.listByStatus('revoked')

const enriched = (entries: typeof pending) =>
entries.map((entry) => {
const profile = userStore.get(entry.login)
return {
...entry,
name: profile?.name ?? entry.login,
avatarUrl: profile?.avatarUrl ?? null,
}
})

const pendingUsers = enriched(pending)
const approvedUsers = enriched(approved)
const revokedUsers = enriched(revoked)

return c.html(
<Layout currentPath="/admin/users" user={c.get('user')}>
<AdminUsersPage
pending={pendingUsers}
approved={approvedUsers}
revoked={revokedUsers}
/>
</Layout>,
)
})

// POST /admin/users/approve
admin.post('/users/approve', async (c) => {
const body = await c.req.parseBody()
const login = String(body.login ?? '').trim()
const user = c.get('user')
if (login && user) {
accessStore.approve(login, user.login)
}
return c.redirect(resolveUrl('/admin/users'))
})

// POST /admin/users/deny
admin.post('/users/deny', async (c) => {
const body = await c.req.parseBody()
const login = String(body.login ?? '').trim()
const user = c.get('user')
if (login && user) {
accessStore.deny(login, user.login)
}
return c.redirect(resolveUrl('/admin/users'))
})

// POST /admin/users/revoke
admin.post('/users/revoke', async (c) => {
const body = await c.req.parseBody()
const login = String(body.login ?? '').trim()
const user = c.get('user')
if (login && user) {
accessStore.revoke(login, user.login)
}
return c.redirect(resolveUrl('/admin/users'))
})

// POST /admin/users/add
admin.post('/users/add', async (c) => {
const body = await c.req.parseBody()
const login = String(body.login ?? '').trim()
if (login) {
accessStore.setApproved(login, 'admin')
}
return c.redirect(resolveUrl('/admin/users'))
})

return admin
}
Loading
Loading