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 = ``;
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() {