@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,13 @@
1
1
  import type { SlyxupSessionInfo } from '@slyxup/core';
2
2
  import { useAuth, useUser } from '@slyxup/react';
3
- import { type FormEvent, useCallback, useEffect, useState } from 'react';
3
+ import {
4
+ type FormEvent,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { initPaddle, openPaddleCheckout } from '../../lib/paddle';
4
11
  import { injectStyles } from '../../styles';
5
12
 
6
13
  export interface UserProfileProps {
@@ -44,20 +51,33 @@ function deviceLabel(ua: string | null): string {
44
51
  ? 'macOS'
45
52
  : /Android/i.test(ua)
46
53
  ? 'Android'
47
- : /iPhone|iPad|iOS/i.test(ua)
54
+ : /iPhone|iPad|iPod/i.test(ua)
48
55
  ? 'iOS'
49
- : /Linux/i.test(ua)
50
- ? 'Linux'
51
- : 'Unknown OS';
56
+ : /CrOS/i.test(ua)
57
+ ? 'Chrome OS'
58
+ : /Linux/i.test(ua)
59
+ ? 'Linux'
60
+ : 'Unknown OS';
61
+ // Order matters: check Edge before Chrome (Edg/ appears in Chrome UA)
52
62
  const browser = /Edg\//i.test(ua)
53
63
  ? 'Edge'
54
- : /Chrome\//i.test(ua)
55
- ? 'Chrome'
56
- : /Safari\//i.test(ua)
57
- ? 'Safari'
58
- : /Firefox\//i.test(ua)
59
- ? 'Firefox'
60
- : 'Browser';
64
+ : /OPR|Opera/i.test(ua)
65
+ ? 'Opera'
66
+ : /Vivaldi/i.test(ua)
67
+ ? 'Vivaldi'
68
+ : /Brave/i.test(ua)
69
+ ? 'Brave'
70
+ : /Chrome\//i.test(ua) && !/CriOS/i.test(ua)
71
+ ? 'Chrome'
72
+ : /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
73
+ ? 'Safari'
74
+ : /Firefox\//i.test(ua) || /FxiOS/i.test(ua)
75
+ ? 'Firefox'
76
+ : /SamsungBrowser/i.test(ua)
77
+ ? 'Samsung Browser'
78
+ : /Mobile/i.test(ua) || /Android/i.test(ua)
79
+ ? 'Mobile Browser'
80
+ : 'Browser';
61
81
  return `${browser} · ${os}`;
62
82
  }
63
83
 
@@ -86,6 +106,7 @@ function formatCurrency(amount: number, currency: string): string {
86
106
  interface Plan {
87
107
  id: string;
88
108
  name: string;
109
+ paddlePriceId: string;
89
110
  amount: number;
90
111
  currency: string;
91
112
  interval: string;
@@ -108,6 +129,7 @@ export function UserProfile({
108
129
  // ── Profile form state ──
109
130
  const [firstName, setFirstName] = useState('');
110
131
  const [lastName, setLastName] = useState('');
132
+ const [username, setUsername] = useState('');
111
133
  const [avatarUrl, setAvatarUrl] = useState('');
112
134
  const [busy, setBusy] = useState(false);
113
135
  const [saved, setSaved] = useState(false);
@@ -127,6 +149,9 @@ export function UserProfile({
127
149
  const [sessionsLoading, setSessionsLoading] = useState(true);
128
150
  const [revokingId, setRevokingId] = useState<string | null>(null);
129
151
  const [othersRevoking, setOthersRevoking] = useState(false);
152
+ const [sessionsTotal, setSessionsTotal] = useState(0);
153
+ const [sessionsPage, setSessionsPage] = useState(0);
154
+ const SESSIONS_PER_PAGE = 10;
130
155
 
131
156
  // ── Billing state ──
132
157
  const [billingLoading, setBillingLoading] = useState(true);
@@ -149,22 +174,62 @@ export function UserProfile({
149
174
  >([]);
150
175
  const [plans, setPlans] = useState<Plan[]>([]);
151
176
  const [plansLoading, setPlansLoading] = useState(false);
177
+ // Project whose plans/subscription are shown — used to attribute checkout customData.
178
+ const [projectId, setProjectId] = useState<string | null>(null);
152
179
  const [checkoutId, setCheckoutId] = useState<string | null>(null);
180
+ const [checkoutDone, setCheckoutDone] = useState(false);
181
+ const [checkoutSuccess, setCheckoutSuccess] = useState(false);
182
+ // Keep the latest subscription available to the async polling loop (avoids
183
+ // stale-closure reads of state inside setTimeout callbacks).
184
+ const subscriptionRef = useRef(subscription);
185
+ useEffect(() => {
186
+ subscriptionRef.current = subscription;
187
+ }, [subscription]);
153
188
 
154
189
  // ── Danger zone state ──
155
190
  const [confirmText, setConfirmText] = useState('');
156
191
  const [deleteBusy, setDeleteBusy] = useState(false);
157
192
  const [deleteError, setDeleteError] = useState<string | null>(null);
158
193
 
159
- // Sync form fields when user loads/updates (ensures firstName/lastName show correctly
160
- // after SlyxUpProvider fetches client.users.me()). Guard against stale overwrites.
194
+ // ── Two-factor (TOTP) state ──
195
+ const [tfaStage, setTfaStage] = useState<'idle' | 'setup' | 'codes'>('idle');
196
+ const [tfaSetup, setTfaSetup] = useState<{
197
+ secret: string;
198
+ provisioningUri: string;
199
+ accountName: string;
200
+ } | null>(null);
201
+ const [tfaCode, setTfaCode] = useState('');
202
+ const [tfaRecoveryCodes, setTfaRecoveryCodes] = useState<string[]>([]);
203
+ const [tfaBusy, setTfaBusy] = useState(false);
204
+ const [tfaError, setTfaError] = useState<string | null>(null);
205
+ const [tfaVerifyCode, setTfaVerifyCode] = useState('');
206
+
207
+ // ── Connected accounts state ──
208
+ const [accounts, setAccounts] = useState<
209
+ { id: string; provider: 'google' | 'github'; createdAt: string }[]
210
+ >([]);
211
+ const [accountsLoading, setAccountsLoading] = useState(false);
212
+ const [accountsError, setAccountsError] = useState<string | null>(null);
213
+ const [unlinkingId, setUnlinkingId] = useState<string | null>(null);
214
+
215
+ // Sync form fields when user loads/updates. Use individual primitives as
216
+ // deps so the effect fires even when the user object reference stays the
217
+ // same (e.g. after a silent reload that returns identical data).
218
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — see comment above
161
219
  useEffect(() => {
162
220
  if (user) {
163
221
  setFirstName(user.firstName ?? '');
164
222
  setLastName(user.lastName ?? '');
223
+ setUsername(user.username ?? '');
165
224
  setAvatarUrl(user.avatarUrl ?? '');
166
225
  }
167
- }, [user]);
226
+ }, [
227
+ user?.firstName,
228
+ user?.lastName,
229
+ user?.username,
230
+ user?.avatarUrl,
231
+ user?.id,
232
+ ]);
168
233
 
169
234
  useEffect(() => {
170
235
  function onKey(e: KeyboardEvent) {
@@ -174,17 +239,26 @@ export function UserProfile({
174
239
  return () => document.removeEventListener('keydown', onKey);
175
240
  }, [modal, onClose]);
176
241
 
177
- const loadSessions = useCallback(async () => {
178
- setSessionsLoading(true);
179
- try {
180
- const res = await client.sessions.list();
181
- setSessions(res.sessions);
182
- } catch {
183
- setSessions([]);
184
- } finally {
185
- setSessionsLoading(false);
186
- }
187
- }, [client]);
242
+ const loadSessions = useCallback(
243
+ async (page = 0) => {
244
+ setSessionsLoading(true);
245
+ try {
246
+ const res = await client.sessions.list({
247
+ limit: SESSIONS_PER_PAGE,
248
+ offset: page * SESSIONS_PER_PAGE,
249
+ });
250
+ setSessions(res.sessions);
251
+ setSessionsTotal(res.total);
252
+ setSessionsPage(page);
253
+ } catch {
254
+ setSessions([]);
255
+ setSessionsTotal(0);
256
+ } finally {
257
+ setSessionsLoading(false);
258
+ }
259
+ },
260
+ [client]
261
+ );
188
262
 
189
263
  const loadBilling = useCallback(async () => {
190
264
  setBillingLoading(true);
@@ -193,20 +267,22 @@ export function UserProfile({
193
267
  const rawApiUrl =
194
268
  (client as unknown as { apiUrl: string }).apiUrl ??
195
269
  'https://auth.slyxup.online';
196
- const billingUrl = rawApiUrl.replace(
197
- 'auth.slyxup.online',
198
- 'billing.slyxup.online'
199
- );
270
+ const billingUrl = (() => {
271
+ // Localhost: swap port 8787 → 8788 (auth → billing)
272
+ if (/^https?:\/\/localhost(:\d+)?$/.test(rawApiUrl)) {
273
+ return rawApiUrl.replace(/:(\d+)$/, ':8788');
274
+ }
275
+ return rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
276
+ })();
200
277
  const token =
278
+ (
279
+ client as unknown as { getToken?: () => string | undefined }
280
+ ).getToken?.() ??
201
281
  (
202
282
  client as unknown as {
203
283
  _token?: string;
204
- getToken?: () => string | undefined;
205
284
  }
206
- )?._token ??
207
- (
208
- client as unknown as { getToken?: () => string | undefined }
209
- )?.getToken?.();
285
+ )?._token;
210
286
  const headers: Record<string, string> = {
211
287
  'Content-Type': 'application/json',
212
288
  };
@@ -217,9 +293,11 @@ export function UserProfile({
217
293
  if (pubKey && pubKey !== 'pk_test_missing')
218
294
  headers['X-Publishable-Key'] = pubKey;
219
295
 
220
- // Derive projectId for plans: prefer user.projectId, then try subscription's projectId, fallback none
221
- const projectId =
296
+ // Derive projectId for plans: prefer user.projectId, then try to resolve from publishableKey's project (for examples)
297
+ // For the example apps, the publishableKey is for the example project, not the user's projectId
298
+ const projectId: string | null =
222
299
  (user as unknown as { projectId?: string | null })?.projectId ?? null;
300
+ setProjectId(projectId);
223
301
 
224
302
  // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
225
303
  async function fetchJson(url: string) {
@@ -315,17 +393,20 @@ export function UserProfile({
315
393
  }
316
394
  }
317
395
 
318
- // Plans (needs projectId — if missing, try without and degrade gracefully)
396
+ // Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
397
+ let gotPlans = false;
319
398
  const planPaths: string[] = [];
320
- if (projectId)
399
+ if (projectId) {
321
400
  planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
322
- // Also try without projectId as last resort (will 400 but we catch)
323
- planPaths.push(
324
- `${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`
325
- );
326
- planPaths.push(`${billingUrl}/v1/plans`);
327
-
328
- let gotPlans = false;
401
+ } else if (pubKey && pubKey !== 'pk_test_missing') {
402
+ // No projectId but have publishableKey — let billing resolve project via X-Publishable-Key header.
403
+ // Billing plans route returns [] in test/localhost when projectId is missing, which is fine.
404
+ planPaths.push(`${billingUrl}/v1/billing/plans`);
405
+ } else {
406
+ // No projectId and no publishableKey — don't make the request, just show empty
407
+ setPlans([]);
408
+ gotPlans = true;
409
+ }
329
410
  for (const p of planPaths) {
330
411
  try {
331
412
  const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
@@ -349,16 +430,32 @@ export function UserProfile({
349
430
  }, [client, user]);
350
431
 
351
432
  useEffect(() => {
352
- if (tab === 'security') void loadSessions();
433
+ if (tab === 'security') void loadSessions(0);
353
434
  if (tab === 'billing') void loadBilling();
354
435
  }, [tab, loadSessions, loadBilling]);
355
436
 
437
+ // React to a completed Paddle checkout: reload billing immediately and show
438
+ // a success confirmation (fallback for when the webhook is fast / polling lags).
439
+ useEffect(() => {
440
+ function onCheckoutCompleted() {
441
+ setCheckoutSuccess(true);
442
+ void loadBilling();
443
+ setTimeout(() => setCheckoutSuccess(false), 6000);
444
+ }
445
+ window.addEventListener('slyxup:checkout-completed', onCheckoutCompleted);
446
+ return () =>
447
+ window.removeEventListener(
448
+ 'slyxup:checkout-completed',
449
+ onCheckoutCompleted
450
+ );
451
+ }, [loadBilling]);
452
+
356
453
  async function onProfileSubmit(e: FormEvent) {
357
454
  e.preventDefault();
358
455
  setBusy(true);
359
456
  setSaved(false);
360
457
  try {
361
- await client.users.update({ firstName, lastName, avatarUrl });
458
+ await client.users.update({ firstName, lastName, username, avatarUrl });
362
459
  await reload();
363
460
  setSaved(true);
364
461
  setTimeout(() => setSaved(false), 2500);
@@ -379,6 +476,98 @@ export function UserProfile({
379
476
  }
380
477
  }
381
478
 
479
+ const loadAccounts = useCallback(async () => {
480
+ setAccountsLoading(true);
481
+ setAccountsError(null);
482
+ try {
483
+ const res = await client.accounts.list();
484
+ setAccounts(res.accounts);
485
+ } catch {
486
+ setAccounts([]);
487
+ setAccountsError('Could not load connected accounts.');
488
+ } finally {
489
+ setAccountsLoading(false);
490
+ }
491
+ }, [client]);
492
+
493
+ useEffect(() => {
494
+ if (tab === 'security') void loadAccounts();
495
+ }, [tab, loadAccounts]);
496
+
497
+ async function startTfaSetup() {
498
+ setTfaError(null);
499
+ setTfaBusy(true);
500
+ try {
501
+ const res = await client.twoFactor.setup();
502
+ setTfaSetup(res);
503
+ setTfaStage('setup');
504
+ setTfaCode('');
505
+ } catch (err) {
506
+ setTfaError(err instanceof Error ? err.message : 'Failed to start setup');
507
+ } finally {
508
+ setTfaBusy(false);
509
+ }
510
+ }
511
+
512
+ async function submitTfa(e: FormEvent) {
513
+ e.preventDefault();
514
+ if (!tfaSetup) return;
515
+ setTfaError(null);
516
+ setTfaBusy(true);
517
+ try {
518
+ const res = await client.twoFactor.enable(
519
+ tfaSetup.secret,
520
+ tfaCode.trim()
521
+ );
522
+ setTfaRecoveryCodes(res.recoveryCodes);
523
+ setTfaStage('codes');
524
+ await reload();
525
+ } catch (err) {
526
+ setTfaError(
527
+ err instanceof Error ? err.message : 'Invalid code — try again.'
528
+ );
529
+ } finally {
530
+ setTfaBusy(false);
531
+ }
532
+ }
533
+
534
+ async function submitTfaDisable(e: FormEvent) {
535
+ e.preventDefault();
536
+ setTfaError(null);
537
+ setTfaBusy(true);
538
+ try {
539
+ await client.twoFactor.disable(tfaVerifyCode.trim());
540
+ setTfaVerifyCode('');
541
+ setTfaStage('idle');
542
+ setTfaSetup(null);
543
+ setTfaRecoveryCodes([]);
544
+ await reload();
545
+ } catch (err) {
546
+ setTfaError(
547
+ err instanceof Error
548
+ ? err.message
549
+ : 'Invalid code — could not disable 2FA.'
550
+ );
551
+ } finally {
552
+ setTfaBusy(false);
553
+ }
554
+ }
555
+
556
+ async function onUnlink(accountId: string, provider: 'google' | 'github') {
557
+ setUnlinkingId(accountId);
558
+ setAccountsError(null);
559
+ try {
560
+ await client.accounts.unlink(accountId, provider);
561
+ setAccounts((prev) => prev.filter((a) => a.id !== accountId));
562
+ } catch (err) {
563
+ setAccountsError(
564
+ err instanceof Error ? err.message : 'Could not unlink account.'
565
+ );
566
+ } finally {
567
+ setUnlinkingId(null);
568
+ }
569
+ }
570
+
382
571
  async function onPasswordSubmit(e: FormEvent) {
383
572
  e.preventDefault();
384
573
  setPwError(null);
@@ -412,7 +601,7 @@ export function UserProfile({
412
601
  setRevokingId(id);
413
602
  try {
414
603
  await client.sessions.revoke(id);
415
- await loadSessions();
604
+ await loadSessions(sessionsPage);
416
605
  } finally {
417
606
  setRevokingId(null);
418
607
  }
@@ -422,7 +611,7 @@ export function UserProfile({
422
611
  setOthersRevoking(true);
423
612
  try {
424
613
  await client.sessions.revokeOthers();
425
- await loadSessions();
614
+ await loadSessions(0);
426
615
  } finally {
427
616
  setOthersRevoking(false);
428
617
  }
@@ -444,57 +633,50 @@ export function UserProfile({
444
633
  }
445
634
  }
446
635
 
447
- async function handleCheckout(planId: string) {
448
- setCheckoutId(planId);
636
+ async function handleCheckout(plan: Plan) {
637
+ setCheckoutId(plan.id);
638
+ setCheckoutDone(false);
449
639
  try {
640
+ // Always use Paddle.js overlay checkout
641
+ // authApiUrl is available via client apiUrl
450
642
  const rawApiUrl =
451
643
  (client as unknown as { apiUrl: string }).apiUrl ??
452
644
  'https://auth.slyxup.online';
453
- const billingUrl = rawApiUrl.replace(
454
- 'auth.slyxup.online',
455
- 'billing.slyxup.online'
456
- );
457
- const token =
458
- (
459
- client as unknown as {
460
- _token?: string;
461
- getToken?: () => string | undefined;
462
- }
463
- )?._token ??
464
- (
465
- client as unknown as { getToken?: () => string | undefined }
466
- )?.getToken?.();
467
- const headers: Record<string, string> = {
468
- 'Content-Type': 'application/json',
645
+ await initPaddle(rawApiUrl);
646
+ // Pass custom data so the billing webhook can attribute the created
647
+ // subscription to this user + project + plan (Paddle copies custom_data
648
+ // from the transaction to the subscription for recurring items).
649
+ const customData: Record<string, string> = {
650
+ userId: user?.id ?? '',
651
+ planId: plan.id,
469
652
  };
470
- if (token) headers.Authorization = `Bearer ${token}`;
471
- const pubKey = (client as unknown as { publishableKey?: string })
472
- ?.publishableKey;
473
- if (pubKey && pubKey !== 'pk_test_missing')
474
- headers['X-Publishable-Key'] = pubKey;
653
+ if (projectId) customData.projectId = projectId;
654
+ openPaddleCheckout(plan.paddlePriceId, user?.email, customData);
655
+ setCheckoutDone(true);
475
656
 
476
- const res = await fetch(`${billingUrl}/v1/billing/checkout`, {
477
- method: 'POST',
478
- headers,
479
- credentials: 'include',
480
- body: JSON.stringify({ planId }),
481
- });
482
- const data = (await res.json().catch(() => ({}))) as Record<
483
- string,
484
- unknown
485
- >;
486
- if (res.ok && typeof data.checkoutUrl === 'string' && data.checkoutUrl) {
487
- window.location.href = data.checkoutUrl as string;
488
- return;
489
- }
490
- if (!res.ok)
491
- throw new Error(
492
- typeof data.error === 'string'
493
- ? data.error
494
- : `Checkout failed (${res.status})`
495
- );
657
+ // Poll for subscription updates after checkout (webhook may take a few seconds)
658
+ let attempts = 0;
659
+ const maxAttempts = 10;
660
+ const pollInterval = 3000; // 3 seconds
661
+ const poll = async () => {
662
+ attempts++;
663
+ try {
664
+ await loadBilling();
665
+ if (subscriptionRef.current && attempts < maxAttempts) {
666
+ // Found subscription, stop polling
667
+ return;
668
+ }
669
+ } catch {
670
+ // Ignore polling errors
671
+ }
672
+ if (attempts < maxAttempts) {
673
+ setTimeout(poll, pollInterval);
674
+ }
675
+ };
676
+ // Start polling after a short delay to allow webhook to process
677
+ setTimeout(poll, 2000);
678
+ return;
496
679
  } catch (err) {
497
- // Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
498
680
  console.error('[SlyxUp] checkout failed', err);
499
681
  } finally {
500
682
  setCheckoutId(null);
@@ -649,6 +831,24 @@ export function UserProfile({
649
831
  Paste a public image URL for your avatar.
650
832
  </p>
651
833
  </div>
834
+ <div className="slx-field">
835
+ <label className="slx-label" htmlFor="slx-up-username">
836
+ Username
837
+ </label>
838
+ <input
839
+ id="slx-up-username"
840
+ className="slx-input"
841
+ type="text"
842
+ autoComplete="username"
843
+ placeholder="yourname"
844
+ value={username}
845
+ onChange={(e) => setUsername(e.target.value)}
846
+ />
847
+ <p className="slx-hint">
848
+ Used for password sign-in as an alternative to email. Must
849
+ be unique within your project.
850
+ </p>
851
+ </div>
652
852
  <button className="slx-btn" type="submit" disabled={busy}>
653
853
  {busy ? 'Saving…' : 'Save changes'}
654
854
  </button>
@@ -815,6 +1015,284 @@ export function UserProfile({
815
1015
  : 'Sign out other devices'}
816
1016
  </button>
817
1017
  )}
1018
+ {sessionsTotal > SESSIONS_PER_PAGE && (
1019
+ <div className="slx-pagination">
1020
+ <button
1021
+ type="button"
1022
+ className="slx-btn-secondary"
1023
+ disabled={sessionsPage === 0 || sessionsLoading}
1024
+ onClick={() => void loadSessions(sessionsPage - 1)}
1025
+ >
1026
+ Previous
1027
+ </button>
1028
+ <span className="slx-pagination-info">
1029
+ {sessionsPage * SESSIONS_PER_PAGE + 1}–
1030
+ {Math.min(
1031
+ (sessionsPage + 1) * SESSIONS_PER_PAGE,
1032
+ sessionsTotal
1033
+ )}{' '}
1034
+ of {sessionsTotal}
1035
+ </span>
1036
+ <button
1037
+ type="button"
1038
+ className="slx-btn-secondary"
1039
+ disabled={
1040
+ (sessionsPage + 1) * SESSIONS_PER_PAGE >=
1041
+ sessionsTotal || sessionsLoading
1042
+ }
1043
+ onClick={() => void loadSessions(sessionsPage + 1)}
1044
+ >
1045
+ Next
1046
+ </button>
1047
+ </div>
1048
+ )}
1049
+ </>
1050
+ )}
1051
+ </section>
1052
+
1053
+ {/* ── Two-factor authentication ── */}
1054
+ <section className="slx-profile-sec">
1055
+ <h3 className="slx-sec-title">Two-factor authentication</h3>
1056
+ {tfaError && <p className="slx-error-text">{tfaError}</p>}
1057
+
1058
+ {!user.twoFactorEnabled && tfaStage === 'idle' && (
1059
+ <>
1060
+ <p className="slx-hint">
1061
+ Add an authenticator app (Google Authenticator, Authy,
1062
+ 1Password, etc.) to protect your account with a time-based
1063
+ one-time password.
1064
+ </p>
1065
+ <button
1066
+ type="button"
1067
+ className="slx-btn"
1068
+ onClick={() => void startTfaSetup()}
1069
+ disabled={tfaBusy}
1070
+ >
1071
+ {tfaBusy ? 'Starting…' : 'Set up 2FA'}
1072
+ </button>
1073
+ </>
1074
+ )}
1075
+
1076
+ {!user.twoFactorEnabled && tfaStage === 'setup' && (
1077
+ <form onSubmit={submitTfa}>
1078
+ {tfaSetup && (
1079
+ <>
1080
+ <p className="slx-hint">
1081
+ Scan this QR code with your authenticator app:
1082
+ </p>
1083
+ <div
1084
+ style={{
1085
+ display: 'flex',
1086
+ alignItems: 'center',
1087
+ gap: 14,
1088
+ margin: '8px 0',
1089
+ flexWrap: 'wrap',
1090
+ }}
1091
+ >
1092
+ <img
1093
+ src={`https://api.qrserver.com/v1/create-qr-code/?size=132x132&data=${encodeURIComponent(tfaSetup.provisioningUri)}`}
1094
+ alt="QR code to scan with your authenticator app"
1095
+ width={132}
1096
+ height={132}
1097
+ style={{ borderRadius: 6 }}
1098
+ />
1099
+ <div style={{ minWidth: 0 }}>
1100
+ <p className="slx-row-label">
1101
+ Or enter this code manually:
1102
+ </p>
1103
+ <p
1104
+ className="slx-row-value"
1105
+ style={{
1106
+ userSelect: 'all',
1107
+ letterSpacing: 2,
1108
+ fontFamily: 'monospace',
1109
+ }}
1110
+ >
1111
+ {tfaSetup.secret.replace(/(.{4})/g, '$1 ').trim()}
1112
+ </p>
1113
+ <p className="slx-hint">
1114
+ Account: {tfaSetup.accountName}
1115
+ </p>
1116
+ </div>
1117
+ </div>
1118
+ </>
1119
+ )}
1120
+ <div className="slx-field">
1121
+ <label className="slx-label" htmlFor="slx-tfa-code">
1122
+ Enter the 6-digit code
1123
+ </label>
1124
+ <input
1125
+ id="slx-tfa-code"
1126
+ className="slx-input"
1127
+ type="text"
1128
+ inputMode="numeric"
1129
+ autoComplete="one-time-code"
1130
+ maxLength={6}
1131
+ pattern="[0-9]*"
1132
+ placeholder="000000"
1133
+ value={tfaCode}
1134
+ onChange={(e) =>
1135
+ setTfaCode(e.target.value.replace(/\D/g, ''))
1136
+ }
1137
+ required
1138
+ />
1139
+ </div>
1140
+ <button
1141
+ className="slx-btn"
1142
+ type="submit"
1143
+ disabled={tfaBusy}
1144
+ >
1145
+ {tfaBusy ? 'Verifying…' : 'Enable 2FA'}
1146
+ </button>
1147
+ </form>
1148
+ )}
1149
+
1150
+ {!user.twoFactorEnabled && tfaStage === 'codes' && (
1151
+ <>
1152
+ <div
1153
+ className="slx-billing-card"
1154
+ style={{ margin: '4px 0 8px' }}
1155
+ >
1156
+ <p className="slx-billing-plan">
1157
+ 2FA enabled — save recovery codes
1158
+ </p>
1159
+ <p className="slx-billing-detail">
1160
+ Each code can be used once to sign in if you lose access
1161
+ to your authenticator. Store them somewhere safe.
1162
+ </p>
1163
+ <ul
1164
+ style={{
1165
+ display: 'grid',
1166
+ gridTemplateColumns: 'repeat(2, auto)',
1167
+ gap: '4px 24px',
1168
+ justifyContent: 'start',
1169
+ margin: '10px 0',
1170
+ paddingLeft: 0,
1171
+ listStyle: 'none',
1172
+ fontFamily: 'monospace',
1173
+ }}
1174
+ >
1175
+ {tfaRecoveryCodes.map((c) => (
1176
+ <li key={c}>{c}</li>
1177
+ ))}
1178
+ </ul>
1179
+ <button
1180
+ type="button"
1181
+ className="slx-btn-secondary"
1182
+ onClick={() =>
1183
+ void navigator.clipboard
1184
+ ?.writeText(tfaRecoveryCodes.join('\n'))
1185
+ .catch(() => undefined)
1186
+ }
1187
+ >
1188
+ Copy codes
1189
+ </button>
1190
+ </div>
1191
+ <button
1192
+ type="button"
1193
+ className="slx-btn-secondary"
1194
+ onClick={() => void reload()}
1195
+ >
1196
+ Done
1197
+ </button>
1198
+ </>
1199
+ )}
1200
+
1201
+ {user.twoFactorEnabled && (
1202
+ <>
1203
+ <p className="slx-hint">2FA is enabled for this account.</p>
1204
+ <form
1205
+ onSubmit={submitTfaDisable}
1206
+ style={{
1207
+ display: 'flex',
1208
+ gap: 10,
1209
+ alignItems: 'flex-end',
1210
+ flexWrap: 'wrap',
1211
+ marginTop: 8,
1212
+ }}
1213
+ >
1214
+ <div className="slx-field" style={{ flex: '1 1 160px' }}>
1215
+ <label className="slx-label" htmlFor="slx-tfa-disable">
1216
+ Authenticator code
1217
+ </label>
1218
+ <input
1219
+ id="slx-tfa-disable"
1220
+ className="slx-input"
1221
+ type="text"
1222
+ inputMode="numeric"
1223
+ autoComplete="one-time-code"
1224
+ maxLength={6}
1225
+ pattern="[0-9]*"
1226
+ placeholder="000000"
1227
+ value={tfaVerifyCode}
1228
+ onChange={(e) =>
1229
+ setTfaVerifyCode(e.target.value.replace(/\D/g, ''))
1230
+ }
1231
+ required
1232
+ />
1233
+ </div>
1234
+ <button
1235
+ type="submit"
1236
+ className="slx-btn-danger-outline"
1237
+ disabled={tfaBusy}
1238
+ >
1239
+ {tfaBusy ? 'Disabling…' : 'Disable 2FA'}
1240
+ </button>
1241
+ </form>
1242
+ </>
1243
+ )}
1244
+ </section>
1245
+
1246
+ {/* ── Connected accounts ── */}
1247
+ <section className="slx-profile-sec">
1248
+ <h3 className="slx-sec-title">Connected accounts</h3>
1249
+ {accountsError && (
1250
+ <p className="slx-error-text">{accountsError}</p>
1251
+ )}
1252
+ {accountsLoading ? (
1253
+ <p className="slx-hint">Loading…</p>
1254
+ ) : accounts.length === 0 ? (
1255
+ <>
1256
+ <p className="slx-hint">
1257
+ No social accounts connected. You can sign in with Google
1258
+ or GitHub and link them here later.
1259
+ </p>
1260
+ <button
1261
+ type="button"
1262
+ className="slx-btn-secondary"
1263
+ onClick={() => void loadAccounts()}
1264
+ >
1265
+ Refresh
1266
+ </button>
1267
+ </>
1268
+ ) : (
1269
+ <>
1270
+ <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
1271
+ {accounts.map((acc) => (
1272
+ <li
1273
+ key={acc.id}
1274
+ className="slx-session"
1275
+ style={{ alignItems: 'center' }}
1276
+ >
1277
+ <div className="slx-session-meta">
1278
+ <p className="slx-session-device">
1279
+ {acc.provider === 'google' ? 'Google' : 'GitHub'}
1280
+ </p>
1281
+ <p className="slx-session-sub">
1282
+ Connected {formatDate(acc.createdAt)}
1283
+ </p>
1284
+ </div>
1285
+ <button
1286
+ type="button"
1287
+ className="slx-btn-danger-outline"
1288
+ onClick={() => void onUnlink(acc.id, acc.provider)}
1289
+ disabled={unlinkingId === acc.id}
1290
+ >
1291
+ {unlinkingId === acc.id ? '…' : 'Unlink'}
1292
+ </button>
1293
+ </li>
1294
+ ))}
1295
+ </ul>
818
1296
  </>
819
1297
  )}
820
1298
  </section>
@@ -866,6 +1344,22 @@ export function UserProfile({
866
1344
  <>
867
1345
  <section className="slx-profile-sec">
868
1346
  <h3 className="slx-sec-title">Subscription</h3>
1347
+ {checkoutDone && (
1348
+ <p
1349
+ className="slx-error-text"
1350
+ style={{ color: 'var(--slx-success)', marginBottom: 8 }}
1351
+ >
1352
+ Checkout opened — complete your payment in the overlay.
1353
+ </p>
1354
+ )}
1355
+ {checkoutSuccess && (
1356
+ <p
1357
+ className="slx-error-text"
1358
+ style={{ color: 'var(--slx-success)', marginBottom: 8 }}
1359
+ >
1360
+ Payment successful — your subscription is being set up.
1361
+ </p>
1362
+ )}
869
1363
  <div className="slx-billing-card">
870
1364
  <p className="slx-billing-plan">No active subscription</p>
871
1365
  <p className="slx-billing-detail">
@@ -936,7 +1430,7 @@ export function UserProfile({
936
1430
  <button
937
1431
  type="button"
938
1432
  className="slx-btn slx-plan-cta"
939
- onClick={() => handleCheckout(plan.id)}
1433
+ onClick={() => handleCheckout(plan)}
940
1434
  disabled={checkoutId === plan.id}
941
1435
  >
942
1436
  {checkoutId === plan.id
@@ -1135,7 +1629,7 @@ export function UserProfile({
1135
1629
  : 'slx-btn slx-plan-cta'
1136
1630
  }
1137
1631
  disabled={isCurrent || checkoutId === plan.id}
1138
- onClick={() => handleCheckout(plan.id)}
1632
+ onClick={() => handleCheckout(plan)}
1139
1633
  >
1140
1634
  {isCurrent
1141
1635
  ? 'Current plan'