Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 127 additions & 25 deletions server/public/dashboard-settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -676,22 +676,22 @@
<!-- Link Email Modal -->
<div id="linkEmailModal" class="modal-overlay">
<div class="modal">
<h2>Link Another Email</h2>
<h2>Link another email</h2>
<p style="color: var(--color-text-muted); font-size: var(--text-sm); margin-bottom: var(--space-4);">
Enter the email address you want to link. We'll send a verification email to confirm ownership.
Existing accounts can't be combined through this form and require support assistance.
</p>
<div style="margin-bottom: var(--space-4);">
<label for="linkEmailInput" style="font-size: var(--text-xs); font-weight: var(--font-medium); display: block; margin-bottom: var(--space-1);">Email address</label>
<input type="email" id="linkEmailInput" class="form-input" placeholder="you@example.com">
<input type="email" id="linkEmailInput" class="form-input" placeholder="you@example.com" disabled>
</div>
<div id="linkEmailWarning" role="alert" style="display: none; background: var(--color-warning-50); border: 1px solid var(--color-warning-300); border-radius: var(--radius-md); padding: var(--space-3); margin-bottom: var(--space-4); font-size: var(--text-xs);">
<span id="linkEmailWarningMessage"></span>
<a href="mailto:support@agenticadvertising.org">Email support</a>
</div>
<div style="display: flex; gap: var(--space-2); justify-content: flex-end;">
<button class="btn btn-ghost" id="linkEmailCancelBtn">Cancel</button>
<button class="btn btn-primary" id="linkEmailBtn">Send Verification</button>
<button class="btn btn-primary" id="linkEmailBtn" disabled>Send verification</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -989,11 +989,15 @@ <h2 class="edit-section-title">Member directory</h2>
<h2 class="edit-section-title">Linked emails</h2>
<svg class="chevron" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>
</summary>
<p class="edit-section-description">Link additional email addresses to consolidate duplicate accounts.</p>
<p class="edit-section-description">Link additional email addresses after verifying ownership. Existing accounts require support assistance.</p>
<div id="linkedEmailStatus" role="alert" style="display: none; margin-bottom: var(--space-4);">
<span id="linkedEmailStatusMessage"></span>
<a href="mailto:support@agenticadvertising.org">Email support</a>
</div>
<div id="linkedEmailsList" style="margin-bottom: var(--space-4);">
<p style="color: var(--color-text-muted); font-size: var(--text-xs);">Loading...</p>
</div>
<button class="btn btn-ghost" id="openLinkEmailBtn">Link another email</button>
<button class="btn btn-ghost" id="openLinkEmailBtn" disabled>Link another email</button>
</details>

<!-- Notifications -->
Expand Down Expand Up @@ -1046,12 +1050,63 @@ <h3>You're unsubscribed from all emails</h3>
// ==========================================
// Linked Emails
// ==========================================
var linkedEmailStatusKnown = false;
var linkedEmailReconciliationRequired = false;
var linkedEmailMutationPending = false;
var linkedEmailLoadVersion = 0;
var linkedEmailCredentialId = null;
var linkedEmailReconciliationMessage = 'Your email change needs reconciliation. Contact support before making another email change.';

function canMutateLinkedEmails() {
return !!linkedEmailCredentialId && linkedEmailStatusKnown && !linkedEmailReconciliationRequired && !linkedEmailMutationPending;
}

function updateLinkedEmailControls() {
var disabled = !canMutateLinkedEmails();
document.querySelectorAll('.make-primary-btn, #openLinkEmailBtn, #linkEmailBtn, #linkEmailInput').forEach(function(control) {
control.disabled = disabled;
});
}

function showLinkedEmailStatus(message) {
document.getElementById('linkedEmailStatusMessage').textContent = message ? message + ' ' : '';
document.getElementById('linkedEmailStatus').style.display = message ? 'block' : 'none';
}

function applyLinkedEmailStatus(data, authoritative) {
if (data.reconciliation_required === true) {
linkedEmailReconciliationRequired = true;
linkedEmailStatusKnown = true;
showLinkedEmailStatus(data.message || linkedEmailReconciliationMessage);
} else if (data.status_unknown === true) {
linkedEmailStatusKnown = false;
showLinkedEmailStatus(data.message || 'Unable to confirm your email change. Reload to check its status or contact support.');
} else if (authoritative && data.reconciliation_required === false) {
linkedEmailReconciliationRequired = false;
linkedEmailStatusKnown = true;
showLinkedEmailStatus('');
}
updateLinkedEmailControls();
}

async function loadLinkedEmails() {
var loadVersion = ++linkedEmailLoadVersion;
linkedEmailStatusKnown = false;
updateLinkedEmailControls();
try {
var response = await fetch('/api/me/linked-emails', { credentials: 'include' });
if (!response.ok) return;
var data = await response.json();
if (loadVersion !== linkedEmailLoadVersion) return;
if (!response.ok) {
applyLinkedEmailStatus(data, false);
if (!linkedEmailReconciliationRequired) {
showLinkedEmailStatus(data.message || data.error || 'Unable to check email change status. Reload this page or contact support.');
}
return;
}

linkedEmailCredentialId = typeof data.credential_id === 'string' ? data.credential_id : null;
if (!linkedEmailCredentialId) throw new Error('Credential status unavailable');
var container = document.getElementById('linkedEmailsList');
var html = '';

Expand All @@ -1064,7 +1119,7 @@ <h3>You're unsubscribed from all emails</h3>
html += '<div style="display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--color-border);">' +
'<span style="font-size: var(--text-sm);">' + accountEscapeHtml(data.aliases[i].email) + '</span>' +
'<span style="margin-left: 8px; font-size: var(--text-xs); background: var(--color-success-bg); color: var(--color-success-fg); padding: 2px 8px; border-radius: 4px;">Linked</span>' +
'<button class="btn btn-ghost make-primary-btn" data-email="' + accountEscapeHtml(data.aliases[i].email) + '" style="margin-left: auto; font-size: var(--text-xs); padding: 2px 8px;">Make primary</button>' +
'<button class="btn btn-ghost make-primary-btn" disabled data-email="' + accountEscapeHtml(data.aliases[i].email) + '" style="margin-left: auto; font-size: var(--text-xs); padding: 2px 8px;">Make primary</button>' +
'</div>';
}

Expand All @@ -1082,47 +1137,84 @@ <h3>You're unsubscribed from all emails</h3>
setPrimaryEmail(btn.dataset.email);
});
});
applyLinkedEmailStatus(data, true);
if (!linkedEmailStatusKnown) {
showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.');
}
} catch (error) {
console.error('Failed to load linked emails:', error);
if (loadVersion !== linkedEmailLoadVersion) return;
if (!linkedEmailReconciliationRequired) {
showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.');
}
}
}

function primaryEmailOperation(email) {
var key = 'member-primary-email:' + linkedEmailCredentialId;
var saved = window.sessionStorage.getItem(key);
var operation = saved ? JSON.parse(saved) : null;
var normalizedEmail = email.trim().toLowerCase();
if (!operation || operation.email !== normalizedEmail) {
operation = { operation_id: window.crypto.randomUUID(), email: normalizedEmail };
// Persist before sending: retries after a lost response or page reload
// must use the same caller operation ID, never start a second mutation.
window.sessionStorage.setItem(key, JSON.stringify(operation));
}
return { key: key, id: operation.operation_id };
}

async function setPrimaryEmail(email) {
if (!canMutateLinkedEmails()) return;
if (!confirm('Make ' + email + ' your primary email? You will need to use this email to sign in.')) return;

var btns = document.querySelectorAll('.make-primary-btn');
btns.forEach(function(b) { b.disabled = true; });
linkedEmailMutationPending = true;
++linkedEmailLoadVersion;
updateLinkedEmailControls();

try {
var operation = primaryEmailOperation(email);
var response = await fetch('/api/me/linked-emails/primary', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email }),
body: JSON.stringify({ email: email, operation_id: operation.id }),
credentials: 'include'
});

var data = await response.json();
++linkedEmailLoadVersion;
applyLinkedEmailStatus(data, false);

if (!response.ok) {
accountShowToast(data.error || 'Failed to update primary email', true);
if ((response.ok || data.reconciliation_required === false)
&& data.reconciliation_required !== true && data.status_unknown !== true) {
window.sessionStorage.removeItem(operation.key);
}

if (!response.ok || linkedEmailReconciliationRequired || data.status_unknown === true) {
accountShowToast(data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to update primary email', true);
return;
}

accountShowToast('Primary email updated to ' + email);
loadLinkedEmails();
await loadLinkedEmails();
} catch (error) {
accountShowToast('Failed to update primary email', true);
++linkedEmailLoadVersion;
linkedEmailStatusKnown = false;
var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.';
showLinkedEmailStatus(message);
accountShowToast(message, true);
} finally {
btns.forEach(function(b) { b.disabled = false; });
linkedEmailMutationPending = false;
updateLinkedEmailControls();
}
}

function openLinkEmailModal() {
if (!canMutateLinkedEmails()) return;
document.getElementById('linkEmailInput').value = '';
document.getElementById('linkEmailWarning').style.display = 'none';
document.getElementById('linkEmailWarningMessage').textContent = '';
document.getElementById('linkEmailBtn').disabled = false;
document.getElementById('linkEmailBtn').textContent = 'Send Verification';
document.getElementById('linkEmailBtn').textContent = 'Send verification';
updateLinkedEmailControls();
document.getElementById('linkEmailModal').classList.add('show');
}

Expand All @@ -1131,11 +1223,14 @@ <h3>You're unsubscribed from all emails</h3>
}

async function sendLinkVerification() {
if (!canMutateLinkedEmails()) return;
var email = document.getElementById('linkEmailInput').value.trim();
if (!email) return;

var btn = document.getElementById('linkEmailBtn');
btn.disabled = true;
linkedEmailMutationPending = true;
++linkedEmailLoadVersion;
updateLinkedEmailControls();
btn.textContent = 'Sending...';

try {
Expand All @@ -1147,9 +1242,11 @@ <h3>You're unsubscribed from all emails</h3>
});

var data = await response.json();
++linkedEmailLoadVersion;
applyLinkedEmailStatus(data, false);

if (!response.ok) {
var message = data.message || data.error || 'Failed to send verification';
if (!response.ok || linkedEmailReconciliationRequired) {
var message = data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to send verification';
accountShowToast(message, true);
document.getElementById('linkEmailWarningMessage').textContent = message + ' ';
document.getElementById('linkEmailWarning').style.display = 'block';
Expand All @@ -1158,12 +1255,17 @@ <h3>You're unsubscribed from all emails</h3>

closeLinkEmailModal();
accountShowToast('Verification email sent! Check your inbox.');
loadLinkedEmails();
await loadLinkedEmails();
} catch (error) {
accountShowToast('Failed to send verification', true);
++linkedEmailLoadVersion;
linkedEmailStatusKnown = false;
var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.';
showLinkedEmailStatus(message);
accountShowToast(message, true);
} finally {
btn.disabled = false;
btn.textContent = 'Send Verification';
linkedEmailMutationPending = false;
updateLinkedEmailControls();
btn.textContent = 'Send verification';
}
}

Expand Down
5 changes: 3 additions & 2 deletions server/src/addie/admin-status-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { createLogger } from '../logger.js';
import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js';
import { WorkingGroupDatabase } from '../db/working-group-db.js';
import { getWebAdminStatusCache } from './admin-status-cache.js';
import {
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function isWebUserAAOAdmin(workosUserId: string): Promise<boolean>
*/
export async function resolveWebUserAAOAdminAccess(
workosUserId: string,
email: string | null | undefined,
snapshot: AuthorizationSnapshot | null | undefined,
): Promise<AAOAdminAccessDecision> {
return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), email);
return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), snapshot);
}
16 changes: 13 additions & 3 deletions server/src/auth/admin-access.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js';

/**
* Shared platform-admin authority vocabulary.
*
Expand All @@ -17,7 +19,15 @@ export interface AAOAdminAccessDecision {
mechanism: AAOAdminAccessMechanism | null;
}

export function isBreakGlassAdminEmail(email: string | null | undefined): boolean {
/**
* Email-based authority comes only from this request's primary-DB snapshot.
* A pending intent can precede any epoch change, so epoch freshness alone is
* insufficient. Missing state and unverified credentials never grant access.
*/
export function isBreakGlassAdmin(snapshot: AuthorizationSnapshot | null | undefined): boolean {
if (!snapshot || snapshot.credential.emailMutationPending !== false
|| snapshot.credential.emailVerified !== true) return false;
const email = snapshot.credential.email;
if (!email) return false;
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) return false;
Expand All @@ -30,9 +40,9 @@ export function isBreakGlassAdminEmail(email: string | null | undefined): boolea
/** Classify a membership result without duplicating break-glass interpretation. */
export function decideAAOAdminAccess(
isAdminByWorkingGroup: boolean,
email: string | null | undefined,
snapshot: AuthorizationSnapshot | null | undefined,
): AAOAdminAccessDecision {
if (isAdminByWorkingGroup) return { isAdmin: true, mechanism: 'aao_admin_working_group' };
if (isBreakGlassAdminEmail(email)) return { isAdmin: true, mechanism: 'break_glass_admin_email' };
if (isBreakGlassAdmin(snapshot)) return { isAdmin: true, mechanism: 'break_glass_admin_email' };
return { isAdmin: false, mechanism: null };
}
2 changes: 1 addition & 1 deletion server/src/auth/organization-principal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { WorkOSUser } from "../types.js";

export type OrgAuthorizationPrincipal = Pick<
WorkOSUser,
"id" | "authWorkosUserId"
"id" | "authWorkosUserId" | "authorizationSnapshot"
>;

/**
Expand Down
16 changes: 16 additions & 0 deletions server/src/auth/workos-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ const OWNERLESS_PROMOTION_WORKOS_TIMEOUT_MS = 10_000;
const PIPES_WORKOS_TIMEOUT_MS = 10_000;
const AUTHORIZATION_OBSERVER_WORKOS_TIMEOUT_MS = 3_000;
const AUTHORIZATION_ENFORCEMENT_WORKOS_TIMEOUT_MS = 5_000;
const EMAIL_MUTATION_WORKOS_TIMEOUT_MS = 10_000;

let _workos: WorkOS | null = null;
let _ownerlessPromotionWorkos: WorkOS | null = null;
let _pipesWorkos: WorkOS | null = null;
let _authorizationObserverWorkos: WorkOS | null = null;
let _authorizationEnforcementWorkos: WorkOS | null = null;
let _emailMutationWorkos: WorkOS | null = null;
let _clientId = '';

/** Returns the shared WorkOS client. Constructed on first call; WORKOS_API_KEY and WORKOS_CLIENT_ID must be set by then. */
Expand Down Expand Up @@ -97,6 +99,20 @@ export function getAuthorizationEnforcementWorkos(): WorkOS {
return _authorizationEnforcementWorkos;
}

/** One bounded attempt: an email mutation timeout requires reconciliation. */
export function getEmailMutationWorkos(): WorkOS {
if (!_emailMutationWorkos) {
if (!process.env.WORKOS_API_KEY) throw new Error('WORKOS_API_KEY environment variable is required');
if (!process.env.WORKOS_CLIENT_ID) throw new Error('WORKOS_CLIENT_ID environment variable is required');
_emailMutationWorkos = new WorkOS(process.env.WORKOS_API_KEY, {
clientId: process.env.WORKOS_CLIENT_ID,
timeout: EMAIL_MUTATION_WORKOS_TIMEOUT_MS,
maxRetries: 0,
});
}
return _emailMutationWorkos;
}

/**
* Get the authorization URL to redirect users to WorkOS for authentication
*/
Expand Down
Loading
Loading