diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.server.ts
index cfe8d74a..80727675 100644
--- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.server.ts
+++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.server.ts
@@ -1,4 +1,5 @@
import type { PageServerLoad } from "./$types"
+import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility"
import { mayManageParticipants } from "$lib/server/hackathon/capabilities"
import { answeredParticipantIds } from "$lib/server/hackathon/registrationForm"
import { requireGrpc } from "$lib/server/grpc/client"
@@ -80,5 +81,11 @@ export const load: PageServerLoad = async (event) => {
confirmedCount: hackathon.members.filter(
(m) => m.user !== undefined && !m.isWaiting,
).length,
+ // Not for the tab — this page always shows its own — but for the copy: an
+ // empty queue means "no requests yet" in a public hackathon and "nothing
+ // went wrong" in a private one, and a queue that is *not* empty in a
+ // private one is the anomaly worth naming. See the roster's
+ // `waitlistsJoiners`, which decides the tab from the same fact.
+ waitlistsJoiners: hackathon.visibility === Visibility.VISIBILITY_PUBLIC,
}
}
diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.svelte
index 2d4138f4..0157b3a0 100644
--- a/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.svelte
+++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/participants/manage/waitlist/+page.svelte
@@ -59,20 +59,42 @@
{countLabel} · nobody here is in the hackathon yet, and only
organizers can see them
+
+ {#if !data.waitlistsJoiners && data.waiting.length > 0}
+
+ An invitation to this private hackathon admits its holder on the
+ spot, so nobody should be queued here. Approving these people is
+ what puts them in.
+
+ {/if}
+
{#if data.waiting.length === 0}
- Nobody is waiting to join. Approved participants are on the
- Participants tab.
+ {#if data.waitlistsJoiners}
+ Nobody is waiting to join. Approved participants are on the
+ Participants tab.
+ {:else}
+ Nobody is waiting, and in a private hackathon nobody should
+ be: an invitation admits its holder outright, so people join
+ straight onto the Participants tab.
+ {/if}
{:else}
{#each data.waiting as person (person.id)}
diff --git a/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts b/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts
index b0016886..6d3bf2f0 100644
--- a/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts
+++ b/components/frontend/src/routes/(public)/invite/[token]/+page.server.ts
@@ -4,7 +4,9 @@ import type { Actions, PageServerLoad } from "./$types"
import {
createAuthorizedGrpc,
publicHackathonClient,
+ type AuthorizedGrpc,
} from "$lib/server/grpc/client"
+import { Visibility } from "$lib/server/grpc/generated/hackathon/entities/visibility"
import {
parseAnswers,
questionRows,
@@ -25,17 +27,27 @@ import type { CustomSession } from "../../../../auth.d"
// permission check at all and serves anonymous callers — so demanding a session
// first would add a login wall in front of information the link already grants.
//
-// **Redeeming grants visibility, not membership.** `Join` writes a waitlisted
-// row and the organiser still confirms it, so a link forwarded beyond the people
-// it was meant for cannot insert a stranger into the roster.
+// **Redeeming a private hackathon's link grants membership outright.** `Join`
+// confirms the joiner itself when the hackathon is private: the invitation is
+// the organizer's decision about who takes part, and requiring a second
+// confirmation left the invitee holding no role and therefore unable to see the
+// event at all. The flip side is that the link *is* admission — a link forwarded
+// beyond the people it was meant for lets a stranger in, and revoking it or
+// removing the participant are the controls, both after the fact.
//
-// This page is also where somebody comes *back* to. A waitlisted participant in
-// a private hackathon holds no `hackathon:read` — that arrives with the `Member`
-// role on approval — so the event is filtered out of `List`
-// (`hackathon_service.go:1473`) and appears nowhere on their dashboard. Until
-// they are approved, this link is the only trace of what they asked for, which
-// is why `alreadyParticipant` gets a real state on screen rather than a silent
-// redirect somewhere emptier.
+// A public hackathon reached through a link still waitlists, so `autoApproves`
+// below decides which of the two the page describes. Invites are not restricted
+// to private hackathons (`CreateInvite` performs no visibility check), so this
+// cannot be assumed from the route.
+//
+// This page is also where somebody comes *back* to, which still matters for the
+// public case: a waitlisted participant holds no `Member` role, so a private
+// hackathon they are waiting in is filtered out of `List`
+// (`hackathon_service.go:1473`) and appears nowhere on their dashboard. That is
+// now only reachable for somebody waitlisted before auto-approval existed, or
+// whose confirmation half-failed — and it is exactly why `alreadyParticipant`
+// still gets a real state on screen rather than a silent redirect somewhere
+// emptier.
interface Preview {
hackathonId: string
@@ -44,6 +56,9 @@ interface Preview {
startsAt?: Date
endsAt?: Date
status: number
+ /** Whether `Join` confirms on the spot here, which it does for a private
+ * hackathon. Decides whether this page offers a place or asks for one. */
+ autoApproves: boolean
questions: QuestionRow[]
alreadyParticipant: boolean
}
@@ -60,11 +75,40 @@ function authorizedFor(session: CustomSession | null) {
: undefined
}
+/** Ask `PreviewInvite` — as the caller when there is one, anonymously otherwise.
+ *
+ * Who asks decides one field. The RPC performs no permission check and serves
+ * anonymous callers, but it fills `already_participant` by looking the *caller*
+ * up (`hackathon_service.go:454`), so an anonymous preview always reports false
+ * — which is how somebody a private hackathon had just admitted was told
+ * "You're on the list" on the way back to this page.
+ *
+ * A dead access token must not cost a public page its content, so an auth
+ * refusal falls back to the anonymous call: everything but that one field is
+ * identical, and the page's whole point is being readable before signing in.
+ * `usableSession` already screens out the refusal Auth.js reports, but a token
+ * can also lapse between refreshes, and the backend answers that with INTERNAL
+ * rather than UNAUTHENTICATED — see `TODO(backend: jwt-error-codes)` below.
+ */
+function askPreview(token: string, grpc?: AuthorizedGrpc) {
+ if (!grpc) return publicHackathonClient().previewInvite({ token })
+
+ return grpc.hackathon.previewInvite({ token }).catch((e) => {
+ if (
+ e instanceof ClientError &&
+ (e.code === Status.UNAUTHENTICATED || e.code === Status.INTERNAL)
+ ) {
+ return publicHackathonClient().previewInvite({ token })
+ }
+ throw e
+ })
+}
+
/** Exchange the token for what the page renders. */
-async function preview(token: string): Promise
{
+async function preview(token: string, grpc?: AuthorizedGrpc): Promise {
let res
try {
- res = await publicHackathonClient().previewInvite({ token })
+ res = await askPreview(token, grpc)
} catch (e) {
if (e instanceof ClientError) {
// One answer for all four dead cases, because the backend gives one:
@@ -97,18 +141,22 @@ async function preview(token: string): Promise {
startsAt: res.hackathon.startsAt,
endsAt: res.hackathon.endsAt,
status: res.hackathon.status as number,
+ autoApproves: res.hackathon.visibility === Visibility.VISIBILITY_PRIVATE,
questions: questionRows(res.questions),
alreadyParticipant: res.alreadyParticipant,
}
}
export const load: PageServerLoad = async (event) => {
- const p = await preview(event.params.token)
const session = (await event.locals.auth()) as CustomSession | null
// A stale session counts as signed out here: the page then offers the sign-in
// button, which is the one control that fixes it. Offering "Request a place"
// to somebody holding a dead token is how this page produced a 500.
const signedIn = usableSession(session)
+ // Before the preview, not after: the session is what decides who asks, and
+ // asking anonymously is what made `alreadyParticipant` below meaningless.
+ const grpc = authorizedFor(session)
+ const p = await preview(event.params.token, grpc)
// Whether an existing participant has been approved yet, derived rather than
// asked: `PreviewInvite` reports only *that* somebody holds a participant row,
@@ -117,16 +165,13 @@ export const load: PageServerLoad = async (event) => {
// exactly what approval grants — so its presence in their own list is the
// answer, and it costs one call nobody else on this page makes.
let approved = false
- if (signedIn && p.alreadyParticipant) {
- const grpc = authorizedFor(session)
- if (grpc) {
- approved = await grpc.hackathon
- .list({ statusFilter: [] })
- .then((r) => r.hackathons.some((h) => h.id === p.hackathonId))
- // A failure here costs the link into the event, not the page: they are
- // on the list either way, and that is the part they came to read.
- .catch(() => false)
- }
+ if (signedIn && p.alreadyParticipant && grpc) {
+ approved = await grpc.hackathon
+ .list({ statusFilter: [] })
+ .then((r) => r.hackathons.some((h) => h.id === p.hackathonId))
+ // A failure here costs the link into the event, not the page: they are
+ // on the list either way, and that is the part they came to read.
+ .catch(() => false)
}
return {
@@ -141,6 +186,7 @@ export const load: PageServerLoad = async (event) => {
},
questions: p.questions,
alreadyParticipant: p.alreadyParticipant,
+ autoApproves: p.autoApproves,
approved,
signedIn,
}
@@ -164,10 +210,25 @@ export const actions: Actions = {
// Re-read the questions rather than trusting the form: the answers are
// parsed against them, and an organiser may have changed the form while this
// page sat open in somebody's mail client for a week.
- const p = await preview(event.params.token)
+ const p = await preview(event.params.token, grpc)
const answers = parseAnswers(await event.request.formData(), p.questions)
try {
+ // Provision the platform user before joining. `hooks.server.ts` does this
+ // (`:182`) for **protected** routes only, and this route is public on
+ // purpose — so somebody who signs in *from the invitation* and accepts it
+ // on the spot reaches `Join` holding a Keycloak account and no `users`
+ // row. `Join` answers that with NOT_FOUND (`hackathon_service.go:605`),
+ // which the branch below reports as an invalid invitation: exactly how a
+ // live link looked broken to the one person it was written for, somebody
+ // whose first ever visit to the platform is this page.
+ //
+ // `Register` is idempotent — it returns the existing user, syncing the
+ // profile fields Keycloak holds — so this is safe on every join rather
+ // than only a first one, and it needs no "have they registered?" call in
+ // front of it.
+ await grpc.user.register({})
+
await grpc.hackathon.join({
hackathonId: p.hackathonId,
answers,
diff --git a/components/frontend/src/routes/(public)/invite/[token]/+page.svelte b/components/frontend/src/routes/(public)/invite/[token]/+page.svelte
index 85c645eb..418acc84 100644
--- a/components/frontend/src/routes/(public)/invite/[token]/+page.svelte
+++ b/components/frontend/src/routes/(public)/invite/[token]/+page.svelte
@@ -13,6 +13,20 @@
// Either they just asked, or they had already asked before this visit. The
// page reads the same both ways: what matters is that they are on the list.
const onTheList = $derived(Boolean(form?.joined) || data.alreadyParticipant);
+ // A private hackathon confirms the joiner in `Join`, so following this link
+ // is joining rather than applying, and the copy has to say which. Not
+ // assumable from the route: an invite can be minted for a public hackathon
+ // too, and that one still goes to the waitlist.
+ const admitsOnJoin = $derived(data.autoApproves);
+ // A private hackathon confirms its joiners in `Join`, so this combination
+ // should not exist: on the list, yet not confirmed. It means the backend's
+ // auto-approval half-failed (it logs and lets the join stand). Joining again
+ // re-runs the confirmation, which is idempotent, so this is the retry.
+ //
+ // `data.approved` comes from a best-effort lookup that falls back to false,
+ // so a failed one shows this to somebody already in. Pressing Join then is
+ // harmless — the backend skips a participant who is not waiting.
+ const needsRetry = $derived(onTheList && !data.approved && admitsOnJoin);
const hasMandatory = $derived(data.questions.some((q) => q.mandatory));
// Back to this very link after Keycloak, not to the dashboard: a private
@@ -32,6 +46,30 @@
+
+{#snippet joinForm(label: string)}
+
+{/snippet}
+
You've been invited
@@ -58,10 +96,19 @@
{#if onTheList}
-
You're on the list
+ redirect. A confirmed member gets the link into the event
+ itself; somebody still waiting holds no role, so the event is
+ filtered out of every list they can see and this link is their
+ only way back to it. -->
+
+ {#if data.approved}
+ You're in
+ {:else if needsRetry}
+ Almost in
+ {:else}
+ You're on the list
+ {/if}
+
{#if data.approved}
Your place is confirmed. The event is on your dashboard now.
@@ -72,6 +119,12 @@
>
Open {h.name}
+ {:else if needsRetry}
+
+ Your place is held, but the last step did not finish — which is why
+ the event is still hidden from you. Joining again completes it.
+
+ {@render joinForm('Finish joining')}
{:else}
The organizers review each request and will confirm your place. Until
@@ -80,30 +133,16 @@
{/if}
{:else if data.signedIn}
-
Ask for a place
+
+ {admitsOnJoin ? 'Take your place' : 'Ask for a place'}
+
- This puts you on the organizers' list. They decide who takes part.
+ {admitsOnJoin
+ ? 'This invitation is your place — accepting it puts you straight in.'
+ : "This puts you on the organizers' list. They decide who takes part."}
-
+ {@render joinForm(admitsOnJoin ? 'Join' : 'Request a place')}
{:else}
Sign in to continue