Skip to content

Feat(frontend): invitation link - #254

Merged
sabinem merged 2 commits into
mainfrom
feat/frontend-invitation-link
Aug 26, 2026
Merged

Feat(frontend): invitation link#254
sabinem merged 2 commits into
mainfrom
feat/frontend-invitation-link

Conversation

@sabinem

@sabinem sabinem commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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

  • One link works for everyone you send it to — one per mailing, not one per person. It stops working when the event ends.
  • The invitation page works signed in or not, because the link itself is the credential. Signing in from it returns you to the same link.
  • After you ask for a place, that link becomes your status page. Until you're approved the event is invisible everywhere else, so the link is your only way back — and the page says so.
  • Revoked and expired links stay listed, without their URL, so it's visible that revoking happened.
  • Test it against Partner Data Sprint 2026 in the dev fixture, which already carries a working link, a revoked one and an expired one.

Deliberately left out

  • No email is sent. The organizer copies a link and mails it themselves; adding mail is a much larger job.
  • No custom expiry. The only thing a date picker adds is the chance to create a link that's already dead.
  • No record of who used a link. Answering that properly means tying an invitation to an email address, which is a different feature.

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.

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.
@sabinem sabinem changed the title Feat/frontend invitation link Feat(frontend): invitation link Aug 26, 2026
@sabinem
sabinem merged commit c5845f3 into main Aug 26, 2026
2 checks passed
@sabinem
sabinem deleted the feat/frontend-invitation-link branch August 26, 2026 14:46
@sabinem
sabinem restored the feat/frontend-invitation-link branch August 26, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant