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)} + /> +
+
+ + +
+
+ +
+ +