Feat(frontend): invitation link - #254
Merged
Merged
Conversation
A private hackathon had no entrance. It appears on no list, its page refuses anyone who is not already a member, and the backend's invite RPCs landed with nothing calling them — so the "Private — only people you give access to" option produced an event nobody could be given access to. Two pages close it. /invite/<token> is public, because the token is the credential and PreviewInvite serves anonymous callers: somebody opening a link from their mail sees the event before being asked to sign in, and signing in returns them to that same link rather than to a dashboard where a hackathon they have not joined cannot appear. It carries the registration form, since Join validates mandatory answers and /register/<id> cannot serve this person — that route resolves its target through List, which omits a private hackathon for a non-member. The organiser's page is the other half: create, copy, revoke. The link is the deliverable, so it is a full selectable input with a Copy button rather than a button alone — navigator.clipboard needs a secure context and is absent on a plain-http deployment, where selecting the text is the fallback. Expiry is the backend's default and there is no date picker: the only thing a picker adds is the chance to mint a link that is already dead. Redeeming grants visibility, not membership, and the page says so twice — once to the organiser before they paste a link into a hundred inboxes, once to the invitee. A waitlisted participant holds no hackathon:read, so the event is filtered out of List and shows on no dashboard; the invitation link is their only way back, which is why it becomes their status page instead of redirecting somewhere emptier. INVALID_ARGUMENT is a 404 alongside NOT_FOUND. The token is a uuid in the proto, so protovalidate rejects a malformed one before the handler can turn it into NOT_FOUND, and /invite/nonsense was a 500 blaming the server for an ordinary typo. The nav entry is gated on visibility rather than left to the backend: CreateInvite never checks it, so a link for a public hackathon would be minted successfully and grant nothing, because Join only consults a token when the hackathon is private.
Pressing "Request a place" after the page had sat open for a while produced an unexpected-error screen. The access token had expired, Auth.js had tried to refresh it and been refused, and it handed back a session that still carried a user and still carried a token — with only an `error` field saying the token was dead. The page sent it anyway. hooks.server.ts:158 already guards exactly this, and only for protected routes. This one is public on purpose, so the check has to be made in the page or not at all: the hook cannot redirect a public route without defeating the point of it being public. usableSession() is that check, in $lib/server/session so it is shared and tested rather than inlined. All three of its conditions matter, and the third is the one that was missed: a session with a user and a token can still be unusable. A stale session now counts as signed out, so the page offers the sign-in button — which is the one control that fixes it — instead of a Request button that was going to fail. Join's refusal is also translated rather than thrown. UNAUTHENTICATED is what the middleware means to send; INTERNAL is in the same branch because INTERNAL is what actually arrives — internal/middleware/errors.go matches jwt/v4's *ValidationError while auth.go parses with v5, which removed that type, so errors.As never matches and every authentication failure falls past the unauthenticatedErrors list to codes.Internal. Marked TODO(backend: jwt-error-codes); while it stands, a genuine server fault during a join is reported as an expired session, which is the better of the two wrong answers available.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Organizers running a private hackathon can now invite people to it.
Until now they couldn't. A private hackathon is on no list and its page turns away anyone who isn't already a member — so choosing "Private — only people you give access to" produced an event nobody could be given access to.
Now an organizer opens Invitations on a private event, presses Create link, and copies a URL straight into a Mailchimp campaign. Whoever opens it sees the event, answers the registration questions, and asks for a place. The organizer confirms them on the Waitlist page, same as always.
The important part is what a link doesn't do: it lets someone see the event and ask, not join it. A link forwarded further than intended can't put a stranger on the roster, and Revoke kills one immediately.
Details
Deliberately left out
A backend gap worth knowing about
Every authentication failure comes back as Internal rather than Unauthenticated: internal/middleware/errors.go matches token errors from jwt/v4 while auth.go parses with jwt/v5, which removed the type it matches on — so the list of "these are authentication failures" never fires, and the raw parser message reaches the caller. That's why this PR treats INTERNAL from Join as an expired sign-in, marked TODO(backend: jwt-error-codes). Three-line fix in handleJwtError; once it lands, drop INTERNAL from that branch.