File size: 9,678 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | /**
* Business Pro seats panel (#4634/#4635) — invite/list/remove UI for API
* Business owners, extracted out of UnifiedSettings.ts so that mega-component
* doesn't keep absorbing every new billing feature slice inline.
*/
import { escapeHtml } from '@/utils/sanitize';
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
import { getAuthState } from '@/services/auth-state';
import { showToast } from '@/utils/toast';
import {
getSubscription,
listBusinessSeats,
inviteBusinessSeats,
removeBusinessSeat,
type BusinessSeat,
} from '@/services/billing';
export class BusinessSeatsSection {
private seats: BusinessSeat[] = [];
private loading = false;
private error = '';
private ownerDomain: string | null = null;
// Optimistic default (true) so the invite form doesn't flash a "must be
// corporate" rejection during the async listBusinessSeats() round-trip —
// the server call is the authoritative gate at submit time regardless.
private ownerIsCorporateDomain = true;
private accountGeneration = 0;
// grantIds with a removeSeat mutation currently in flight — lets two
// DIFFERENT seats be removed concurrently while still preventing a
// double-click on the SAME seat's button from firing twice.
private readonly removingGrantIds = new Set<string>();
/** `overlay` is the settings modal's root element; content is rendered into `#usBusinessSeats` within it. */
constructor(private readonly overlay: HTMLElement) {}
resetForAccountChange(): void {
this.accountGeneration += 1;
this.seats = [];
this.loading = false;
this.error = '';
this.ownerDomain = null;
this.ownerIsCorporateDomain = true;
this.removingGrantIds.clear();
this.renderInPlace();
}
async load(): Promise<void> {
if (this.loading) return;
const generation = this.accountGeneration;
this.loading = true;
this.error = '';
try {
const result = await listBusinessSeats();
if (generation !== this.accountGeneration) return;
this.seats = result.seats;
this.ownerDomain = result.ownerDomain;
this.ownerIsCorporateDomain = result.ownerIsCorporateDomain;
} catch (err) {
if (generation !== this.accountGeneration) return;
this.error = err instanceof Error ? err.message : 'Failed to load seats';
} finally {
if (generation === this.accountGeneration) {
this.loading = false;
this.renderInPlace();
}
}
}
renderInPlace(): void {
const container = this.overlay.querySelector('#usBusinessSeats');
if (container) {
setTrustedHtml(container, trustedHtml(this.renderContent(), 'legacy direct innerHTML migration'));
}
}
renderContent(): string {
const sub = getSubscription();
const authUser = getAuthState().user;
const ownerEmail = authUser?.email ?? '';
// Server-computed corporate-domain check (matches the same isCorporateDomain()
// the invite/accept mutations enforce, via listBusinessSeats' response) is
// authoritative once loaded; the locally-parsed domain string is only a
// display fallback for the brief window before that response arrives.
const ownerDomain = this.ownerDomain ?? ownerEmail.split('@')[1]?.toLowerCase() ?? '';
const isBusinessOwner = sub?.planKey === 'api_business' && sub?.status === 'active';
const isCorporateDomain = this.ownerIsCorporateDomain;
if (!isBusinessOwner) return '';
const seats = this.seats;
const activeSeats = seats.filter((s) => s.status === 'accepted');
const pendingSeats = seats.filter((s) => s.status === 'pending');
const seatCount = activeSeats.length + pendingSeats.length;
const renderSeat = (seat: BusinessSeat) => {
const statusLabel = seat.status === 'accepted' ? 'Accepted' : seat.status === 'expired' ? 'Expired' : 'Pending';
const statusColor = seat.status === 'accepted' ? '#22c55e' : seat.status === 'expired' ? '#666' : '#eab308';
const expires = seat.status === 'pending'
? `<div style="font-size:11px;color:#666;">Expires ${new Date(seat.expiresAt).toLocaleDateString()}</div>`
: '';
return `
<div class="business-seat-item" style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid #1a1a1a;border-radius:6px;margin-bottom:8px;background:#0d0d0d;">
<div>
<div style="font-size:13px;color:#fff;">${escapeHtml(seat.inviteeEmail)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px;">
<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${statusColor};"></span>
<span style="font-size:11px;color:${statusColor};">${statusLabel}</span>
</div>
${expires}
</div>
<button class="btn btn-ghost business-seat-remove-btn" data-grant-id="${escapeHtml(seat.grantId)}" style="font-size:12px;" ${this.removingGrantIds.has(seat.grantId) ? 'disabled' : ''}>${this.removingGrantIds.has(seat.grantId) ? 'Removing...' : 'Remove'}</button>
</div>
`;
};
const inviteHint = isCorporateDomain
? `Invite teammates on your company domain (@${escapeHtml(ownerDomain)}).`
: 'Add a company email to invite teammates.';
return `
<div class="business-seats-section" style="margin-top:16px;padding:14px 16px;border:1px solid #1a1a1a;border-radius:6px;background:#0d0d0d;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<div style="font-size:13px;font-weight:600;color:#fff;">Business Seats</div>
<div style="font-size:11px;color:#888;">${seatCount} / 4 used</div>
</div>
<div style="font-size:12px;color:#999;margin-bottom:12px;">${escapeHtml(inviteHint)}</div>
${!isCorporateDomain ? `<div style="font-size:12px;color:#ef4444;margin-bottom:12px;">Free or disposable email domains cannot invite teammates. Add a company email to use this feature.</div>` : ''}
${isCorporateDomain ? `
<div class="business-seats-invite-form" style="display:flex;gap:8px;margin-bottom:12px;">
<input type="email" class="business-seats-email-input" placeholder="teammate@${escapeHtml(ownerDomain)}" style="flex:1;padding:8px 10px;background:#111;border:1px solid #1a1a1a;border-radius:4px;color:#fff;font-size:13px;" ${seatCount >= 4 ? 'disabled' : ''} />
<button class="btn btn-primary business-seats-invite-btn" ${seatCount >= 4 ? 'disabled' : ''}>Invite</button>
</div>
` : ''}
${this.error ? `<div style="font-size:12px;color:#ef4444;margin-bottom:12px;">${escapeHtml(this.error)}</div>` : ''}
<div id="usBusinessSeatsList">
${seats.length === 0 ? `<div style="font-size:12px;color:#666;padding:12px;text-align:center;">No seats invited yet.</div>` : seats.map(renderSeat).join('')}
</div>
</div>
`;
}
async handleInvite(): Promise<void> {
const generation = this.accountGeneration;
const input = this.overlay.querySelector<HTMLInputElement>('.business-seats-email-input');
const btn = this.overlay.querySelector<HTMLButtonElement>('.business-seats-invite-btn');
const email = input?.value.trim();
if (!email || !btn || btn.disabled) return;
btn.disabled = true;
btn.textContent = 'Inviting...';
this.error = '';
try {
const result = await inviteBusinessSeats([email]);
if (generation !== this.accountGeneration) return;
if (result.invited[0]?.status === 'created') {
showToast('Invite sent');
} else {
showToast('Already invited');
}
await this.load();
} catch (err) {
if (generation !== this.accountGeneration) return;
const msg = err instanceof Error ? err.message : 'Failed to send invite';
if (msg.includes('SEAT_CAP_REACHED')) {
this.error = 'All 4 seats are used. Remove a seat first.';
} else if (msg.includes('INVITEE_DOMAIN_MISMATCH')) {
this.error = 'Invitee must share your company email domain.';
} else if (msg.includes('OWNER_DOMAIN_NOT_CORPORATE')) {
this.error = 'Your account email must be a company domain to invite teammates.';
} else if (msg.includes('CANNOT_INVITE_SELF')) {
this.error = 'You cannot invite yourself.';
} else if (msg.includes('INVITEE_DOMAIN_NOT_CORPORATE')) {
this.error = 'The invitee email must be a company domain.';
} else {
this.error = msg;
}
// renderInPlace() below regenerates the invite form fresh (button
// re-enabled, input cleared) — no manual btn/input reset needed.
this.renderInPlace();
}
}
async handleRemove(grantId: string): Promise<void> {
const generation = this.accountGeneration;
if (this.removingGrantIds.has(grantId)) return;
if (!confirm('Remove this seat? The invitee will lose Pro access immediately.')) return;
this.removingGrantIds.add(grantId);
this.renderInPlace();
try {
const result = await removeBusinessSeat(grantId);
if (generation !== this.accountGeneration) return;
showToast(result.status === 'already_inactive' ? 'Seat was already inactive' : 'Seat removed');
await this.load();
} catch (err) {
if (generation !== this.accountGeneration) return;
this.error = err instanceof Error ? err.message : 'Failed to remove seat';
this.renderInPlace();
} finally {
if (generation === this.accountGeneration) {
this.removingGrantIds.delete(grantId);
this.renderInPlace();
}
}
}
}
|