From e900d8c9b3cc7c258c1b6c48092c8e956a289674 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 21 Jul 2026 18:27:45 +0100 Subject: [PATCH 01/14] Add OAuth login flow and signed user sessions --- .dev.vars.example | 3 ++ README.md | 45 +++++++++++++++++++ package.json | 2 + src/layouts/Base.astro | 62 ++++++++++++++++++++----- src/lib/base64url.ts | 22 +++++++++ src/lib/db/users.sql | 13 ++++++ src/lib/oauth.ts | 90 +++++++++++++++++++++++++++++++++++++ src/lib/pkce.ts | 17 +++++++ src/lib/session.ts | 92 ++++++++++++++++++++++++++++++++++++++ src/lib/users.ts | 29 ++++++++++++ src/pages/auth/callback.ts | 86 +++++++++++++++++++++++++++++++++++ src/pages/auth/login.ts | 45 +++++++++++++++++++ src/pages/auth/logout.ts | 7 +++ 13 files changed, 501 insertions(+), 12 deletions(-) create mode 100644 .dev.vars.example create mode 100644 src/lib/base64url.ts create mode 100644 src/lib/db/users.sql create mode 100644 src/lib/oauth.ts create mode 100644 src/lib/pkce.ts create mode 100644 src/lib/session.ts create mode 100644 src/lib/users.ts create mode 100644 src/pages/auth/callback.ts create mode 100644 src/pages/auth/login.ts create mode 100644 src/pages/auth/logout.ts diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 00000000..440162d4 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +AUTH_CLIENT_ID="replace-with-oauth-client-id-from-auth.fossbilling.net" +AUTH_CLIENT_SECRET="replace-with-oauth-client-secret-from-auth.fossbilling.net" +SESSION_SECRET="replace-with-openssl-rand-base64-32" diff --git a/README.md b/README.md index 590cf6d9..931f16e8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,25 @@ Install dependencies: npm install ``` +Create local secrets: + +```bash +cp .dev.vars.example .dev.vars +``` + +`AUTH_CLIENT_ID` / `AUTH_CLIENT_SECRET` are issued by an admin of the +[`FOSSBilling/auth`](https://github.com/FOSSBilling/auth) service (dynamic client +registration is disabled there) — request a client with both +`https://extensions.fossbilling.org/auth/callback` and +`http://localhost:4321/auth/callback` as redirect URIs. `SESSION_SECRET` can be any +random string, e.g. `openssl rand -base64 32`. + +Apply the `users` table to your local D1 database: + +```bash +npm run db:migrate:local +``` + Start the development server: ```bash @@ -63,6 +82,32 @@ npm run dev The site uses Cloudflare D1 for extension data, so some pages require a populated local D1 database or a Wrangler-powered local environment to fully match production. +## Authentication + +Sign-in is delegated to FOSSBilling's central auth service at +[auth.fossbilling.net](https://auth.fossbilling.net) via OAuth2/OIDC (Authorization +Code + PKCE), implemented under `src/pages/auth/` and `src/lib/`. That service is +identity-only — it never exposes roles or permissions. Extension ownership, +submitter/moderator status, and any other authorization concept live entirely in this +app's own `users` table (`src/lib/db/users.sql`), keyed by the auth service's `sub` +claim, in the same `DB_EXTENSIONS` D1 database this app already reads from. + +Sessions are a self-contained, HMAC-signed cookie minted after the initial token +exchange — this app does not depend on the auth service's own token lifetimes beyond +that exchange. + +Production secrets: + +```bash +npx wrangler secret put AUTH_CLIENT_ID +npx wrangler secret put AUTH_CLIENT_SECRET +npx wrangler secret put SESSION_SECRET +``` + +```bash +npm run db:migrate:remote +``` + ## License The extension directory website is licensed under the GNU Affero General Public License Version 3. See [LICENSE](./LICENSE) for details. diff --git a/package.json b/package.json index 4ac758df..000f68d7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "cf-typegen": "wrangler types", "cf-deploy": "wrangler deploy", + "db:migrate:local": "wrangler d1 execute DB_EXTENSIONS --local --file=./src/lib/db/users.sql", + "db:migrate:remote": "wrangler d1 execute DB_EXTENSIONS --remote --file=./src/lib/db/users.sql", "dev": "astro dev", "build": "astro build", "preview": "astro preview", diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 2414d7e6..492c48ed 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -4,12 +4,15 @@ import LogoLight from '@/assets/logo-light.svg'; import LogoDark from '@/assets/logo-dark.svg'; import Container from '@/components/Container.astro'; import { Sun, Moon } from '@lucide/astro'; +import { env } from 'cloudflare:workers'; +import { getSessionUser } from '@/lib/session'; interface Props { title: string; } const { title } = Astro.props; +const user = await getSessionUser(Astro.cookies, env.SESSION_SECRET); --- @@ -73,22 +76,57 @@ const { title } = Astro.props; > beta - +
+ { + user ? ( +
+ {user.picture && ( + + )} + +
+ +
+
+ ) : ( + + Sign in with GitHub + + ) + } + +
+ { + Astro.url.searchParams.get('auth_error') && ( +
+ Sign-in with GitHub failed. Please try again. +
+ ) + }
diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts new file mode 100644 index 00000000..1cf5ffe2 --- /dev/null +++ b/src/lib/base64url.ts @@ -0,0 +1,22 @@ +export function base64urlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function base64urlDecode(value: string): Uint8Array { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob( + padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '='), + ); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/src/lib/db/users.sql b/src/lib/db/users.sql new file mode 100644 index 00000000..c75f6a7d --- /dev/null +++ b/src/lib/db/users.sql @@ -0,0 +1,13 @@ +-- Local user/profile table, keyed by the central auth service's `sub`. +-- Identity only comes from auth.fossbilling.net; anything about what a user +-- can *do* here (extension ownership, submitter/moderator status, etc.) is +-- modeled in this app's own tables, referencing users(id). +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + picture TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 00000000..ac0aab1b --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,90 @@ +// Client for FOSSBilling's central auth service (auth.fossbilling.net). +// Identity only — see that repo's README for the identity/authorization boundary. +// Roles, permissions, and extension ownership are modeled in this app's own +// database (see users.ts), never requested from or trusted to the auth service. + +const ISSUER = 'https://auth.fossbilling.net'; +const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth2/authorize`; +const TOKEN_ENDPOINT = `${ISSUER}/oauth2/token`; +const USERINFO_ENDPOINT = `${ISSUER}/oauth2/userinfo`; + +const SCOPE = 'openid profile email offline_access'; + +// Short-lived cookies that carry the PKCE verifier and CSRF state across the +// redirect to the auth service and back. Cleared as soon as the callback +// consumes them. +export const OAUTH_VERIFIER_COOKIE = 'fb_oauth_verifier'; +export const OAUTH_STATE_COOKIE = 'fb_oauth_state'; +export const OAUTH_COOKIE_MAX_AGE = 60 * 10; // 10 minutes + +export function buildAuthorizeUrl(opts: { + clientId: string; + redirectUri: string; + state: string; + codeChallenge: string; +}): string { + const url = new URL(AUTHORIZE_ENDPOINT); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', opts.clientId); + url.searchParams.set('redirect_uri', opts.redirectUri); + url.searchParams.set('scope', SCOPE); + url.searchParams.set('code_challenge', opts.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('state', opts.state); + return url.toString(); +} + +type TokenResponse = { + access_token: string; + token_type: string; + expires_in: number; +}; + +export async function exchangeCodeForToken(opts: { + code: string; + redirectUri: string; + codeVerifier: string; + clientId: string; + clientSecret: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: opts.code, + redirect_uri: opts.redirectUri, + client_id: opts.clientId, + client_secret: opts.clientSecret, + code_verifier: opts.codeVerifier, + }); + + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + throw new Error(`Token exchange failed with status ${response.status}`); + } + + return response.json(); +} + +export type UserInfo = { + sub: string; + name?: string; + email?: string; + email_verified?: boolean; + picture?: string; +}; + +export async function fetchUserInfo(accessToken: string): Promise { + const response = await fetch(USERINFO_ENDPOINT, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new Error(`Userinfo request failed with status ${response.status}`); + } + + return response.json(); +} diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 00000000..80cda2d4 --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,17 @@ +import { base64urlEncode } from './base64url'; + +export function generateCodeVerifier(): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier), + ); + return base64urlEncode(new Uint8Array(digest)); +} + +export function generateState(): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(16))); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 00000000..8114d30d --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,92 @@ +import type { AstroCookies } from 'astro'; +import { base64urlEncode, base64urlDecode } from './base64url'; + +// A self-contained, HMAC-signed session cookie. Deliberately does not persist +// or depend on the auth service's own tokens past the initial code exchange — +// once we have the user's identity we mint our own session, independent of +// the auth service's access/refresh token lifetimes. + +export const SESSION_COOKIE = 'fb_session'; +export const SESSION_MAX_AGE = 60 * 60 * 24 * 30; // 30 days + +export type SessionUser = { + sub: string; + name: string; + email: string; + picture?: string; +}; + +type SessionPayload = SessionUser & { exp: number }; + +async function importSigningKey(secret: string): Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); +} + +export async function createSessionCookieValue( + user: SessionUser, + secret: string, +): Promise { + const payload: SessionPayload = { + ...user, + exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE, + }; + const payloadB64 = base64urlEncode( + new TextEncoder().encode(JSON.stringify(payload)), + ); + const key = await importSigningKey(secret); + const signature = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(payloadB64), + ); + return `${payloadB64}.${base64urlEncode(new Uint8Array(signature))}`; +} + +async function verifySessionCookieValue( + value: string, + secret: string, +): Promise { + const [payloadB64, signatureB64] = value.split('.'); + if (!payloadB64 || !signatureB64) return null; + + const key = await importSigningKey(secret); + const valid = await crypto.subtle.verify( + 'HMAC', + key, + base64urlDecode(signatureB64), + new TextEncoder().encode(payloadB64), + ); + if (!valid) return null; + + let payload: SessionPayload; + try { + payload = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64))); + } catch { + return null; + } + + if ( + typeof payload.exp !== 'number' || + payload.exp < Math.floor(Date.now() / 1000) + ) { + return null; + } + + const { exp: _exp, ...user } = payload; + return user; +} + +export async function getSessionUser( + cookies: AstroCookies, + secret: string, +): Promise { + const value = cookies.get(SESSION_COOKIE)?.value; + if (!value) return null; + return verifySessionCookieValue(value, secret); +} diff --git a/src/lib/users.ts b/src/lib/users.ts new file mode 100644 index 00000000..f1570aeb --- /dev/null +++ b/src/lib/users.ts @@ -0,0 +1,29 @@ +import type { UserInfo } from './oauth'; + +export async function upsertUser( + db: D1Database, + info: UserInfo, +): Promise { + const now = new Date().toISOString(); + await db + .prepare( + `INSERT INTO users (id, name, email, email_verified, picture, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + email = excluded.email, + email_verified = excluded.email_verified, + picture = excluded.picture, + updated_at = excluded.updated_at`, + ) + .bind( + info.sub, + info.name ?? null, + info.email ?? null, + info.email_verified ? 1 : 0, + info.picture ?? null, + now, + now, + ) + .run(); +} diff --git a/src/pages/auth/callback.ts b/src/pages/auth/callback.ts new file mode 100644 index 00000000..6ce8ba00 --- /dev/null +++ b/src/pages/auth/callback.ts @@ -0,0 +1,86 @@ +import type { APIRoute } from 'astro'; +import { env } from 'cloudflare:workers'; +import { + exchangeCodeForToken, + fetchUserInfo, + OAUTH_VERIFIER_COOKIE, + OAUTH_STATE_COOKIE, +} from '@/lib/oauth'; +import { + createSessionCookieValue, + SESSION_COOKIE, + SESSION_MAX_AGE, +} from '@/lib/session'; +import { upsertUser } from '@/lib/users'; + +export const GET: APIRoute = async ({ cookies, redirect, url }) => { + const verifier = cookies.get(OAUTH_VERIFIER_COOKIE)?.value; + const expectedState = cookies.get(OAUTH_STATE_COOKIE)?.value; + cookies.delete(OAUTH_VERIFIER_COOKIE, { path: '/' }); + cookies.delete(OAUTH_STATE_COOKIE, { path: '/' }); + + if (url.searchParams.get('error')) { + return redirect('/?auth_error=1'); + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if ( + !code || + !state || + !verifier || + !expectedState || + state !== expectedState + ) { + return redirect('/?auth_error=1'); + } + + const redirectUri = `${url.origin}/auth/callback`; + + let accessToken: string; + let userInfo; + try { + const token = await exchangeCodeForToken({ + code, + redirectUri, + codeVerifier: verifier, + clientId: env.AUTH_CLIENT_ID, + clientSecret: env.AUTH_CLIENT_SECRET, + }); + accessToken = token.access_token; + userInfo = await fetchUserInfo(accessToken); + } catch { + return redirect('/?auth_error=1'); + } + + // The users table mirrors profile data for future ownership features; a + // write failure here shouldn't block signing in, since the session itself + // doesn't depend on it. + try { + await upsertUser(env.DB_EXTENSIONS, userInfo); + } catch { + // Ignored — see comment above. + } + + const secure = url.protocol === 'https:'; + const sessionValue = await createSessionCookieValue( + { + sub: userInfo.sub, + name: userInfo.name ?? '', + email: userInfo.email ?? '', + picture: userInfo.picture, + }, + env.SESSION_SECRET, + ); + + cookies.set(SESSION_COOKIE, sessionValue, { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: SESSION_MAX_AGE, + }); + + return redirect('/'); +}; diff --git a/src/pages/auth/login.ts b/src/pages/auth/login.ts new file mode 100644 index 00000000..bffa9d97 --- /dev/null +++ b/src/pages/auth/login.ts @@ -0,0 +1,45 @@ +import type { APIRoute } from 'astro'; +import { env } from 'cloudflare:workers'; +import { + generateCodeVerifier, + generateCodeChallenge, + generateState, +} from '@/lib/pkce'; +import { + buildAuthorizeUrl, + OAUTH_VERIFIER_COOKIE, + OAUTH_STATE_COOKIE, + OAUTH_COOKIE_MAX_AGE, +} from '@/lib/oauth'; + +export const GET: APIRoute = async ({ cookies, redirect, url }) => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = generateState(); + const secure = url.protocol === 'https:'; + + cookies.set(OAUTH_VERIFIER_COOKIE, verifier, { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: OAUTH_COOKIE_MAX_AGE, + }); + cookies.set(OAUTH_STATE_COOKIE, state, { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: OAUTH_COOKIE_MAX_AGE, + }); + + const redirectUri = `${url.origin}/auth/callback`; + const authorizeUrl = buildAuthorizeUrl({ + clientId: env.AUTH_CLIENT_ID, + redirectUri, + state, + codeChallenge: challenge, + }); + + return redirect(authorizeUrl); +}; diff --git a/src/pages/auth/logout.ts b/src/pages/auth/logout.ts new file mode 100644 index 00000000..d94b6265 --- /dev/null +++ b/src/pages/auth/logout.ts @@ -0,0 +1,7 @@ +import type { APIRoute } from 'astro'; +import { SESSION_COOKIE } from '@/lib/session'; + +export const POST: APIRoute = async ({ cookies, redirect }) => { + cookies.delete(SESSION_COOKIE, { path: '/' }); + return redirect('/'); +}; From 1c2816d785bbceed7ac7d3676710d54d9bfeeaf8 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 21 Jul 2026 18:29:23 +0100 Subject: [PATCH 02/14] Add Cloudflare Env typings for auth secrets --- src/env.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/env.d.ts diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 00000000..991a6b9d --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,14 @@ +export {}; + +// Extends the `Cloudflare.Env` interface generated in worker-configuration.d.ts +// with secrets that aren't Wrangler bindings (set via `wrangler secret put`, +// or `.dev.vars` locally) so `cloudflare:workers`' `env` stays fully typed. +declare global { + namespace Cloudflare { + interface Env { + AUTH_CLIENT_ID: string; + AUTH_CLIENT_SECRET: string; + SESSION_SECRET: string; + } + } +} From ad93b4046db1eb3ab7376f622af84c0b68afa9ca Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 21 Jul 2026 19:47:27 +0100 Subject: [PATCH 03/14] Tighten auth callback and session handling --- src/lib/db/users.sql | 6 ++---- src/lib/oauth.ts | 2 -- src/lib/session.ts | 13 +++++++------ src/pages/auth/callback.ts | 13 ++++--------- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/lib/db/users.sql b/src/lib/db/users.sql index c75f6a7d..57044531 100644 --- a/src/lib/db/users.sql +++ b/src/lib/db/users.sql @@ -1,7 +1,5 @@ --- Local user/profile table, keyed by the central auth service's `sub`. --- Identity only comes from auth.fossbilling.net; anything about what a user --- can *do* here (extension ownership, submitter/moderator status, etc.) is --- modeled in this app's own tables, referencing users(id). +-- Keyed by the central auth service's `sub`. Ownership, roles, and other +-- authorization concepts belong in this app's own tables, not here. CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, name TEXT, diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index ac0aab1b..3abb2bd4 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -36,8 +36,6 @@ export function buildAuthorizeUrl(opts: { type TokenResponse = { access_token: string; - token_type: string; - expires_in: number; }; export async function exchangeCodeForToken(opts: { diff --git a/src/lib/session.ts b/src/lib/session.ts index 8114d30d..b5a3f789 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -71,15 +71,16 @@ async function verifySessionCookieValue( return null; } - if ( - typeof payload.exp !== 'number' || - payload.exp < Math.floor(Date.now() / 1000) - ) { + if (payload.exp < Math.floor(Date.now() / 1000)) { return null; } - const { exp: _exp, ...user } = payload; - return user; + return { + sub: payload.sub, + name: payload.name, + email: payload.email, + picture: payload.picture, + }; } export async function getSessionUser( diff --git a/src/pages/auth/callback.ts b/src/pages/auth/callback.ts index 6ce8ba00..1acc88e5 100644 --- a/src/pages/auth/callback.ts +++ b/src/pages/auth/callback.ts @@ -38,7 +38,6 @@ export const GET: APIRoute = async ({ cookies, redirect, url }) => { const redirectUri = `${url.origin}/auth/callback`; - let accessToken: string; let userInfo; try { const token = await exchangeCodeForToken({ @@ -48,20 +47,16 @@ export const GET: APIRoute = async ({ cookies, redirect, url }) => { clientId: env.AUTH_CLIENT_ID, clientSecret: env.AUTH_CLIENT_SECRET, }); - accessToken = token.access_token; - userInfo = await fetchUserInfo(accessToken); + userInfo = await fetchUserInfo(token.access_token); } catch { return redirect('/?auth_error=1'); } - // The users table mirrors profile data for future ownership features; a - // write failure here shouldn't block signing in, since the session itself - // doesn't depend on it. + // A write failure here shouldn't block signing in — the session below + // doesn't depend on it, only future ownership features will. try { await upsertUser(env.DB_EXTENSIONS, userInfo); - } catch { - // Ignored — see comment above. - } + } catch {} const secure = url.protocol === 'https:'; const sessionValue = await createSessionCookieValue( From 7459db1065de44f8d60ceddadc260309373c8cba Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 21 Jul 2026 20:36:28 +0100 Subject: [PATCH 04/14] Update extension UI button variants --- src/components/DetailsCard.astro | 4 +- src/components/ExtensionHeader.astro | 8 +++- src/layouts/Base.astro | 59 +++++++++++++++++++--------- src/pages/index.astro | 4 +- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/components/DetailsCard.astro b/src/components/DetailsCard.astro index 67546d05..6549110d 100644 --- a/src/components/DetailsCard.astro +++ b/src/components/DetailsCard.astro @@ -94,7 +94,9 @@ const details: DetailsItem[] = [ href={detail.link} target="_blank" rel="noopener noreferrer" - class="btn-sm-outline" + class="btn" + data-size="sm" + data-variant="outline" > {detail.text} diff --git a/src/components/ExtensionHeader.astro b/src/components/ExtensionHeader.astro index 5b7d0aec..98f8091c 100644 --- a/src/components/ExtensionHeader.astro +++ b/src/components/ExtensionHeader.astro @@ -12,7 +12,13 @@ const latest = getLatestRelease(ext); ---
- + { user ? ( -
- {user.picture && ( - - )} - + <> + +
-
-
+ ) : ( - - Sign in with GitHub + + ) } @@ -110,7 +129,9 @@ const user = await getSessionUser(Astro.cookies, env.SESSION_SECRET); data-side="bottom" data-align="end" onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))" - class="btn-icon-outline size-8" + class="btn" + data-size="icon-sm" + data-variant="outline" >