|
|
|
|
@@ -66,7 +66,7 @@ let verify_failed_for_uuid: string | null = null;
|
|
|
|
|
// Tracks the type of Novi API failure for the UI — 'rate_limited' or 'api_error'.
|
|
|
|
|
// A transient API error is NOT the same as a real membership denial; this lets us
|
|
|
|
|
// show a distinct "Verification Unavailable" state instead of "Access Denied".
|
|
|
|
|
let verify_error_type: 'rate_limited' | 'api_error' | null = $state(null);
|
|
|
|
|
let verify_error_type: 'rate_limited' | 'api_error' | 'network_error' | null = $state(null);
|
|
|
|
|
// Shown inside the spinner — updated during rate-limit retry waits.
|
|
|
|
|
let verifying_status_msg: string = $state('Verifying identity...');
|
|
|
|
|
// Incremented by handle_verify_retry() to re-run Effect 2 without a full page reload.
|
|
|
|
|
@@ -148,7 +148,10 @@ $effect(() => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const VERIFIED_TTL_MS_DEFAULT = 45 * 60 * 1000; // 45 minutes
|
|
|
|
|
// WHY 12 hours: members open IDAA in a hotel/conference context and should not need to
|
|
|
|
|
// re-verify mid-day. After our client-side Novi call is replaced by server-side (FastAPI),
|
|
|
|
|
// this TTL can be tuned independently. Until then, 12h covers a full workday.
|
|
|
|
|
const VERIFIED_TTL_MS_DEFAULT = 12 * 60 * 60 * 1000; // 12 hours
|
|
|
|
|
|
|
|
|
|
// Effect 1: Set URL origin and params
|
|
|
|
|
$effect(() => {
|
|
|
|
|
@@ -171,8 +174,6 @@ $effect(() => {
|
|
|
|
|
if (!browser) return;
|
|
|
|
|
|
|
|
|
|
const site_cfg = $ae_loc.site_cfg_json || {};
|
|
|
|
|
const api_key: string | null = site_cfg.novi_idaa_api_key ?? null;
|
|
|
|
|
const api_root: string = site_cfg.novi_api_root_url ?? 'https://www.idaa.org/api';
|
|
|
|
|
const admin_li: string[] = site_cfg.novi_admin_li ?? [];
|
|
|
|
|
const trusted_li: string[] = site_cfg.novi_trusted_li ?? [];
|
|
|
|
|
const ttl_ms: number = site_cfg.novi_verified_ttl_ms ?? VERIFIED_TTL_MS_DEFAULT;
|
|
|
|
|
@@ -280,62 +281,65 @@ $effect(() => {
|
|
|
|
|
if (!has_valid_session) {
|
|
|
|
|
novi_verifying = true;
|
|
|
|
|
}
|
|
|
|
|
verify_novi_uuid(current_uuid, api_key, api_root);
|
|
|
|
|
verify_novi_uuid(current_uuid);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verifies a Novi UUID against the Novi API and sets permissions accordingly.
|
|
|
|
|
* "All or nothing" — if no API key is configured or the call fails, access is denied.
|
|
|
|
|
* Called from within untrack(), so store writes here will not trigger reactive loops.
|
|
|
|
|
* Verifies a Novi UUID via the Aether server-side proxy endpoint.
|
|
|
|
|
* The backend calls Novi server-to-server, eliminating browser IP exposure (hotel WiFi,
|
|
|
|
|
* VPN blocks, Cloudflare IP reputation) that caused false "Access Denied" for some members.
|
|
|
|
|
*
|
|
|
|
|
* Retry policy:
|
|
|
|
|
* 429 (rate limited) → wait 10s, retry once.
|
|
|
|
|
* Network error / timeout → wait 3s, retry once.
|
|
|
|
|
* Anything else (4xx, 5xx, empty 200) → fail immediately.
|
|
|
|
|
*
|
|
|
|
|
* The Novi fetch is wrapped in an AbortController with a 12s hard timeout.
|
|
|
|
|
* Without it, a hung Novi server leaves verify_in_flight=true indefinitely.
|
|
|
|
|
* Aether endpoint: GET /v3/action/idaa/novi_member/{uuid}
|
|
|
|
|
* Response codes:
|
|
|
|
|
* 200 → verified — data.full_name and data.email populated
|
|
|
|
|
* 404 → not a member (or Novi returned empty 200, handled server-side) → deny
|
|
|
|
|
* 429 → Novi rate limited → wait 10s, retry once
|
|
|
|
|
* 503 → Novi unreachable / Novi 5xx → show api_error UI
|
|
|
|
|
* Network error / AbortError → wait 3s, retry once
|
|
|
|
|
*/
|
|
|
|
|
async function verify_novi_uuid(
|
|
|
|
|
uuid: string,
|
|
|
|
|
api_key: string | null,
|
|
|
|
|
api_root_url: string,
|
|
|
|
|
is_retry: boolean = false
|
|
|
|
|
) {
|
|
|
|
|
if (!api_key) {
|
|
|
|
|
// WHY: Do NOT clear $idaa_loc state here, and do NOT log "Starting verification".
|
|
|
|
|
// The api_key can be transiently null when $ae_loc.site_cfg_json hasn't finished
|
|
|
|
|
// loading (SWR: root layout fires $ae_loc = new_loc before cfg is in the store).
|
|
|
|
|
// Clearing novi_verified/novi_uuid here would destroy a valid cached session, making
|
|
|
|
|
// the TTL check fail on the very next Effect 2 run — causing a re-auth loop.
|
|
|
|
|
// If the api_key is genuinely not configured, $idaa_loc starts with
|
|
|
|
|
// novi_verified=false and novi_uuid=null (defaults), so access is denied naturally.
|
|
|
|
|
// novi_verifying is left at its current value intentionally:
|
|
|
|
|
// - If has_valid_session was false above, novi_verifying=true (spinner stays visible
|
|
|
|
|
// until api_key loads and Effect 2 re-runs with a real key).
|
|
|
|
|
// - If has_valid_session was true, novi_verifying=false (children keep rendering).
|
|
|
|
|
console.warn('IDAA Layout: Novi API key not yet available — skipping verification (will retry when site_cfg_json loads).');
|
|
|
|
|
async function verify_novi_uuid(uuid: string, is_retry: boolean = false) {
|
|
|
|
|
// WHY: Check presence of novi_idaa_api_key to detect whether this site has IDAA
|
|
|
|
|
// configured. The key itself is no longer used for auth (the Aether server holds it),
|
|
|
|
|
// but its absence means either (a) this is not the IDAA site / dev domain, or (b)
|
|
|
|
|
// site_cfg_json hasn't finished loading yet. In both cases: skip and wait for the
|
|
|
|
|
// next Effect 2 trigger. This also guards the 'ghost' account_id state (domain-not-found
|
|
|
|
|
// fallback gives site_cfg_json = {}) — prevents a spurious API call with a bad account_id.
|
|
|
|
|
const site_cfg = $ae_loc.site_cfg_json || {};
|
|
|
|
|
if (!site_cfg.novi_idaa_api_key) {
|
|
|
|
|
console.warn('IDAA Layout: Novi not configured for this site (or site_cfg_json still loading) — skipping verification.');
|
|
|
|
|
verify_in_flight = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const account_id = $ae_loc.account_id;
|
|
|
|
|
const api_key = $ae_api.api_secret_key;
|
|
|
|
|
const api_url = $ae_api.base_url;
|
|
|
|
|
|
|
|
|
|
if (!account_id || account_id === 'ghost' || !api_key || !api_url) {
|
|
|
|
|
// Aether config not yet available. Do not clear $idaa_loc: that would destroy a
|
|
|
|
|
// valid cached session and cause a re-auth loop on the next Effect 2 run.
|
|
|
|
|
console.warn('IDAA Layout: Aether API config not yet available — skipping verification (will retry when config loads).');
|
|
|
|
|
verify_in_flight = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
verifying_status_msg = 'Verifying identity...';
|
|
|
|
|
verify_error_type = null;
|
|
|
|
|
console.log(`IDAA Layout: Starting Novi UUID verification for ${uuid}...`);
|
|
|
|
|
console.log(`IDAA Layout: Starting Novi UUID verification (server-side) for ${uuid}...`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const headers = new Headers();
|
|
|
|
|
headers.append('Authorization', `Basic ${api_key}`);
|
|
|
|
|
|
|
|
|
|
// Hard timeout: abort if Novi doesn't respond within 12 seconds.
|
|
|
|
|
// Prevents verify_in_flight from getting stuck on a hung server.
|
|
|
|
|
// Hard timeout: abort if the Aether endpoint doesn't respond within 12 seconds.
|
|
|
|
|
const abort_ctrl = new AbortController();
|
|
|
|
|
const abort_timeout_id = setTimeout(() => abort_ctrl.abort(), 12_000);
|
|
|
|
|
let response: Response;
|
|
|
|
|
try {
|
|
|
|
|
response = await fetch(`${api_root_url}/customers/${uuid}`, {
|
|
|
|
|
response = await fetch(`${api_url}/v3/action/idaa/novi_member/${uuid}`, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers,
|
|
|
|
|
headers: {
|
|
|
|
|
'x-aether-api-key': api_key,
|
|
|
|
|
'x-account-id': account_id
|
|
|
|
|
},
|
|
|
|
|
signal: abort_ctrl.signal
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
@@ -345,64 +349,52 @@ async function verify_novi_uuid(
|
|
|
|
|
if (response.status === 429) {
|
|
|
|
|
if (is_retry) {
|
|
|
|
|
verify_error_type = 'rate_limited';
|
|
|
|
|
throw new Error(`Novi API rate limited for UUID ${uuid} (retry also failed)`);
|
|
|
|
|
throw new Error(`Novi verification rate limited for UUID ${uuid} (retry also failed)`);
|
|
|
|
|
}
|
|
|
|
|
console.warn(`IDAA Layout: Novi API rate limited (429) for ${uuid}. Retrying in 10s...`);
|
|
|
|
|
console.warn(`IDAA Layout: Rate limited (429) for ${uuid}. Retrying in 10s...`);
|
|
|
|
|
verifying_status_msg = 'High traffic — retrying in 10 seconds...';
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
|
|
|
|
|
await verify_novi_uuid(uuid, api_key, api_root_url, true);
|
|
|
|
|
await verify_novi_uuid(uuid, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status === 503) {
|
|
|
|
|
// Novi unreachable or Novi 5xx — Aether backend returns 503.
|
|
|
|
|
// Mirror the network-error retry path: one automatic 3s wait before giving up.
|
|
|
|
|
if (is_retry) {
|
|
|
|
|
throw new Error(`Novi verification: Novi unreachable (503) — retry also failed`);
|
|
|
|
|
}
|
|
|
|
|
console.warn(`IDAA Layout: Novi unreachable (503) for ${uuid}. Retrying in 3s...`);
|
|
|
|
|
verifying_status_msg = 'Connection issue — retrying...';
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
|
|
|
|
await verify_novi_uuid(uuid, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Novi API returned ${response.status} for UUID ${uuid}`);
|
|
|
|
|
// 404 = not a member (Novi 404, or Novi empty-200 anti-pattern handled server-side).
|
|
|
|
|
throw new Error(`Novi verification returned ${response.status} for UUID ${uuid}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
// log_lvl = 2;
|
|
|
|
|
if (log_lvl > 1) {
|
|
|
|
|
console.log(`IDAA Layout: Novi API response for ${uuid}:`, result);
|
|
|
|
|
console.log(`IDAA Layout: Novi verification response for ${uuid}:`, result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NOTE: We are currently trusting that the pages in the Novi CMS are handling the actual current active member checks. It (Novi CMS page config) denies access to the page as a whole if they are not a member. 2026-05-19
|
|
|
|
|
const verified_name: string | null = result?.data?.full_name ?? null;
|
|
|
|
|
const verified_email: string | null = result?.data?.email ?? null;
|
|
|
|
|
|
|
|
|
|
// Build display name: prefer "First L." format, fall back to full Name field.
|
|
|
|
|
const first_name = result?.FirstName ?? null;
|
|
|
|
|
const last_initial = result?.LastName
|
|
|
|
|
? `${result.LastName.charAt(0).toUpperCase()}.`
|
|
|
|
|
: '';
|
|
|
|
|
const verified_name =
|
|
|
|
|
first_name && last_initial
|
|
|
|
|
? `${first_name} ${last_initial}`
|
|
|
|
|
: (result?.Name ?? null);
|
|
|
|
|
|
|
|
|
|
// Normalize email — Novi occasionally includes spaces where + should be.
|
|
|
|
|
const verified_email = result?.Email
|
|
|
|
|
? result.Email.replace(/\s+/g, '+')
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
// WHY: Novi may return HTTP 200 with empty/null fields for UUIDs that don't
|
|
|
|
|
// exist or have been deleted (common API anti-pattern — empty 200 vs 404).
|
|
|
|
|
// Without this check, any UUID would pass verification as long as Novi doesn't
|
|
|
|
|
// return a 4xx status. Require at least one identity field to treat as a real member.
|
|
|
|
|
if (!verified_email && !first_name && !(result?.Name)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Novi API returned 200 but no member data for UUID ${uuid} — treating as unverified`
|
|
|
|
|
);
|
|
|
|
|
if (!result?.data?.verified || (!verified_name && !verified_email)) {
|
|
|
|
|
throw new Error(`Novi verification returned 200 but no identity data for UUID ${uuid}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Other result fields for future use:
|
|
|
|
|
// Role ("Regular User"), MembershipExpires, MemberStatus ("current"), CreatedDate, LastUpdatedDate, Groups ("GroupUniqueID", "GroupName", "InheritingMember", "JoinDate")
|
|
|
|
|
|
|
|
|
|
$idaa_loc.novi_uuid = uuid;
|
|
|
|
|
$idaa_loc.novi_email = verified_email;
|
|
|
|
|
$idaa_loc.novi_full_name = verified_name;
|
|
|
|
|
$idaa_loc.novi_verified = true;
|
|
|
|
|
$idaa_loc.novi_verified_ts = Date.now();
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`IDAA Layout: Novi UUID verified. Name: ${verified_name}, Email: ${verified_email}`
|
|
|
|
|
);
|
|
|
|
|
console.log(`IDAA Layout: Novi UUID verified (server-side). Name: ${verified_name}, Email: ${verified_email}`);
|
|
|
|
|
|
|
|
|
|
// Determine permission level based on verified UUID.
|
|
|
|
|
// UUID confirmed real → at minimum authenticated. Check lists for higher levels.
|
|
|
|
|
@@ -429,8 +421,7 @@ async function verify_novi_uuid(
|
|
|
|
|
$idaa_loc.bb.qry__enabled = 'enabled';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Network errors (TypeError: Failed to fetch) and AbortError (our 12s timeout) get one
|
|
|
|
|
// automatic retry after a short wait — they are almost always transient (cellular drop,
|
|
|
|
|
// hotel WiFi hiccup, momentary Novi server lag).
|
|
|
|
|
// automatic retry after a short wait — they are almost always transient.
|
|
|
|
|
const is_network_or_timeout =
|
|
|
|
|
error instanceof TypeError ||
|
|
|
|
|
(error instanceof Error && error.name === 'AbortError');
|
|
|
|
|
@@ -441,18 +432,19 @@ async function verify_novi_uuid(
|
|
|
|
|
console.warn(`IDAA Layout: Novi verification ${reason} for ${uuid}. Retrying in 3s...`);
|
|
|
|
|
verifying_status_msg = 'Connection issue — retrying...';
|
|
|
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
|
|
|
|
await verify_novi_uuid(uuid, api_key, api_root_url, true);
|
|
|
|
|
await verify_novi_uuid(uuid, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verification failed — all-or-nothing means deny access.
|
|
|
|
|
console.error(
|
|
|
|
|
`IDAA Layout: Novi UUID verification failed for ${uuid}:`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
console.error(`IDAA Layout: Novi UUID verification failed for ${uuid}:`, error);
|
|
|
|
|
verify_failed_for_uuid = uuid; // Latch — stop retry loop for this UUID. See declaration comment.
|
|
|
|
|
// 'rate_limited' is set before throw for 429; everything else is an unexpected API error.
|
|
|
|
|
if (!verify_error_type) verify_error_type = 'api_error';
|
|
|
|
|
// 'rate_limited' is set before throw for 429.
|
|
|
|
|
// 'network_error' for persistent network/timeout failures after retry.
|
|
|
|
|
// 'api_error' for all other failures (404 non-member, 503 Novi down, etc.).
|
|
|
|
|
if (!verify_error_type) {
|
|
|
|
|
verify_error_type = is_network_or_timeout ? 'network_error' : 'api_error';
|
|
|
|
|
}
|
|
|
|
|
$idaa_loc.novi_uuid = null;
|
|
|
|
|
$idaa_loc.novi_email = null;
|
|
|
|
|
$idaa_loc.novi_full_name = null;
|
|
|
|
|
@@ -538,9 +530,19 @@ function handle_verify_retry() {
|
|
|
|
|
<p class="text-sm">
|
|
|
|
|
The membership directory is temporarily busy (rate limited). Please wait a moment and try again.
|
|
|
|
|
</p>
|
|
|
|
|
{:else if verify_error_type === 'network_error'}
|
|
|
|
|
<p class="text-sm">
|
|
|
|
|
We were unable to reach the membership directory after two attempts. This is usually caused by a network filter blocking the connection — it is not a problem with your membership.
|
|
|
|
|
</p>
|
|
|
|
|
<ul class="mt-1 list-disc pl-5 text-left text-sm">
|
|
|
|
|
<li>Turn off your VPN if one is running</li>
|
|
|
|
|
<li>Switch from hotel or conference WiFi to your phone's cellular data</li>
|
|
|
|
|
<li>Try a different network (mobile data, home WiFi)</li>
|
|
|
|
|
<li>If on a corporate or hospital network, try from a personal device</li>
|
|
|
|
|
</ul>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="text-sm">
|
|
|
|
|
We were unable to connect to the membership directory. This is usually a temporary network issue — it is not a problem with your membership or access.
|
|
|
|
|
We were unable to connect to the membership directory. This is usually a temporary issue — it is not a problem with your membership or access.
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
<p class="text-xs italic text-gray-500">
|
|
|
|
|
|