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();
      }
    }
  }
}