Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/team-self-serve-member-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
---

feat(team): self-serve member admin in team.html, admins can change roles

Closes the self-service gap left after #3235 — an org owner can now do everything from the team UI without filing an escalation.

## What changed

- **Team UI consolidates "invite" and "promote" into one "Add member" flow** (`server/public/team.html`). The modal posts to the unified `POST /api/organizations/:orgId/members/by-email` endpoint that walks the four-state machine (invite / create / update / no-op). The same modal handles adding a new teammate and promoting an existing member.
- **Auto-provision toggle in the Verified Domains card** — owners can flip `auto_provision_verified_domain` per org via the existing `PATCH /api/organizations/:orgId/settings`. Hidden when no verified domain exists. Owner-only (admins shouldn't be able to widen org membership unilaterally, especially now that admins can promote auto-joined members to admin).
- **Admins can change member roles** — `PATCH /api/organizations/:orgId/members/:membershipId` and Path 3 of `/members/by-email` now allow org admins to promote `member ↔ admin`. Caps in place: admins can't assign `owner`, can't change a current owner's role, and (matching the existing PATCH endpoint) no caller of either endpoint can change their own role. Owners and AAO super-admins are unrestricted.
- **`/members/by-email` accepts `seat_type`** — the unified endpoint is now a true superset of `/invitations`. seat_type is staged via `invitation_seat_types` for both Path 1 (invite) and Path 2 (direct add) so the `organization_membership.created` webhook hands the right seat_type to the local cache. Path 2 clears any stale staging rows for the same `(org, email)` pair before staging, so a prior failed direct-add can't pollute a later invite.

## Tests

- New `server/tests/integration/member-by-email-policy.test.ts` (16 tests) covers all role-cap branches, self-role-change blocking on both endpoints, seat_type propagation, and the owner-only auto-provision toggle.
10 changes: 10 additions & 0 deletions server/public/nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,16 @@
function checkMarketingOptIn() {
if (sessionStorage.getItem('mkt_optin_dismissed_v1')) return;
if (currentPath.startsWith('/onboarding')) return;
// Dev-mode users always have null preferences and aren't the audience for
// marketing nudges. Skipping on localhost also keeps the overlay from
// intercepting clicks during automated UI tests.
if (typeof window !== 'undefined' && window.location && (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '::1'
)) {
return;
}

fetch(apiBaseUrl + '/api/email-preferences', { credentials: 'include' })
.then((res) => res.ok ? res.json() : null)
Expand Down
95 changes: 85 additions & 10 deletions server/public/team.html
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,16 @@
<script src="/nav.js"></script>
<script src="/dashboard-nav.js"></script>

<!-- Invite Modal -->
<!-- Add Member Modal -->
<div id="inviteModal" class="modal-overlay">
<div class="modal">
<h2>Invite Team Member</h2>
<h2>Add team member</h2>
<div id="inviteError" class="error-message" style="display: none;"></div>
<form id="inviteForm">
<div class="form-group">
<label for="inviteEmail">Email Address</label>
<label for="inviteEmail">Email address</label>
<input type="email" id="inviteEmail" placeholder="colleague@company.com" required>
<p class="form-help">An invitation email will be sent to this address</p>
<p class="form-help">If they already have an account, they're added directly. If not, an invitation is sent. If they're already a member, their role is updated.</p>
</div>
<div class="form-group">
<label for="inviteRole">Role</label>
Expand All @@ -298,7 +298,7 @@ <h2>Invite Team Member</h2>
</div>
<div class="modal-buttons">
<button type="button" class="btn btn-secondary" onclick="closeInviteModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Send Invitation</button>
<button type="submit" class="btn btn-primary">Add member</button>
</div>
</form>
</div>
Expand Down Expand Up @@ -386,7 +386,7 @@ <h1>Team Management</h1>
<div class="card">
<h2>
<span>Team Members</span>
<button id="inviteBtn" class="btn btn-primary" onclick="openInviteModal()">+ Invite Member</button>
<button id="inviteBtn" class="btn btn-primary" onclick="openInviteModal()">+ Add member</button>
</h2>

<div id="seatUsageSummary" style="display: none; margin-bottom: var(--space-4);"></div>
Expand Down Expand Up @@ -476,6 +476,18 @@ <h2>Verified Domains</h2>
<div id="domainsContent">
<div style="text-align: center; padding: 20px; color: var(--color-text-secondary);">Loading domains...</div>
</div>
<div id="autoProvisionRow" style="display: none; margin-top: var(--space-4); padding-top: var(--space-4); border-top: var(--border-1) solid var(--color-gray-100);">
<label style="display: flex; align-items: flex-start; gap: var(--space-3); cursor: pointer;">
<input type="checkbox" id="autoProvisionToggle" style="margin-top: 4px;">
<span>
<strong style="display: block; color: var(--color-text-heading); margin-bottom: var(--space-1);">Auto-add verified-domain employees</strong>
<span style="color: var(--color-text-secondary); font-size: var(--text-sm);">
When on (default), anyone who signs in with an email at one of your verified domains is automatically added as a member. Turn off to require explicit invites only.
</span>
</span>
</label>
<div id="autoProvisionStatus" style="display: none; margin-top: var(--space-2); font-size: var(--text-sm);"></div>
</div>
<div style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="openDomainVerification()">Manage Domains</button>
</div>
Expand Down Expand Up @@ -985,7 +997,10 @@ <h2>
const seat_type = document.getElementById('inviteSeatType').value;

try {
const response = await fetch(`/api/organizations/${currentOrg}/invitations`, {
// Unified state-machine endpoint: invites if the user has no account,
// creates a membership if they have an account but aren't in the org,
// updates their role if they're already a member.
const response = await fetch(`/api/organizations/${currentOrg}/members/by-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role, seat_type }),
Expand All @@ -994,16 +1009,16 @@ <h2>
const data = await response.json();

if (!response.ok) {
document.getElementById('inviteError').textContent = data.message || 'Failed to send invitation';
document.getElementById('inviteError').textContent = data.message || 'Failed to add member';
document.getElementById('inviteError').style.display = 'block';
return;
}

closeInviteModal();
loadTeamMembers();
} catch (error) {
console.error('Error sending invitation:', error);
document.getElementById('inviteError').textContent = 'Failed to send invitation. Please try again.';
console.error('Error adding member:', error);
document.getElementById('inviteError').textContent = 'Failed to add member. Please try again.';
document.getElementById('inviteError').style.display = 'block';
}
});
Expand Down Expand Up @@ -1224,6 +1239,11 @@ <h2>
</p>
`;
}

// Render the auto-provision toggle whenever there is at least one
// verified domain. With no verified domain the toggle is a no-op
// (autoLinkByVerifiedDomain has nothing to match), so we hide it.
renderAutoProvisionToggle(orgId, data);
} catch (error) {
console.error('Error loading domains:', error);
domainsContent.innerHTML = `
Expand All @@ -1234,6 +1254,61 @@ <h2>
}
}

function renderAutoProvisionToggle(orgId, data) {
const row = document.getElementById('autoProvisionRow');
const toggle = document.getElementById('autoProvisionToggle');
const status = document.getElementById('autoProvisionStatus');
const hasVerified = (data.domains || []).some(d => d.verified);
// Owner-only: turning auto-provision on widens org membership in a way
// an admin shouldn't be able to do unilaterally (admins can promote
// auto-joined members to admin under the new role-cap policy). The
// backend enforces the same restriction; the UI mirrors it.
const isOwner = currentUserRole === 'owner';

if (!hasVerified || !isOwner) {
row.style.display = 'none';
return;
}

row.style.display = 'block';
toggle.checked = data.auto_provision_verified_domain !== false;
status.style.display = 'none';
status.textContent = '';

// Replace any prior listener by cloning the node
const fresh = toggle.cloneNode(true);
toggle.parentNode.replaceChild(fresh, toggle);
fresh.addEventListener('change', async (e) => {
const desired = e.target.checked;
e.target.disabled = true;
try {
const response = await fetch(`/api/organizations/${orgId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ auto_provision_verified_domain: desired }),
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.message || 'Failed to update setting');
}
status.style.display = 'block';
status.style.color = 'var(--color-success-700)';
status.textContent = desired
? 'Auto-add is on. Verified-domain sign-ins will join automatically.'
: 'Auto-add is off. Members must be invited explicitly.';
} catch (err) {
console.error('Error updating auto_provision_verified_domain:', err);
e.target.checked = !desired;
status.style.display = 'block';
status.style.color = 'var(--color-danger-700)';
status.textContent = 'Could not update — please try again.';
} finally {
e.target.disabled = false;
}
});
}

// Store domain users for batch add
let domainUsersList = [];

Expand Down
Loading
Loading