Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ DATABASE_URL=
BOARD_PASSWORD=
BOARD_ADMIN_PASSWORD=
BOARD_SOCIALS_PASSWORD=
# Signs the /board login cookie. Any random string of 32+ characters,
# Passwords for /board/ucla (UCLA's hub). Fiona and Will get
# BOARD_UCLA_ADMIN_PASSWORD (admin view); everyone else on the UCLA board
# gets BOARD_UCLA_PASSWORD. Use values different from Berkeley's; neither
# chapter's passwords open the other's hub.
BOARD_UCLA_ADMIN_PASSWORD=
BOARD_UCLA_PASSWORD=
# Signs the board hub login cookies (both chapters). Any random string of 32+ characters,
# e.g. the output of: openssl rand -base64 32
BOARD_SESSION_SECRET=
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ yarn-error.log*
next-env.d.ts

# /board member list with phone numbers. Never commit.
scripts/board-members.local.json
scripts/*.local.json
51 changes: 42 additions & 9 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
# Setting up /board
# Setting up the board hubs

`/board` is the UC Berkeley chapter's board hub. It isn't linked from the
site and tells search engines not to index it. To see anything, people
need a password.
Each chapter has its own board hub: UC Berkeley at `/board`, UCLA at
`/board/ucla`. They run the same code and share one database, where every
row belongs to a chapter. Neither hub is linked from the site, and both
tell search engines not to index them. To see anything, people need their
chapter's password; a Berkeley password never opens UCLA's hub, or the
reverse.

Per-chapter settings (teams, labels, Projects content, who submissions are
emailed to) are in `lib/board/chapters.ts` and `lib/board/chapter-server.ts`.

## Passwords

### Berkeley (`/board`)

| Railway variable | Who gets it | What it unlocks |
| ------------------------ | -------------------- | --------------------------------------- |
| `BOARD_PASSWORD` | everyone on the board | view everything, submit assignments |
Expand All @@ -18,7 +26,28 @@ Use different passwords for each role. People stay signed in on a device for 30
days. To remove someone's access, change the password they used; that
signs out everyone who used it, and you share the new one with the rest.

Ten wrong tries from the same connection lock the form for 15 minutes.
### UCLA (`/board/ucla`)

| Railway variable | Who gets it | What it unlocks |
| --------------------------- | ----------------------- | ------------------------------------- |
| `BOARD_UCLA_PASSWORD` | everyone on the UCLA board | view everything, submit deliverables |
| `BOARD_UCLA_ADMIN_PASSWORD` | Fiona and Will | edit everything on the UCLA hub |

Use passwords that differ from Berkeley's.

Ten wrong tries from the same connection lock a hub's form for 15 minutes.

## Submissions

The Assignments tab's form emails each submission through Brevo, with
reply-to set to the submitter's email (members with none on file are asked
for one).

- Berkeley: to Rana.
- UCLA: to Fiona and Will, Cc Rana. Submissions from the App Dev and Data
teams also go to Rizwaan.

The recipient lists are in `lib/board/chapter-server.ts`.

## One-time setup in Railway

Expand All @@ -27,15 +56,17 @@ Ten wrong tries from the same connection lock the form for 15 minutes.
Postgres service's `DATABASE_URL` (Railway offers it as a reference).
2. **Passwords and secret.** In the same Variables screen, add:
- `BOARD_PASSWORD`, `BOARD_SOCIALS_PASSWORD`, `BOARD_ADMIN_PASSWORD`
- `BOARD_UCLA_PASSWORD`, `BOARD_UCLA_ADMIN_PASSWORD`
- `BOARD_SESSION_SECRET`: a random string of at least 32 characters.
`openssl rand -base64 32` makes one.
3. Redeploy, open `https://www.getvindicated.org/board`, and sign in with
the admin password. The board makes its own tables and fills in the
starting assignments, socials, announcements and meetings the first time
it loads.
4. **Members.** Open the **Board** tab and choose the
`board-members.local.json` file. It has phone numbers, so it is never
committed; Rana has the copy.
4. **Members.** On each hub, sign in with the admin password, open the
**Board** tab and choose the member file: `board-members.local.json`
for Berkeley, `board-members-ucla.local.json` for UCLA. They have
contact details, so they are never committed.

Until the secret and at least one password are set, the page shows the
password box and nothing gets through.
Expand All @@ -49,9 +80,11 @@ DATABASE_URL=postgres://localhost:5432/board
BOARD_PASSWORD=member-test
BOARD_SOCIALS_PASSWORD=socials-test
BOARD_ADMIN_PASSWORD=admin-test
BOARD_UCLA_PASSWORD=<pick one>
BOARD_UCLA_ADMIN_PASSWORD=<pick another>
BOARD_SESSION_SECRET=any-random-string-at-least-32-characters-long
```

Then `pnpm dev`, and open
http://localhost:3000/board. Without `BREVO_API_KEY`, `pnpm dev` prints
http://localhost:3000/board or http://localhost:3000/board/ucla. Without `BREVO_API_KEY`, `pnpm dev` prints
submission emails to the terminal instead of sending them.
13 changes: 7 additions & 6 deletions app/api/board/[table]/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,27 @@ import { deleteRow, updateRow } from "@/lib/board/tables";

async function target(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) {
const { table, id: rawId } = await ctx.params;
const t = editable(table);
const id = idFrom(rawId);
if (!t || !id) return notFound();
const user = await guard(request, t.roles);
if (!id) return notFound();
const user = await guard(request);
if (user instanceof Response) return user;
return { t, id };
const t = editable(table, user.chapter);
if (!t || !t.roles.includes(user.role)) return notFound();
return { t, id, chapter: user.chapter };
}

export async function PATCH(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) {
const x = await target(request, ctx);
if (x instanceof Response) return x;
const body = await readJson(request);
if (!body) return bad("Check the form.");
const r = await updateRow(x.t.def, x.id, body, derived(x.t.name, body));
const r = await updateRow(x.t.def, x.chapter, x.id, body, derived(x.t.name, body));
return r.ok ? Response.json({ id: r.id }) : bad(r.error, r.status);
}

export async function DELETE(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) {
const x = await target(request, ctx);
if (x instanceof Response) return x;
const r = await deleteRow(x.t.def, x.id);
const r = await deleteRow(x.t.def, x.chapter, x.id);
return r.ok ? Response.json({ id: r.id }) : bad(r.error, r.status);
}
8 changes: 4 additions & 4 deletions app/api/board/[table]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { createRow } from "@/lib/board/tables";

export async function POST(request: Request, ctx: { params: Promise<{ table: string }> }) {
const { table } = await ctx.params;
const t = editable(table);
if (!t) return notFound();
const user = await guard(request, t.roles);
const user = await guard(request);
if (user instanceof Response) return user;
const t = editable(table, user.chapter);
if (!t || !t.roles.includes(user.role)) return notFound();

const body = await readJson(request);
if (!body) return bad("Check the form.");
const r = await createRow(t.def, body, derived(t.name, body));
const r = await createRow(t.def, body, { ...derived(t.name, body), chapter: user.chapter });
return r.ok ? Response.json({ id: r.id }, { status: 201 }) : bad(r.error, r.status);
}
21 changes: 10 additions & 11 deletions app/api/board/import-members/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";

const TEAMS = ["Executive", "Data", "Software", "Research & Legal", "Outreach"];
import { chapterConfig, type ChapterId } from "@/lib/board/chapters";

type Row = {
name: string;
Expand All @@ -23,13 +22,13 @@ export async function POST(request: Request) {
console.warn(`[board] Member import refused (${user.status}).`);
return user;
}
const res = await importMembers(request);
if (!res.ok) console.warn(`[board] Member import rejected (${res.status}).`);
const res = await importMembers(request, user.chapter);
if (!res.ok) console.warn(`[board] Member import (${user.chapter}) rejected (${res.status}).`);
return res;
}

async function importMembers(request: Request): Promise<Response> {

async function importMembers(request: Request, chapter: ChapterId): Promise<Response> {
const TEAMS = ["Executive", ...chapterConfig(chapter).teams];
const body = await readJson(request);
const list = body?.members;
if (!Array.isArray(list) || list.length === 0) return bad("That file doesn't have a member list in it.");
Expand Down Expand Up @@ -58,23 +57,23 @@ async function importMembers(request: Request): Promise<Response> {
}

const sql = db();
const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_members`;
const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_members WHERE chapter = ${chapter}`;
if (n > 0 && body?.replace !== true) {
return bad("Members are already loaded. Replacing them would undo any edits made on the Board tab.", 409);
}

try {
await sql.begin(async (tx) => {
await tx`DELETE FROM board_members`;
await tx`DELETE FROM board_members WHERE chapter = ${chapter}`;
for (const [i, r] of rows.entries()) {
await tx`INSERT INTO board_members (name, first_name, role, team, also_team, tier, is_lead, email, phone, sort)
VALUES (${r.name}, ${r.name.split(/\s+/)[0]}, ${r.role}, ${r.team}, ${r.alsoTeam}, ${r.tier}, ${r.lead}, ${r.email}, ${r.phone}, ${i})`;
await tx`INSERT INTO board_members (chapter, name, first_name, role, team, also_team, tier, is_lead, email, phone, sort)
VALUES (${chapter}, ${r.name}, ${r.name.split(/\s+/)[0]}, ${r.role}, ${r.team}, ${r.alsoTeam}, ${r.tier}, ${r.lead}, ${r.email}, ${r.phone}, ${i})`;
}
});
} catch (e) {
console.error("[board] Member import failed:", e);
return bad("The members couldn't be saved. Try again; if it keeps happening, tell Claude.", 500);
}
console.log(`[board] Imported ${rows.length} members.`);
console.log(`[board] Imported ${rows.length} ${chapter} members.`);
return Response.json({ ok: true, count: rows.length });
}
16 changes: 11 additions & 5 deletions app/api/board/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createSession, roleForPassword, sessionCookie, setupProblem, SESSION_DAYS } from "@/lib/board/session";
import { chapterOf } from "@/lib/board/api";

// Wrong-password limit per IP: 10 tries per 15 minutes. Kept in memory,
// which is enough for one Railway instance.
Expand All @@ -14,9 +15,14 @@ function clientIp(request: Request) {
);
}

// Signs in to one chapter's hub (?chapter=ucla; Berkeley by default). Only
// that chapter's passwords are checked.
export async function POST(request: Request) {
const chapter = chapterOf(request);
if (!chapter) return Response.json({ error: "Not found" }, { status: 404 });

// Missing Railway variables shouldn't look like a wrong password.
const problem = setupProblem();
const problem = setupProblem(chapter);
if (problem) {
console.error(`[board] Sign-in is off: ${problem} See BOARD_SETUP.md.`);
return Response.json(
Expand All @@ -25,7 +31,7 @@ export async function POST(request: Request) {
);
}

const ip = clientIp(request);
const ip = `${chapter}:${clientIp(request)}`;
const now = Date.now();
const rec = fails.get(ip);
if (rec && now - rec.since > WINDOW_MS) fails.delete(ip);
Expand All @@ -41,8 +47,8 @@ export async function POST(request: Request) {
/* treated as a wrong password */
}

const role = password ? roleForPassword(password) : null;
const token = role ? createSession(role) : null;
const role = password ? roleForPassword(chapter, password) : null;
const token = role ? createSession(chapter, role) : null;
if (!role || !token) {
const r = fails.get(ip) ?? { n: 0, since: now };
fails.set(ip, { n: r.n + 1, since: r.since });
Expand All @@ -52,6 +58,6 @@ export async function POST(request: Request) {
fails.delete(ip);
return Response.json(
{ ok: true },
{ headers: { "set-cookie": sessionCookie(token, SESSION_DAYS * 86400) } },
{ headers: { "set-cookie": sessionCookie(chapter, token, SESSION_DAYS * 86400) } },
);
}
6 changes: 4 additions & 2 deletions app/api/board/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { chapterOf } from "@/lib/board/api";
import { sessionCookie } from "@/lib/board/session";

export async function POST() {
return Response.json({ ok: true }, { headers: { "set-cookie": sessionCookie("", 0) } });
export async function POST(request: Request) {
const chapter = chapterOf(request) ?? "berkeley";
return Response.json({ ok: true }, { headers: { "set-cookie": sessionCookie(chapter, "", 0) } });
}
23 changes: 23 additions & 0 deletions app/api/board/person-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { chapterConfig } from "@/lib/board/chapters";
import { db } from "@/lib/board/db";

const STATUSES = ["pending", "submitted", "missing", "excused"];

// Weekly deliverables (UCLA) have one status per person: setting it marks
// all of that person's tasks in the current week. Admins only.
export async function POST(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
if (!chapterConfig(user.chapter).weekly) return bad("This chapter sets status per task.", 404);
const body = await readJson(request);
const person = typeof body?.person === "string" ? body.person.trim() : "";
const status = typeof body?.status === "string" ? body.status : "";
if (!person || !STATUSES.includes(status)) return bad("Choose a person and a status.");
const sql = db();
const rows = await sql`UPDATE board_assignments SET status = ${status}
WHERE chapter = ${user.chapter} AND person = ${person}
AND round = (SELECT round FROM board_rounds WHERE chapter = ${user.chapter} AND is_current)
RETURNING id`;
return rows.length ? Response.json({ ok: true, count: rows.length }) : bad("That person has no tasks this week.", 404);
}
Loading