Skip to content
Open
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# build output
dist/
dist-organizer/

# dependencies
node_modules/
Expand All @@ -13,7 +14,9 @@ pnpm-debug.log*

# environment variables
.env
.env.production
.env.*
.dev.vars*
worker-configuration.d.ts

# macOS-specific files
.DS_Store
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ Before the first production deployment, repository administrators must create th
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.

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.

## Organizer access (not deployed)

The public website deliberately contains no organizer route or navigation link. A separate organizer-only Astro build and Worker configuration are prepared for the future confirmed subdomain.

- `pnpm build:organizer` produces the private site in `dist-organizer/`.
- `wrangler.organizer.jsonc` defines the separate `devcongress-organizer` Worker and intentionally has no route until the hostname is confirmed.
- `ORGANIZER_ORIGIN` must be set to that exact HTTPS origin before the Worker will serve auth requests.
- After the initial script deployment creates the Worker, set `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_ROLE_KEY` as Cloudflare secrets. Do not put them in this repository.

When the hostname is confirmed, add it as a Cloudflare custom domain, then add the matching Supabase redirect URL and Google OAuth authorized JavaScript origin.
## 👀 Want to learn more?

Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
9 changes: 9 additions & 0 deletions astro.organizer.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @ts-check
import { defineConfig } from 'astro/config';

export default defineConfig({
srcDir: './src-organizer',
publicDir: './public',
outDir: './dist-organizer',
output: 'static',
});
5 changes: 5 additions & 0 deletions organizer.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Set these locally only after the organizer hostname is confirmed.
ORGANIZER_ORIGIN=https://organizer.example.devcongress.org
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"build:organizer": "astro build --config astro.organizer.config.mjs",
"deploy:organizer:dry-run": "pnpm build:organizer && wrangler deploy --config wrangler.organizer.jsonc --dry-run",
"deploy": "pnpm build && wrangler deploy",
"deploy:dry-run": "pnpm build && wrangler deploy --dry-run",
"astro": "astro"
},
"dependencies": {
"@supabase/supabase-js": "2.106.2",
"astro": "^6.4.2",
"zod": "^4.4.3"
},
Expand Down
71 changes: 69 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions src-organizer/pages/auth/callback.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
---

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="robots" content="noindex, nofollow" />
<title>Completing sign in | DevCongress</title>
</head>
<body>
<main><section><h1 data-title>Checking your account</h1><p data-message>Finishing Google sign-in securely.</p><a href="/" data-back hidden>Back to sign in</a></section></main>
</body>
</html>

<style>
:root { color: #e5e5e5; background: #1c1c1c; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; } main { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
section { width: min(100%, 460px); padding: 40px; border: 1px solid #3a3a3a; border-radius: 12px; background: #242424; }
h1 { margin: 0; letter-spacing: -0.15px; font-size: clamp(32px, 8vw, 44px); line-height: 1.05; } p, a { color: #a1a1a1; line-height: 1.6; }
</style>

<script>
import { createClient } from '@supabase/supabase-js';

type AuthConfig = { supabaseUrl: string; supabaseAnonKey: string };
const title = document.querySelector<HTMLElement>('[data-title]');
const message = document.querySelector<HTMLElement>('[data-message]');
const back = document.querySelector<HTMLAnchorElement>('[data-back]');
function showError(value: string) { if (title) title.textContent = 'Sign-in unavailable'; if (message) message.textContent = value; if (back) back.hidden = false; }

async function completeSignIn() {
const parameters = new URLSearchParams(window.location.search);
const code = parameters.get('code');
const callbackError = parameters.get('error');
if (callbackError || !code) return showError(callbackError ?? 'Google did not return a sign-in code. Please try again.');
try {
const response = await fetch('/api/auth/config', { credentials: 'same-origin' });
const config = await response.json() as AuthConfig & { error?: string };
if (!response.ok || !config.supabaseUrl || !config.supabaseAnonKey) throw new Error(config.error ?? 'Organizer sign-in is not configured.');
const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
auth: { persistSession: true, autoRefreshToken: false, detectSessionInUrl: false, flowType: 'pkce', storage: window.sessionStorage, storageKey: 'devcon-organizer-auth' },
});
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
if (error || !data.session?.access_token) throw new Error('Google organizer sign-in could not be completed. Please try again.');
const exchange = await fetch('/api/auth/admin/exchange', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: data.session.access_token }) });
const payload = await exchange.json().catch(() => null) as { error?: string } | null;
await supabase.auth.signOut();
if (!exchange.ok) throw new Error(payload?.error ?? 'Google organizer sign-in could not be completed. Please try again.');
window.location.replace('/');
} catch (error) { showError(error instanceof Error ? error.message : 'Google organizer sign-in could not be completed. Please try again.'); }
}
void completeSignIn();
</script>
95 changes: 95 additions & 0 deletions src-organizer/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
---

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#1C1C1C" />
<title>Organizer sign in | DevCongress</title>
</head>
<body>
<main>
<section class="card" aria-labelledby="title">
<img src="/images/logo.png" alt="dev:congress{};" height="42" />
<p class="eyebrow">Organizer access</p>
<h1 id="title">Sign in with Google</h1>
<p>Only approved DevCongress organizers can continue.</p>
<p class="message" data-message role="status" aria-live="polite"></p>
<button type="button" data-sign-in>Continue with Google</button>
<button type="button" class="secondary" data-sign-out hidden>Sign out</button>
</section>
</main>
</body>
</html>

<style>
:root { color: #e5e5e5; background: #1c1c1c; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
main { display: grid; min-height: 100vh; place-items: center; padding: 24px; }
.card { width: min(100%, 460px); padding: 40px; border: 1px solid #3a3a3a; border-radius: 12px; background: #242424; }
img { display: block; width: auto; margin-bottom: 36px; }
.eyebrow { margin: 0 0 10px; color: #a1a1a1; font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; letter-spacing: -0.15px; font-size: clamp(32px, 8vw, 44px); line-height: 1.05; }
p:not(.eyebrow) { color: #a1a1a1; line-height: 1.6; }
.message { min-height: 24px; color: #e5e5e5 !important; font-size: 14px; }
button { width: 100%; min-height: 46px; border: 0; border-radius: 8px; background: #e5e5e5; color: #1c1c1c; font: inherit; font-weight: 700; cursor: pointer; }
button:disabled { cursor: wait; opacity: .65; }
button.secondary { margin-top: 12px; border: 1px solid #4a4a4a; background: transparent; color: #e5e5e5; }
</style>

<script>
import { createClient } from '@supabase/supabase-js';

type AuthConfig = { supabaseUrl: string; supabaseAnonKey: string };
const message = document.querySelector<HTMLElement>('[data-message]');
const signIn = document.querySelector<HTMLButtonElement>('[data-sign-in]');
const signOut = document.querySelector<HTMLButtonElement>('[data-sign-out]');

function setMessage(value: string) { if (message) message.textContent = value; }

async function authConfig(): Promise<AuthConfig> {
const response = await fetch('/api/auth/config', { credentials: 'same-origin' });
const payload = await response.json() as AuthConfig & { error?: string };
if (!response.ok || !payload.supabaseUrl || !payload.supabaseAnonKey) throw new Error(payload.error ?? 'Organizer sign-in is not configured.');
return payload;
}

async function updateSession() {
const response = await fetch('/api/auth/session', { credentials: 'same-origin' });
const session = await response.json() as { authenticated?: boolean; email?: string };
if (session.authenticated) {
setMessage(`Signed in as ${session.email ?? 'an organizer'}.`);
signIn?.setAttribute('hidden', '');
if (signOut) signOut.hidden = false;
}
}

signIn?.addEventListener('click', async () => {
signIn.disabled = true;
setMessage('Opening Google sign-in…');
try {
const config = await authConfig();
const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
auth: { persistSession: true, autoRefreshToken: false, detectSessionInUrl: false, flowType: 'pkce', storage: window.sessionStorage, storageKey: 'devcon-organizer-auth' },
});
const callbackUrl = new URL('/api/auth/admin/callback', window.location.origin);
const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: callbackUrl.toString(), scopes: 'email profile', queryParams: { prompt: 'select_account' } } });
if (error) throw error;
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Unable to start Google sign-in. Please try again.');
signIn.disabled = false;
}
});

signOut?.addEventListener('click', async () => {
signOut.disabled = true;
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
window.location.replace('/');
});

void updateSession();
</script>
Loading
Loading