fix(pres_mgmt): cross-session presenter auth, URL encoding, sign-in gate

- 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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-23 14:39:40 -04:00
parent 219f0a6507
commit 6d5c87bad0
4 changed files with 119 additions and 34 deletions

View File

@@ -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] // 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 // which has no sign-in handler. Includes presenter_id + presentation_id so the handler
// can grant presenter-level auth (not just session read access). // can grant presenter-level auth (not just session read access).
const sign_in_url = encodeURI( // Per-param encodeURIComponent is required for query values — encodeURI() does not
`${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}` // 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 = `<div>${to_name},<p>Your sign-in link for ${presentation_name ?? 'Presentation'} (Session: ${session_name ?? 'Session'}): <a href="${sign_in_url}">${sign_in_url}</a></p><p>This link takes you to the session page — your presentation and file upload sections will be available after you sign in.</p></div>`; const body_html = `<div>${to_name},<p>Your sign-in link for ${presentation_name ?? 'Presentation'} (Session: ${session_name ?? 'Session'}): <a href="${sign_in_url}">${sign_in_url}</a></p><p>This link takes you to the session page — your presentation and file upload sections will be available after you sign in.</p></div>`;
return await api.send_email({ return await api.send_email({
api_cfg, api_cfg,

View File

@@ -828,9 +828,10 @@ export async function email_sign_in__event_session({
session_name: string; session_name: string;
}) { }) {
const subject = `Pres Mgmt Hub Sign In Link for ${session_name}`; const subject = `Pres Mgmt Hub Sign In Link for ${session_name}`;
const sign_in_url = encodeURI( // Per-param encodeURIComponent is required for query values — encodeURI() does not
`${base_url}/events/${event_id}/session/${event_session_id}?person_id=${person_id}&person_pass=${person_passcode}&session_id=${event_session_id}` // 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 = `<div>${to_name},<p>Your sign-in link for ${session_name}: <a href="${sign_in_url}">${sign_in_url}</a></p></div>`; const body_html = `<div>${to_name},<p>Your sign-in link for ${session_name}: <a href="${sign_in_url}">${sign_in_url}</a></p></div>`;
return await api.send_email({ return await api.send_email({
api_cfg, api_cfg,

View File

@@ -137,11 +137,19 @@ let lq__auth__event_presenter_obj = $derived(
); );
// True if this person is authed as the presenter for this specific record. // 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 // Checks four signals in priority order:
// automatically recognised on their other presentations at the same event. // 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( let presenter_is_authed = $derived(
$ae_loc.trusted_access || $ae_loc.trusted_access ||
!!events_auth_loc.current.auth__kv.presenter[$lq__event_presenter_obj?.event_presenter_id ?? ''] || !!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__kv.session[$lq__event_presenter_obj?.event_session_id ?? ''] ||
( (
!!events_auth_loc.current.auth__person.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 Copy Access Link
{/snippet} --> {/snippet} -->
<MyClipboard <MyClipboard
value={encodeURI( value={`${$ae_loc.url_origin}/events/${$lq__event_presenter_obj.event_id}/session/${$lq__event_presenter_obj.event_session_id}?person_id=${encodeURIComponent($lq__event_presenter_obj.person_id ?? $lq__event_presenter_obj.email ?? '')}&person_pass=${encodeURIComponent($lq__event_presenter_obj.person_passcode ?? $lq__event_presenter_obj.passcode ?? '')}&presentation_id=${encodeURIComponent($lq__event_presenter_obj?.event_presentation_id ?? '')}&presenter_id=${encodeURIComponent($lq__event_presenter_obj?.event_presenter_id ?? '')}`}
`${$ae_loc.url_origin}/events/${$lq__event_presenter_obj.event_id}/session/${$lq__event_presenter_obj.event_session_id}?person_id=${$lq__event_presenter_obj.person_id ?? $lq__event_presenter_obj.email}&person_pass=${$lq__event_presenter_obj.person_passcode ?? $lq__event_presenter_obj.passcode}&presentation_id=${$lq__event_presenter_obj?.event_presentation_id}&presenter_id=${$lq__event_presenter_obj?.event_presenter_id}`
)}
btn_text="Copy Access Link" btn_text="Copy Access Link"
btn_title="Copy the presenter access link to the clipboard." btn_title="Copy the presenter access link to the clipboard."
btn_class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500 border border-secondary-500 hover:preset-filled-secondary-500" btn_class="btn btn-sm preset-tonal-secondary hover:preset-filled-secondary-500 border border-secondary-500 hover:preset-filled-secondary-500"

View File

@@ -45,13 +45,23 @@ onMount(() => {
); );
let url_person_id = data.url.searchParams.get('person_id'); 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_person_pass = data.url.searchParams.get('person_pass');
let url_presentation_id = data.url.searchParams.get('presentation_id'); let url_presentation_id = data.url.searchParams.get('presentation_id');
let url_presenter_id = data.url.searchParams.get('presenter_id'); let url_presenter_id = data.url.searchParams.get('presenter_id');
let url_session_id = data.url.searchParams.get('session_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. // Gate on having a sign-in target (presenter or session), not on the passcode value.
if (url_person_pass) { // 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( 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}` `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__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.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! 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!
$events_sess.auth__kv.presentation[url_presentation_id] = true; if (url_presentation_id) $events_sess.auth__kv.presentation[url_presentation_id] = true;
$events_sess.auth__kv.presenter[url_presenter_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. // We need to set the selected presentation and presenter objects based on the respective IDs.
if (url_session_id) { if (url_session_id) {
@@ -103,21 +113,25 @@ onMount(() => {
}); });
// Grants auth for every presenter record and POC session this person holds in the event. // 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 // WHY: a single sign-in link grants auth for one presenter_id/session_id. This function
// queries both Dexie (fast, warm cache) AND the API (reliable, cold cache) in parallel, // queries Dexie (fast, warm cache) AND the API (reliable, cold cache) in parallel to
// merges the results, and pre-populates auth__kv for all roles in one shot. // 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) // person_id: the identity value from the sign-in URL. May be a Person string ID (when a
// OR an email address (the common case when no Person record exists — for LCI roughly // Person record exists, e.g. linked from iMIS) or an email address (common for LCI
// 75% of presenters are not in iMIS). Email is detected via '@' and the lookup is // presenters not in iMIS). Email is detected via '@'.
// routed to presenter.email instead of presenter.person_id.
// //
// auth__kv.presenter is keyed by BOTH event_presenter_id AND email so that // presenter_id_hint: the event_presenter_id from the sign-in URL. Used to look up the
// presenter_agree_enabled works across all sessions without per-ID lookups. // signing presenter's email directly from Dexie, which is then added to the email
async function expand_auth_for_person(person_id: string, event_id: string) { // 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 { try {
const is_email = person_id.includes('@'); 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 presenter_field = is_email ? 'email' : 'person_id';
const [dexie_presenters, dexie_sessions, api_presenters, api_sessions] = await Promise.all([ 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) .where(presenter_field).equals(person_id)
.filter(p => p.event_id === event_id) .filter(p => p.event_id === event_id)
.toArray(), .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. // Skip when signing in with email — email won't match poc_person_id.
is_email ? Promise.resolve([] as Session[]) : db_events.session is_email ? Promise.resolve([] as Session[]) : db_events.session
.where('poc_person_id').equals(person_id) .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) }).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_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 api_session_li: Session[] = Array.isArray(api_sessions) ? (api_sessions as Session[]) : [];
const presenter_kv: Record<string, Presenter> = {}; const presenter_kv: Record<string, Presenter> = {};
@@ -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; 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<string, true> = {};
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)) { for (const p of Object.values(presenter_kv)) {
events_auth_loc.current.auth__kv.presenter[p.event_presenter_id] = true; events_auth_loc.current.auth__kv.presenter[p.event_presenter_id] = true;
// Also key by email — presenter_agree_enabled checks this so any record // Also key by email — presenter_is_authed checks this so any record sharing
// sharing the same email auto-unlocks without needing the exact ID. // 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.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; 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'; 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)) { for (const s of Object.values(session_kv)) {
events_auth_loc.current.auth__kv.session[s.event_session_id] = true; 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) { } catch (e) {
console.warn('expand_auth_for_person: lookup failed', 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_auth_loc.current.auth__person.presenter_id =
$events_sess.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() { function sign_out() {