From 6d5c87bad0fca943921f9f2f82b2df2060934bb9 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 23 Jun 2026 14:39:40 -0400 Subject: [PATCH] fix(pres_mgmt): cross-session presenter auth, URL encoding, sign-in gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expand_auth_for_person: added presenter_id_hint param to look up the signing presenter's email from Dexie, enabling cross-session auth even when person_id in the URL is a string ID (not an email address) - presenter_is_authed: added auth__kv.presenter[email] check so a presenter signed in on one session auto-unlocks matching records across all sessions for the same event - URL construction: replaced encodeURI() with per-param encodeURIComponent() in email_sign_in__event_presenter, email_sign_in__event_session, and the Copy Link button — encodeURI() silently passes '+' unencoded, causing URLSearchParams.get() to decode it as a space and break '+' email aliases - Sign-in gate: changed from `if (url_person_pass)` to presence of url_person_id + presenter_id/session_id so sign-in works when passcode is empty/null (common for presenter records without a passcode configured) - Fixed param?: Type syntax in sign_in_out.svelte (presenter_id_hint) — Vite's type-stripping leaves the ? marker producing invalid JS on HMR Co-Authored-By: Claude Sonnet 4.6 --- .../ae_events/ae_events__event_presenter.ts | 7 +- src/lib/ae_events/ae_events__event_session.ts | 7 +- .../presenter/[presenter_id]/+page.svelte | 16 ++- .../events/[event_id]/sign_in_out.svelte | 123 ++++++++++++++---- 4 files changed, 119 insertions(+), 34 deletions(-) diff --git a/src/lib/ae_events/ae_events__event_presenter.ts b/src/lib/ae_events/ae_events__event_presenter.ts index c1eccc31..6c91a78e 100644 --- a/src/lib/ae_events/ae_events__event_presenter.ts +++ b/src/lib/ae_events/ae_events__event_presenter.ts @@ -609,9 +609,10 @@ export async function email_sign_in__event_presenter({ // Routes to the session page (which has the sign-in handler mounted) not /presenter/[id] // which has no sign-in handler. Includes presenter_id + presentation_id so the handler // can grant presenter-level auth (not just session read access). - const sign_in_url = encodeURI( - `${base_url}/events/${event_id}/session/${event_session_id}?person_id=${person_id}&person_pass=${person_passcode}&presenter_id=${event_presenter_id}&presentation_id=${event_presentation_id}` - ); + // Per-param encodeURIComponent is required for query values — encodeURI() does not + // encode '+', which URLSearchParams.get() then decodes as a space, breaking email + // addresses that contain '+' (e.g. test+alias@example.com). + const sign_in_url = `${base_url}/events/${event_id}/session/${event_session_id}?person_id=${encodeURIComponent(person_id ?? '')}&person_pass=${encodeURIComponent(person_passcode ?? '')}&presenter_id=${encodeURIComponent(event_presenter_id ?? '')}&presentation_id=${encodeURIComponent(event_presentation_id ?? '')}`; const body_html = `
${to_name},

Your sign-in link for ${presentation_name ?? 'Presentation'} (Session: ${session_name ?? 'Session'}): ${sign_in_url}

This link takes you to the session page — your presentation and file upload sections will be available after you sign in.

`; return await api.send_email({ api_cfg, diff --git a/src/lib/ae_events/ae_events__event_session.ts b/src/lib/ae_events/ae_events__event_session.ts index a88bf507..782392b5 100644 --- a/src/lib/ae_events/ae_events__event_session.ts +++ b/src/lib/ae_events/ae_events__event_session.ts @@ -828,9 +828,10 @@ export async function email_sign_in__event_session({ session_name: string; }) { const subject = `Pres Mgmt Hub Sign In Link for ${session_name}`; - const sign_in_url = encodeURI( - `${base_url}/events/${event_id}/session/${event_session_id}?person_id=${person_id}&person_pass=${person_passcode}&session_id=${event_session_id}` - ); + // Per-param encodeURIComponent is required for query values — encodeURI() does not + // encode '+', which URLSearchParams.get() then decodes as a space, breaking email + // addresses that contain '+' (e.g. test+alias@example.com). + const sign_in_url = `${base_url}/events/${event_id}/session/${event_session_id}?person_id=${encodeURIComponent(person_id ?? '')}&person_pass=${encodeURIComponent(person_passcode ?? '')}&session_id=${encodeURIComponent(event_session_id ?? '')}`; const body_html = `
${to_name},

Your sign-in link for ${session_name}: ${sign_in_url}

`; return await api.send_email({ api_cfg, diff --git a/src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/+page.svelte b/src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/+page.svelte index 281aa7b4..9e5ae627 100644 --- a/src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/+page.svelte +++ b/src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/+page.svelte @@ -137,11 +137,19 @@ let lq__auth__event_presenter_obj = $derived( ); // True if this person is authed as the presenter for this specific record. -// Also matches by person_id so a presenter signed in via one presentation link is -// automatically recognised on their other presentations at the same event. +// Checks four signals in priority order: +// 1. Trusted staff — always authed. +// 2. Exact presenter ID in auth__kv.presenter — direct grant from sign-in link. +// 3. Email key in auth__kv.presenter — expand_auth_for_person keys by email so a +// presenter signed into ONE presentation auto-grants access to any OTHER presenter +// record that shares the same email address (e.g. same person in two sessions). +// Matches the same check used in ae_comp__event_presenter_obj_li.svelte. +// 4. Session-level read access — signed in as a session POC or presenter for this session. +// 5. Direct person_id identity — signed in as this person_id (string ID match). let presenter_is_authed = $derived( $ae_loc.trusted_access || !!events_auth_loc.current.auth__kv.presenter[$lq__event_presenter_obj?.event_presenter_id ?? ''] || + !!($lq__event_presenter_obj?.email && events_auth_loc.current.auth__kv.presenter[$lq__event_presenter_obj.email]) || !!events_auth_loc.current.auth__kv.session[$lq__event_presenter_obj?.event_session_id ?? ''] || ( !!events_auth_loc.current.auth__person.id && @@ -290,9 +298,7 @@ let presenter_can_upload = $derived(presenter_is_authed && presenter_agree_ok); Copy Access Link {/snippet} --> { ); let url_person_id = data.url.searchParams.get('person_id'); + // URLSearchParams.get() decodes '+' as a space per the x-www-form-urlencoded spec. + // Email addresses legitimately contain '+'. Restore it so that DB lookups and + // auth__kv keys match the stored value. Covers manually-crafted URLs where + was + // not percent-encoded; new emailed links use %2B and decode correctly without this. + if (url_person_id && url_person_id.includes('@')) { + url_person_id = url_person_id.replace(/ /g, '+'); + } let url_person_pass = data.url.searchParams.get('person_pass'); let url_presentation_id = data.url.searchParams.get('presentation_id'); let url_presenter_id = data.url.searchParams.get('presenter_id'); let url_session_id = data.url.searchParams.get('session_id'); - // This should be turned into a function to correctly authenticate the person and allow them access to their presentations and presenter details. - if (url_person_pass) { + // Gate on having a sign-in target (presenter or session), not on the passcode value. + // WHY: passcode may be empty/null for presenter records without one configured; + // the old gate `if (url_person_pass)` broke when passcode was empty string. + // The presenter_id / session_id in the URL is the real sign-in signal. + if (url_person_id !== null && (url_presenter_id || url_session_id)) { console.log( `ae_events_pres_mgmt session [slug] +page.svelte: event_session_id=${$events_slct.event_session_id}; person_id=${url_person_id}; person_pass=${url_person_pass}; presentation_id=${url_presentation_id}; presenter_id=${url_presenter_id}` ); @@ -66,9 +76,9 @@ onMount(() => { $events_sess.auth__person.session_id = url_session_id; // For POC or LCI Champions for sessions. Do not set for a presenter! $events_sess.auth__kv.person[url_person_id] = true; - $events_sess.auth__kv.session[url_session_id] = true; // For POC or LCI Champions for sessions. Do not set for a presenter! - $events_sess.auth__kv.presentation[url_presentation_id] = true; - $events_sess.auth__kv.presenter[url_presenter_id] = true; + if (url_session_id) $events_sess.auth__kv.session[url_session_id] = true; // For POC or LCI Champions for sessions. Do not set for a presenter! + if (url_presentation_id) $events_sess.auth__kv.presentation[url_presentation_id] = true; + if (url_presenter_id) $events_sess.auth__kv.presenter[url_presenter_id] = true; // We need to set the selected presentation and presenter objects based on the respective IDs. if (url_session_id) { @@ -103,21 +113,25 @@ onMount(() => { }); // Grants auth for every presenter record and POC session this person holds in the event. -// WHY: a single sign-in link only grants auth for one presenter_id/session_id. This -// queries both Dexie (fast, warm cache) AND the API (reliable, cold cache) in parallel, -// merges the results, and pre-populates auth__kv for all roles in one shot. +// WHY: a single sign-in link grants auth for one presenter_id/session_id. This function +// queries Dexie (fast, warm cache) AND the API (reliable, cold cache) in parallel to +// pre-populate auth__kv for ALL of the person's roles at this event in one shot. // -// person_id may be a real Person UUID (when a Person record exists, e.g. from iMIS) -// OR an email address (the common case when no Person record exists — for LCI roughly -// 75% of presenters are not in iMIS). Email is detected via '@' and the lookup is -// routed to presenter.email instead of presenter.person_id. +// person_id: the identity value from the sign-in URL. May be a Person string ID (when a +// Person record exists, e.g. linked from iMIS) or an email address (common for LCI +// presenters not in iMIS). Email is detected via '@'. // -// auth__kv.presenter is keyed by BOTH event_presenter_id AND email so that -// presenter_agree_enabled works across all sessions without per-ID lookups. -async function expand_auth_for_person(person_id: string, event_id: string) { +// presenter_id_hint: the event_presenter_id from the sign-in URL. Used to look up the +// signing presenter's email directly from Dexie, which is then added to the email +// cross-session search. This means cross-session auth works even when person_id is a +// string ID (not an email) — we don't have to depend on person_id being an email to +// find other records for the same person. +// +// auth__kv.presenter is keyed by BOTH event_presenter_id AND email so presenter_is_authed +// checks work across sessions without needing the exact ID. +async function expand_auth_for_person(person_id: string, event_id: string, presenter_id_hint: string | null = null) { try { const is_email = person_id.includes('@'); - // Route the presenter lookup to the right field based on what we received const presenter_field = is_email ? 'email' : 'person_id'; const [dexie_presenters, dexie_sessions, api_presenters, api_sessions] = await Promise.all([ @@ -125,7 +139,7 @@ async function expand_auth_for_person(person_id: string, event_id: string) { .where(presenter_field).equals(person_id) .filter(p => p.event_id === event_id) .toArray(), - // POC sessions always link via poc_person_id (a real Person UUID). + // POC sessions always link via poc_person_id (a real Person string ID). // Skip when signing in with email — email won't match poc_person_id. is_email ? Promise.resolve([] as Session[]) : db_events.session .where('poc_person_id').equals(person_id) @@ -151,7 +165,7 @@ async function expand_auth_for_person(person_id: string, event_id: string) { }).catch(() => null) ]); - // Merge Dexie + API results, deduplicated by ID using plain objects (not Map) + // Merge Dexie + API results, deduplicated by presenter ID const api_presenter_li: Presenter[] = Array.isArray(api_presenters) ? (api_presenters as Presenter[]) : []; const api_session_li: Session[] = Array.isArray(api_sessions) ? (api_sessions as Session[]) : []; const presenter_kv: Record = {}; @@ -163,20 +177,83 @@ async function expand_auth_for_person(person_id: string, event_id: string) { if (s.event_session_id) session_kv[s.event_session_id] = s; } + // Email cross-session pass: find all presenter records that share an email with + // any record found above, plus the signing presenter's own email (from + // presenter_id_hint). This covers two cases: + // • person_id is a string ID: found records have emails, use those to find + // additional email-only records (person_id = null) for the same person. + // • person_id is an email: initial search already found by email, but the hint + // lookup adds the signing presenter's email if it's not the same value. + // Emails that were the original search term (is_email) are skipped to avoid + // re-querying what we just fetched. + // Build the set of emails to search using a plain object for deduplication + // (consistent with presenter_kv / session_kv patterns above). + const emails_to_search_kv: Record = {}; + for (const p of Object.values(presenter_kv)) { + if (p.email && (!is_email || p.email !== person_id)) { + emails_to_search_kv[p.email] = true; + } + } + + // Look up the signing presenter by presenter_id_hint to get their email from Dexie. + // WHY: the presenter record in IDB has the canonical email; we don't need to depend + // on person_id being an email address to do the cross-session lookup. + if (presenter_id_hint) { + const hint_presenter = await db_events.presenter + .where('event_presenter_id').equals(presenter_id_hint) + .first(); + if (hint_presenter?.email && (!is_email || hint_presenter.email !== person_id)) { + emails_to_search_kv[hint_presenter.email] = true; + } + } + + if (Object.keys(emails_to_search_kv).length > 0) { + const email_results = await Promise.all( + Object.keys(emails_to_search_kv).map(async (email) => { + const [extra_dexie, extra_api] = await Promise.all([ + db_events.presenter + .where('email').equals(email) + .filter(p => p.event_id === event_id) + .toArray(), + api.search_ae_obj({ + api_cfg: $ae_api, + obj_type: 'event_presenter', + search_query: { and: [ + { field: 'event_id', op: 'eq', value: event_id }, + { field: 'email', op: 'eq', value: email } + ]}, + limit: 200 + }).catch(() => null) + ]); + return [ + ...extra_dexie, + ...(Array.isArray(extra_api) ? extra_api as Presenter[] : []) + ]; + }) + ); + for (const group of email_results) { + for (const p of group) { + if (p.event_presenter_id && !presenter_kv[p.event_presenter_id]) { + presenter_kv[p.event_presenter_id] = p; + } + } + } + } + for (const p of Object.values(presenter_kv)) { events_auth_loc.current.auth__kv.presenter[p.event_presenter_id] = true; - // Also key by email — presenter_agree_enabled checks this so any record - // sharing the same email auto-unlocks without needing the exact ID. + // Also key by email — presenter_is_authed checks this so any record sharing + // the same email auto-unlocks without needing the exact presenter ID. if (p.email) events_auth_loc.current.auth__kv.presenter[p.email] = true; if (p.event_presentation_id) events_auth_loc.current.auth__kv.presentation[p.event_presentation_id] = true; - // 'read' matches presenter_sign_in — truthy, signals read-only session access for presenters + // 'read' — truthy, signals read-only session access for presenters if (p.event_session_id) events_auth_loc.current.auth__kv.session[p.event_session_id] = 'read'; } for (const s of Object.values(session_kv)) { events_auth_loc.current.auth__kv.session[s.event_session_id] = true; } - console.log(`expand_auth_for_person: ${Object.keys(presenter_kv).length} presenter(s) [by ${presenter_field}], ${Object.keys(session_kv).length} POC session(s) auto-granted`); + console.log(`expand_auth_for_person: ${Object.keys(presenter_kv).length} presenter(s), ${Object.keys(session_kv).length} POC session(s) auto-granted`); } catch (e) { console.warn('expand_auth_for_person: lookup failed', e); } @@ -252,7 +329,7 @@ function presenter_sign_in() { events_auth_loc.current.auth__person.presenter_id = $events_sess.auth__person.presenter_id; - void expand_auth_for_person($events_sess.auth__person.id, data.params.event_id); + void expand_auth_for_person($events_sess.auth__person.id, data.params.event_id, $events_sess.auth__person.presenter_id); } function sign_out() {