@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.
- package/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/README.md +23 -3
- package/dist/components/SignIn/SignIn.d.ts.map +1 -1
- package/dist/components/SignIn/SignIn.js +29 -3
- package/dist/components/SignIn/SignIn.js.map +1 -1
- package/dist/components/SignUp/SignUp.d.ts.map +1 -1
- package/dist/components/SignUp/SignUp.js +10 -2
- package/dist/components/SignUp/SignUp.js.map +1 -1
- package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/components/UserProfile/UserProfile.js +275 -65
- package/dist/components/UserProfile/UserProfile.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/paddle.d.ts +39 -0
- package/dist/lib/paddle.d.ts.map +1 -0
- package/dist/lib/paddle.js +94 -0
- package/dist/lib/paddle.js.map +1 -0
- package/dist/styles.d.ts +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +10 -0
- package/dist/styles.js.map +1 -1
- package/package.json +10 -10
- package/src/components/SignIn/SignIn.tsx +112 -47
- package/src/components/SignUp/SignUp.tsx +38 -1
- package/src/components/UserProfile/UserProfile.tsx +590 -96
- package/src/index.ts +1 -0
- package/src/lib/paddle.ts +131 -0
- package/src/styles.ts +10 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { SlyxupSessionInfo } from '@slyxup/core';
|
|
2
2
|
import { useAuth, useUser } from '@slyxup/react';
|
|
3
|
-
import {
|
|
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|
|
|
54
|
+
: /iPhone|iPad|iPod/i.test(ua)
|
|
48
55
|
? 'iOS'
|
|
49
|
-
: /
|
|
50
|
-
? '
|
|
51
|
-
:
|
|
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
|
-
: /
|
|
55
|
-
? '
|
|
56
|
-
: /
|
|
57
|
-
? '
|
|
58
|
-
: /
|
|
59
|
-
? '
|
|
60
|
-
:
|
|
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
|
-
//
|
|
160
|
-
|
|
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
|
-
}, [
|
|
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(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 =
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
448
|
-
setCheckoutId(
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
)
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
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 (
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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
|
|
1632
|
+
onClick={() => handleCheckout(plan)}
|
|
1139
1633
|
>
|
|
1140
1634
|
{isCurrent
|
|
1141
1635
|
? 'Current plan'
|