Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
role: roleProp,
skills = [],
linkedinUrl,
profileDetailsHref = '#',
profileDetailsHref,
actions,
}: {
name: string;
Expand All @@ -26,6 +26,8 @@
role?: string;
skills?: string[];
linkedinUrl?: string;
/** When unset, no "View" link is rendered — the caller has decided this
* viewer has nowhere to go (e.g. a member who can't read others). */
profileDetailsHref?: string;
/**
* Extra controls rendered beside "View" — e.g. an owner's Approve/Remove
Expand Down Expand Up @@ -112,8 +114,10 @@
</div>

<div class="flex w-full shrink-0 items-center justify-end gap-2 sm:w-auto">
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic path from page data; resolve() is route-literal typed -->
<a class="btn btn-sm btn-ghost" href={resolve(profileDetailsHref as any)} aria-label="View {name} profile">View</a>
{#if profileDetailsHref}
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic path from page data; resolve() is route-literal typed -->
<a class="btn btn-sm btn-ghost" href={resolve(profileDetailsHref as any)} aria-label="View {name} profile">View</a>
{/if}
{#if actions}
{@render actions()}
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const load: PageServerLoad = async (event) => {
return {
participants,
ownerCount,
hackathonId: event.params.id,
// 0 means unlimited — the page renders the fullness gauge and the
// over-capacity warning only when a cap is set.
maxParticipants: hackathon.maxParticipants ?? 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@
function mayDemote(p: Participant): boolean {
return data.mayManage && p.isOwner && !p.isSelf && data.ownerCount > 1;
}

// "View" opens this person's registration answers. Your own row goes to
// your editable form; another person's needs organizer rights, so it is a
// read-only ?userId= view offered only to managers — a plain member would
// deterministically 403, matching how every organizer-only action here is
// hidden rather than left to fail.
function viewHref(p: Participant): string | undefined {
if (p.isSelf) return `/register/${data.hackathonId}`;
if (data.mayManage) return `/register/${data.hackathonId}?userId=${p.id}`;
return undefined;
}
</script>

<!-- One definition for both views. The table and the cards used to carry their
Expand Down Expand Up @@ -245,7 +256,7 @@
<ParticipantCard
name={participant.name}
role={participant.roleLabel}
profileDetailsHref="#participant-{participant.id}"
profileDetailsHref={viewHref(participant)}
>
{#snippet actions()}
{@render rowActions(participant)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export const load: PageServerLoad = async (event) => {
const { hackathon } = requireGrpc(event.locals.grpc)
const hackathonId = event.params.id

// An organizer reading another participant's answers, read-only. Absent, the
// caller reads their own editable form. `?userId=` is a query param rather
// than a route segment because the destination is the same form, one person
// over — the backend's Write check on GetRegistrationResponse is what gates it.
const targetUserId = event.url.searchParams.get("userId") || undefined
const readOnly = targetUserId !== undefined

// Prefer the member view when the caller can see it (private events, or
// confirmed members): Get carries the same schema plus their membership.
let found
Expand Down Expand Up @@ -46,7 +53,31 @@ export const load: PageServerLoad = async (event) => {
// Answers already on file, so the form opens filled in and can be corrected
// rather than re-typed from memory. Its own RPC, not part of Get: Get denies
// waitlisted users, who are exactly the people still reviewing their form.
const existing = await hackathon.getRegistrationResponse({ hackathonId })
// With a userId this reads someone else's answers; the backend refuses a
// non-organizer caller, which surfaces as the 403 below.
let existing
try {
existing = await hackathon.getRegistrationResponse({
hackathonId,
userId: targetUserId,
})
} catch (e) {
if (e instanceof ClientError && e.code === Status.PERMISSION_DENIED)
error(403, "You don't have permission to view this registration")
if (e instanceof ClientError && e.code === Status.NOT_FOUND)
error(404, "That participant is not registered here")
throw e
}

// The heading names the person whose answers these are. `found.members` comes
// from Get, which only a confirmed member (the organizer here) reaches, so
// the roster is present exactly when a userId can be.
const targetName = readOnly
? found.members.find((m) => m.user?.id === targetUserId)?.user
?.displayName ||
found.members.find((m) => m.user?.id === targetUserId)?.user?.username ||
"This participant"
: undefined

// Struct values arrive as unknown; the form only ever renders text, and a
// list field (`tags`) round-trips as a comma-separated string.
Expand All @@ -63,6 +94,8 @@ export const load: PageServerLoad = async (event) => {
alreadySubmitted: existing.submitted,
answers,
consentValues: existing.consents ?? {},
readOnly,
targetName,
}
}

Expand Down
33 changes: 31 additions & 2 deletions components/frontend/src/routes/(app)/register/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,39 @@
<svelte:head><title>Registration · {data.name}</title></svelte:head>

<div class="mx-auto w-full max-w-2xl p-4 sm:p-6">
<h1 class="text-2xl font-bold">{data.alreadySubmitted ? 'Your registration' : 'Registration'}</h1>
<h1 class="text-2xl font-bold">
{#if data.readOnly}{data.targetName}'s registration{:else}{data.alreadySubmitted ? 'Your registration' : 'Registration'}{/if}
</h1>
<p class="mt-1 text-sm text-ink-3">{data.name}</p>

{#if form?.submitted}
{#if data.readOnly}
<!-- An organizer reading a participant's answers for catering and
check-in. Read-only: these are someone else's answers, so the
fields are shown as text and there is nothing to submit. -->
{#if !data.alreadySubmitted}
<p class="mt-6 text-sm text-ink-3">No response yet.</p>
{:else}
<dl class="mt-6 flex flex-col gap-5">
{#each data.fields as f (f.key)}
<div>
<dt class="text-sm font-medium">{f.label}</dt>
<dd class="mt-1 text-sm text-ink-2">
{data.answers[f.key] || '—'}
</dd>
</div>
{/each}
{#each data.consents as c (c.key)}
<div class="flex items-start gap-2">
<input
type="checkbox" class="mt-1" disabled
checked={data.consentValues[c.key] ?? false}
/>
<span class="text-sm">{c.label}</span>
</div>
{/each}
</dl>
{/if}
{:else if form?.submitted}
<div class="card border-success mt-6 p-4">
<p class="font-semibold">Thanks — your answers are in.</p>
<p class="mt-1 text-sm">
Expand Down
Loading