Skip to content

Commit ae2fb42

Browse files
committed
feat(organizer): establish secure website console access
Add the first website-owned organizer console slice at /organizer-console, including a protected Worker route, Google OAuth PKCE login and callback pages, and session-aware Organizer/Sign out controls across the public site.\n\nThe Worker validates Google identity with Supabase, requires an active admin_memberships row before issuing an opaque HTTP-only devcon_admin cookie, stores only a token hash in admin_sessions, checks current membership status and role on protected requests, records login/logout audit events, and rejects cross-origin session mutations. Public assets stay asset-first; only organizer and auth paths execute Worker code.\n\nDocument the required Cloudflare Worker secrets, Supabase redirect URLs, and Google OAuth origin. Verify with pnpm install --frozen-lockfile, pnpm build, pnpm wrangler deploy --dry-run, generated Worker types, and local Worker smoke checks for public 200, login 200, unauthenticated and forged-cookie console redirects, cross-origin logout 403, missing-config 503, and public 404 behavior.
1 parent 17adae7 commit ae2fb42

14 files changed

Lines changed: 1035 additions & 11 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ pnpm-debug.log*
1313

1414
# environment variables
1515
.env
16-
.env.production
16+
.env.*
17+
.dev.vars*
18+
worker-configuration.d.ts
1719

1820
# macOS-specific files
1921
.DS_Store

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ All commands are run from the root of the project, from a terminal:
4040

4141
## Cloudflare Workers deployment
4242

43-
This site remains fully static. `wrangler.jsonc` publishes Astro's `dist/` output through Cloudflare Workers Static Assets; it does not add a server entrypoint, API routes, authentication, or database bindings.
43+
Public pages remain static. `wrangler.jsonc` publishes Astro's `dist/` output through Cloudflare Workers Static Assets. The only runtime paths are `/organizer-console/**` and `/api/auth/**`; all other public assets remain asset-first.
4444

4545
- `pnpm deploy:dry-run` builds the site and validates the Worker asset deployment locally.
4646
- `pnpm deploy` builds and deploys the static site with Wrangler.
@@ -55,6 +55,27 @@ Before the first production deployment, repository administrators must create th
5555
The first deployment should be validated at its Workers preview URL. Attach `devcongress.org` only after URL/content parity checks pass. GitHub Pages continues to deploy independently during the agreed soak window and remains the rollback path.
5656

5757
Forks and fork-origin pull requests run the build jobs only. GitHub Pages artifacts, Workers dry-run validation, protected environments, and production deployments run only from `devcongress/website` itself.
58+
59+
## Organizer console setup
60+
61+
The organizer console lives at `/organizer-console/`. It uses the existing Supabase project and Google OAuth flow, but creates its own app session on `devcongress.org`.
62+
63+
The current production Worker is static-assets-only, so Cloudflare will not show runtime variables yet. After this feature deploys the Worker script for `devcongress-website`, configure these Worker secrets in Cloudflare and rerun the deployment if the first production run needs them:
64+
65+
- `SUPABASE_URL` — the Supabase project URL.
66+
- `SUPABASE_ANON_KEY` — the browser-safe Supabase anon key. The Worker deliberately returns this only from its same-origin auth-config endpoint.
67+
- `SUPABASE_SERVICE_ROLE_KEY` — server-only Supabase key. Never expose this key in browser code, GitHub Actions variables, or the repository.
68+
69+
`keep_vars: true` preserves Cloudflare dashboard variables during GitHub Actions deployments. For local Worker testing, create an ignored `.dev.vars` file with the same three values.
70+
71+
In Supabase Auth, add both callback destinations to the allowed redirect URLs:
72+
73+
- `https://devcongress.org/api/auth/admin/callback`
74+
- `https://devcongress-website.admins-a7d.workers.dev/api/auth/admin/callback`
75+
76+
Also add `https://devcongress.org` to the authorized JavaScript origins for the existing Google OAuth client.
77+
78+
Google authentication proves identity only. Access is granted only when the verified Google email has an active row in `public.admin_memberships`; the Worker then stores only a hash of an opaque, HTTP-only `devcon_admin` session token in `public.admin_sessions`.
5879
## 👀 Want to learn more?
5980

6081
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"astro": "astro"
1515
},
1616
"dependencies": {
17+
"@supabase/supabase-js": "2.106.2",
1718
"astro": "^6.4.2",
1819
"zod": "^4.4.3"
1920
},

pnpm-lock.yaml

Lines changed: 69 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Navbar.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
---
2+
import OrganizerActions from './OrganizerActions.astro';
3+
24
interface Props { slackUrl: string; paystackUrl: string; }
35
const { slackUrl, paystackUrl } = Astro.props;
46
---
@@ -16,6 +18,7 @@ const { slackUrl, paystackUrl } = Astro.props;
1618
</nav>
1719

1820
<div class="navbar-actions">
21+
<OrganizerActions />
1922
<a href={paystackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-outline">
2023
Support Us
2124
</a>
@@ -36,6 +39,7 @@ const { slackUrl, paystackUrl } = Astro.props;
3639
<a href="#meetups">Meetups</a>
3740
</nav>
3841
<div class="mobile-actions">
42+
<OrganizerActions />
3943
<a href={paystackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-outline">Support Us</a>
4044
<a href={slackUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">Join Slack</a>
4145
</div>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<div class="organizer-actions" data-organizer-actions>
2+
<a href="/organizer-console/login/" class="organizer-link" data-organizer-link>Organizer</a>
3+
<button type="button" class="organizer-signout" data-organizer-signout hidden>Sign out</button>
4+
</div>
5+
6+
<style>
7+
.organizer-actions {
8+
display: inline-flex;
9+
align-items: center;
10+
gap: var(--s2);
11+
}
12+
13+
.organizer-link,
14+
.organizer-signout {
15+
min-height: 36px;
16+
padding: 0 var(--s3);
17+
border: 2px solid var(--black);
18+
border-radius: var(--r-full);
19+
background: var(--bg-white);
20+
color: var(--text);
21+
font-family: var(--font-body);
22+
font-size: var(--text-xs);
23+
font-weight: 700;
24+
line-height: 1;
25+
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
26+
}
27+
28+
.organizer-link {
29+
display: inline-flex;
30+
align-items: center;
31+
}
32+
33+
.organizer-signout {
34+
color: var(--text-mid);
35+
}
36+
37+
@media (hover: hover) and (pointer: fine) {
38+
.organizer-link:hover,
39+
.organizer-signout:hover {
40+
transform: translateY(-1px);
41+
background: var(--yellow);
42+
}
43+
}
44+
45+
.organizer-link:active,
46+
.organizer-signout:active {
47+
transform: scale(0.97);
48+
}
49+
</style>
50+
51+
<script>
52+
type OrganizerSession = {
53+
authenticated: boolean;
54+
};
55+
56+
async function updateOrganizerActions(actions: HTMLElement) {
57+
const link = actions.querySelector<HTMLAnchorElement>('[data-organizer-link]');
58+
const signOut = actions.querySelector<HTMLButtonElement>('[data-organizer-signout]');
59+
if (!link || !signOut) return;
60+
61+
try {
62+
const response = await fetch('/api/auth/session', {
63+
credentials: 'same-origin',
64+
headers: { Accept: 'application/json' },
65+
});
66+
const session = await response.json() as OrganizerSession;
67+
68+
if (session.authenticated) {
69+
link.href = '/organizer-console/';
70+
signOut.hidden = false;
71+
}
72+
} catch {
73+
// Keep the safe, signed-out link when session verification is unavailable.
74+
}
75+
76+
signOut.addEventListener('click', async () => {
77+
signOut.disabled = true;
78+
try {
79+
await fetch('/api/auth/logout', {
80+
method: 'POST',
81+
credentials: 'same-origin',
82+
headers: { 'Content-Type': 'application/json' },
83+
});
84+
} finally {
85+
window.location.assign('/');
86+
}
87+
});
88+
}
89+
90+
document.querySelectorAll<HTMLElement>('[data-organizer-actions]').forEach((actions) => {
91+
void updateOrganizerActions(actions);
92+
});
93+
</script>

src/layouts/Base.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import FooterSection from '../components/FooterSection.astro';
55
interface Props {
66
title?: string;
77
description?: string;
8+
robots?: string;
89
}
910
const {
1011
title = "DevCongress | Africa's home for builders",
1112
description = "DevCongress is a tech community where developers, designers, founders, and makers across Africa connect, grow, and build together.",
13+
robots = 'index, follow',
1214
} = Astro.props;
1315
1416
const siteEntries = await getCollection('site');
@@ -22,7 +24,7 @@ const site = siteEntries[0]!.data;
2224
<meta name="viewport" content="width=device-width, initial-scale=1" />
2325
<title>{title}</title>
2426
<meta name="description" content={description} />
25-
<meta name="robots" content="index, follow" />
27+
<meta name="robots" content={robots} />
2628
<meta name="theme-color" content="#F5F2E8" />
2729
<meta property="og:title" content={title} />
2830
<meta property="og:description" content={description} />

src/pages/meetups/[slug].astro

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import { getCollection } from 'astro:content';
33
import Base from '../../layouts/Base.astro';
4+
import OrganizerActions from '../../components/OrganizerActions.astro';
45
56
export async function getStaticPaths() {
67
const meetups = await getCollection('meetups');
@@ -76,7 +77,10 @@ const folderPhotos = photos.filter((photo) => photo.type === 'folder');
7677
<a href="/" class="mnav-logo">
7778
<img src="/images/logo.png" alt="DevCongress" height="36" />
7879
</a>
79-
<a href="/#meetups" class="mnav-back">← All meetups</a>
80+
<div class="mnav-actions">
81+
<OrganizerActions />
82+
<a href="/#meetups" class="mnav-back">← All meetups</a>
83+
</div>
8084
</div>
8185
</header>
8286

@@ -368,7 +372,8 @@ const folderPhotos = photos.filter((photo) => photo.type === 'folder');
368372
background: var(--bg); border-bottom: 2px solid var(--black);
369373
padding-block: var(--s4);
370374
}
371-
.mnav-inner { display: flex; align-items: center; justify-content: space-between; }
375+
.mnav-inner { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
376+
.mnav-actions { display: inline-flex; align-items: center; gap: var(--s3); }
372377
.mnav-back { font-size: var(--text-sm); font-weight: 500; color: var(--text-mid); transition: color 150ms ease; }
373378
.mnav-back:hover { color: var(--pink); }
374379

src/pages/meetups/index.astro

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import { getCollection } from 'astro:content';
33
import Base from '../../layouts/Base.astro';
4+
import OrganizerActions from '../../components/OrganizerActions.astro';
45
56
const meetups = await getCollection('meetups');
67
@@ -41,7 +42,10 @@ const statusConfig = {
4142
<a href="/" class="mnav-logo">
4243
<img src="/images/logo.png" alt="DevCongress" height="36" />
4344
</a>
44-
<a href="/" class="mnav-back">← Home</a>
45+
<div class="mnav-actions">
46+
<OrganizerActions />
47+
<a href="/" class="mnav-back">← Home</a>
48+
</div>
4549
</div>
4650
</header>
4751

@@ -98,7 +102,8 @@ const statusConfig = {
98102
background: var(--bg); border-bottom: 2px solid var(--black);
99103
padding-block: var(--s4);
100104
}
101-
.mnav-inner { display: flex; align-items: center; justify-content: space-between; }
105+
.mnav-inner { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
106+
.mnav-actions { display: inline-flex; align-items: center; gap: var(--s3); }
102107
.mnav-back { font-size: var(--text-sm); font-weight: 500; color: var(--text-mid); transition: color 150ms; }
103108
.mnav-back:hover { color: var(--pink); }
104109

0 commit comments

Comments
 (0)