@slyxup/ui 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useAuth, useUser } from '@slyxup/react';
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useCallback, useEffect, useRef, useState, } from 'react';
4
+ import { initPaddle, openPaddleCheckout } from '../../lib/paddle';
4
5
  import { injectStyles } from '../../styles';
5
6
  function initials(user) {
6
7
  const f = user.firstName?.trim();
@@ -27,20 +28,33 @@ function deviceLabel(ua) {
27
28
  ? 'macOS'
28
29
  : /Android/i.test(ua)
29
30
  ? 'Android'
30
- : /iPhone|iPad|iOS/i.test(ua)
31
+ : /iPhone|iPad|iPod/i.test(ua)
31
32
  ? 'iOS'
32
- : /Linux/i.test(ua)
33
- ? 'Linux'
34
- : 'Unknown OS';
33
+ : /CrOS/i.test(ua)
34
+ ? 'Chrome OS'
35
+ : /Linux/i.test(ua)
36
+ ? 'Linux'
37
+ : 'Unknown OS';
38
+ // Order matters: check Edge before Chrome (Edg/ appears in Chrome UA)
35
39
  const browser = /Edg\//i.test(ua)
36
40
  ? 'Edge'
37
- : /Chrome\//i.test(ua)
38
- ? 'Chrome'
39
- : /Safari\//i.test(ua)
40
- ? 'Safari'
41
- : /Firefox\//i.test(ua)
42
- ? 'Firefox'
43
- : 'Browser';
41
+ : /OPR|Opera/i.test(ua)
42
+ ? 'Opera'
43
+ : /Vivaldi/i.test(ua)
44
+ ? 'Vivaldi'
45
+ : /Brave/i.test(ua)
46
+ ? 'Brave'
47
+ : /Chrome\//i.test(ua) && !/CriOS/i.test(ua)
48
+ ? 'Chrome'
49
+ : /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
50
+ ? 'Safari'
51
+ : /Firefox\//i.test(ua) || /FxiOS/i.test(ua)
52
+ ? 'Firefox'
53
+ : /SamsungBrowser/i.test(ua)
54
+ ? 'Samsung Browser'
55
+ : /Mobile/i.test(ua) || /Android/i.test(ua)
56
+ ? 'Mobile Browser'
57
+ : 'Browser';
44
58
  return `${browser} · ${os}`;
45
59
  }
46
60
  function formatDate(iso) {
@@ -72,6 +86,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
72
86
  // ── Profile form state ──
73
87
  const [firstName, setFirstName] = useState('');
74
88
  const [lastName, setLastName] = useState('');
89
+ const [username, setUsername] = useState('');
75
90
  const [avatarUrl, setAvatarUrl] = useState('');
76
91
  const [busy, setBusy] = useState(false);
77
92
  const [saved, setSaved] = useState(false);
@@ -89,26 +104,61 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
89
104
  const [sessionsLoading, setSessionsLoading] = useState(true);
90
105
  const [revokingId, setRevokingId] = useState(null);
91
106
  const [othersRevoking, setOthersRevoking] = useState(false);
107
+ const [sessionsTotal, setSessionsTotal] = useState(0);
108
+ const [sessionsPage, setSessionsPage] = useState(0);
109
+ const SESSIONS_PER_PAGE = 10;
92
110
  // ── Billing state ──
93
111
  const [billingLoading, setBillingLoading] = useState(true);
94
112
  const [subscription, setSubscription] = useState(null);
95
113
  const [invoices, setInvoices] = useState([]);
96
114
  const [plans, setPlans] = useState([]);
97
115
  const [plansLoading, setPlansLoading] = useState(false);
116
+ // Project whose plans/subscription are shown — used to attribute checkout customData.
117
+ const [projectId, setProjectId] = useState(null);
98
118
  const [checkoutId, setCheckoutId] = useState(null);
119
+ const [checkoutDone, setCheckoutDone] = useState(false);
120
+ const [checkoutSuccess, setCheckoutSuccess] = useState(false);
121
+ // Keep the latest subscription available to the async polling loop (avoids
122
+ // stale-closure reads of state inside setTimeout callbacks).
123
+ const subscriptionRef = useRef(subscription);
124
+ useEffect(() => {
125
+ subscriptionRef.current = subscription;
126
+ }, [subscription]);
99
127
  // ── Danger zone state ──
100
128
  const [confirmText, setConfirmText] = useState('');
101
129
  const [deleteBusy, setDeleteBusy] = useState(false);
102
130
  const [deleteError, setDeleteError] = useState(null);
103
- // Sync form fields when user loads/updates (ensures firstName/lastName show correctly
104
- // after SlyxUpProvider fetches client.users.me()). Guard against stale overwrites.
131
+ // ── Two-factor (TOTP) state ──
132
+ const [tfaStage, setTfaStage] = useState('idle');
133
+ const [tfaSetup, setTfaSetup] = useState(null);
134
+ const [tfaCode, setTfaCode] = useState('');
135
+ const [tfaRecoveryCodes, setTfaRecoveryCodes] = useState([]);
136
+ const [tfaBusy, setTfaBusy] = useState(false);
137
+ const [tfaError, setTfaError] = useState(null);
138
+ const [tfaVerifyCode, setTfaVerifyCode] = useState('');
139
+ // ── Connected accounts state ──
140
+ const [accounts, setAccounts] = useState([]);
141
+ const [accountsLoading, setAccountsLoading] = useState(false);
142
+ const [accountsError, setAccountsError] = useState(null);
143
+ const [unlinkingId, setUnlinkingId] = useState(null);
144
+ // Sync form fields when user loads/updates. Use individual primitives as
145
+ // deps so the effect fires even when the user object reference stays the
146
+ // same (e.g. after a silent reload that returns identical data).
147
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — see comment above
105
148
  useEffect(() => {
106
149
  if (user) {
107
150
  setFirstName(user.firstName ?? '');
108
151
  setLastName(user.lastName ?? '');
152
+ setUsername(user.username ?? '');
109
153
  setAvatarUrl(user.avatarUrl ?? '');
110
154
  }
111
- }, [user]);
155
+ }, [
156
+ user?.firstName,
157
+ user?.lastName,
158
+ user?.username,
159
+ user?.avatarUrl,
160
+ user?.id,
161
+ ]);
112
162
  useEffect(() => {
113
163
  function onKey(e) {
114
164
  if (e.key === 'Escape' && modal)
@@ -117,14 +167,20 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
117
167
  document.addEventListener('keydown', onKey);
118
168
  return () => document.removeEventListener('keydown', onKey);
119
169
  }, [modal, onClose]);
120
- const loadSessions = useCallback(async () => {
170
+ const loadSessions = useCallback(async (page = 0) => {
121
171
  setSessionsLoading(true);
122
172
  try {
123
- const res = await client.sessions.list();
173
+ const res = await client.sessions.list({
174
+ limit: SESSIONS_PER_PAGE,
175
+ offset: page * SESSIONS_PER_PAGE,
176
+ });
124
177
  setSessions(res.sessions);
178
+ setSessionsTotal(res.total);
179
+ setSessionsPage(page);
125
180
  }
126
181
  catch {
127
182
  setSessions([]);
183
+ setSessionsTotal(0);
128
184
  }
129
185
  finally {
130
186
  setSessionsLoading(false);
@@ -136,9 +192,15 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
136
192
  try {
137
193
  const rawApiUrl = client.apiUrl ??
138
194
  'https://auth.slyxup.online';
139
- const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
140
- const token = client?._token ??
141
- client?.getToken?.();
195
+ const billingUrl = (() => {
196
+ // Localhost: swap port 8787 → 8788 (auth → billing)
197
+ if (/^https?:\/\/localhost(:\d+)?$/.test(rawApiUrl)) {
198
+ return rawApiUrl.replace(/:(\d+)$/, ':8788');
199
+ }
200
+ return rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
201
+ })();
202
+ const token = client.getToken?.() ??
203
+ client?._token;
142
204
  const headers = {
143
205
  'Content-Type': 'application/json',
144
206
  };
@@ -149,8 +211,10 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
149
211
  ?.publishableKey;
150
212
  if (pubKey && pubKey !== 'pk_test_missing')
151
213
  headers['X-Publishable-Key'] = pubKey;
152
- // Derive projectId for plans: prefer user.projectId, then try subscription's projectId, fallback none
214
+ // Derive projectId for plans: prefer user.projectId, then try to resolve from publishableKey's project (for examples)
215
+ // For the example apps, the publishableKey is for the example project, not the user's projectId
153
216
  const projectId = user?.projectId ?? null;
217
+ setProjectId(projectId);
154
218
  // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
155
219
  async function fetchJson(url) {
156
220
  const res = await fetch(url, { headers, credentials: 'include' });
@@ -231,14 +295,22 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
231
295
  // try next
232
296
  }
233
297
  }
234
- // Plans (needs projectId — if missing, try without and degrade gracefully)
298
+ // Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
299
+ let gotPlans = false;
235
300
  const planPaths = [];
236
- if (projectId)
301
+ if (projectId) {
237
302
  planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
238
- // Also try without projectId as last resort (will 400 but we catch)
239
- planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`);
240
- planPaths.push(`${billingUrl}/v1/plans`);
241
- let gotPlans = false;
303
+ }
304
+ else if (pubKey && pubKey !== 'pk_test_missing') {
305
+ // No projectId but have publishableKey — let billing resolve project via X-Publishable-Key header.
306
+ // Billing plans route returns [] in test/localhost when projectId is missing, which is fine.
307
+ planPaths.push(`${billingUrl}/v1/billing/plans`);
308
+ }
309
+ else {
310
+ // No projectId and no publishableKey — don't make the request, just show empty
311
+ setPlans([]);
312
+ gotPlans = true;
313
+ }
242
314
  for (const p of planPaths) {
243
315
  try {
244
316
  const pj = (await fetchJson(p));
@@ -266,16 +338,27 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
266
338
  }, [client, user]);
267
339
  useEffect(() => {
268
340
  if (tab === 'security')
269
- void loadSessions();
341
+ void loadSessions(0);
270
342
  if (tab === 'billing')
271
343
  void loadBilling();
272
344
  }, [tab, loadSessions, loadBilling]);
345
+ // React to a completed Paddle checkout: reload billing immediately and show
346
+ // a success confirmation (fallback for when the webhook is fast / polling lags).
347
+ useEffect(() => {
348
+ function onCheckoutCompleted() {
349
+ setCheckoutSuccess(true);
350
+ void loadBilling();
351
+ setTimeout(() => setCheckoutSuccess(false), 6000);
352
+ }
353
+ window.addEventListener('slyxup:checkout-completed', onCheckoutCompleted);
354
+ return () => window.removeEventListener('slyxup:checkout-completed', onCheckoutCompleted);
355
+ }, [loadBilling]);
273
356
  async function onProfileSubmit(e) {
274
357
  e.preventDefault();
275
358
  setBusy(true);
276
359
  setSaved(false);
277
360
  try {
278
- await client.users.update({ firstName, lastName, avatarUrl });
361
+ await client.users.update({ firstName, lastName, username, avatarUrl });
279
362
  await reload();
280
363
  setSaved(true);
281
364
  setTimeout(() => setSaved(false), 2500);
@@ -297,6 +380,95 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
297
380
  setResending(false);
298
381
  }
299
382
  }
383
+ const loadAccounts = useCallback(async () => {
384
+ setAccountsLoading(true);
385
+ setAccountsError(null);
386
+ try {
387
+ const res = await client.accounts.list();
388
+ setAccounts(res.accounts);
389
+ }
390
+ catch {
391
+ setAccounts([]);
392
+ setAccountsError('Could not load connected accounts.');
393
+ }
394
+ finally {
395
+ setAccountsLoading(false);
396
+ }
397
+ }, [client]);
398
+ useEffect(() => {
399
+ if (tab === 'security')
400
+ void loadAccounts();
401
+ }, [tab, loadAccounts]);
402
+ async function startTfaSetup() {
403
+ setTfaError(null);
404
+ setTfaBusy(true);
405
+ try {
406
+ const res = await client.twoFactor.setup();
407
+ setTfaSetup(res);
408
+ setTfaStage('setup');
409
+ setTfaCode('');
410
+ }
411
+ catch (err) {
412
+ setTfaError(err instanceof Error ? err.message : 'Failed to start setup');
413
+ }
414
+ finally {
415
+ setTfaBusy(false);
416
+ }
417
+ }
418
+ async function submitTfa(e) {
419
+ e.preventDefault();
420
+ if (!tfaSetup)
421
+ return;
422
+ setTfaError(null);
423
+ setTfaBusy(true);
424
+ try {
425
+ const res = await client.twoFactor.enable(tfaSetup.secret, tfaCode.trim());
426
+ setTfaRecoveryCodes(res.recoveryCodes);
427
+ setTfaStage('codes');
428
+ await reload();
429
+ }
430
+ catch (err) {
431
+ setTfaError(err instanceof Error ? err.message : 'Invalid code — try again.');
432
+ }
433
+ finally {
434
+ setTfaBusy(false);
435
+ }
436
+ }
437
+ async function submitTfaDisable(e) {
438
+ e.preventDefault();
439
+ setTfaError(null);
440
+ setTfaBusy(true);
441
+ try {
442
+ await client.twoFactor.disable(tfaVerifyCode.trim());
443
+ setTfaVerifyCode('');
444
+ setTfaStage('idle');
445
+ setTfaSetup(null);
446
+ setTfaRecoveryCodes([]);
447
+ await reload();
448
+ }
449
+ catch (err) {
450
+ setTfaError(err instanceof Error
451
+ ? err.message
452
+ : 'Invalid code — could not disable 2FA.');
453
+ }
454
+ finally {
455
+ setTfaBusy(false);
456
+ }
457
+ }
458
+ async function onUnlink(accountId, provider) {
459
+ setUnlinkingId(accountId);
460
+ setAccountsError(null);
461
+ try {
462
+ await client.accounts.unlink(accountId, provider);
463
+ setAccounts((prev) => prev.filter((a) => a.id !== accountId));
464
+ }
465
+ catch (err) {
466
+ setAccountsError(err instanceof Error ? err.message : 'Could not unlink account.');
467
+ }
468
+ finally {
469
+ setUnlinkingId(null);
470
+ }
471
+ }
300
472
  async function onPasswordSubmit(e) {
301
473
  e.preventDefault();
302
474
  setPwError(null);
@@ -329,7 +501,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
329
501
  setRevokingId(id);
330
502
  try {
331
503
  await client.sessions.revoke(id);
332
- await loadSessions();
504
+ await loadSessions(sessionsPage);
333
505
  }
334
506
  finally {
335
507
  setRevokingId(null);
@@ -339,7 +511,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
339
511
  setOthersRevoking(true);
340
512
  try {
341
513
  await client.sessions.revokeOthers();
342
- await loadSessions();
514
+ await loadSessions(0);
343
515
  }
344
516
  finally {
345
517
  setOthersRevoking(false);
@@ -360,41 +532,51 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
360
532
  setDeleteBusy(false);
361
533
  }
362
534
  }
363
- async function handleCheckout(planId) {
364
- setCheckoutId(planId);
535
+ async function handleCheckout(plan) {
536
+ setCheckoutId(plan.id);
537
+ setCheckoutDone(false);
365
538
  try {
539
+ // Always use Paddle.js overlay checkout
540
+ // authApiUrl is available via client apiUrl
366
541
  const rawApiUrl = client.apiUrl ??
367
542
  'https://auth.slyxup.online';
368
- const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
369
- const token = client?._token ??
370
- client?.getToken?.();
371
- const headers = {
372
- 'Content-Type': 'application/json',
543
+ await initPaddle(rawApiUrl);
544
+ // Pass custom data so the billing webhook can attribute the created
545
+ // subscription to this user + project + plan (Paddle copies custom_data
546
+ // from the transaction to the subscription for recurring items).
547
+ const customData = {
548
+ userId: user?.id ?? '',
549
+ planId: plan.id,
373
550
  };
374
- if (token)
375
- headers.Authorization = `Bearer ${token}`;
376
- const pubKey = client
377
- ?.publishableKey;
378
- if (pubKey && pubKey !== 'pk_test_missing')
379
- headers['X-Publishable-Key'] = pubKey;
380
- const res = await fetch(`${billingUrl}/v1/billing/checkout`, {
381
- method: 'POST',
382
- headers,
383
- credentials: 'include',
384
- body: JSON.stringify({ planId }),
385
- });
386
- const data = (await res.json().catch(() => ({})));
387
- if (res.ok && typeof data.checkoutUrl === 'string' && data.checkoutUrl) {
388
- window.location.href = data.checkoutUrl;
389
- return;
390
- }
391
- if (!res.ok)
392
- throw new Error(typeof data.error === 'string'
393
- ? data.error
394
- : `Checkout failed (${res.status})`);
551
+ if (projectId)
552
+ customData.projectId = projectId;
553
+ openPaddleCheckout(plan.paddlePriceId, user?.email, customData);
554
+ setCheckoutDone(true);
555
+ // Poll for subscription updates after checkout (webhook may take a few seconds)
556
+ let attempts = 0;
557
+ const maxAttempts = 10;
558
+ const pollInterval = 3000; // 3 seconds
559
+ const poll = async () => {
560
+ attempts++;
561
+ try {
562
+ await loadBilling();
563
+ if (subscriptionRef.current && attempts < maxAttempts) {
564
+ // Found subscription, stop polling
565
+ return;
566
+ }
567
+ }
568
+ catch {
569
+ // Ignore polling errors
570
+ }
571
+ if (attempts < maxAttempts) {
572
+ setTimeout(poll, pollInterval);
573
+ }
574
+ };
575
+ // Start polling after a short delay to allow webhook to process
576
+ setTimeout(poll, 2000);
577
+ return;
395
578
  }
396
579
  catch (err) {
397
- // Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
398
580
  console.error('[SlyxUp] checkout failed', err);
399
581
  }
400
582
  finally {
@@ -415,7 +597,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
415
597
  const bodyInner = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "slx-profile-head", children: [_jsx("h2", { className: "slx-profile-title", children: "Account settings" }), modal && (_jsx("button", { type: "button", className: "slx-profile-close", onClick: (e) => {
416
598
  e.stopPropagation();
417
599
  onClose?.();
418
- }, "aria-label": "Close account settings", children: "\u2715" }))] }), _jsxs("div", { className: "slx-profile-body", children: [_jsxs("nav", { className: "slx-profile-nav", "aria-label": "Settings sections", children: [_jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'profile' ? ' on' : ''}`, onClick: () => setTab('profile'), "aria-current": tab === 'profile' ? 'page' : undefined, children: [_jsx(ProfileIcon, {}), " Profile"] }), _jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'security' ? ' on' : ''}`, onClick: () => setTab('security'), "aria-current": tab === 'security' ? 'page' : undefined, children: [_jsx(ShieldIcon, {}), " Security"] }), _jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`, onClick: () => setTab('billing'), "aria-current": tab === 'billing' ? 'page' : undefined, children: [_jsx(CreditCardIcon, {}), " Billing"] })] }), _jsxs("div", { className: "slx-profile-content", children: [tab === 'profile' && (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsxs("div", { className: "slx-avatar-row", children: [_jsx("div", { className: "slx-avatar-lg", "aria-hidden": "true", children: user.avatarUrl ? (_jsx("img", { src: user.avatarUrl, alt: "" })) : (initials(user)) }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-row-value", style: { margin: 0, wordBreak: 'break-word' }, children: name }), _jsx("p", { className: "slx-row-label", style: { wordBreak: 'break-all' }, children: user.email })] })] }), saved && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)' }, children: "Profile saved." })), _jsxs("form", { onSubmit: onProfileSubmit, children: [_jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-first", children: "First name" }), _jsx("input", { id: "slx-up-first", className: "slx-input", type: "text", autoComplete: "given-name", value: firstName, onChange: (e) => setFirstName(e.target.value) })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-last", children: "Last name" }), _jsx("input", { id: "slx-up-last", className: "slx-input", type: "text", autoComplete: "family-name", value: lastName, onChange: (e) => setLastName(e.target.value) })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-avatar", children: "Avatar URL" }), _jsx("input", { id: "slx-up-avatar", className: "slx-input", type: "url", placeholder: "https://\u2026", value: avatarUrl, onChange: (e) => setAvatarUrl(e.target.value) }), _jsx("p", { className: "slx-hint", children: "Paste a public image URL for your avatar." })] }), _jsx("button", { className: "slx-btn", type: "submit", disabled: busy, children: busy ? 'Saving…' : 'Save changes' })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Email" }), _jsxs("div", { className: "slx-row", style: { flexWrap: 'wrap', gap: 8 }, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-row-value", style: { wordBreak: 'break-all' }, children: user.email }), _jsx("p", { className: "slx-row-label", children: "Primary email" })] }), emailVerified ? (_jsx("span", { className: "slx-badge slx-badge-ok", children: "Verified" })) : (_jsxs("span", { style: {
600
+ }, "aria-label": "Close account settings", children: "\u2715" }))] }), _jsxs("div", { className: "slx-profile-body", children: [_jsxs("nav", { className: "slx-profile-nav", "aria-label": "Settings sections", children: [_jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'profile' ? ' on' : ''}`, onClick: () => setTab('profile'), "aria-current": tab === 'profile' ? 'page' : undefined, children: [_jsx(ProfileIcon, {}), " Profile"] }), _jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'security' ? ' on' : ''}`, onClick: () => setTab('security'), "aria-current": tab === 'security' ? 'page' : undefined, children: [_jsx(ShieldIcon, {}), " Security"] }), _jsxs("button", { type: "button", className: `slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`, onClick: () => setTab('billing'), "aria-current": tab === 'billing' ? 'page' : undefined, children: [_jsx(CreditCardIcon, {}), " Billing"] })] }), _jsxs("div", { className: "slx-profile-content", children: [tab === 'profile' && (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsxs("div", { className: "slx-avatar-row", children: [_jsx("div", { className: "slx-avatar-lg", "aria-hidden": "true", children: user.avatarUrl ? (_jsx("img", { src: user.avatarUrl, alt: "" })) : (initials(user)) }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-row-value", style: { margin: 0, wordBreak: 'break-word' }, children: name }), _jsx("p", { className: "slx-row-label", style: { wordBreak: 'break-all' }, children: user.email })] })] }), saved && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)' }, children: "Profile saved." })), _jsxs("form", { onSubmit: onProfileSubmit, children: [_jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-first", children: "First name" }), _jsx("input", { id: "slx-up-first", className: "slx-input", type: "text", autoComplete: "given-name", value: firstName, onChange: (e) => setFirstName(e.target.value) })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-last", children: "Last name" }), _jsx("input", { id: "slx-up-last", className: "slx-input", type: "text", autoComplete: "family-name", value: lastName, onChange: (e) => setLastName(e.target.value) })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-avatar", children: "Avatar URL" }), _jsx("input", { id: "slx-up-avatar", className: "slx-input", type: "url", placeholder: "https://\u2026", value: avatarUrl, onChange: (e) => setAvatarUrl(e.target.value) }), _jsx("p", { className: "slx-hint", children: "Paste a public image URL for your avatar." })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-up-username", children: "Username" }), _jsx("input", { id: "slx-up-username", className: "slx-input", type: "text", autoComplete: "username", placeholder: "yourname", value: username, onChange: (e) => setUsername(e.target.value) }), _jsx("p", { className: "slx-hint", children: "Used for password sign-in as an alternative to email. Must be unique within your project." })] }), _jsx("button", { className: "slx-btn", type: "submit", disabled: busy, children: busy ? 'Saving…' : 'Save changes' })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Email" }), _jsxs("div", { className: "slx-row", style: { flexWrap: 'wrap', gap: 8 }, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-row-value", style: { wordBreak: 'break-all' }, children: user.email }), _jsx("p", { className: "slx-row-label", children: "Primary email" })] }), emailVerified ? (_jsx("span", { className: "slx-badge slx-badge-ok", children: "Verified" })) : (_jsxs("span", { style: {
419
601
  display: 'inline-flex',
420
602
  alignItems: 'center',
421
603
  gap: 8,
@@ -428,14 +610,42 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
428
610
  .filter(Boolean)
429
611
  .join(' · ') })] }), !s.isCurrent && (_jsx("button", { type: "button", className: "slx-btn-danger-outline", onClick: () => onRevoke(s.id), disabled: revokingId === s.id, children: revokingId === s.id ? '…' : 'Revoke' }))] }, s.id))), sessions.some((s) => !s.isCurrent) && (_jsx("button", { type: "button", className: "slx-btn-danger-outline", style: { width: '100%', marginTop: 4 }, onClick: onRevokeOthers, disabled: othersRevoking, children: othersRevoking
430
612
  ? 'Signing out…'
431
- : 'Sign out other devices' }))] }))] }), _jsxs("section", { className: "slx-danger-zone", children: [_jsx("p", { className: "slx-danger-title", children: "Danger zone" }), _jsx("p", { className: "slx-danger-desc", children: "Permanently deletes your account and all associated data. Active sessions are revoked immediately. This cannot be undone." }), deleteError && _jsx("p", { className: "slx-error-text", children: deleteError }), _jsxs("div", { className: "slx-field", children: [_jsxs("label", { className: "slx-label", htmlFor: "slx-del-confirm", children: ["Type ", _jsx("strong", { children: "DELETE" }), " to confirm"] }), _jsx("input", { id: "slx-del-confirm", className: "slx-input", type: "text", value: confirmText, onChange: (e) => setConfirmText(e.target.value), placeholder: "DELETE" })] }), _jsx("button", { type: "button", className: "slx-btn", style: {
613
+ : 'Sign out other devices' })), sessionsTotal > SESSIONS_PER_PAGE && (_jsxs("div", { className: "slx-pagination", children: [_jsx("button", { type: "button", className: "slx-btn-secondary", disabled: sessionsPage === 0 || sessionsLoading, onClick: () => void loadSessions(sessionsPage - 1), children: "Previous" }), _jsxs("span", { className: "slx-pagination-info", children: [sessionsPage * SESSIONS_PER_PAGE + 1, "\u2013", Math.min((sessionsPage + 1) * SESSIONS_PER_PAGE, sessionsTotal), ' ', "of ", sessionsTotal] }), _jsx("button", { type: "button", className: "slx-btn-secondary", disabled: (sessionsPage + 1) * SESSIONS_PER_PAGE >=
614
+ sessionsTotal || sessionsLoading, onClick: () => void loadSessions(sessionsPage + 1), children: "Next" })] }))] }))] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Two-factor authentication" }), tfaError && _jsx("p", { className: "slx-error-text", children: tfaError }), !user.twoFactorEnabled && tfaStage === 'idle' && (_jsxs(_Fragment, { children: [_jsx("p", { className: "slx-hint", children: "Add an authenticator app (Google Authenticator, Authy, 1Password, etc.) to protect your account with a time-based one-time password." }), _jsx("button", { type: "button", className: "slx-btn", onClick: () => void startTfaSetup(), disabled: tfaBusy, children: tfaBusy ? 'Starting…' : 'Set up 2FA' })] })), !user.twoFactorEnabled && tfaStage === 'setup' && (_jsxs("form", { onSubmit: submitTfa, children: [tfaSetup && (_jsxs(_Fragment, { children: [_jsx("p", { className: "slx-hint", children: "Scan this QR code with your authenticator app:" }), _jsxs("div", { style: {
615
+ display: 'flex',
616
+ alignItems: 'center',
617
+ gap: 14,
618
+ margin: '8px 0',
619
+ flexWrap: 'wrap',
620
+ }, children: [_jsx("img", { src: `https://api.qrserver.com/v1/create-qr-code/?size=132x132&data=${encodeURIComponent(tfaSetup.provisioningUri)}`, alt: "QR code to scan with your authenticator app", width: 132, height: 132, style: { borderRadius: 6 } }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-row-label", children: "Or enter this code manually:" }), _jsx("p", { className: "slx-row-value", style: {
621
+ userSelect: 'all',
622
+ letterSpacing: 2,
623
+ fontFamily: 'monospace',
624
+ }, children: tfaSetup.secret.replace(/(.{4})/g, '$1 ').trim() }), _jsxs("p", { className: "slx-hint", children: ["Account: ", tfaSetup.accountName] })] })] })] })), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-tfa-code", children: "Enter the 6-digit code" }), _jsx("input", { id: "slx-tfa-code", className: "slx-input", type: "text", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, pattern: "[0-9]*", placeholder: "000000", value: tfaCode, onChange: (e) => setTfaCode(e.target.value.replace(/\D/g, '')), required: true })] }), _jsx("button", { className: "slx-btn", type: "submit", disabled: tfaBusy, children: tfaBusy ? 'Verifying…' : 'Enable 2FA' })] })), !user.twoFactorEnabled && tfaStage === 'codes' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "slx-billing-card", style: { margin: '4px 0 8px' }, children: [_jsx("p", { className: "slx-billing-plan", children: "2FA enabled \u2014 save recovery codes" }), _jsx("p", { className: "slx-billing-detail", children: "Each code can be used once to sign in if you lose access to your authenticator. Store them somewhere safe." }), _jsx("ul", { style: {
625
+ display: 'grid',
626
+ gridTemplateColumns: 'repeat(2, auto)',
627
+ gap: '4px 24px',
628
+ justifyContent: 'start',
629
+ margin: '10px 0',
630
+ paddingLeft: 0,
631
+ listStyle: 'none',
632
+ fontFamily: 'monospace',
633
+ }, children: tfaRecoveryCodes.map((c) => (_jsx("li", { children: c }, c))) }), _jsx("button", { type: "button", className: "slx-btn-secondary", onClick: () => void navigator.clipboard
634
+ ?.writeText(tfaRecoveryCodes.join('\n'))
635
+ .catch(() => undefined), children: "Copy codes" })] }), _jsx("button", { type: "button", className: "slx-btn-secondary", onClick: () => void reload(), children: "Done" })] })), user.twoFactorEnabled && (_jsxs(_Fragment, { children: [_jsx("p", { className: "slx-hint", children: "2FA is enabled for this account." }), _jsxs("form", { onSubmit: submitTfaDisable, style: {
636
+ display: 'flex',
637
+ gap: 10,
638
+ alignItems: 'flex-end',
639
+ flexWrap: 'wrap',
640
+ marginTop: 8,
641
+ }, children: [_jsxs("div", { className: "slx-field", style: { flex: '1 1 160px' }, children: [_jsx("label", { className: "slx-label", htmlFor: "slx-tfa-disable", children: "Authenticator code" }), _jsx("input", { id: "slx-tfa-disable", className: "slx-input", type: "text", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, pattern: "[0-9]*", placeholder: "000000", value: tfaVerifyCode, onChange: (e) => setTfaVerifyCode(e.target.value.replace(/\D/g, '')), required: true })] }), _jsx("button", { type: "submit", className: "slx-btn-danger-outline", disabled: tfaBusy, children: tfaBusy ? 'Disabling…' : 'Disable 2FA' })] })] }))] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Connected accounts" }), accountsError && (_jsx("p", { className: "slx-error-text", children: accountsError })), accountsLoading ? (_jsx("p", { className: "slx-hint", children: "Loading\u2026" })) : accounts.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "slx-hint", children: "No social accounts connected. You can sign in with Google or GitHub and link them here later." }), _jsx("button", { type: "button", className: "slx-btn-secondary", onClick: () => void loadAccounts(), children: "Refresh" })] })) : (_jsx(_Fragment, { children: _jsx("ul", { style: { listStyle: 'none', margin: 0, padding: 0 }, children: accounts.map((acc) => (_jsxs("li", { className: "slx-session", style: { alignItems: 'center' }, children: [_jsxs("div", { className: "slx-session-meta", children: [_jsx("p", { className: "slx-session-device", children: acc.provider === 'google' ? 'Google' : 'GitHub' }), _jsxs("p", { className: "slx-session-sub", children: ["Connected ", formatDate(acc.createdAt)] })] }), _jsx("button", { type: "button", className: "slx-btn-danger-outline", onClick: () => void onUnlink(acc.id, acc.provider), disabled: unlinkingId === acc.id, children: unlinkingId === acc.id ? '…' : 'Unlink' })] }, acc.id))) }) }))] }), _jsxs("section", { className: "slx-danger-zone", children: [_jsx("p", { className: "slx-danger-title", children: "Danger zone" }), _jsx("p", { className: "slx-danger-desc", children: "Permanently deletes your account and all associated data. Active sessions are revoked immediately. This cannot be undone." }), deleteError && _jsx("p", { className: "slx-error-text", children: deleteError }), _jsxs("div", { className: "slx-field", children: [_jsxs("label", { className: "slx-label", htmlFor: "slx-del-confirm", children: ["Type ", _jsx("strong", { children: "DELETE" }), " to confirm"] }), _jsx("input", { id: "slx-del-confirm", className: "slx-input", type: "text", value: confirmText, onChange: (e) => setConfirmText(e.target.value), placeholder: "DELETE" })] }), _jsx("button", { type: "button", className: "slx-btn", style: {
432
642
  background: 'var(--slx-danger)',
433
643
  borderColor: 'var(--slx-danger)',
434
644
  }, onClick: onDeleteAccount, disabled: confirmText !== 'DELETE' || deleteBusy, children: deleteBusy ? 'Deleting…' : 'Delete my account forever' })] })] })), tab === 'billing' &&
435
- (billingLoading ? (_jsx("section", { className: "slx-profile-sec", children: _jsx("p", { className: "slx-hint", children: "Loading billing information\u2026" }) })) : !subscription ? (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Subscription" }), _jsxs("div", { className: "slx-billing-card", children: [_jsx("p", { className: "slx-billing-plan", children: "No active subscription" }), _jsx("p", { className: "slx-billing-detail", children: "You don't have a subscription yet. Choose a plan to get started." })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Available plans" }), plansLoading ? (_jsx("p", { className: "slx-hint", children: "Loading plans\u2026" })) : plans.length === 0 ? (_jsxs("div", { className: "slx-billing-card", style: { textAlign: 'center' }, children: [_jsx("p", { className: "slx-billing-detail", children: "No plans configured for this project yet." }), _jsx("p", { className: "slx-hint", style: { marginTop: 6 }, children: "Ask your admin to create a plan in billing." })] })) : (_jsx("div", { className: "slx-billing-plans", children: plans.map((plan) => (_jsxs("div", { className: `slx-plan-card${plan.isPopular ? ' popular' : ''}`, children: [plan.isPopular && (_jsx("span", { className: "slx-plan-badge", children: "POPULAR" })), _jsx("p", { className: "slx-plan-name", children: plan.name }), _jsxs("p", { style: { margin: '2px 0 0' }, children: [_jsx("span", { className: "slx-plan-price", children: formatCurrency(plan.amount, plan.currency) }), _jsxs("span", { className: "slx-plan-interval", children: ["/", plan.interval] })] }), plan.trialDays ? (_jsxs("p", { className: "slx-billing-detail", style: {
645
+ (billingLoading ? (_jsx("section", { className: "slx-profile-sec", children: _jsx("p", { className: "slx-hint", children: "Loading billing information\u2026" }) })) : !subscription ? (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Subscription" }), checkoutDone && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)', marginBottom: 8 }, children: "Checkout opened \u2014 complete your payment in the overlay." })), checkoutSuccess && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)', marginBottom: 8 }, children: "Payment successful \u2014 your subscription is being set up." })), _jsxs("div", { className: "slx-billing-card", children: [_jsx("p", { className: "slx-billing-plan", children: "No active subscription" }), _jsx("p", { className: "slx-billing-detail", children: "You don't have a subscription yet. Choose a plan to get started." })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Available plans" }), plansLoading ? (_jsx("p", { className: "slx-hint", children: "Loading plans\u2026" })) : plans.length === 0 ? (_jsxs("div", { className: "slx-billing-card", style: { textAlign: 'center' }, children: [_jsx("p", { className: "slx-billing-detail", children: "No plans configured for this project yet." }), _jsx("p", { className: "slx-hint", style: { marginTop: 6 }, children: "Ask your admin to create a plan in billing." })] })) : (_jsx("div", { className: "slx-billing-plans", children: plans.map((plan) => (_jsxs("div", { className: `slx-plan-card${plan.isPopular ? ' popular' : ''}`, children: [plan.isPopular && (_jsx("span", { className: "slx-plan-badge", children: "POPULAR" })), _jsx("p", { className: "slx-plan-name", children: plan.name }), _jsxs("p", { style: { margin: '2px 0 0' }, children: [_jsx("span", { className: "slx-plan-price", children: formatCurrency(plan.amount, plan.currency) }), _jsxs("span", { className: "slx-plan-interval", children: ["/", plan.interval] })] }), plan.trialDays ? (_jsxs("p", { className: "slx-billing-detail", style: {
436
646
  color: 'var(--slx-accent)',
437
647
  marginTop: 4,
438
- }, children: [plan.trialDays, " day free trial"] })) : (_jsx("p", { className: "slx-billing-detail", style: { visibility: 'hidden', marginTop: 4 }, children: "\u00A0" })), _jsx("ul", { className: "slx-plan-features", children: (plan.features ?? []).map((f) => (_jsx("li", { children: f }, f))) }), _jsx("button", { type: "button", className: "slx-btn slx-plan-cta", onClick: () => handleCheckout(plan.id), disabled: checkoutId === plan.id, children: checkoutId === plan.id
648
+ }, children: [plan.trialDays, " day free trial"] })) : (_jsx("p", { className: "slx-billing-detail", style: { visibility: 'hidden', marginTop: 4 }, children: "\u00A0" })), _jsx("ul", { className: "slx-plan-features", children: (plan.features ?? []).map((f) => (_jsx("li", { children: f }, f))) }), _jsx("button", { type: "button", className: "slx-btn slx-plan-cta", onClick: () => handleCheckout(plan), disabled: checkoutId === plan.id, children: checkoutId === plan.id
439
649
  ? 'Redirecting…'
440
650
  : 'Choose plan' })] }, plan.id))) }))] }), invoices.length > 0 && (_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Invoices" }), invoices.map((inv) => (_jsxs("div", { className: "slx-invoice-row", children: [_jsx("span", { className: "slx-invoice-date", children: inv.billedAt ? formatDate(inv.billedAt) : '—' }), _jsx("span", { className: "slx-invoice-amount", children: formatCurrency(inv.amount, inv.currency) }), _jsx("span", { className: `slx-badge ${inv.status === 'paid'
441
651
  ? 'slx-badge-ok'
@@ -465,7 +675,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
465
675
  marginTop: 4,
466
676
  }, children: [plan.trialDays, " day trial"] })) : (_jsx("p", { className: "slx-billing-detail", style: { visibility: 'hidden', marginTop: 4 }, children: "\u00A0" })), _jsx("ul", { className: "slx-plan-features", children: (plan.features ?? []).map((f) => (_jsx("li", { children: f }, f))) }), _jsx("button", { type: "button", className: isCurrent
467
677
  ? 'slx-btn-secondary slx-plan-cta'
468
- : 'slx-btn slx-plan-cta', disabled: isCurrent || checkoutId === plan.id, onClick: () => handleCheckout(plan.id), children: isCurrent
678
+ : 'slx-btn slx-plan-cta', disabled: isCurrent || checkoutId === plan.id, onClick: () => handleCheckout(plan), children: isCurrent
469
679
  ? 'Current plan'
470
680
  : checkoutId === plan.id
471
681
  ? 'Redirecting…'