Skip to content
Draft
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
30 changes: 4 additions & 26 deletions client/src/components/LoginPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
67 changes: 7 additions & 60 deletions client/src/components/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 (
<main className="login-page">
Expand All @@ -46,36 +19,10 @@ export function LoginPage() {
← Back
</a>
<h1>Join Stack</h1>
<p>Use your email and a password. If this email is new, we’ll create your account.</p>

<form onSubmit={handleSubmit}>
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 6 characters"
autoComplete="current-password"
required
/>
</label>
<button type="submit" disabled={submitting}>
{submitting ? "Logging in..." : "Log in / Sign up"}
</button>
</form>

<p>Sign in with your Hack Club account to continue.</p>
<a className="login-card__oauth" href={loginHref}>
Continue with Hack Club Auth
</a>
{error ? <p className="login-card__error">{error}</p> : null}
</section>
</main>
Expand Down
44 changes: 44 additions & 0 deletions server/airtable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand All @@ -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 {
Expand Down
144 changes: 85 additions & 59 deletions server/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]
Expand All @@ -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");
Expand Down Expand Up @@ -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.")}`
);
}
});

Expand Down
Loading