@slyxup/ui 0.2.9 → 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 +335 -74
- 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 +842 -136
- package/src/index.ts +1 -0
- package/src/lib/paddle.ts +131 -0
- package/src/styles.ts +10 -0
|
@@ -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|
|
|
31
|
+
: /iPhone|iPad|iPod/i.test(ua)
|
|
31
32
|
? 'iOS'
|
|
32
|
-
: /
|
|
33
|
-
? '
|
|
34
|
-
:
|
|
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
|
-
: /
|
|
38
|
-
? '
|
|
39
|
-
: /
|
|
40
|
-
? '
|
|
41
|
-
: /
|
|
42
|
-
? '
|
|
43
|
-
:
|
|
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
|
-
//
|
|
104
|
-
|
|
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
|
-
}, [
|
|
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);
|
|
@@ -134,21 +190,31 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
134
190
|
setBillingLoading(true);
|
|
135
191
|
setPlansLoading(true);
|
|
136
192
|
try {
|
|
137
|
-
const rawApiUrl = client.apiUrl ??
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
193
|
+
const rawApiUrl = client.apiUrl ??
|
|
194
|
+
'https://auth.slyxup.online';
|
|
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;
|
|
141
204
|
const headers = {
|
|
142
205
|
'Content-Type': 'application/json',
|
|
143
206
|
};
|
|
144
207
|
if (token)
|
|
145
208
|
headers.Authorization = `Bearer ${token}`;
|
|
146
209
|
// Also forward publishable key if available (helps billing resolve project)
|
|
147
|
-
const pubKey = client
|
|
210
|
+
const pubKey = client
|
|
211
|
+
?.publishableKey;
|
|
148
212
|
if (pubKey && pubKey !== 'pk_test_missing')
|
|
149
213
|
headers['X-Publishable-Key'] = pubKey;
|
|
150
|
-
// Derive projectId for plans: prefer user.projectId, then try
|
|
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
|
|
151
216
|
const projectId = user?.projectId ?? null;
|
|
217
|
+
setProjectId(projectId);
|
|
152
218
|
// Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
|
|
153
219
|
async function fetchJson(url) {
|
|
154
220
|
const res = await fetch(url, { headers, credentials: 'include' });
|
|
@@ -163,7 +229,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
163
229
|
`${billingUrl}/v1/subscription`,
|
|
164
230
|
]) {
|
|
165
231
|
try {
|
|
166
|
-
const j = await fetchJson(path);
|
|
232
|
+
const j = (await fetchJson(path));
|
|
167
233
|
if (j && j.ok !== false) {
|
|
168
234
|
subData = j;
|
|
169
235
|
break;
|
|
@@ -184,9 +250,17 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
184
250
|
setSubscription({
|
|
185
251
|
id: String(sub.id ?? ''),
|
|
186
252
|
status: String(sub.status ?? 'active'),
|
|
187
|
-
planId: sub.planId ??
|
|
188
|
-
|
|
189
|
-
|
|
253
|
+
planId: sub.planId ??
|
|
254
|
+
sub.plan_id ??
|
|
255
|
+
null,
|
|
256
|
+
planName: sub.planName ??
|
|
257
|
+
sub.plan_name ??
|
|
258
|
+
sub.name ??
|
|
259
|
+
null,
|
|
260
|
+
currentPeriodEnd: sub.currentPeriodEnd ??
|
|
261
|
+
sub.current_period_end ??
|
|
262
|
+
sub.currentPeriod_end ??
|
|
263
|
+
null,
|
|
190
264
|
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false),
|
|
191
265
|
});
|
|
192
266
|
}
|
|
@@ -198,7 +272,10 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
198
272
|
setSubscription(null);
|
|
199
273
|
}
|
|
200
274
|
// Invoices
|
|
201
|
-
for (const path of [
|
|
275
|
+
for (const path of [
|
|
276
|
+
`${billingUrl}/v1/billing/invoices`,
|
|
277
|
+
`${billingUrl}/v1/invoices`,
|
|
278
|
+
]) {
|
|
202
279
|
try {
|
|
203
280
|
const invJ = (await fetchJson(path));
|
|
204
281
|
if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
|
|
@@ -207,7 +284,9 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
207
284
|
amount: Number(inv.amount ?? 0),
|
|
208
285
|
currency: String(inv.currency ?? 'USD'),
|
|
209
286
|
status: String(inv.status ?? 'pending'),
|
|
210
|
-
billedAt: inv.billedAt ??
|
|
287
|
+
billedAt: inv.billedAt ??
|
|
288
|
+
inv.billed_at ??
|
|
289
|
+
null,
|
|
211
290
|
})));
|
|
212
291
|
break;
|
|
213
292
|
}
|
|
@@ -216,14 +295,22 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
216
295
|
// try next
|
|
217
296
|
}
|
|
218
297
|
}
|
|
219
|
-
// Plans (needs projectId — if missing, try
|
|
298
|
+
// Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
|
|
299
|
+
let gotPlans = false;
|
|
220
300
|
const planPaths = [];
|
|
221
|
-
if (projectId)
|
|
301
|
+
if (projectId) {
|
|
222
302
|
planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
|
227
314
|
for (const p of planPaths) {
|
|
228
315
|
try {
|
|
229
316
|
const pj = (await fetchJson(p));
|
|
@@ -251,16 +338,27 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
251
338
|
}, [client, user]);
|
|
252
339
|
useEffect(() => {
|
|
253
340
|
if (tab === 'security')
|
|
254
|
-
void loadSessions();
|
|
341
|
+
void loadSessions(0);
|
|
255
342
|
if (tab === 'billing')
|
|
256
343
|
void loadBilling();
|
|
257
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]);
|
|
258
356
|
async function onProfileSubmit(e) {
|
|
259
357
|
e.preventDefault();
|
|
260
358
|
setBusy(true);
|
|
261
359
|
setSaved(false);
|
|
262
360
|
try {
|
|
263
|
-
await client.users.update({ firstName, lastName, avatarUrl });
|
|
361
|
+
await client.users.update({ firstName, lastName, username, avatarUrl });
|
|
264
362
|
await reload();
|
|
265
363
|
setSaved(true);
|
|
266
364
|
setTimeout(() => setSaved(false), 2500);
|
|
@@ -282,6 +380,95 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
282
380
|
setResending(false);
|
|
283
381
|
}
|
|
284
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
|
+
}
|
|
285
472
|
async function onPasswordSubmit(e) {
|
|
286
473
|
e.preventDefault();
|
|
287
474
|
setPwError(null);
|
|
@@ -314,7 +501,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
314
501
|
setRevokingId(id);
|
|
315
502
|
try {
|
|
316
503
|
await client.sessions.revoke(id);
|
|
317
|
-
await loadSessions();
|
|
504
|
+
await loadSessions(sessionsPage);
|
|
318
505
|
}
|
|
319
506
|
finally {
|
|
320
507
|
setRevokingId(null);
|
|
@@ -324,7 +511,7 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
324
511
|
setOthersRevoking(true);
|
|
325
512
|
try {
|
|
326
513
|
await client.sessions.revokeOthers();
|
|
327
|
-
await loadSessions();
|
|
514
|
+
await loadSessions(0);
|
|
328
515
|
}
|
|
329
516
|
finally {
|
|
330
517
|
setOthersRevoking(false);
|
|
@@ -345,35 +532,51 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
345
532
|
setDeleteBusy(false);
|
|
346
533
|
}
|
|
347
534
|
}
|
|
348
|
-
async function handleCheckout(
|
|
349
|
-
setCheckoutId(
|
|
535
|
+
async function handleCheckout(plan) {
|
|
536
|
+
setCheckoutId(plan.id);
|
|
537
|
+
setCheckoutDone(false);
|
|
350
538
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
539
|
+
// Always use Paddle.js overlay checkout
|
|
540
|
+
// authApiUrl is available via client apiUrl
|
|
541
|
+
const rawApiUrl = client.apiUrl ??
|
|
542
|
+
'https://auth.slyxup.online';
|
|
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,
|
|
550
|
+
};
|
|
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;
|
|
374
578
|
}
|
|
375
579
|
catch (err) {
|
|
376
|
-
// Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
|
|
377
580
|
console.error('[SlyxUp] checkout failed', err);
|
|
378
581
|
}
|
|
379
582
|
finally {
|
|
@@ -388,24 +591,67 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
388
591
|
const name = displayName(user);
|
|
389
592
|
// Resolve current plan name via plans lookup if subscription has planId but no planName
|
|
390
593
|
const currentPlan = subscription
|
|
391
|
-
? plans.find((p) => p.id === subscription.planId) ?? null
|
|
594
|
+
? (plans.find((p) => p.id === subscription.planId) ?? null)
|
|
392
595
|
: null;
|
|
393
596
|
const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
|
|
394
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) => {
|
|
395
598
|
e.stopPropagation();
|
|
396
599
|
onClose?.();
|
|
397
|
-
}, "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: {
|
|
398
601
|
display: 'inline-flex',
|
|
399
602
|
alignItems: 'center',
|
|
400
603
|
gap: 8,
|
|
401
604
|
flexWrap: 'wrap',
|
|
402
|
-
}, children: [_jsx("span", { className: "slx-badge slx-badge-warn", children: "Unverified" }), _jsx("button", { type: "button", className: "slx-link", onClick: onResendVerification, disabled: resending, children: resending ? 'Sending…' : resent ? 'Sent ✓' : 'Resend' })] }))] })] })] })), tab === 'security' && (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Change password" }), pwSaved && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)' }, children: "Password updated." })), pwError && _jsx("p", { className: "slx-error-text", children: pwError }), _jsxs("form", { onSubmit: onPasswordSubmit, children: [_jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-current", children: "Current password" }), _jsx("input", { id: "slx-pw-current", className: "slx-input", type: "password", autoComplete: "current-password", value: currentPassword, onChange: (e) => setCurrentPassword(e.target.value), required: true })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-new", children: "New password" }), _jsx("input", { id: "slx-pw-new", className: "slx-input", type: "password", autoComplete: "new-password", minLength: 8, value: newPassword, onChange: (e) => setNewPassword(e.target.value), required: true }), _jsx("p", { className: "slx-hint", children: "At least 8 characters." })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-confirm", children: "Confirm new password" }), _jsx("input", { id: "slx-pw-confirm", className: "slx-input", type: "password", autoComplete: "new-password", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true })] }), _jsx("button", { className: "slx-btn", type: "submit", disabled: pwBusy, children: pwBusy ? 'Updating…' : 'Update password' })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Active sessions" }), sessionsLoading ? (_jsx("p", { className: "slx-hint", children: "Loading sessions\u2026" })) : sessions.length === 0 ? (_jsx("p", { className: "slx-hint", children: "No active sessions." })) : (_jsxs(_Fragment, { children: [sessions.map((s) => (_jsxs("div", { className: "slx-session", children: [_jsxs("div", { className: "slx-session-meta", children: [_jsxs("p", { className: "slx-session-device", children: [deviceLabel(s.userAgent), s.isCurrent && _jsx("span", { className: "slx-badge slx-badge-accent", children: "This device" })] }), _jsx("p", { className: "slx-session-sub", children: [
|
|
605
|
+
}, children: [_jsx("span", { className: "slx-badge slx-badge-warn", children: "Unverified" }), _jsx("button", { type: "button", className: "slx-link", onClick: onResendVerification, disabled: resending, children: resending ? 'Sending…' : resent ? 'Sent ✓' : 'Resend' })] }))] })] })] })), tab === 'security' && (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Change password" }), pwSaved && (_jsx("p", { className: "slx-error-text", style: { color: 'var(--slx-success)' }, children: "Password updated." })), pwError && _jsx("p", { className: "slx-error-text", children: pwError }), _jsxs("form", { onSubmit: onPasswordSubmit, children: [_jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-current", children: "Current password" }), _jsx("input", { id: "slx-pw-current", className: "slx-input", type: "password", autoComplete: "current-password", value: currentPassword, onChange: (e) => setCurrentPassword(e.target.value), required: true })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-new", children: "New password" }), _jsx("input", { id: "slx-pw-new", className: "slx-input", type: "password", autoComplete: "new-password", minLength: 8, value: newPassword, onChange: (e) => setNewPassword(e.target.value), required: true }), _jsx("p", { className: "slx-hint", children: "At least 8 characters." })] }), _jsxs("div", { className: "slx-field", children: [_jsx("label", { className: "slx-label", htmlFor: "slx-pw-confirm", children: "Confirm new password" }), _jsx("input", { id: "slx-pw-confirm", className: "slx-input", type: "password", autoComplete: "new-password", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true })] }), _jsx("button", { className: "slx-btn", type: "submit", disabled: pwBusy, children: pwBusy ? 'Updating…' : 'Update password' })] })] }), _jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Active sessions" }), sessionsLoading ? (_jsx("p", { className: "slx-hint", children: "Loading sessions\u2026" })) : sessions.length === 0 ? (_jsx("p", { className: "slx-hint", children: "No active sessions." })) : (_jsxs(_Fragment, { children: [sessions.map((s) => (_jsxs("div", { className: "slx-session", children: [_jsxs("div", { className: "slx-session-meta", children: [_jsxs("p", { className: "slx-session-device", children: [deviceLabel(s.userAgent), s.isCurrent && (_jsx("span", { className: "slx-badge slx-badge-accent", children: "This device" }))] }), _jsx("p", { className: "slx-session-sub", children: [
|
|
606
|
+
s.ipAddress,
|
|
607
|
+
`created ${formatDate(s.createdAt)}`,
|
|
608
|
+
`expires ${formatDate(s.expiresAt)}`,
|
|
609
|
+
]
|
|
403
610
|
.filter(Boolean)
|
|
404
|
-
.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
|
|
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
|
|
612
|
+
? 'Signing out…'
|
|
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: {
|
|
405
642
|
background: 'var(--slx-danger)',
|
|
406
643
|
borderColor: 'var(--slx-danger)',
|
|
407
644
|
}, onClick: onDeleteAccount, disabled: confirmText !== 'DELETE' || deleteBusy, children: deleteBusy ? 'Deleting…' : 'Delete my account forever' })] })] })), tab === 'billing' &&
|
|
408
|
-
(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: {
|
|
646
|
+
color: 'var(--slx-accent)',
|
|
647
|
+
marginTop: 4,
|
|
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
|
|
649
|
+
? 'Redirecting…'
|
|
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'
|
|
651
|
+
? 'slx-badge-ok'
|
|
652
|
+
: inv.status === 'overdue'
|
|
653
|
+
? 'slx-badge-warn'
|
|
654
|
+
: 'slx-badge-accent'}`, children: inv.status })] }, inv.id)))] }))] })) : (_jsxs(_Fragment, { children: [_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Current plan" }), _jsxs("div", { className: "slx-billing-card", children: [_jsxs("div", { style: {
|
|
409
655
|
display: 'flex',
|
|
410
656
|
justifyContent: 'space-between',
|
|
411
657
|
alignItems: 'flex-start',
|
|
@@ -413,14 +659,23 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
413
659
|
flexWrap: 'wrap',
|
|
414
660
|
}, children: [_jsxs("div", { style: { minWidth: 0 }, children: [_jsx("p", { className: "slx-billing-plan", children: resolvedPlanName ?? 'Subscription' }), _jsxs("p", { className: "slx-billing-detail", children: ["Status:", ' ', _jsx("span", { className: `slx-billing-status slx-billing-status-${subscription.status}`, children: subscription.status })] }), subscription.currentPeriodEnd && (_jsx("p", { className: "slx-billing-detail", children: subscription.cancelAtPeriodEnd
|
|
415
661
|
? `Cancels ${formatDate(subscription.currentPeriodEnd)}`
|
|
416
|
-
: `Renews ${formatDate(subscription.currentPeriodEnd)}` })), subscription.cancelAtPeriodEnd && (_jsx("p", { className: "slx-billing-detail", style: {
|
|
662
|
+
: `Renews ${formatDate(subscription.currentPeriodEnd)}` })), subscription.cancelAtPeriodEnd && (_jsx("p", { className: "slx-billing-detail", style: {
|
|
663
|
+
color: 'var(--slx-danger)',
|
|
664
|
+
fontWeight: 600,
|
|
665
|
+
}, children: "Scheduled to cancel at period end" }))] }), currentPlan && (_jsxs("span", { className: "slx-badge slx-badge-accent", style: { flexShrink: 0 }, children: [formatCurrency(currentPlan.amount, currentPlan.currency), "/", currentPlan.interval] }))] }), currentPlan?.features &&
|
|
666
|
+
currentPlan.features.length > 0 && (_jsx("ul", { className: "slx-plan-features", style: { margin: '12px 0 0' }, children: currentPlan.features.map((f) => (_jsx("li", { children: f }, f))) })), _jsx("div", { className: "slx-billing-actions", children: _jsx("button", { type: "button", className: "slx-btn-secondary", onClick: () => void loadBilling(), children: "Refresh" }) })] })] }), plansLoading ? (_jsx("section", { className: "slx-profile-sec", children: _jsx("p", { className: "slx-hint", children: "Loading available plans\u2026" }) })) : plans.length > 0 ? (_jsxs("section", { className: "slx-profile-sec", children: [_jsx("h3", { className: "slx-sec-title", children: "Available plans" }), _jsx("p", { className: "slx-hint", style: { marginBottom: 8 }, children: "Switch plans anytime. Changes apply at the next billing cycle." }), _jsx("div", { className: "slx-billing-plans", children: plans.map((plan) => {
|
|
417
667
|
const isCurrent = subscription.planId
|
|
418
668
|
? subscription.planId === plan.id
|
|
419
669
|
: resolvedPlanName === plan.name;
|
|
420
670
|
const currentAmount = currentPlan?.amount ?? 0;
|
|
421
671
|
const isUpgrade = plan.amount > currentAmount;
|
|
422
672
|
const isDowngrade = plan.amount < currentAmount && !isCurrent;
|
|
423
|
-
return (_jsxs("div", { className: `slx-plan-card${plan.isPopular ? ' popular' : ''}`, style: isCurrent ? { opacity: 0.92 } : undefined, children: [plan.isPopular && !isCurrent && _jsx("span", { className: "slx-plan-badge", children: "POPULAR" }), isCurrent && _jsx("span", { className: "slx-plan-badge", style: { background: 'var(--slx-success)' }, children: "CURRENT" }), _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: {
|
|
673
|
+
return (_jsxs("div", { className: `slx-plan-card${plan.isPopular ? ' popular' : ''}`, style: isCurrent ? { opacity: 0.92 } : undefined, children: [plan.isPopular && !isCurrent && (_jsx("span", { className: "slx-plan-badge", children: "POPULAR" })), isCurrent && (_jsx("span", { className: "slx-plan-badge", style: { background: 'var(--slx-success)' }, children: "CURRENT" })), _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: {
|
|
674
|
+
color: 'var(--slx-accent)',
|
|
675
|
+
marginTop: 4,
|
|
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
|
|
677
|
+
? 'slx-btn-secondary slx-plan-cta'
|
|
678
|
+
: 'slx-btn slx-plan-cta', disabled: isCurrent || checkoutId === plan.id, onClick: () => handleCheckout(plan), children: isCurrent
|
|
424
679
|
? 'Current plan'
|
|
425
680
|
: checkoutId === plan.id
|
|
426
681
|
? 'Redirecting…'
|
|
@@ -429,13 +684,19 @@ export function UserProfile({ modal = true, onClose, onDeleted, }) {
|
|
|
429
684
|
: isDowngrade
|
|
430
685
|
? 'Downgrade'
|
|
431
686
|
: 'Switch plan' })] }, plan.id));
|
|
432
|
-
}) })] })) : null, 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'
|
|
687
|
+
}) })] })) : null, 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'
|
|
688
|
+
? 'slx-badge-ok'
|
|
689
|
+
: inv.status === 'overdue'
|
|
690
|
+
? 'slx-badge-warn'
|
|
691
|
+
: 'slx-badge-accent'}`, children: inv.status })] }, inv.id)))] }))] })))] })] })] }));
|
|
433
692
|
if (!modal) {
|
|
434
693
|
return (_jsx("div", { className: "slx-profile-modal", style: { maxHeight: 'none' }, children: bodyInner }));
|
|
435
694
|
}
|
|
436
695
|
// Modal overlay — click on backdrop closes, click inside modal does not.
|
|
437
696
|
// Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
|
|
438
|
-
return (
|
|
697
|
+
return (
|
|
698
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
|
|
699
|
+
_jsx("div", { className: "slx-overlay", onMouseDown: (e) => {
|
|
439
700
|
if (e.target === e.currentTarget)
|
|
440
701
|
onClose?.();
|
|
441
702
|
}, onClick: (e) => {
|