From 529b9579ab687d2486e800ce4358b2e1bcbdc78c Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Jha Date: Mon, 31 Aug 2026 20:53:48 +0530 Subject: [PATCH 1/2] Give members a dashboard, and the club a way to run GSoC mentorship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joining used to end on a read-only card in the right-hand column of /join: you filled in seven fields, it told you they were saved, and there was nothing else. Everything the club wanted a member to do next had nowhere to live. READ THE BATCH OUT OF THE ADDRESS. Every member signs in as abhinav.23bcs10045@sst.scaler.com — 2023-27, branch BCS, roll 10045 — so the form no longer asks for what it already knows. lib/batch.ts parses it on read and NOTHING is stored: the rules pin the address to request.auth.token.email, so a derived value cannot be forged and cannot drift out of step with the document beside it. That deleted the two regexes and the "Unparsed" bucket the organisers' dashboard used to guess with. It also means batch is not queryable, which is stated where it matters. THREE QUESTIONS, NOT SEVEN. year_branch, level, programs and programs_other are gone. The first is in the address; the second was a self-assessment made before somebody had met the club that nothing acted on; the last two were a preference nobody read, now replaced by enrolling in a programme, which is a decision with a consequence. THREE ROUTES INSTEAD OF FOUR STATES. /join is the door, /onboarding asks the three questions, /dashboard is where a member lives. Each ships as static HTML before auth resolves, so every one of them paints a neutral card until it knows who you are — treating "still checking" as "signed out" flashes a sign-in prompt at every returning member. MENTORSHIP. Organisers publish mentors from /admin; members pick a first preference and then either a second or "just my first". Exactly one of those two, enforced in both directions, because "I only want Priya" and "I have not decided" must not look the same to whoever pairs the cohort. No capacity: a slot counter turns choosing a mentor into a race. Nothing here allocates — preferences are counted and listed, and the pairing stays a human decision. mentors/ is the first collection a client may write that is not its own row. It is admin-only and validated anyway; the widening is acceptable because a mentor entry is published, organiser-authored, non-personal copy, and the same argument deliberately does not extend to admins/. A SHELL OF ITS OWN. The signed-in routes carried the marketing nav — six links arguing the case for joining, ending in a filled button pointing at the page you were already on. They are a route group now, with a header that says who you are signed in as. Route groups rather than a pathname check: the decision is made at build time, so no app page ships somebody else's chrome and strips it after hydration. Verified with 101 assertions in scripts/e2e-auth.mjs driving a real browser against both emulators, 80 in scripts/rules-emulator.mjs executing the rules as five different people, and the QA sweep clean across 80 combinations. Two bugs came out of the driving rather than the reading: /join navigated away while signInWithPopup was still finalising, orphaning the Google window about one run in three, and the app header's wordmark was a 33x28 tap target on mobile. --- CONTRIBUTING.md | 28 +- FIREBASE.md | 187 ++++-- firestore.rules | 220 ++++++-- web/app/{ => (app)}/admin/page.tsx | 9 +- web/app/(app)/dashboard/page.tsx | 64 +++ web/app/(app)/layout.tsx | 24 + web/app/(app)/onboarding/page.tsx | 63 +++ web/app/{ => (site)}/hall-of-fame/page.tsx | 0 web/app/{ => (site)}/how-to-join/page.tsx | 0 web/app/{ => (site)}/join/page.tsx | 0 web/app/(site)/layout.tsx | 35 ++ web/app/{ => (site)}/page.tsx | 0 web/app/{ => (site)}/privacy/page.tsx | 0 web/app/{ => (site)}/programmes/page.tsx | 0 web/app/{ => (site)}/projects/page.tsx | 0 web/app/{ => (site)}/team/page.tsx | 0 web/app/layout.tsx | 39 +- web/components/AdminDashboard.tsx | 343 +++++------ web/components/AdminMentors.tsx | 423 ++++++++++++++ web/components/AdminMentorship.tsx | 420 ++++++++++++++ web/components/AppFooter.tsx | 49 ++ web/components/AppHeader.tsx | 113 ++++ web/components/JoinGate.tsx | 600 +++++++------------- web/components/MemberDashboard.tsx | 285 ++++++++++ web/components/MemberOnly.tsx | 106 ++++ web/components/MentorPicker.tsx | 626 +++++++++++++++++++++ web/components/Nav.tsx | 25 +- web/components/OnboardingGate.tsx | 130 +++++ web/components/ProfileForm.tsx | 363 ++++++------ web/components/admin/ui.tsx | 127 +++++ web/content/join.ts | 10 +- web/lib/batch.ts | 149 +++++ web/lib/firebase.ts | 21 + web/lib/mentorship.ts | 265 +++++++++ web/lib/profile.ts | 82 +-- web/scripts/assert-site.mjs | 37 +- web/scripts/e2e-auth.mjs | 552 +++++++++++++++--- web/scripts/rules-emulator.mjs | 233 +++++++- web/scripts/rules.mjs | 137 ++++- web/scripts/smoke.mjs | 73 ++- 40 files changed, 4766 insertions(+), 1072 deletions(-) rename web/app/{ => (app)}/admin/page.tsx (81%) create mode 100644 web/app/(app)/dashboard/page.tsx create mode 100644 web/app/(app)/layout.tsx create mode 100644 web/app/(app)/onboarding/page.tsx rename web/app/{ => (site)}/hall-of-fame/page.tsx (100%) rename web/app/{ => (site)}/how-to-join/page.tsx (100%) rename web/app/{ => (site)}/join/page.tsx (100%) create mode 100644 web/app/(site)/layout.tsx rename web/app/{ => (site)}/page.tsx (100%) rename web/app/{ => (site)}/privacy/page.tsx (100%) rename web/app/{ => (site)}/programmes/page.tsx (100%) rename web/app/{ => (site)}/projects/page.tsx (100%) rename web/app/{ => (site)}/team/page.tsx (100%) create mode 100644 web/components/AdminMentors.tsx create mode 100644 web/components/AdminMentorship.tsx create mode 100644 web/components/AppFooter.tsx create mode 100644 web/components/AppHeader.tsx create mode 100644 web/components/MemberDashboard.tsx create mode 100644 web/components/MemberOnly.tsx create mode 100644 web/components/MentorPicker.tsx create mode 100644 web/components/OnboardingGate.tsx create mode 100644 web/components/admin/ui.tsx create mode 100644 web/lib/batch.ts create mode 100644 web/lib/mentorship.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cabd86..9b86695 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,12 +38,16 @@ This used to be a single `club.ts`. It was split when the site became five pages because one 800-line file holding five pages' content meant every content PR touched it and every one of them conflicted. -> **Editing the join form's options?** `join.ts` holds the four paths, the three -> experience levels and the interest checkboxes — and `firestore.rules` at the repo -> root keeps a **second copy** of those values, because Firestore rules cannot import -> anything. Change one without the other and every applicant who picks the new option -> gets a permission error on submit, while the page still renders perfectly. Run -> `npm run rules` to check, and see [FIREBASE.md](FIREBASE.md). +> **Editing the join form's options?** `join.ts` holds the four paths, the two hostels +> and the programme list — and `firestore.rules` at the repo root keeps a **second copy** +> of those values, because Firestore rules cannot import anything. Change one without the +> other and every member who picks the new option gets a permission error on save, while +> the page still renders perfectly. Run `npm run rules` to check, and see +> [FIREBASE.md](FIREBASE.md). +> +> The signed-in half of the site — `/join`, `/onboarding`, `/dashboard`, `/admin` — is not +> content and does not live in `web/content/`. Nothing there is published on the site; see +> FIREBASE.md before changing it. ```ts // content/people.ts @@ -127,8 +131,8 @@ npm run qa # must report 0 issues — same npm run browsers # all three engines ``` -`npm run qa` drives real Chromium across **all six routes** × four viewports × both -themes — 48 combinations. It checks contrast, tap-target sizes, text size, heading +`npm run qa` drives real Chromium across **every route** × four viewports × both +themes — 80 combinations. It checks contrast, tap-target sizes, text size, heading order, alt text, horizontal overflow, and that `.tap` is never combined with a margin utility. **Zero issues is the bar**, and it is not negotiable for a site whose audience includes people reading it on a phone on campus wifi. @@ -138,6 +142,14 @@ button's visibility on every route, and that the outline and scroll reveals re-d after a client-side navigation. Run it before `qa`, because a page serving no JavaScript passes most of what `qa` checks. +**Both of those run signed out**, so on `/onboarding` and `/dashboard` they only ever see +the "sign in first" card. If you touched sign-in, the profile form, the dashboard or +anything under `/admin`, a green run there means the door is not broken and nothing more — +the signed-in half is covered by `npm run e2e:auth`, which drives the whole flow in a real +browser against the Auth and Firestore emulators, and by `npm run rules:emulator`, which +executes `firestore.rules` as several different people. Both need the emulators running; +see [FIREBASE.md](FIREBASE.md). + If you changed anything visual, also **look at it in both themes**. On this project that has caught bugs every single time — including a wordmark rendering at 1.11:1 (black on black) that every automated checker passed, because the checker read the diff --git a/FIREBASE.md b/FIREBASE.md index 7893117..e8e1675 100644 --- a/FIREBASE.md +++ b/FIREBASE.md @@ -48,16 +48,30 @@ before launch. Each is a numbered step below. ## What it does -Members sign in, fill a profile once, and organisers read the roster. +Members sign in, answer three questions once, land on a dashboard, and can enrol in the +GSoC mentorship cohort. Organisers read the roster and publish the mentors. ``` -/join ──Google sign-in (@sst.scaler.com only)──▶ Firebase Auth - │ - ▼ - profile form ──setDoc()──▶ users/{uid} one doc per member - │ owner-only read/write - ▼ -/admin ──list, admins only──────────▶ breakdowns + table + CSV export +/join ──Google sign-in (@sst.scaler.com only)──▶ Firebase Auth + │ + ▼ +/onboarding name · GitHub · hostel ──setDoc()──▶ users/{uid} one doc per member + (batch/branch/year are READ FROM │ owner-only read/write + the address, never stored) │ + │ │ + ▼ │ +/dashboard their details, and the mentorship card │ + │ │ + │ pick a 1st preference, and │ + │ either a 2nd or "first only" │ + ▼ │ + enrollments/{uid} ◀───────────────┐ │ + ▲ │ │ + │ │ ▼ +/admin ──list, admins only──▶ roster · breakdowns · CSV + ──write───────────────▶ mentors/{id} ──────┘ + published by organisers, + readable by every member ``` No server, no admin SDK, no API route. The site stays a static build; everything is the @@ -70,21 +84,46 @@ client talking to Firestore under the rules. | `web/lib/firebase.ts` | Lazy client init, collection names, the allowed domain. | | `web/lib/auth.tsx` | Sign-in, sign-out, and the admin check. | | `web/lib/profile.ts` | The profile shape and its read/write. | -| `web/components/JoinGate.tsx` | The four states of `/join`. | -| `web/components/ProfileForm.tsx` | The form itself. | +| `web/lib/batch.ts` | Batch, branch and roll, parsed out of the college address. | +| `web/lib/mentorship.ts` | Mentors and enrolments. | +| `web/components/JoinGate.tsx` | The door: `/join`, sign-in only. | +| `web/components/MemberOnly.tsx` | The three states before "signed in", shared by both member routes. | +| `web/components/ProfileForm.tsx` | The three questions. | +| `web/components/MemberDashboard.tsx` | `/dashboard`. Also decides who still needs onboarding. | +| `web/components/MentorPicker.tsx` | Enrolment and the preference picker. | | `web/components/AdminDashboard.tsx` | The organisers' view. | +| `web/components/AdminMentors.tsx` | Publishing, editing, hiding and deleting mentors. | +| `web/components/AdminMentorship.tsx` | Who enrolled, and the demand per mentor. | | `web/scripts/rules.mjs` | Text check: rules vs the form. Runs in CI. | | `web/scripts/rules-emulator.mjs` | Executes the rules as several different users. | +| `web/scripts/e2e-auth.mjs` | Drives the whole flow in a real browser. | | `web/.env.example` | The variables, with notes. | -Three collections: +Five collections: | Collection | Who can read | Who can write | |---|---|---| | `users/{uid}` | that member, and admins | that member only, validated | | `admins/{email}` | your own row only | **nobody, from any client** | +| `mentors/{id}` | every signed-in member | **admins** — see below | +| `enrollments/{uid}` | that member, and admins | that member only, validated | | `applications/{id}` | nobody | nobody — legacy, kept sealed | +**`mentors` is the one collection a client may write that is not its own row.** That +widening was deliberate, and it is acceptable because a mentor entry is published, +organiser-authored, non-personal copy — the worst a stolen admin session can do there is +deface a list. The same argument does **not** hold for `admins`, which is why every client +write to that collection stays denied: appointing an admin is the one privilege escalation +this model would otherwise allow. + +**Nothing about a member's batch is stored anywhere.** `23bcs10045` in +`asha.23bcs10045@sst.scaler.com` is the 2023–27 batch, branch BCS, roll 10045, and +`web/lib/batch.ts` reads it on demand. The rules pin the stored address to +`request.auth.token.email`, so a value derived from it cannot be forged and cannot drift +out of step with the document it describes. The consequence to know about: you cannot +*query* by batch, because it is not a field — the organisers' dashboard filters in the +browser over a membership it has already read. + --- ## Setup @@ -364,24 +403,45 @@ write it — not even an admin — so it is managed by hand: has to exist. Keying by email rather than uid means you can add an organiser **before** they have ever -signed in. They see a **Dashboard** link on `/join` and can open `/admin`. +signed in. They see a link to the organisers' dashboard on `/dashboard` and can open +`/admin`. To remove an organiser, delete their document. Do not add a `write` rule to this collection: denying it is what stops a compromised admin session appointing more admins. +### 4. Publish the first mentor + +Until one mentor exists, the mentorship card on every member's dashboard says enrolment +has not opened — which is true, and better than a button that cannot work. So an organiser +has to publish one before the cohort can start. + +**`/admin` → Mentors → Add a mentor.** Name, description, programme; organisation, GitHub +and email are optional. The description is the field that matters: it is what a student +reads before choosing, so write what the mentor works on and what they are *not* the +person to ask, not an adjective about them. + +Retiring a mentor is **Hide from members**, not Delete. Hiding takes them out of the +picker while every preference already recorded against them still shows their name; +deleting is only offered for a mentor nobody has picked, because a deleted mentor with a +preference pointing at them leaves an id where a name should be. + --- -## Reading submissions +## Reading the data -**Firestore → Data → `applications`.** Access is governed by who has permissions on -the Firebase project, not by the rules in this repo — so adding an organiser means -adding their Google account under **Project settings → Users and permissions**, and -removing one means removing it there. +**Everything is on `/admin`**, which is where an organiser should be looking: the +membership with its breakdowns, the mentor list, who enrolled and which mentors they +asked for. It is served by the `list` rules on `users` and `enrollments`, which only an +address in `admins` satisfies — the page itself is not the gate, and it ships to anybody +who asks for the URL. -There is deliberately no admin page on the site. Building one means giving a client -`read` on this collection, which is the one thing the rules exist to prevent. If you -need one later, it needs a real server with authentication, and it is a much larger -change than it looks. +For anything the dashboard does not show, **Firestore → Data** in the console. Console +access is governed by who has permissions on the Firebase *project* — **Project settings → +Users and permissions** — which is a separate list from `admins` and a much more powerful +one. Somebody who only needs the roster belongs in `admins`, not in the project. + +Nothing turns a preference into an allocation. The dashboard shows demand per mentor and +who asked for whom; pairing the cohort is still a decision somebody makes. ### The document shape @@ -389,24 +449,67 @@ One document per member at `users/{uid}`, where `{uid}` is the Firebase Auth uid ```js users/l8JdTxxca59NYDtbsrhTGdF0iKLE { - uid "l8JdTxx..." // same as the document id - email "asha@sst.scaler.com" // pinned to the signed-in address by the rules + uid "l8JdTxx..." // same as the document id + email "asha.23bcs10045@sst.scaler.com" // pinned to the signed-in address name "Asha Verma" - year_branch "3rd year, ECE" // free text, one field - hostel "uniworld-1" // closed set - level "some-git" // closed set - path "program-track" // closed set - programs ["gsoc", "outreachy"] // closed set, at least one - interests ["web"] // optional - github "asha" // optional, omitted when blank - heard_from "senior" // optional - why "…" // ≤400 chars - updates true - created_at // written once, frozen by the rules - updated_at // moves on every save + hostel "uniworld-1" // closed set + github "asha" // optional, omitted when blank + path "program-track" // optional, closed set — see below + created_at // written once, frozen by the rules + updated_at // moves on every save } ``` +Three fields are asked for; the rest is either identity or carried in. **There is no +`year_branch`, `level` or `programs`** — those were removed, and the rules reject a +document that still carries them. Batch, branch and year come from the address +(`23bcs10045` → 2023–27, BCS, roll 10045). Experience level and programme interest were +self-assessments made before somebody had met the club, that nothing acted on; interest is +now expressed by *enrolling*, which is a decision with a consequence. + +`path` is the one field nobody is asked for. Every closing action on the site links to +`/join?path=`; the value rides through sign-in and both redirects in the query string +and is saved silently, then shown back on the dashboard where it can be changed or +cleared. It is optional because most members arrive through the nav button with no path +at all. + +```js +mentors/61SsdoQwMgUKoXo5HxsZ { // auto-generated id + name "Priya Nair" + description "Kubernetes and Go. Good on proposal structure; not for frontend." + programme "gsoc" // closed set, same list as PROGRAMS + org "CNCF" // optional + github "priya" // optional + email "…" // optional + active true // false hides them from the picker + created_at + updated_at +} + +enrollments/l8JdTxxca59NYDtbsrhTGdF0iKLE { // keyed by uid, like a profile + uid "l8JdTxx..." + email "asha.23bcs10045@sst.scaler.com" + programme "gsoc" + mentor_1 "61SsdoQ..." // must name a mentor that EXISTS + mentor_2 "mentor-arjun" // absent exactly when first_only is true + first_only false + created_at + updated_at +} +``` + +**Exactly one of `mentor_2` and `first_only: true`, always, enforced in both directions.** +A document carrying both is contradictory; one carrying neither stores an unanswered +question as though it were an answer, and an organiser pairing thirty students needs to +tell "I only want Priya" apart from "I have not decided". Both mentor ids must reference a +document that exists, which costs one read per write and is what stops the interest list +displaying a raw id where a name should be. + +Members may **delete their own enrolment**, which profiles deliberately forbid: a profile +is the club's roster and losing one loses a member, whereas an enrolment is an expression +of interest and withdrawing it is the member's own decision. Admins can read every +enrolment and change none. + **Not JSON files** — Firestore documents, which are JSON-*like* with typed fields (string, number, boolean, array, map, timestamp). They look like JSON in the console and export as JSON, but they are rows in a real database with per-field security. @@ -439,15 +542,19 @@ scale. Past a few thousand members that needs revisiting. ## Changing the form -**`firestore.rules` hardcodes the allowed values for `level`, `path`, `hostel`, -`interests` and `programs`, because Firestore rules cannot import anything.** They are a -second copy of the lists in `web/content/join.ts`. +**`firestore.rules` hardcodes the allowed values for `hostel`, `path` and `programme`, +because Firestore rules cannot import anything.** They are a second copy of the lists in +`web/content/join.ts`. -If you add a path, a level, a hostel, an interest or a programme, **you must update both -files.** Otherwise +If you add a hostel, a path or a programme, **you must update both files.** Otherwise every applicant who picks the new option gets a permission error on submit — the form looks perfect, the page renders correctly, and only that one option is broken. +`programme` is written **twice** in the rules — once for a mentor and once for an +enrolment — and both copies have to match `PROGRAMS`. Update one and not the other and an +organiser can publish a mentor that no member is then allowed to choose. `npm run rules` +checks both. + This is not hypothetical: two of the five values were wrong when this was first written (`some` for `some-git`, `hackathon` for `build-day`). So there is a check: diff --git a/firestore.rules b/firestore.rules index 62999f1..abc186d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -7,15 +7,34 @@ rules_version = '2'; // static export with no server — so there is no back end doing checks. Everything that // protects members' data is in this file. // -// The site now has sign-in. Three collections: +// The site now has sign-in. Five collections: // // users/{uid} one profile per member. Readable and writable ONLY by that -// member, plus readable by admins. Contains names, college -// addresses, year, branch and hostel. +// member, plus readable by admins. Contains their name, college +// address and hostel. // admins/{email} membership = adminship. Nobody can write it from any client. +// mentors/{id} the mentor list an organiser publishes. Readable by every member, +// WRITABLE BY ADMINS — see below. +// enrollments/{uid} one per member: which mentors they want, in order. Theirs to +// write and to withdraw; admins may list them all. // applications/{id} legacy, from before sign-in. Create-only and unreadable, kept // so the rows already there stay protected. // +// `mentors` IS THE FIRST COLLECTION A CLIENT MAY WRITE THAT IS NOT ITS OWN ROW, and that +// widening was deliberate rather than incidental. It is acceptable because a mentor entry +// is published, organiser-authored, non-personal copy — the same kind of thing that lives +// in web/content/ — so the worst a stolen admin session can do here is deface a list. It +// cannot read a member's details it could not already read, and it cannot grant itself +// anything. Note that the same argument does NOT hold for `admins`, which is why every +// client write to that collection stays denied: appointing an admin is the one privilege +// escalation this model would otherwise allow. +// +// WHAT IS NOT A FIELD HERE, ON PURPOSE. Batch, branch and year are not stored anywhere. +// They are read out of the address — `abhinav.23bcs10045@sst.scaler.com` — by +// web/lib/batch.ts, and the address is pinned to `request.auth.token.email` below. A +// derived value cannot disagree with the document it describes, so there is nothing here +// to validate and nothing that can drift. +// // ONLY @sst.scaler.com MAY REGISTER, and this is where that is enforced. web/lib/auth.tsx // also passes the domain to Google and signs out anyone off-domain, but both of those // are conveniences: a client can be modified, and a token from another domain is still a @@ -74,11 +93,11 @@ service cloud.firestore { /** The profile shape. Mirrors web/lib/profile.ts and web/content/join.ts. * - * FOUR FIELDS WERE REMOVED from this list — why, heard_from, interests and updates — - * because each cost a member time at sign-up and nothing read it back. `hasOnly` is - * strict, so a document still carrying one of them is now REJECTED rather than - * tolerated. That is safe here only because the site had never been deployed with - * sign-in, so no member profile exists in production yet. If that changes, removing a + * EIGHT FIELDS HAVE BEEN REMOVED from this list over time — why, heard_from, + * interests, updates, year_branch, level, programs and programs_other — because each + * cost a member time at sign-up and nothing read it back. `hasOnly` is strict, so a + * document still carrying one of them is REJECTED rather than tolerated. That is safe + * only because no member profile exists in production yet. Once one does, removing a * field means either leaving it in `hasOnly` or migrating the documents first — a * strict list turns an old field into a member who can no longer edit their profile. */ function isWellFormedProfile(d, uid) { @@ -87,17 +106,18 @@ service cloud.firestore { // alone would let somebody append a hundred keys of their own — including, say, // an `isAdmin` field that a future careless rule might read. d.keys().hasOnly([ - 'uid', 'email', 'name', 'year_branch', 'hostel', 'level', 'path', - 'programs', 'programs_other', 'github', 'created_at', 'updated_at' - ]) - && d.keys().hasAll([ - 'uid', 'email', 'name', 'year_branch', 'hostel', 'level', 'path', - 'programs', 'updated_at' + 'uid', 'email', 'name', 'hostel', 'github', 'path', + 'created_at', 'updated_at' ]) + && d.keys().hasAll(['uid', 'email', 'name', 'hostel', 'updated_at']) // Identity cannot be forged. The document id, the uid inside it and the signed-in // token must all agree, and the stored address must be the one that signed in — // so a member cannot file a profile under somebody else's name or address. + // + // This is also what makes the DERIVED batch trustworthy: web/lib/batch.ts reads + // the year and branch out of this address, so pinning the address is what stops + // somebody claiming a batch they are not in. && d.uid == uid && d.uid == request.auth.uid && d.email == request.auth.token.email @@ -105,36 +125,18 @@ service cloud.firestore { // Required strings, bounded. The form's maxlength is a courtesy to the reader; // these are the real limits, because a direct SDK call never sees the form. && d.name is string && d.name.size() > 0 && d.name.size() <= 120 - && d.year_branch is string && d.year_branch.size() > 0 && d.year_branch.size() <= 120 // Optional strings. Present-or-absent rather than nullable, so an absent github - // unambiguously means "not given". - && (!('github' in d) || (d.github is string && d.github.size() <= 100)) + // unambiguously means "not given" — and a cleared one is sent as deleteField() + // rather than as "", which these size checks would refuse. + && (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100)) // Closed sets — see the drift warning in the header. && d.hostel in ['uniworld-1', 'uniworld-2'] - && d.level in ['none', 'some-git', 'merged'] - && d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track'] - - // Programmes: required and non-empty, which is the form's rule too. - && d.programs is list - && d.programs.size() > 0 - && d.programs.size() <= 10 - && d.programs.hasOnly([ - 'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob', - 'gssoc', 'ssoc', 'esoc', 'other' - ]) - // 'other' and its free text are a pair, checked in BOTH directions: no bare - // 'other' with nothing explaining it, and no stray text without the tick that - // is supposed to have produced it. - && (!d.programs.hasAny(['other']) - || ('programs_other' in d - && d.programs_other is string - && d.programs_other.size() > 0)) - && (!('programs_other' in d) - || (d.programs_other is string - && d.programs_other.size() <= 120 - && d.programs.hasAny(['other']))) + // `path` is optional: it is carried in from a ?path= link rather than asked for, + // and most members arrive without one. + && (!('path' in d) + || d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track']) // The server's clock, never the client's, so "member since" and "last edited" // cannot be backdated. @@ -142,6 +144,89 @@ service cloud.firestore { && (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at); } + /** A mentor entry, as an organiser writes it. Admin-only, but validated anyway: the + * shape is what the members' picker renders, and an admin fat-fingering a direct SDK + * call should not be able to put an unbounded string in front of every member. */ + function isWellFormedMentor(d) { + return + d.keys().hasOnly([ + 'name', 'description', 'programme', 'org', 'github', 'email', + 'active', 'created_at', 'updated_at' + ]) + && d.keys().hasAll(['name', 'description', 'programme', 'active', 'updated_at']) + + && d.name is string && d.name.size() > 0 && d.name.size() <= 120 + // The long one. 600 rather than 120 because this is the paragraph a student reads + // before choosing, and a mentor boundary worth stating does not fit in a tweet. + && d.description is string && d.description.size() > 0 && d.description.size() <= 600 + && d.active is bool + + && (!('org' in d) || (d.org is string && d.org.size() > 0 && d.org.size() <= 120)) + && (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100)) + && (!('email' in d) || (d.email is string && d.email.size() > 0 && d.email.size() <= 160)) + + // The same closed set the profile used to carry for `programs`, kept because a + // mentor belongs to a named programme and `npm run rules` diffs it against + // PROGRAMS in web/content/join.ts. + && d.programme in [ + 'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob', + 'gssoc', 'ssoc', 'esoc', 'other' + ] + + && d.updated_at == request.time + && (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at); + } + + /** A member's mentor preferences. + * + * THE PAIRING IS THE INTERESTING PART, and it is checked in both directions for the + * same reason the old programs/programs_other pair was: a document carrying both a + * second choice and "first preference only" is contradictory, and one carrying + * neither is an unanswered question stored as though it were an answer. Exactly one + * of them, always. + * + * Both mentor ids must name a document that EXISTS. That costs one extra read per + * write and it is what stops the organisers' interest list from displaying a raw id + * where a name should be. */ + function isWellFormedEnrollment(d, uid) { + return + d.keys().hasOnly([ + 'uid', 'email', 'programme', 'mentor_1', 'mentor_2', 'first_only', + 'created_at', 'updated_at' + ]) + && d.keys().hasAll(['uid', 'email', 'programme', 'mentor_1', 'first_only', 'updated_at']) + + && d.uid == uid + && d.uid == request.auth.uid + && d.email == request.auth.token.email + + && d.programme in [ + 'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob', + 'gssoc', 'ssoc', 'esoc', 'other' + ] + + && d.first_only is bool + && d.mentor_1 is string + && d.mentor_1.size() > 0 + && d.mentor_1.size() <= 64 + && exists(/databases/$(database)/documents/mentors/$(d.mentor_1)) + + // "First preference only" means there is no second one. + && (!d.first_only || !('mentor_2' in d)) + // And not saying so means there must be. + && (d.first_only || 'mentor_2' in d) + && (!('mentor_2' in d) + || (d.mentor_2 is string + && d.mentor_2.size() > 0 + && d.mentor_2.size() <= 64 + // The same mentor twice is not two preferences. + && d.mentor_2 != d.mentor_1 + && exists(/databases/$(database)/documents/mentors/$(d.mentor_2)))) + + && d.updated_at == request.time + && (!('created_at' in d) || d.created_at == request.time || d.created_at == resource.data.created_at); + } + // ---------------------------------------------------------------- members match /users/{uid} { @@ -184,6 +269,63 @@ service cloud.firestore { allow write: if false; } + // ---------------------------------------------------------------- mentors + + match /mentors/{id} { + // Every signed-in member may read the whole list — it is what the picker on the + // dashboard renders, and the list itself is not a secret. `list` is allowed here + // where it is admin-only on users/, because the difference is what the documents + // contain: published copy about a volunteer, versus a roster of students. + // + // Hidden mentors (active: false) come back too, deliberately. The client filters + // them out of the picker, but a member who picked somebody before they were hidden + // still needs to see a name rather than an id. + allow get, list: if isMember(); + + // ADMIN WRITE. See the note in the header for why this widening is acceptable and + // where the same argument stops applying. + allow create, update: if isAdmin() && isWellFormedMentor(request.resource.data); + + // Deleting is allowed, and the guard against orphaning a preference lives in the + // client — see web/lib/mentorship.ts. Rules cannot express "no document in another + // collection points at this one", because that needs a query and rules cannot + // query. The consequence of getting it wrong is cosmetic (an id shows where a name + // should) rather than a disclosure, which is why it is acceptable to enforce it + // one level up rather than here. + allow delete: if isAdmin(); + } + + // ------------------------------------------------------------ enrollments + + match /enrollments/{uid} { + // Same get/list split, and for the same reason as users/: a single `read` rule + // naming the owner cannot express "and admins may query the collection", because + // Firestore evaluates a list without knowing the documents. + allow get: if isMember() && (request.auth.uid == uid || isAdmin()); + allow list: if isAdmin(); + + allow create: if isMember() + && request.auth.uid == uid + && isWellFormedEnrollment(request.resource.data, uid); + + allow update: if isMember() + && request.auth.uid == uid + && isWellFormedEnrollment(request.resource.data, uid) + && request.resource.data.uid == resource.data.uid + && request.resource.data.email == resource.data.email + && request.resource.data.created_at == resource.data.created_at; + + // A MEMBER MAY WITHDRAW, which users/{uid} deliberately forbids. The two documents + // mean different things: a profile is the club's roster and deleting one loses a + // member, whereas this is an expression of interest and taking it back is the + // member's own decision — putting that behind an email to an organiser would be + // the site making somebody ask permission to change their mind. + // + // Owner only. An admin cannot delete somebody's enrollment, for the same reason + // they cannot delete a profile. + allow delete: if isMember() && request.auth.uid == uid; + } + // ---------------------------------------------------- legacy applications match /applications/{id} { diff --git a/web/app/admin/page.tsx b/web/app/(app)/admin/page.tsx similarity index 81% rename from web/app/admin/page.tsx rename to web/app/(app)/admin/page.tsx index d03f168..15662ea 100644 --- a/web/app/admin/page.tsx +++ b/web/app/(app)/admin/page.tsx @@ -19,7 +19,7 @@ import AdminDashboard from "@/components/AdminDashboard"; export const metadata: Metadata = { title: "Organisers", - description: "Club membership, by hostel, year, branch and programme.", + description: "Club membership by batch, year, branch and hostel, and the mentorship cohort.", robots: { index: false, follow: false }, }; @@ -32,9 +32,10 @@ export default function Admin() { Who is in the club.

- Every registered member, and the breakdowns most often asked for — by hostel, by - year, by branch, by programme. Counts are over the whole membership; the search - and filter below narrow only the list. + Every registered member and the breakdowns most often asked for — by batch, by + year, by branch, by hostel — then the mentor list and who has chosen whom. Counts + are over the whole membership; the search and filters below narrow only the + lists.

diff --git a/web/app/(app)/dashboard/page.tsx b/web/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..67bda5e --- /dev/null +++ b/web/app/(app)/dashboard/page.tsx @@ -0,0 +1,64 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import MemberDashboard from "@/components/MemberDashboard"; + +// A MEMBER'S OWN PAGE. Where signing in leads, and where everything the club asks a +// member to do afterwards lives. +// +// It is also the one route that decides whether somebody still needs onboarding — /join +// sends every signed-in reader here without reading their profile, so that question has +// exactly one answer in exactly one place. See the header of MemberDashboard.tsx. +// +// NOT A PRIVILEGE GATE. This HTML ships to anybody who asks for it; the site is a static +// export with no server to refuse them. What refuses them is firestore.rules. See +// lib/auth.tsx before concluding that a page nobody links to is a page nobody can read. +// +// ABSENT FROM PAGES, so it is in neither the nav strip nor the footer's route list — the +// nav's one persistent button already changes to "Profile" and points here once somebody +// is signed in, and the same word in the strip as well would be the site's one action +// said twice. +// +// `noindex`, because a page that only means anything to one signed-in person has no +// business in a search index. + +export const metadata: Metadata = { + title: "Your dashboard", + description: "Your club details, and the programmes you are enrolled in.", + robots: { index: false, follow: false }, +}; + +export default function Dashboard() { + return ( +
+ {/* `.page-top` is the shared clearance under the floating header — see the note in + globals.css. It is the same number for the app shell because the app shell is + deliberately the same height as the site nav. */} +
+ {/* THE CAP IS ON AN INNER DIV, NOT ON `.section`. This is the trap globals.css + documents against `.page-top` and `pt-*`, hit in the other direction: every + custom class in that file is declared after `@tailwind utilities`, so at equal + specificity source order hands the win to `.section` and its `max-w-[88rem]` + silently beats a `max-w-6xl` sitting next to it. The markup said 72rem and the + screen said 88rem, with nothing anywhere to explain the difference. + 72rem: the details are a four-tile row, which wants the room, and the mentor + picker is prose a person reads, which does not. This is where they stop + fighting. */} +
+

+ Your club. +

+ + {/* useSearchParams inside the dashboard needs a Suspense boundary, or + `next build` refuses to prerender this route — at build time rather than at + runtime, which is the good version of that error. The fallback reserves + roughly the first card's height so the page does not jump. */} +
+ }> + + +
+
+
+
+ ); +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx new file mode 100644 index 0000000..c190b3a --- /dev/null +++ b/web/app/(app)/layout.tsx @@ -0,0 +1,24 @@ +import AppHeader from "@/components/AppHeader"; +import AppFooter from "@/components/AppFooter"; + +// The SIGNED-IN area: /onboarding, /dashboard, /admin. +// +// It carries none of the public site's chrome — see the note in (site)/layout.tsx for +// what was removed and why. What it has instead is a header that says who you are signed +// in as and a footer that carries the three privacy anchors, which are the only part of +// the site footer a member actually needs from here. +// +// THIS IS NOT A PRIVILEGE BOUNDARY. A layout decides what to paint, not who may read. +// Every one of these routes is static HTML on a CDN and anybody can fetch it; what +// refuses to hand over data is firestore.rules. See the note at the top of lib/auth.tsx +// before concluding that an app shell makes anything safe. + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + + ); +} diff --git a/web/app/(app)/onboarding/page.tsx b/web/app/(app)/onboarding/page.tsx new file mode 100644 index 0000000..b976f67 --- /dev/null +++ b/web/app/(app)/onboarding/page.tsx @@ -0,0 +1,63 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import OnboardingGate from "@/components/OnboardingGate"; + +// STEP TWO OF JOINING, on its own route. /join proves which college you are at; this asks +// the three things the college address cannot answer. +// +// NOT A PRIVILEGE GATE, and it does not pretend to be one. The site is a static export, so +// this HTML ships to anybody who asks for it — signed in or not, member or not. What +// refuses to store or return anything is firestore.rules. See lib/auth.tsx before +// concluding that a hidden route is a safe one. +// +// ABSENT FROM PAGES, so it is in neither the nav strip nor the footer's route list. It is +// somewhere you are sent, once, not somewhere you browse to. +// +// `noindex`, because a form that only means anything to one signed-in person has no +// business in a search result. + +export const metadata: Metadata = { + title: "Finish joining", + description: "Three questions, and you are a member of the Scaler Open Source Club.", + robots: { index: false, follow: false }, +}; + +export default function Onboarding() { + return ( +
+
+ {/* CENTRED AND NARROW, which the dashboard is not. This is a single task with one + control at the end of it, and a form column stretched across a 1400px page is + the layout that makes a sign-up feel like paperwork — the name and GitHub + fields end up a foot apart. 42rem is the widest that two-up row reads + comfortably at. + THE CAP IS ON AN INNER DIV, NOT ON `.section`. Custom classes in globals.css + are declared after `@tailwind utilities`, so `.section`'s `max-w-[88rem]` wins + on source order and a `max-w-2xl` beside it does nothing at all — the markup + says 42rem and the screen says 88rem. Same trap as `.page-top` and `pt-*`, + which that file documents. */} +
+

Almost there

+

+ Three questions. +

+

+ You only do this once, and you can change any of it later. It is what the + organisers see when they are putting build-day pairs and programme cohorts + together. +

+ + {/* The gate reads the query string, which needs a Suspense boundary or + `next build` refuses to prerender this route — at build time rather than at + runtime, which is the good version of that error. The fallback reserves + roughly the card's height so the page does not jump when it resolves. */} +
+ }> + + +
+
+
+
+ ); +} diff --git a/web/app/hall-of-fame/page.tsx b/web/app/(site)/hall-of-fame/page.tsx similarity index 100% rename from web/app/hall-of-fame/page.tsx rename to web/app/(site)/hall-of-fame/page.tsx diff --git a/web/app/how-to-join/page.tsx b/web/app/(site)/how-to-join/page.tsx similarity index 100% rename from web/app/how-to-join/page.tsx rename to web/app/(site)/how-to-join/page.tsx diff --git a/web/app/join/page.tsx b/web/app/(site)/join/page.tsx similarity index 100% rename from web/app/join/page.tsx rename to web/app/(site)/join/page.tsx diff --git a/web/app/(site)/layout.tsx b/web/app/(site)/layout.tsx new file mode 100644 index 0000000..c2519a3 --- /dev/null +++ b/web/app/(site)/layout.tsx @@ -0,0 +1,35 @@ +import Nav from "@/components/Nav"; +import Reveal from "@/components/Reveal"; +import Footer from "@/components/Footer"; + +// The PUBLIC site's chrome: the nav strip, the scroll reveals, and the four-column +// footer. Everything here is addressed to somebody deciding whether to join. +// +// IT USED TO BE IN THE ROOT LAYOUT, which meant it also wrapped /dashboard, /onboarding +// and /admin — so a member who had joined last month still got a bar arguing the case for +// joining, ending in a filled button pointing at the page they were already on. Splitting +// it out is what makes "the marketing chrome" a thing a route opts into rather than a +// thing every route inherits. +// +// A ROUTE GROUP RATHER THAN A CLIENT-SIDE PATHNAME CHECK. `(site)` does not appear in any +// URL, so nothing about the routes changed — but the decision is made at build time, per +// route, in the static export. The alternative was a client component reading +// usePathname() and returning null, which ships the marketing nav's markup to every app +// page and then removes it after hydration: a visible flash of somebody else's chrome, on +// the one surface that should feel like it was built for the person signed into it. +// +// Reveal renders nothing; it opts the document in to the scroll settle. It lives here +// rather than at the root because an app surface should not animate its own furniture in +// as you scroll past it — and doing it once per group is also what stops each route +// re-registering its own observer on navigation. + +export default function SiteLayout({ children }: { children: React.ReactNode }) { + return ( + <> +
- + - - - + +

- Programme percentages are of members, and members pick more than one — so those - add up past 100%. Year and branch are parsed from the single free-text field a - member types, so anything unrecognised is counted as{" "} - Unparsed rather than guessed at. + Batch, branch and year are read from each member's college address rather than + asked for — 23bcs10045 is the 2023–27 + batch, branch BCS. An address that does not follow that pattern is counted as{" "} + Unknown rather than guessed at.

{/* The list. Filtering is local — the whole membership is already in memory, so a @@ -420,7 +373,7 @@ export default function AdminDashboard() { setQ(e.target.value)} - placeholder="Search name, email, branch, GitHub" + placeholder="Search name, email, batch, GitHub" className={ctl + " w-60"} aria-label="Search members" /> @@ -437,10 +390,21 @@ export default function AdminDashboard() { ))} - {/* Built from the data rather than a fixed list: year is parsed out of a free - text field, so the only honest set of options is the one that actually - appears — including "Unparsed", which an organiser needs to be able to - isolate and go fix. */} + {/* Built from the data rather than a fixed list: the club gains a batch every + year, and a hardcoded set would silently stop offering the newest one. */} + - - {activeFilters > 0 && ( ) : ( @@ -586,19 +523,16 @@ export default function AdminDashboard() { {r.name} {r.email} - {r.year_branch} - {label(HOSTELS, r.hostel)} - {label(LEVELS, r.level)} + + {batchBucket(r.email)} + {branchBucket(r.email)} + + + {yearBucket(r.email)} + + {labelOf(HOSTELS, r.hostel)} - {(r.programs ?? []).map((v) => label(PROGRAMS, v)).join(", ") || "—"} - {/* The free text behind "Other". It was collected, validated and - exported to CSV but never shown on screen, so the one programme a - member had to type was the one an organiser could not see. */} - {r.programs_other && ( - - Other: {r.programs_other} - - )} + {r.path ? PATHS.find((p) => p.id === r.path)?.name ?? r.path : "—"} {r.github ? ( @@ -638,6 +572,21 @@ export default function AdminDashboard() { address. Treat the export the way you would a class list: it does not go in a group chat, and it is not published on the site.

+ + {/* --------------------------------------------------------- mentorship */} +
+

+ Mentorship +

+

+ The mentors members can choose from, and who has chosen whom. Adding a mentor + here is what opens enrolment on every member's dashboard. +

+
+ + void load()} /> + + ); } diff --git a/web/components/AdminMentors.tsx b/web/components/AdminMentors.tsx new file mode 100644 index 0000000..ae87587 --- /dev/null +++ b/web/components/AdminMentors.tsx @@ -0,0 +1,423 @@ +"use client"; + +// The mentor list, as the organisers manage it. Add, edit, hide, delete. +// +// THIS IS THE ONE PLACE IN THE APP WHERE A CLIENT WRITES A DOCUMENT THAT IS NOT ITS OWN. +// The rules permit it for admins only, and they validate the shape anyway — see +// `isWellFormedMentor` in firestore.rules and the note in its header explaining why this +// widening is acceptable here and specifically not for the `admins` collection. +// +// HIDE IS THE NORMAL RETIREMENT, DELETE IS FOR MISTAKES. A mentor who is done for the term +// gets `active: false`: they vanish from the students' picker, and every preference +// already recorded against them still renders their name. Deleting is only offered for a +// mentor NOBODY HAS PICKED, because Firestore rules cannot express "no document in another +// collection references this one" — that needs a query, and rules cannot query. So the +// guard is here, where the enrollments are already in memory, and the button is replaced +// by the reason rather than disabled with no explanation. Getting it wrong is cosmetic — +// the interest list would show a truncated id where a name should be — but it is exactly +// the kind of cosmetic wrong that nobody can explain six months later. +// +// THE DESCRIPTION IS THE FIELD THAT MATTERS. It is what a student reads before choosing, +// so it gets a textarea and 600 characters rather than an input and 120. The placeholder +// asks for the thing that actually helps — what they work on and what they are useful +// for — because "Priya is great" helps nobody choose between two people. + +import { useMemo, useState } from "react"; +import { field, labelOf } from "@/components/admin/ui"; +import { PROGRAMS } from "@/content/join"; +import { + deleteMentor, + pickCounts, + saveMentor, + type Enrollment, + type Mentor, + type MentorInput, +} from "@/lib/mentorship"; + +/** A blank mentor, for the add form. `gsoc` because that is the cohort the club runs; + * the select is there so a second programme needs no code change. */ +const BLANK: MentorInput = { + name: "", + description: "", + programme: "gsoc", + org: "", + github: "", + email: "", + active: true, +}; + +function Editor({ + initial, + saving, + onSave, + onCancel, +}: { + initial: MentorInput; + saving: boolean; + onSave: (input: MentorInput) => void; + onCancel: () => void; +}) { + const [v, setV] = useState(initial); + const set = (k: K, value: MentorInput[K]) => + setV((cur) => ({ ...cur, [k]: value })); + + return ( +
{ + e.preventDefault(); + onSave(v); + }} + className="space-y-4 rounded-tile border border-seam bg-sunk p-5" + > +
+
+ + set("name", e.target.value)} + /> +
+
+ + +
+
+ +
+ +