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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user