From 5288a93d2d27ec7fbd4fc30b94ff79dc6e001d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 15:27:11 +0000 Subject: [PATCH 1/2] Initial plan From 0ea56594668b11ce6c7af44120413540047c927a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 15:38:40 +0000 Subject: [PATCH 2/2] feat: switch login to Hack Club Auth and sync _users Airtable records Agent-Logs-Url: https://github.com/hackclub/stack/sessions/85f2e65b-e85b-4fc6-9629-8eed4fac16e0 Co-authored-by: scooterthedev <92922224+scooterthedev@users.noreply.github.com> --- client/src/components/LoginPage.css | 30 +----- client/src/components/LoginPage.jsx | 67 ++----------- server/airtable.js | 44 +++++++++ server/authRoutes.js | 144 ++++++++++++++++------------ server/hackclubAuth.js | 9 +- server/users.js | 19 +++- 6 files changed, 159 insertions(+), 154 deletions(-) diff --git a/client/src/components/LoginPage.css b/client/src/components/LoginPage.css index 7837340..7509ff0 100644 --- a/client/src/components/LoginPage.css +++ b/client/src/components/LoginPage.css @@ -31,27 +31,10 @@ margin: 0 0 18px; } -.login-card form { - display: grid; - gap: 14px; -} - -.login-card label { - display: grid; - gap: 6px; - font-weight: 700; -} - -.login-card input { - border: 3px solid #162456; - border-radius: 12px; - padding: 12px 14px; - background: #fff; - color: #162456; - font: inherit; -} - -.login-card button { +.login-card__oauth { + display: inline-flex; + justify-content: center; + text-decoration: none; border: 3px solid #162456; border-radius: 999px; padding: 12px 18px; @@ -63,11 +46,6 @@ box-shadow: 4px 4px 0 #162456; } -.login-card button:disabled { - cursor: not-allowed; - opacity: 0.7; -} - .login-card__secondary { background: #fff !important; } diff --git a/client/src/components/LoginPage.jsx b/client/src/components/LoginPage.jsx index 69484c1..28d06c9 100644 --- a/client/src/components/LoginPage.jsx +++ b/client/src/components/LoginPage.jsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react"; -import { useAuth } from "../auth/AuthContext.jsx"; +import { useMemo } from "react"; import "./LoginPage.css"; function normalizeReturnTo(value) { @@ -8,36 +7,10 @@ function normalizeReturnTo(value) { } export function LoginPage() { - const { reload } = useAuth(); const params = new URLSearchParams(window.location.search); const returnTo = useMemo(() => normalizeReturnTo(params.get("returnTo")), [params]); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [submitting, setSubmitting] = useState(false); - - async function handleSubmit(event) { - event.preventDefault(); - setSubmitting(true); - setError(""); - - try { - const response = await fetch("/api/auth/password/login", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.error || "Failed to log in."); - await reload(); - window.location.href = returnTo; - } catch (err) { - setError(err.message); - } finally { - setSubmitting(false); - } - } + const error = params.get("error"); + const loginHref = `/api/auth/hackclub/login?returnTo=${encodeURIComponent(returnTo)}`; return (
@@ -46,36 +19,10 @@ export function LoginPage() { ← Back

Join Stack

-

Use your email and a password. If this email is new, we’ll create your account.

- -
- - - -
- +

Sign in with your Hack Club account to continue.

+ + Continue with Hack Club Auth + {error ?

{error}

: null}
diff --git a/server/airtable.js b/server/airtable.js index c37a566..10e82c5 100644 --- a/server/airtable.js +++ b/server/airtable.js @@ -86,6 +86,13 @@ function normalizeAirtableValue(value) { return value; } +function asIso(value, fallback = null) { + if (!value) return fallback; + if (value instanceof Date) return value.toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? fallback : parsed.toISOString(); +} + function buildAirtableRecord(row, sharedFields) { const fields = {}; @@ -110,6 +117,43 @@ function getErrorMessage(error) { return error instanceof Error ? error.message : String(error); } +export async function upsertAuthUserToAirtable(user, profile = {}) { + if (!hasAirtableConfig) { + return { ok: false, configured: false }; + } + + const fields = { + user_id: Number(user?.id), + Name: user?.name ?? profile?.name ?? "", + bricks: Number(user?.coins ?? 0), + slack_id: user?.slack_id ?? profile?.slack_id ?? profile?.slack_user_id ?? null, + "slack username": user?.slack_username ?? profile?.slack_username ?? profile?.slack?.username ?? null, + role: user?.role || "user", + "last sign in": asIso(user?.last_sign_in_at, new Date().toISOString()), + "created at": asIso(user?.created_at, new Date().toISOString()), + email: user?.email ?? profile?.email ?? profile?.email_address ?? null, + "hackatime hours": Number(user?.hackatime_hours ?? 0), + }; + + const response = await fetch(getAirtableUrl("_users"), { + method: "PATCH", + headers: getAirtableHeaders(), + body: JSON.stringify({ + performUpsert: { + fieldsToMergeOn: ["user_id"], + }, + records: [{ fields }], + }), + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Airtable _users sync failed (${response.status}): ${details}`); + } + + return response.json(); +} + export async function syncDatabaseToAirtable() { if (!hasAirtableConfig) { return { diff --git a/server/authRoutes.js b/server/authRoutes.js index dd44046..731f297 100644 --- a/server/authRoutes.js +++ b/server/authRoutes.js @@ -2,24 +2,29 @@ import crypto from "crypto"; import express from "express"; import rateLimit from "express-rate-limit"; import { - createUserFromEmailPassword, - getUserByEmail, getUserById, - setPasswordForExistingUser, toPublicUser, - updateUserPasswordHash, - updateUserRoleFromEmail, + upsertUserFromHackClub, } from "./users.js"; -import { hashPasswordForStorage, verifyPasswordForLogin } from "./passwordHash.js"; +import { upsertAuthUserToAirtable } from "./airtable.js"; +import { + appOriginFromRedirectUri, + exchangeAuthorizationCode, + fetchHackClubMe, + getAppOrigin, + getAuthorizeUrl, + resolveOAuthRedirectUri, +} from "./hackclubAuth.js"; -const passwordLoginLimiter = rateLimit({ +const LOCAL_DEV_AUTH_COOKIE = "stack.local_user"; +const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; +const oauthLoginLimiter = rateLimit({ windowMs: 10 * 60 * 1000, - max: 40, + max: 60, standardHeaders: true, legacyHeaders: false, message: "Too many login attempts. Please try again in a few minutes.", }); -const LOCAL_DEV_AUTH_COOKIE = "stack.local_user"; function normalizeReturnTo(value) { if (typeof value !== "string") return "/main"; @@ -30,10 +35,6 @@ function normalizeReturnTo(value) { return trimmed; } -function getAppOrigin() { - return process.env.APP_ORIGIN || "http://localhost:5173"; -} - function isLocalhostRequest(req) { const hostHeader = (req.get("X-Forwarded-Host") || req.get("Host") || "") .split(",")[0] @@ -51,14 +52,6 @@ function localDevCookieSecret() { return process.env.DEV_AUTH_COOKIE_SECRET || process.env.SESSION_SECRET || "dev-local-auth-cookie-secret"; } -function normalizeEmail(email) { - return typeof email === "string" ? email.trim().toLowerCase() : ""; -} - -function isValidEmail(email) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - function signLocalUserId(userId) { const payload = String(userId); const signature = crypto.createHmac("sha256", localDevCookieSecret()).update(payload).digest("hex"); @@ -94,57 +87,90 @@ function setLocalDevAuthCookie(req, res, userId) { export function createAuthRouter() { const router = express.Router(); - router.get("/hackclub/login", (req, res) => { + router.get("/hackclub/login", oauthLoginLimiter, (req, res) => { const returnTo = normalizeReturnTo(req.query?.returnTo); - res.redirect(302, `${getAppOrigin()}/login?returnTo=${encodeURIComponent(returnTo)}&auth=password`); - }); - - router.get("/hackclub/callback", (req, res) => { - res.redirect(302, `${getAppOrigin()}/login?auth=password`); - }); - - router.post("/password/login", passwordLoginLimiter, async (req, res) => { try { - const email = normalizeEmail(req.body?.email); - const password = String(req.body?.password || ""); + const redirectUri = resolveOAuthRedirectUri(req); + const state = crypto.randomBytes(24).toString("hex"); + req.session.oauth = { + state, + returnTo, + redirectUri, + createdAt: Date.now(), + }; + res.redirect(302, getAuthorizeUrl({ state, redirectUri })); + } catch (error) { + console.error("[auth] failed to start Hack Club auth:", error); + res.redirect(302, `${getAppOrigin()}/login?error=${encodeURIComponent("Failed to start login.")}`); + } + }); - if (!isValidEmail(email)) { - res.status(422).json({ error: "Enter a valid email address." }); - return; + router.get("/hackclub/callback", oauthLoginLimiter, async (req, res) => { + const stateFromQuery = String(req.query?.state || ""); + const code = String(req.query?.code || ""); + const authError = req.query?.error ? String(req.query.error) : ""; + const oauthState = req.session?.oauth; + delete req.session.oauth; + let redirectUri = oauthState?.redirectUri || ""; + if (!redirectUri) { + try { + redirectUri = resolveOAuthRedirectUri(req); + } catch { + redirectUri = ""; } + } + const appOrigin = appOriginFromRedirectUri(redirectUri); + const returnTo = normalizeReturnTo(oauthState?.returnTo); + + if (authError) { + res.redirect( + 302, + `${appOrigin}/login?returnTo=${encodeURIComponent(returnTo)}&error=${encodeURIComponent(authError)}` + ); + return; + } - if (password.length < 6) { - res.status(422).json({ error: "Password must be at least 6 characters." }); - return; - } + if (!oauthState?.state || oauthState.state !== stateFromQuery || !code) { + res.redirect( + 302, + `${appOrigin}/login?returnTo=${encodeURIComponent(returnTo)}&error=${encodeURIComponent("Invalid login state.")}` + ); + return; + } - const existingUser = await getUserByEmail(email); - let user; + if (!oauthState.createdAt || Date.now() - oauthState.createdAt > OAUTH_STATE_TTL_MS) { + res.redirect( + 302, + `${appOrigin}/login?returnTo=${encodeURIComponent(returnTo)}&error=${encodeURIComponent("Login attempt expired.")}` + ); + return; + } - if (existingUser?.password_hash) { - const outcome = verifyPasswordForLogin(password, existingUser.password_hash); - if (!outcome.valid) { - res.status(401).json({ error: "Wrong email or password." }); - return; - } - if (outcome.migrateToHash) { - await updateUserPasswordHash(existingUser.id, outcome.migrateToHash); - } - user = await updateUserRoleFromEmail(existingUser.id, email); - } else if (existingUser) { - user = await setPasswordForExistingUser(existingUser.id, email, hashPasswordForStorage(password)); - } else { - user = await createUserFromEmailPassword(email, hashPasswordForStorage(password)); + try { + if (!redirectUri) { + throw new Error("OAuth redirect URI is not configured."); } + const token = await exchangeAuthorizationCode(code, redirectUri); + const profile = await fetchHackClubMe(token.access_token); + const user = await upsertUserFromHackClub({ profile, token }); req.session.userId = user.id; - delete req.session.hackclubSub; + req.session.hackclubSub = user.hackclub_sub; setLocalDevAuthCookie(req, res, user.id); - res.json({ user: toPublicUser(user) }); + try { + await upsertAuthUserToAirtable(user, profile); + } catch (airtableError) { + console.error("[auth] failed to sync _users Airtable record:", airtableError); + } + + res.redirect(302, `${appOrigin}${returnTo}`); } catch (error) { - console.error("[auth] password login failed:", error); - res.status(500).json({ error: "Failed to log in." }); + console.error("[auth] Hack Club callback failed:", error); + res.redirect( + 302, + `${appOrigin}/login?returnTo=${encodeURIComponent(returnTo)}&error=${encodeURIComponent("Failed to log in.")}` + ); } }); diff --git a/server/hackclubAuth.js b/server/hackclubAuth.js index b0e674a..41ce488 100644 --- a/server/hackclubAuth.js +++ b/server/hackclubAuth.js @@ -1,7 +1,6 @@ const AUTH_BASE = "https://auth.hackclub.com"; const OAUTH_CALLBACK_PATH = "/api/auth/hackclub/callback"; -const DEFAULT_SCOPES = [ - "openid", +const REQUIRED_SCOPES = [ "email", "name", "profile", @@ -138,10 +137,8 @@ export function appOriginFromRedirectUri(redirectUri) { export function getAuthorizeUrl({ state, redirectUri }) { const clientId = requiredEnv("HC_CLIENT_ID"); const configuredScopes = (process.env.HC_SCOPES || "").trim(); - const scopeList = configuredScopes - ? configuredScopes.split(/\s+/).filter(Boolean) - : DEFAULT_SCOPES; - const scope = [...new Set(scopeList)].join(" "); + const scopeList = configuredScopes ? configuredScopes.split(/\s+/).filter(Boolean) : []; + const scope = [...new Set([...scopeList, ...REQUIRED_SCOPES])].join(" "); const params = new URLSearchParams({ client_id: clientId, diff --git a/server/users.js b/server/users.js index 4ba8dac..21e5bb4 100644 --- a/server/users.js +++ b/server/users.js @@ -1,6 +1,6 @@ import { pool } from "./db.js"; -const DEFAULT_ROLE = "member"; +const DEFAULT_ROLE = "user"; function parseEmailList(value) { return (value || "") @@ -32,6 +32,7 @@ export async function ensureUsersTable() { slug TEXT, profile_image_url TEXT, slack_id TEXT, + slack_username TEXT, verification_status TEXT, role TEXT NOT NULL DEFAULT '${DEFAULT_ROLE}', coins NUMERIC(10, 2) NOT NULL DEFAULT 0, @@ -45,6 +46,7 @@ export async function ensureUsersTable() { raw_token JSONB, password_hash TEXT, password_set_at TIMESTAMPTZ, + last_sign_in_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) @@ -58,6 +60,7 @@ export async function ensureUsersTable() { await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS slug TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_image_url TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS slack_id TEXT`); + await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS slack_username TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_status TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS coins NUMERIC(10, 2) NOT NULL DEFAULT 0`); @@ -71,6 +74,7 @@ export async function ensureUsersTable() { await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS raw_token JSONB`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_set_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS last_sign_in_at TIMESTAMPTZ`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`); await pool.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`); @@ -208,7 +212,9 @@ export async function upsertUserFromHackClub({ profile, token }) { const slug = profile.slug ?? profile.username ?? null; const profileImageUrl = profile.profile_image_url ?? profile.picture ?? profile.avatar_url ?? null; const slackId = profile.slack_id ?? profile.slack_user_id ?? null; + const slackUsername = profile.slack_username ?? profile.slack?.username ?? profile.slack_name ?? null; const verificationStatus = profile.verification_status ?? profile.verification ?? null; + const lastSignInAt = new Date(); const accessToken = token.access_token ?? null; const refreshToken = token.refresh_token ?? null; @@ -232,6 +238,7 @@ export async function upsertUserFromHackClub({ profile, token }) { slug, profile_image_url, slack_id, + slack_username, verification_status, role, access_token, @@ -241,9 +248,10 @@ export async function upsertUserFromHackClub({ profile, token }) { expires_in_seconds, scope, raw_profile, - raw_token + raw_token, + last_sign_in_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 ) ON CONFLICT (hackclub_sub) DO UPDATE SET email = COALESCE(EXCLUDED.email, users.email), @@ -251,6 +259,7 @@ export async function upsertUserFromHackClub({ profile, token }) { slug = COALESCE(EXCLUDED.slug, users.slug), profile_image_url = COALESCE(EXCLUDED.profile_image_url, users.profile_image_url), slack_id = COALESCE(EXCLUDED.slack_id, users.slack_id), + slack_username = COALESCE(EXCLUDED.slack_username, users.slack_username), verification_status = COALESCE(EXCLUDED.verification_status, users.verification_status), role = EXCLUDED.role, access_token = EXCLUDED.access_token, @@ -261,6 +270,7 @@ export async function upsertUserFromHackClub({ profile, token }) { scope = EXCLUDED.scope, raw_profile = EXCLUDED.raw_profile, raw_token = EXCLUDED.raw_token, + last_sign_in_at = EXCLUDED.last_sign_in_at, updated_at = NOW() RETURNING * `, @@ -271,6 +281,7 @@ export async function upsertUserFromHackClub({ profile, token }) { slug, profileImageUrl, slackId, + slackUsername, verificationStatus, role, accessToken, @@ -281,6 +292,7 @@ export async function upsertUserFromHackClub({ profile, token }) { scope, JSON.stringify(profile ?? {}), JSON.stringify(token ?? {}), + lastSignInAt, ] ); @@ -486,6 +498,7 @@ export function toPublicUser(row) { slug: row.slug, profileImageUrl: row.profile_image_url, slackId: row.slack_id, + slackUsername: row.slack_username, verificationStatus: row.verification_status, role: effectiveRole(row), coins: Number(row.coins ?? 0),