@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,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,37 +239,65 @@ 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);
|
|
191
265
|
setPlansLoading(true);
|
|
192
266
|
try {
|
|
193
267
|
const rawApiUrl =
|
|
194
|
-
(client as unknown as { apiUrl: string }).apiUrl ??
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
268
|
+
(client as unknown as { apiUrl: string }).apiUrl ??
|
|
269
|
+
'https://auth.slyxup.online';
|
|
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
|
+
})();
|
|
277
|
+
const token =
|
|
278
|
+
(
|
|
279
|
+
client as unknown as { getToken?: () => string | undefined }
|
|
280
|
+
).getToken?.() ??
|
|
281
|
+
(
|
|
282
|
+
client as unknown as {
|
|
283
|
+
_token?: string;
|
|
284
|
+
}
|
|
285
|
+
)?._token;
|
|
198
286
|
const headers: Record<string, string> = {
|
|
199
287
|
'Content-Type': 'application/json',
|
|
200
288
|
};
|
|
201
289
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
202
290
|
// Also forward publishable key if available (helps billing resolve project)
|
|
203
|
-
const pubKey = (client as unknown as { publishableKey?: string })
|
|
204
|
-
|
|
291
|
+
const pubKey = (client as unknown as { publishableKey?: string })
|
|
292
|
+
?.publishableKey;
|
|
293
|
+
if (pubKey && pubKey !== 'pk_test_missing')
|
|
294
|
+
headers['X-Publishable-Key'] = pubKey;
|
|
205
295
|
|
|
206
|
-
// Derive projectId for plans: prefer user.projectId, then try
|
|
207
|
-
|
|
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 =
|
|
299
|
+
(user as unknown as { projectId?: string | null })?.projectId ?? null;
|
|
300
|
+
setProjectId(projectId);
|
|
208
301
|
|
|
209
302
|
// Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
|
|
210
303
|
async function fetchJson(url: string) {
|
|
@@ -220,7 +313,7 @@ export function UserProfile({
|
|
|
220
313
|
`${billingUrl}/v1/subscription`,
|
|
221
314
|
]) {
|
|
222
315
|
try {
|
|
223
|
-
const j = await fetchJson(path) as Record<string, unknown>;
|
|
316
|
+
const j = (await fetchJson(path)) as Record<string, unknown>;
|
|
224
317
|
if (j && (j as { ok?: boolean }).ok !== false) {
|
|
225
318
|
subData = j;
|
|
226
319
|
break;
|
|
@@ -236,17 +329,32 @@ export function UserProfile({
|
|
|
236
329
|
subscriptions?: Record<string, unknown>[];
|
|
237
330
|
};
|
|
238
331
|
let sub: Record<string, unknown> | null = null;
|
|
239
|
-
if (sd.subscription !== undefined)
|
|
240
|
-
|
|
332
|
+
if (sd.subscription !== undefined)
|
|
333
|
+
sub = sd.subscription as Record<string, unknown> | null;
|
|
334
|
+
else if (Array.isArray(sd.subscriptions) && sd.subscriptions.length > 0)
|
|
335
|
+
sub = sd.subscriptions[0] as Record<string, unknown>;
|
|
241
336
|
|
|
242
337
|
if (sub) {
|
|
243
338
|
setSubscription({
|
|
244
339
|
id: String(sub.id ?? ''),
|
|
245
340
|
status: String(sub.status ?? 'active'),
|
|
246
|
-
planId:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
341
|
+
planId:
|
|
342
|
+
(sub.planId as string | null) ??
|
|
343
|
+
(sub.plan_id as string | null) ??
|
|
344
|
+
null,
|
|
345
|
+
planName:
|
|
346
|
+
(sub.planName as string | null) ??
|
|
347
|
+
(sub.plan_name as string | null) ??
|
|
348
|
+
(sub.name as string | null) ??
|
|
349
|
+
null,
|
|
350
|
+
currentPeriodEnd:
|
|
351
|
+
(sub.currentPeriodEnd as string | null) ??
|
|
352
|
+
(sub.current_period_end as string | null) ??
|
|
353
|
+
(sub.currentPeriod_end as string | null) ??
|
|
354
|
+
null,
|
|
355
|
+
cancelAtPeriodEnd: Boolean(
|
|
356
|
+
sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false
|
|
357
|
+
),
|
|
250
358
|
});
|
|
251
359
|
} else {
|
|
252
360
|
setSubscription(null);
|
|
@@ -256,9 +364,15 @@ export function UserProfile({
|
|
|
256
364
|
}
|
|
257
365
|
|
|
258
366
|
// Invoices
|
|
259
|
-
for (const path of [
|
|
367
|
+
for (const path of [
|
|
368
|
+
`${billingUrl}/v1/billing/invoices`,
|
|
369
|
+
`${billingUrl}/v1/invoices`,
|
|
370
|
+
]) {
|
|
260
371
|
try {
|
|
261
|
-
const invJ = (await fetchJson(path)) as {
|
|
372
|
+
const invJ = (await fetchJson(path)) as {
|
|
373
|
+
ok?: boolean;
|
|
374
|
+
invoices?: unknown[];
|
|
375
|
+
};
|
|
262
376
|
if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
|
|
263
377
|
setInvoices(
|
|
264
378
|
(invJ.invoices as Record<string, unknown>[]).map((inv) => ({
|
|
@@ -266,7 +380,10 @@ export function UserProfile({
|
|
|
266
380
|
amount: Number(inv.amount ?? 0),
|
|
267
381
|
currency: String(inv.currency ?? 'USD'),
|
|
268
382
|
status: String(inv.status ?? 'pending'),
|
|
269
|
-
billedAt:
|
|
383
|
+
billedAt:
|
|
384
|
+
(inv.billedAt as string | null) ??
|
|
385
|
+
(inv.billed_at as string | null) ??
|
|
386
|
+
null,
|
|
270
387
|
}))
|
|
271
388
|
);
|
|
272
389
|
break;
|
|
@@ -276,14 +393,20 @@ export function UserProfile({
|
|
|
276
393
|
}
|
|
277
394
|
}
|
|
278
395
|
|
|
279
|
-
// Plans (needs projectId — if missing, try
|
|
280
|
-
const planPaths: string[] = [];
|
|
281
|
-
if (projectId) planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
|
|
282
|
-
// Also try without projectId as last resort (will 400 but we catch)
|
|
283
|
-
planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`);
|
|
284
|
-
planPaths.push(`${billingUrl}/v1/plans`);
|
|
285
|
-
|
|
396
|
+
// Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
|
|
286
397
|
let gotPlans = false;
|
|
398
|
+
const planPaths: string[] = [];
|
|
399
|
+
if (projectId) {
|
|
400
|
+
planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
|
|
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
|
+
}
|
|
287
410
|
for (const p of planPaths) {
|
|
288
411
|
try {
|
|
289
412
|
const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
|
|
@@ -307,16 +430,32 @@ export function UserProfile({
|
|
|
307
430
|
}, [client, user]);
|
|
308
431
|
|
|
309
432
|
useEffect(() => {
|
|
310
|
-
if (tab === 'security') void loadSessions();
|
|
433
|
+
if (tab === 'security') void loadSessions(0);
|
|
311
434
|
if (tab === 'billing') void loadBilling();
|
|
312
435
|
}, [tab, loadSessions, loadBilling]);
|
|
313
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
|
+
|
|
314
453
|
async function onProfileSubmit(e: FormEvent) {
|
|
315
454
|
e.preventDefault();
|
|
316
455
|
setBusy(true);
|
|
317
456
|
setSaved(false);
|
|
318
457
|
try {
|
|
319
|
-
await client.users.update({ firstName, lastName, avatarUrl });
|
|
458
|
+
await client.users.update({ firstName, lastName, username, avatarUrl });
|
|
320
459
|
await reload();
|
|
321
460
|
setSaved(true);
|
|
322
461
|
setTimeout(() => setSaved(false), 2500);
|
|
@@ -337,6 +476,98 @@ export function UserProfile({
|
|
|
337
476
|
}
|
|
338
477
|
}
|
|
339
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
|
+
|
|
340
571
|
async function onPasswordSubmit(e: FormEvent) {
|
|
341
572
|
e.preventDefault();
|
|
342
573
|
setPwError(null);
|
|
@@ -358,7 +589,9 @@ export function UserProfile({
|
|
|
358
589
|
setConfirmPassword('');
|
|
359
590
|
setTimeout(() => setPwSaved(false), 3000);
|
|
360
591
|
} catch (err) {
|
|
361
|
-
setPwError(
|
|
592
|
+
setPwError(
|
|
593
|
+
err instanceof Error ? err.message : 'Failed to change password'
|
|
594
|
+
);
|
|
362
595
|
} finally {
|
|
363
596
|
setPwBusy(false);
|
|
364
597
|
}
|
|
@@ -368,7 +601,7 @@ export function UserProfile({
|
|
|
368
601
|
setRevokingId(id);
|
|
369
602
|
try {
|
|
370
603
|
await client.sessions.revoke(id);
|
|
371
|
-
await loadSessions();
|
|
604
|
+
await loadSessions(sessionsPage);
|
|
372
605
|
} finally {
|
|
373
606
|
setRevokingId(null);
|
|
374
607
|
}
|
|
@@ -378,7 +611,7 @@ export function UserProfile({
|
|
|
378
611
|
setOthersRevoking(true);
|
|
379
612
|
try {
|
|
380
613
|
await client.sessions.revokeOthers();
|
|
381
|
-
await loadSessions();
|
|
614
|
+
await loadSessions(0);
|
|
382
615
|
} finally {
|
|
383
616
|
setOthersRevoking(false);
|
|
384
617
|
}
|
|
@@ -392,39 +625,58 @@ export function UserProfile({
|
|
|
392
625
|
await client.auth.signOut().catch(() => undefined);
|
|
393
626
|
onDeleted?.();
|
|
394
627
|
} catch (err) {
|
|
395
|
-
setDeleteError(
|
|
628
|
+
setDeleteError(
|
|
629
|
+
err instanceof Error ? err.message : 'Failed to delete account'
|
|
630
|
+
);
|
|
396
631
|
} finally {
|
|
397
632
|
setDeleteBusy(false);
|
|
398
633
|
}
|
|
399
634
|
}
|
|
400
635
|
|
|
401
|
-
async function handleCheckout(
|
|
402
|
-
setCheckoutId(
|
|
636
|
+
async function handleCheckout(plan: Plan) {
|
|
637
|
+
setCheckoutId(plan.id);
|
|
638
|
+
setCheckoutDone(false);
|
|
403
639
|
try {
|
|
640
|
+
// Always use Paddle.js overlay checkout
|
|
641
|
+
// authApiUrl is available via client apiUrl
|
|
404
642
|
const rawApiUrl =
|
|
405
|
-
(client as unknown as { apiUrl: string }).apiUrl ??
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
643
|
+
(client as unknown as { apiUrl: string }).apiUrl ??
|
|
644
|
+
'https://auth.slyxup.online';
|
|
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,
|
|
652
|
+
};
|
|
653
|
+
if (projectId) customData.projectId = projectId;
|
|
654
|
+
openPaddleCheckout(plan.paddlePriceId, user?.email, customData);
|
|
655
|
+
setCheckoutDone(true);
|
|
656
|
+
|
|
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;
|
|
426
679
|
} catch (err) {
|
|
427
|
-
// Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
|
|
428
680
|
console.error('[SlyxUp] checkout failed', err);
|
|
429
681
|
} finally {
|
|
430
682
|
setCheckoutId(null);
|
|
@@ -435,11 +687,13 @@ export function UserProfile({
|
|
|
435
687
|
if (!user) return null;
|
|
436
688
|
|
|
437
689
|
const emailVerified = user.emailVerified;
|
|
438
|
-
const name = displayName(
|
|
690
|
+
const name = displayName(
|
|
691
|
+
user as { firstName: string | null; lastName: string | null; email: string }
|
|
692
|
+
);
|
|
439
693
|
|
|
440
694
|
// Resolve current plan name via plans lookup if subscription has planId but no planName
|
|
441
695
|
const currentPlan = subscription
|
|
442
|
-
? plans.find((p) => p.id === subscription.planId) ?? null
|
|
696
|
+
? (plans.find((p) => p.id === subscription.planId) ?? null)
|
|
443
697
|
: null;
|
|
444
698
|
const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
|
|
445
699
|
|
|
@@ -497,20 +751,39 @@ export function UserProfile({
|
|
|
497
751
|
<section className="slx-profile-sec">
|
|
498
752
|
<div className="slx-avatar-row">
|
|
499
753
|
<div className="slx-avatar-lg" aria-hidden="true">
|
|
500
|
-
{user.avatarUrl ?
|
|
754
|
+
{user.avatarUrl ? (
|
|
755
|
+
<img src={user.avatarUrl} alt="" />
|
|
756
|
+
) : (
|
|
757
|
+
initials(
|
|
758
|
+
user as {
|
|
759
|
+
firstName: string | null;
|
|
760
|
+
lastName: string | null;
|
|
761
|
+
email: string;
|
|
762
|
+
}
|
|
763
|
+
)
|
|
764
|
+
)}
|
|
501
765
|
</div>
|
|
502
766
|
<div style={{ minWidth: 0 }}>
|
|
503
|
-
<p
|
|
767
|
+
<p
|
|
768
|
+
className="slx-row-value"
|
|
769
|
+
style={{ margin: 0, wordBreak: 'break-word' }}
|
|
770
|
+
>
|
|
504
771
|
{name}
|
|
505
772
|
</p>
|
|
506
|
-
<p
|
|
773
|
+
<p
|
|
774
|
+
className="slx-row-label"
|
|
775
|
+
style={{ wordBreak: 'break-all' }}
|
|
776
|
+
>
|
|
507
777
|
{user.email}
|
|
508
778
|
</p>
|
|
509
779
|
</div>
|
|
510
780
|
</div>
|
|
511
781
|
|
|
512
782
|
{saved && (
|
|
513
|
-
<p
|
|
783
|
+
<p
|
|
784
|
+
className="slx-error-text"
|
|
785
|
+
style={{ color: 'var(--slx-success)' }}
|
|
786
|
+
>
|
|
514
787
|
Profile saved.
|
|
515
788
|
</p>
|
|
516
789
|
)}
|
|
@@ -554,7 +827,27 @@ export function UserProfile({
|
|
|
554
827
|
value={avatarUrl}
|
|
555
828
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
556
829
|
/>
|
|
557
|
-
<p className="slx-hint">
|
|
830
|
+
<p className="slx-hint">
|
|
831
|
+
Paste a public image URL for your avatar.
|
|
832
|
+
</p>
|
|
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>
|
|
558
851
|
</div>
|
|
559
852
|
<button className="slx-btn" type="submit" disabled={busy}>
|
|
560
853
|
{busy ? 'Saving…' : 'Save changes'}
|
|
@@ -566,7 +859,10 @@ export function UserProfile({
|
|
|
566
859
|
<h3 className="slx-sec-title">Email</h3>
|
|
567
860
|
<div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
|
|
568
861
|
<div style={{ minWidth: 0 }}>
|
|
569
|
-
<p
|
|
862
|
+
<p
|
|
863
|
+
className="slx-row-value"
|
|
864
|
+
style={{ wordBreak: 'break-all' }}
|
|
865
|
+
>
|
|
570
866
|
{user.email}
|
|
571
867
|
</p>
|
|
572
868
|
<p className="slx-row-label">Primary email</p>
|
|
@@ -582,7 +878,9 @@ export function UserProfile({
|
|
|
582
878
|
flexWrap: 'wrap',
|
|
583
879
|
}}
|
|
584
880
|
>
|
|
585
|
-
<span className="slx-badge slx-badge-warn">
|
|
881
|
+
<span className="slx-badge slx-badge-warn">
|
|
882
|
+
Unverified
|
|
883
|
+
</span>
|
|
586
884
|
<button
|
|
587
885
|
type="button"
|
|
588
886
|
className="slx-link"
|
|
@@ -604,7 +902,10 @@ export function UserProfile({
|
|
|
604
902
|
<section className="slx-profile-sec">
|
|
605
903
|
<h3 className="slx-sec-title">Change password</h3>
|
|
606
904
|
{pwSaved && (
|
|
607
|
-
<p
|
|
905
|
+
<p
|
|
906
|
+
className="slx-error-text"
|
|
907
|
+
style={{ color: 'var(--slx-success)' }}
|
|
908
|
+
>
|
|
608
909
|
Password updated.
|
|
609
910
|
</p>
|
|
610
911
|
)}
|
|
@@ -673,10 +974,18 @@ export function UserProfile({
|
|
|
673
974
|
<div className="slx-session-meta">
|
|
674
975
|
<p className="slx-session-device">
|
|
675
976
|
{deviceLabel(s.userAgent)}
|
|
676
|
-
{s.isCurrent &&
|
|
977
|
+
{s.isCurrent && (
|
|
978
|
+
<span className="slx-badge slx-badge-accent">
|
|
979
|
+
This device
|
|
980
|
+
</span>
|
|
981
|
+
)}
|
|
677
982
|
</p>
|
|
678
983
|
<p className="slx-session-sub">
|
|
679
|
-
{[
|
|
984
|
+
{[
|
|
985
|
+
s.ipAddress,
|
|
986
|
+
`created ${formatDate(s.createdAt)}`,
|
|
987
|
+
`expires ${formatDate(s.expiresAt)}`,
|
|
988
|
+
]
|
|
680
989
|
.filter(Boolean)
|
|
681
990
|
.join(' · ')}
|
|
682
991
|
</p>
|
|
@@ -701,9 +1010,289 @@ export function UserProfile({
|
|
|
701
1010
|
onClick={onRevokeOthers}
|
|
702
1011
|
disabled={othersRevoking}
|
|
703
1012
|
>
|
|
704
|
-
{othersRevoking
|
|
1013
|
+
{othersRevoking
|
|
1014
|
+
? 'Signing out…'
|
|
1015
|
+
: 'Sign out other devices'}
|
|
705
1016
|
</button>
|
|
706
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>
|
|
707
1296
|
</>
|
|
708
1297
|
)}
|
|
709
1298
|
</section>
|
|
@@ -711,8 +1300,9 @@ export function UserProfile({
|
|
|
711
1300
|
<section className="slx-danger-zone">
|
|
712
1301
|
<p className="slx-danger-title">Danger zone</p>
|
|
713
1302
|
<p className="slx-danger-desc">
|
|
714
|
-
Permanently deletes your account and all associated data.
|
|
715
|
-
cannot be
|
|
1303
|
+
Permanently deletes your account and all associated data.
|
|
1304
|
+
Active sessions are revoked immediately. This cannot be
|
|
1305
|
+
undone.
|
|
716
1306
|
</p>
|
|
717
1307
|
{deleteError && <p className="slx-error-text">{deleteError}</p>}
|
|
718
1308
|
<div className="slx-field">
|
|
@@ -754,9 +1344,28 @@ export function UserProfile({
|
|
|
754
1344
|
<>
|
|
755
1345
|
<section className="slx-profile-sec">
|
|
756
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
|
+
)}
|
|
757
1363
|
<div className="slx-billing-card">
|
|
758
1364
|
<p className="slx-billing-plan">No active subscription</p>
|
|
759
|
-
<p className="slx-billing-detail">
|
|
1365
|
+
<p className="slx-billing-detail">
|
|
1366
|
+
You don't have a subscription yet. Choose a plan to
|
|
1367
|
+
get started.
|
|
1368
|
+
</p>
|
|
760
1369
|
</div>
|
|
761
1370
|
</section>
|
|
762
1371
|
|
|
@@ -765,8 +1374,13 @@ export function UserProfile({
|
|
|
765
1374
|
{plansLoading ? (
|
|
766
1375
|
<p className="slx-hint">Loading plans…</p>
|
|
767
1376
|
) : plans.length === 0 ? (
|
|
768
|
-
<div
|
|
769
|
-
|
|
1377
|
+
<div
|
|
1378
|
+
className="slx-billing-card"
|
|
1379
|
+
style={{ textAlign: 'center' }}
|
|
1380
|
+
>
|
|
1381
|
+
<p className="slx-billing-detail">
|
|
1382
|
+
No plans configured for this project yet.
|
|
1383
|
+
</p>
|
|
770
1384
|
<p className="slx-hint" style={{ marginTop: 6 }}>
|
|
771
1385
|
Ask your admin to create a plan in billing.
|
|
772
1386
|
</p>
|
|
@@ -774,19 +1388,37 @@ export function UserProfile({
|
|
|
774
1388
|
) : (
|
|
775
1389
|
<div className="slx-billing-plans">
|
|
776
1390
|
{plans.map((plan) => (
|
|
777
|
-
<div
|
|
778
|
-
{plan.
|
|
1391
|
+
<div
|
|
1392
|
+
key={plan.id}
|
|
1393
|
+
className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
|
|
1394
|
+
>
|
|
1395
|
+
{plan.isPopular && (
|
|
1396
|
+
<span className="slx-plan-badge">POPULAR</span>
|
|
1397
|
+
)}
|
|
779
1398
|
<p className="slx-plan-name">{plan.name}</p>
|
|
780
1399
|
<p style={{ margin: '2px 0 0' }}>
|
|
781
|
-
<span className="slx-plan-price">
|
|
782
|
-
|
|
1400
|
+
<span className="slx-plan-price">
|
|
1401
|
+
{formatCurrency(plan.amount, plan.currency)}
|
|
1402
|
+
</span>
|
|
1403
|
+
<span className="slx-plan-interval">
|
|
1404
|
+
/{plan.interval}
|
|
1405
|
+
</span>
|
|
783
1406
|
</p>
|
|
784
1407
|
{plan.trialDays ? (
|
|
785
|
-
<p
|
|
1408
|
+
<p
|
|
1409
|
+
className="slx-billing-detail"
|
|
1410
|
+
style={{
|
|
1411
|
+
color: 'var(--slx-accent)',
|
|
1412
|
+
marginTop: 4,
|
|
1413
|
+
}}
|
|
1414
|
+
>
|
|
786
1415
|
{plan.trialDays} day free trial
|
|
787
1416
|
</p>
|
|
788
1417
|
) : (
|
|
789
|
-
<p
|
|
1418
|
+
<p
|
|
1419
|
+
className="slx-billing-detail"
|
|
1420
|
+
style={{ visibility: 'hidden', marginTop: 4 }}
|
|
1421
|
+
>
|
|
790
1422
|
|
|
791
1423
|
</p>
|
|
792
1424
|
)}
|
|
@@ -798,10 +1430,12 @@ export function UserProfile({
|
|
|
798
1430
|
<button
|
|
799
1431
|
type="button"
|
|
800
1432
|
className="slx-btn slx-plan-cta"
|
|
801
|
-
onClick={() => handleCheckout(plan
|
|
1433
|
+
onClick={() => handleCheckout(plan)}
|
|
802
1434
|
disabled={checkoutId === plan.id}
|
|
803
1435
|
>
|
|
804
|
-
{checkoutId === plan.id
|
|
1436
|
+
{checkoutId === plan.id
|
|
1437
|
+
? 'Redirecting…'
|
|
1438
|
+
: 'Choose plan'}
|
|
805
1439
|
</button>
|
|
806
1440
|
</div>
|
|
807
1441
|
))}
|
|
@@ -814,11 +1448,19 @@ export function UserProfile({
|
|
|
814
1448
|
<h3 className="slx-sec-title">Invoices</h3>
|
|
815
1449
|
{invoices.map((inv) => (
|
|
816
1450
|
<div key={inv.id} className="slx-invoice-row">
|
|
817
|
-
<span className="slx-invoice-date">
|
|
818
|
-
|
|
1451
|
+
<span className="slx-invoice-date">
|
|
1452
|
+
{inv.billedAt ? formatDate(inv.billedAt) : '—'}
|
|
1453
|
+
</span>
|
|
1454
|
+
<span className="slx-invoice-amount">
|
|
1455
|
+
{formatCurrency(inv.amount, inv.currency)}
|
|
1456
|
+
</span>
|
|
819
1457
|
<span
|
|
820
1458
|
className={`slx-badge ${
|
|
821
|
-
inv.status === 'paid'
|
|
1459
|
+
inv.status === 'paid'
|
|
1460
|
+
? 'slx-badge-ok'
|
|
1461
|
+
: inv.status === 'overdue'
|
|
1462
|
+
? 'slx-badge-warn'
|
|
1463
|
+
: 'slx-badge-accent'
|
|
822
1464
|
}`}
|
|
823
1465
|
>
|
|
824
1466
|
{inv.status}
|
|
@@ -843,10 +1485,14 @@ export function UserProfile({
|
|
|
843
1485
|
}}
|
|
844
1486
|
>
|
|
845
1487
|
<div style={{ minWidth: 0 }}>
|
|
846
|
-
<p className="slx-billing-plan">
|
|
1488
|
+
<p className="slx-billing-plan">
|
|
1489
|
+
{resolvedPlanName ?? 'Subscription'}
|
|
1490
|
+
</p>
|
|
847
1491
|
<p className="slx-billing-detail">
|
|
848
1492
|
Status:{' '}
|
|
849
|
-
<span
|
|
1493
|
+
<span
|
|
1494
|
+
className={`slx-billing-status slx-billing-status-${subscription.status}`}
|
|
1495
|
+
>
|
|
850
1496
|
{subscription.status}
|
|
851
1497
|
</span>
|
|
852
1498
|
</p>
|
|
@@ -858,26 +1504,47 @@ export function UserProfile({
|
|
|
858
1504
|
</p>
|
|
859
1505
|
)}
|
|
860
1506
|
{subscription.cancelAtPeriodEnd && (
|
|
861
|
-
<p
|
|
1507
|
+
<p
|
|
1508
|
+
className="slx-billing-detail"
|
|
1509
|
+
style={{
|
|
1510
|
+
color: 'var(--slx-danger)',
|
|
1511
|
+
fontWeight: 600,
|
|
1512
|
+
}}
|
|
1513
|
+
>
|
|
862
1514
|
Scheduled to cancel at period end
|
|
863
1515
|
</p>
|
|
864
1516
|
)}
|
|
865
1517
|
</div>
|
|
866
1518
|
{currentPlan && (
|
|
867
|
-
<span
|
|
868
|
-
|
|
1519
|
+
<span
|
|
1520
|
+
className="slx-badge slx-badge-accent"
|
|
1521
|
+
style={{ flexShrink: 0 }}
|
|
1522
|
+
>
|
|
1523
|
+
{formatCurrency(
|
|
1524
|
+
currentPlan.amount,
|
|
1525
|
+
currentPlan.currency
|
|
1526
|
+
)}
|
|
1527
|
+
/{currentPlan.interval}
|
|
869
1528
|
</span>
|
|
870
1529
|
)}
|
|
871
1530
|
</div>
|
|
872
|
-
{currentPlan?.features &&
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1531
|
+
{currentPlan?.features &&
|
|
1532
|
+
currentPlan.features.length > 0 && (
|
|
1533
|
+
<ul
|
|
1534
|
+
className="slx-plan-features"
|
|
1535
|
+
style={{ margin: '12px 0 0' }}
|
|
1536
|
+
>
|
|
1537
|
+
{currentPlan.features.map((f) => (
|
|
1538
|
+
<li key={f}>{f}</li>
|
|
1539
|
+
))}
|
|
1540
|
+
</ul>
|
|
1541
|
+
)}
|
|
879
1542
|
<div className="slx-billing-actions">
|
|
880
|
-
<button
|
|
1543
|
+
<button
|
|
1544
|
+
type="button"
|
|
1545
|
+
className="slx-btn-secondary"
|
|
1546
|
+
onClick={() => void loadBilling()}
|
|
1547
|
+
>
|
|
881
1548
|
Refresh
|
|
882
1549
|
</button>
|
|
883
1550
|
</div>
|
|
@@ -893,7 +1560,8 @@ export function UserProfile({
|
|
|
893
1560
|
<section className="slx-profile-sec">
|
|
894
1561
|
<h3 className="slx-sec-title">Available plans</h3>
|
|
895
1562
|
<p className="slx-hint" style={{ marginBottom: 8 }}>
|
|
896
|
-
Switch plans anytime. Changes apply at the next billing
|
|
1563
|
+
Switch plans anytime. Changes apply at the next billing
|
|
1564
|
+
cycle.
|
|
897
1565
|
</p>
|
|
898
1566
|
<div className="slx-billing-plans">
|
|
899
1567
|
{plans.map((plan) => {
|
|
@@ -902,26 +1570,49 @@ export function UserProfile({
|
|
|
902
1570
|
: resolvedPlanName === plan.name;
|
|
903
1571
|
const currentAmount = currentPlan?.amount ?? 0;
|
|
904
1572
|
const isUpgrade = plan.amount > currentAmount;
|
|
905
|
-
const isDowngrade =
|
|
1573
|
+
const isDowngrade =
|
|
1574
|
+
plan.amount < currentAmount && !isCurrent;
|
|
906
1575
|
return (
|
|
907
1576
|
<div
|
|
908
1577
|
key={plan.id}
|
|
909
1578
|
className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
|
|
910
1579
|
style={isCurrent ? { opacity: 0.92 } : undefined}
|
|
911
1580
|
>
|
|
912
|
-
{plan.isPopular && !isCurrent &&
|
|
913
|
-
|
|
1581
|
+
{plan.isPopular && !isCurrent && (
|
|
1582
|
+
<span className="slx-plan-badge">POPULAR</span>
|
|
1583
|
+
)}
|
|
1584
|
+
{isCurrent && (
|
|
1585
|
+
<span
|
|
1586
|
+
className="slx-plan-badge"
|
|
1587
|
+
style={{ background: 'var(--slx-success)' }}
|
|
1588
|
+
>
|
|
1589
|
+
CURRENT
|
|
1590
|
+
</span>
|
|
1591
|
+
)}
|
|
914
1592
|
<p className="slx-plan-name">{plan.name}</p>
|
|
915
1593
|
<p style={{ margin: '2px 0 0' }}>
|
|
916
|
-
<span className="slx-plan-price">
|
|
917
|
-
|
|
1594
|
+
<span className="slx-plan-price">
|
|
1595
|
+
{formatCurrency(plan.amount, plan.currency)}
|
|
1596
|
+
</span>
|
|
1597
|
+
<span className="slx-plan-interval">
|
|
1598
|
+
/{plan.interval}
|
|
1599
|
+
</span>
|
|
918
1600
|
</p>
|
|
919
1601
|
{plan.trialDays ? (
|
|
920
|
-
<p
|
|
1602
|
+
<p
|
|
1603
|
+
className="slx-billing-detail"
|
|
1604
|
+
style={{
|
|
1605
|
+
color: 'var(--slx-accent)',
|
|
1606
|
+
marginTop: 4,
|
|
1607
|
+
}}
|
|
1608
|
+
>
|
|
921
1609
|
{plan.trialDays} day trial
|
|
922
1610
|
</p>
|
|
923
1611
|
) : (
|
|
924
|
-
<p
|
|
1612
|
+
<p
|
|
1613
|
+
className="slx-billing-detail"
|
|
1614
|
+
style={{ visibility: 'hidden', marginTop: 4 }}
|
|
1615
|
+
>
|
|
925
1616
|
|
|
926
1617
|
</p>
|
|
927
1618
|
)}
|
|
@@ -932,9 +1623,13 @@ export function UserProfile({
|
|
|
932
1623
|
</ul>
|
|
933
1624
|
<button
|
|
934
1625
|
type="button"
|
|
935
|
-
className={
|
|
1626
|
+
className={
|
|
1627
|
+
isCurrent
|
|
1628
|
+
? 'slx-btn-secondary slx-plan-cta'
|
|
1629
|
+
: 'slx-btn slx-plan-cta'
|
|
1630
|
+
}
|
|
936
1631
|
disabled={isCurrent || checkoutId === plan.id}
|
|
937
|
-
onClick={() => handleCheckout(plan
|
|
1632
|
+
onClick={() => handleCheckout(plan)}
|
|
938
1633
|
>
|
|
939
1634
|
{isCurrent
|
|
940
1635
|
? 'Current plan'
|
|
@@ -958,11 +1653,19 @@ export function UserProfile({
|
|
|
958
1653
|
<h3 className="slx-sec-title">Invoices</h3>
|
|
959
1654
|
{invoices.map((inv) => (
|
|
960
1655
|
<div key={inv.id} className="slx-invoice-row">
|
|
961
|
-
<span className="slx-invoice-date">
|
|
962
|
-
|
|
1656
|
+
<span className="slx-invoice-date">
|
|
1657
|
+
{inv.billedAt ? formatDate(inv.billedAt) : '—'}
|
|
1658
|
+
</span>
|
|
1659
|
+
<span className="slx-invoice-amount">
|
|
1660
|
+
{formatCurrency(inv.amount, inv.currency)}
|
|
1661
|
+
</span>
|
|
963
1662
|
<span
|
|
964
1663
|
className={`slx-badge ${
|
|
965
|
-
inv.status === 'paid'
|
|
1664
|
+
inv.status === 'paid'
|
|
1665
|
+
? 'slx-badge-ok'
|
|
1666
|
+
: inv.status === 'overdue'
|
|
1667
|
+
? 'slx-badge-warn'
|
|
1668
|
+
: 'slx-badge-accent'
|
|
966
1669
|
}`}
|
|
967
1670
|
>
|
|
968
1671
|
{inv.status}
|
|
@@ -989,6 +1692,7 @@ export function UserProfile({
|
|
|
989
1692
|
// Modal overlay — click on backdrop closes, click inside modal does not.
|
|
990
1693
|
// Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
|
|
991
1694
|
return (
|
|
1695
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
|
|
992
1696
|
<div
|
|
993
1697
|
className="slx-overlay"
|
|
994
1698
|
onMouseDown={(e) => {
|
|
@@ -999,6 +1703,8 @@ export function UserProfile({
|
|
|
999
1703
|
}}
|
|
1000
1704
|
role="presentation"
|
|
1001
1705
|
>
|
|
1706
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, no keyboard action needed */}
|
|
1707
|
+
{/* biome-ignore lint/a11y/useSemanticElements: dialog is correct for modal */}
|
|
1002
1708
|
<div
|
|
1003
1709
|
role="dialog"
|
|
1004
1710
|
aria-modal="true"
|