@slyxup/ui 0.2.14 → 0.3.0
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/dist/components/AdminPanel/AdminPanel.d.ts.map +1 -1
- package/dist/components/AdminPanel/AdminPanel.js +214 -28
- package/dist/components/AdminPanel/AdminPanel.js.map +1 -1
- package/package.json +6 -3
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -70
- package/src/components/AdminPanel/AdminPanel.tsx +0 -663
- package/src/components/BillingPortal/BillingPortal.tsx +0 -184
- package/src/components/EmailVerification/EmailVerification.tsx +0 -182
- package/src/components/ForgotPassword/ForgotPassword.tsx +0 -124
- package/src/components/PricingTable/PricingTable.tsx +0 -157
- package/src/components/ResetPassword/ResetPassword.tsx +0 -122
- package/src/components/SignIn/SignIn.tsx +0 -239
- package/src/components/SignUp/SignUp.tsx +0 -211
- package/src/components/SocialButtons/SocialButtons.tsx +0 -47
- package/src/components/UserButton/UserButton.tsx +0 -101
- package/src/components/UserProfile/UserProfile.tsx +0 -1776
- package/src/icons.tsx +0 -71
- package/src/index.ts +0 -56
- package/src/lib/paddle.ts +0 -131
- package/src/styles.ts +0 -619
- package/test/components.test.tsx +0 -135
- package/test/icons.test.tsx +0 -32
- package/test/styles.test.ts +0 -47
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -2
|
@@ -1,1776 +0,0 @@
|
|
|
1
|
-
import type { SlyxupSessionInfo } from '@slyxup/core';
|
|
2
|
-
import { useAuth, useUser } from '@slyxup/react';
|
|
3
|
-
import {
|
|
4
|
-
type FormEvent,
|
|
5
|
-
useCallback,
|
|
6
|
-
useEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from 'react';
|
|
10
|
-
import { initPaddle, openPaddleCheckout } from '../../lib/paddle';
|
|
11
|
-
import { injectStyles } from '../../styles';
|
|
12
|
-
|
|
13
|
-
export interface UserProfileProps {
|
|
14
|
-
/** Render as a centered modal overlay (default). Set false for inline usage. */
|
|
15
|
-
modal?: boolean;
|
|
16
|
-
onClose?: () => void;
|
|
17
|
-
/** Called after the account is deleted — redirect or reset app state here. */
|
|
18
|
-
onDeleted?: () => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
type Tab = 'profile' | 'security' | 'billing';
|
|
22
|
-
|
|
23
|
-
function initials(user: {
|
|
24
|
-
firstName: string | null;
|
|
25
|
-
lastName?: string | null;
|
|
26
|
-
email: string;
|
|
27
|
-
}): string {
|
|
28
|
-
const f = user.firstName?.trim();
|
|
29
|
-
const l = user.lastName?.trim();
|
|
30
|
-
if (f && l) return (f[0] + l[0]).toUpperCase();
|
|
31
|
-
if (f) return f.slice(0, 1).toUpperCase();
|
|
32
|
-
if (l) return l.slice(0, 1).toUpperCase();
|
|
33
|
-
return user.email.slice(0, 1).toUpperCase();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function displayName(user: {
|
|
37
|
-
firstName: string | null;
|
|
38
|
-
lastName: string | null;
|
|
39
|
-
email: string;
|
|
40
|
-
}): string {
|
|
41
|
-
const parts = [user.firstName?.trim(), user.lastName?.trim()].filter(Boolean);
|
|
42
|
-
const name = parts.join(' ');
|
|
43
|
-
return name || user.email;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function deviceLabel(ua: string | null): string {
|
|
47
|
-
if (!ua) return 'Unknown device';
|
|
48
|
-
const os = /Windows/i.test(ua)
|
|
49
|
-
? 'Windows'
|
|
50
|
-
: /Mac OS X|Macintosh/i.test(ua)
|
|
51
|
-
? 'macOS'
|
|
52
|
-
: /Android/i.test(ua)
|
|
53
|
-
? 'Android'
|
|
54
|
-
: /iPhone|iPad|iPod/i.test(ua)
|
|
55
|
-
? 'iOS'
|
|
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)
|
|
62
|
-
const browser = /Edg\//i.test(ua)
|
|
63
|
-
? 'Edge'
|
|
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';
|
|
81
|
-
return `${browser} · ${os}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function formatDate(iso: string): string {
|
|
85
|
-
const d = new Date(iso);
|
|
86
|
-
return Number.isNaN(d.getTime())
|
|
87
|
-
? iso
|
|
88
|
-
: d.toLocaleDateString(undefined, {
|
|
89
|
-
month: 'short',
|
|
90
|
-
day: 'numeric',
|
|
91
|
-
year: 'numeric',
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function formatCurrency(amount: number, currency: string): string {
|
|
96
|
-
try {
|
|
97
|
-
return new Intl.NumberFormat(undefined, {
|
|
98
|
-
style: 'currency',
|
|
99
|
-
currency: currency.toUpperCase(),
|
|
100
|
-
}).format(amount / 100);
|
|
101
|
-
} catch {
|
|
102
|
-
return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
interface Plan {
|
|
107
|
-
id: string;
|
|
108
|
-
name: string;
|
|
109
|
-
paddlePriceId: string;
|
|
110
|
-
amount: number;
|
|
111
|
-
currency: string;
|
|
112
|
-
interval: string;
|
|
113
|
-
trialDays: number | null;
|
|
114
|
-
features: string[] | null;
|
|
115
|
-
isPopular: boolean;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function UserProfile({
|
|
119
|
-
modal = true,
|
|
120
|
-
onClose,
|
|
121
|
-
onDeleted,
|
|
122
|
-
}: UserProfileProps) {
|
|
123
|
-
injectStyles();
|
|
124
|
-
const { isLoaded, user, reload } = useUser();
|
|
125
|
-
const { client } = useAuth();
|
|
126
|
-
|
|
127
|
-
const [tab, setTab] = useState<Tab>('profile');
|
|
128
|
-
|
|
129
|
-
// ── Profile form state ──
|
|
130
|
-
const [firstName, setFirstName] = useState('');
|
|
131
|
-
const [lastName, setLastName] = useState('');
|
|
132
|
-
const [username, setUsername] = useState('');
|
|
133
|
-
const [avatarUrl, setAvatarUrl] = useState('');
|
|
134
|
-
const [busy, setBusy] = useState(false);
|
|
135
|
-
const [saved, setSaved] = useState(false);
|
|
136
|
-
const [resending, setResending] = useState(false);
|
|
137
|
-
const [resent, setResent] = useState(false);
|
|
138
|
-
|
|
139
|
-
// ── Password form state ──
|
|
140
|
-
const [currentPassword, setCurrentPassword] = useState('');
|
|
141
|
-
const [newPassword, setNewPassword] = useState('');
|
|
142
|
-
const [confirmPassword, setConfirmPassword] = useState('');
|
|
143
|
-
const [pwBusy, setPwBusy] = useState(false);
|
|
144
|
-
const [pwError, setPwError] = useState<string | null>(null);
|
|
145
|
-
const [pwSaved, setPwSaved] = useState(false);
|
|
146
|
-
|
|
147
|
-
// ── Sessions state ──
|
|
148
|
-
const [sessions, setSessions] = useState<SlyxupSessionInfo[]>([]);
|
|
149
|
-
const [sessionsLoading, setSessionsLoading] = useState(true);
|
|
150
|
-
const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
151
|
-
const [othersRevoking, setOthersRevoking] = useState(false);
|
|
152
|
-
const [sessionsTotal, setSessionsTotal] = useState(0);
|
|
153
|
-
const [sessionsPage, setSessionsPage] = useState(0);
|
|
154
|
-
const SESSIONS_PER_PAGE = 10;
|
|
155
|
-
|
|
156
|
-
// ── Billing state ──
|
|
157
|
-
const [billingLoading, setBillingLoading] = useState(true);
|
|
158
|
-
const [subscription, setSubscription] = useState<{
|
|
159
|
-
id: string;
|
|
160
|
-
status: string;
|
|
161
|
-
planId?: string | null;
|
|
162
|
-
planName: string | null;
|
|
163
|
-
currentPeriodEnd: string | null;
|
|
164
|
-
cancelAtPeriodEnd: boolean;
|
|
165
|
-
} | null>(null);
|
|
166
|
-
const [invoices, setInvoices] = useState<
|
|
167
|
-
{
|
|
168
|
-
id: string;
|
|
169
|
-
amount: number;
|
|
170
|
-
currency: string;
|
|
171
|
-
status: string;
|
|
172
|
-
billedAt: string | null;
|
|
173
|
-
}[]
|
|
174
|
-
>([]);
|
|
175
|
-
const [plans, setPlans] = useState<Plan[]>([]);
|
|
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);
|
|
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]);
|
|
188
|
-
|
|
189
|
-
// ── Danger zone state ──
|
|
190
|
-
const [confirmText, setConfirmText] = useState('');
|
|
191
|
-
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
192
|
-
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
193
|
-
|
|
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
|
|
219
|
-
useEffect(() => {
|
|
220
|
-
if (user) {
|
|
221
|
-
setFirstName(user.firstName ?? '');
|
|
222
|
-
setLastName(user.lastName ?? '');
|
|
223
|
-
setUsername(user.username ?? '');
|
|
224
|
-
setAvatarUrl(user.avatarUrl ?? '');
|
|
225
|
-
}
|
|
226
|
-
}, [
|
|
227
|
-
user?.firstName,
|
|
228
|
-
user?.lastName,
|
|
229
|
-
user?.username,
|
|
230
|
-
user?.avatarUrl,
|
|
231
|
-
user?.id,
|
|
232
|
-
]);
|
|
233
|
-
|
|
234
|
-
useEffect(() => {
|
|
235
|
-
function onKey(e: KeyboardEvent) {
|
|
236
|
-
if (e.key === 'Escape' && modal) onClose?.();
|
|
237
|
-
}
|
|
238
|
-
document.addEventListener('keydown', onKey);
|
|
239
|
-
return () => document.removeEventListener('keydown', onKey);
|
|
240
|
-
}, [modal, onClose]);
|
|
241
|
-
|
|
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
|
-
);
|
|
262
|
-
|
|
263
|
-
const loadBilling = useCallback(async () => {
|
|
264
|
-
setBillingLoading(true);
|
|
265
|
-
setPlansLoading(true);
|
|
266
|
-
try {
|
|
267
|
-
const rawApiUrl =
|
|
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;
|
|
286
|
-
const headers: Record<string, string> = {
|
|
287
|
-
'Content-Type': 'application/json',
|
|
288
|
-
};
|
|
289
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
290
|
-
// Also forward publishable key if available (helps billing resolve project)
|
|
291
|
-
const pubKey = (client as unknown as { publishableKey?: string })
|
|
292
|
-
?.publishableKey;
|
|
293
|
-
if (pubKey && pubKey !== 'pk_test_missing')
|
|
294
|
-
headers['X-Publishable-Key'] = pubKey;
|
|
295
|
-
|
|
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);
|
|
301
|
-
|
|
302
|
-
// Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
|
|
303
|
-
async function fetchJson(url: string) {
|
|
304
|
-
const res = await fetch(url, { headers, credentials: 'include' });
|
|
305
|
-
if (!res.ok) throw new Error(String(res.status));
|
|
306
|
-
return res.json();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Subscription
|
|
310
|
-
let subData: unknown = null;
|
|
311
|
-
for (const path of [
|
|
312
|
-
`${billingUrl}/v1/billing/subscription${projectId ? `?projectId=${projectId}` : ''}`,
|
|
313
|
-
`${billingUrl}/v1/subscription`,
|
|
314
|
-
]) {
|
|
315
|
-
try {
|
|
316
|
-
const j = (await fetchJson(path)) as Record<string, unknown>;
|
|
317
|
-
if (j && (j as { ok?: boolean }).ok !== false) {
|
|
318
|
-
subData = j;
|
|
319
|
-
break;
|
|
320
|
-
}
|
|
321
|
-
} catch {
|
|
322
|
-
// try next path
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
if (subData) {
|
|
326
|
-
const sd = subData as {
|
|
327
|
-
ok?: boolean;
|
|
328
|
-
subscription?: Record<string, unknown> | null;
|
|
329
|
-
subscriptions?: Record<string, unknown>[];
|
|
330
|
-
};
|
|
331
|
-
let sub: Record<string, unknown> | null = null;
|
|
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>;
|
|
336
|
-
|
|
337
|
-
if (sub) {
|
|
338
|
-
setSubscription({
|
|
339
|
-
id: String(sub.id ?? ''),
|
|
340
|
-
status: String(sub.status ?? 'active'),
|
|
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
|
-
),
|
|
358
|
-
});
|
|
359
|
-
} else {
|
|
360
|
-
setSubscription(null);
|
|
361
|
-
}
|
|
362
|
-
} else {
|
|
363
|
-
setSubscription(null);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Invoices
|
|
367
|
-
for (const path of [
|
|
368
|
-
`${billingUrl}/v1/billing/invoices`,
|
|
369
|
-
`${billingUrl}/v1/invoices`,
|
|
370
|
-
]) {
|
|
371
|
-
try {
|
|
372
|
-
const invJ = (await fetchJson(path)) as {
|
|
373
|
-
ok?: boolean;
|
|
374
|
-
invoices?: unknown[];
|
|
375
|
-
};
|
|
376
|
-
if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
|
|
377
|
-
setInvoices(
|
|
378
|
-
(invJ.invoices as Record<string, unknown>[]).map((inv) => ({
|
|
379
|
-
id: String(inv.id),
|
|
380
|
-
amount: Number(inv.amount ?? 0),
|
|
381
|
-
currency: String(inv.currency ?? 'USD'),
|
|
382
|
-
status: String(inv.status ?? 'pending'),
|
|
383
|
-
billedAt:
|
|
384
|
-
(inv.billedAt as string | null) ??
|
|
385
|
-
(inv.billed_at as string | null) ??
|
|
386
|
-
null,
|
|
387
|
-
}))
|
|
388
|
-
);
|
|
389
|
-
break;
|
|
390
|
-
}
|
|
391
|
-
} catch {
|
|
392
|
-
// try next
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
|
|
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
|
-
}
|
|
410
|
-
for (const p of planPaths) {
|
|
411
|
-
try {
|
|
412
|
-
const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
|
|
413
|
-
if (pj?.ok !== false && Array.isArray(pj.plans)) {
|
|
414
|
-
setPlans(pj.plans);
|
|
415
|
-
gotPlans = true;
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
} catch {
|
|
419
|
-
// continue
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
if (!gotPlans) setPlans([]);
|
|
423
|
-
} catch {
|
|
424
|
-
// Billing unavailable — show empty state, keep plans empty
|
|
425
|
-
setPlans([]);
|
|
426
|
-
} finally {
|
|
427
|
-
setBillingLoading(false);
|
|
428
|
-
setPlansLoading(false);
|
|
429
|
-
}
|
|
430
|
-
}, [client, user]);
|
|
431
|
-
|
|
432
|
-
useEffect(() => {
|
|
433
|
-
if (tab === 'security') void loadSessions(0);
|
|
434
|
-
if (tab === 'billing') void loadBilling();
|
|
435
|
-
}, [tab, loadSessions, loadBilling]);
|
|
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
|
-
|
|
453
|
-
async function onProfileSubmit(e: FormEvent) {
|
|
454
|
-
e.preventDefault();
|
|
455
|
-
setBusy(true);
|
|
456
|
-
setSaved(false);
|
|
457
|
-
try {
|
|
458
|
-
await client.users.update({ firstName, lastName, username, avatarUrl });
|
|
459
|
-
await reload();
|
|
460
|
-
setSaved(true);
|
|
461
|
-
setTimeout(() => setSaved(false), 2500);
|
|
462
|
-
} finally {
|
|
463
|
-
setBusy(false);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
async function onResendVerification() {
|
|
468
|
-
if (!user) return;
|
|
469
|
-
setResending(true);
|
|
470
|
-
try {
|
|
471
|
-
await client.auth.resendVerification(user.email);
|
|
472
|
-
setResent(true);
|
|
473
|
-
setTimeout(() => setResent(false), 4000);
|
|
474
|
-
} finally {
|
|
475
|
-
setResending(false);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
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
|
-
|
|
571
|
-
async function onPasswordSubmit(e: FormEvent) {
|
|
572
|
-
e.preventDefault();
|
|
573
|
-
setPwError(null);
|
|
574
|
-
setPwSaved(false);
|
|
575
|
-
if (newPassword.length < 8) {
|
|
576
|
-
setPwError('New password must be at least 8 characters.');
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (newPassword !== confirmPassword) {
|
|
580
|
-
setPwError('Passwords do not match.');
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
setPwBusy(true);
|
|
584
|
-
try {
|
|
585
|
-
await client.password.change({ currentPassword, newPassword });
|
|
586
|
-
setPwSaved(true);
|
|
587
|
-
setCurrentPassword('');
|
|
588
|
-
setNewPassword('');
|
|
589
|
-
setConfirmPassword('');
|
|
590
|
-
setTimeout(() => setPwSaved(false), 3000);
|
|
591
|
-
} catch (err) {
|
|
592
|
-
setPwError(
|
|
593
|
-
err instanceof Error ? err.message : 'Failed to change password'
|
|
594
|
-
);
|
|
595
|
-
} finally {
|
|
596
|
-
setPwBusy(false);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function onRevoke(id: string) {
|
|
601
|
-
setRevokingId(id);
|
|
602
|
-
try {
|
|
603
|
-
await client.sessions.revoke(id);
|
|
604
|
-
await loadSessions(sessionsPage);
|
|
605
|
-
} finally {
|
|
606
|
-
setRevokingId(null);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async function onRevokeOthers() {
|
|
611
|
-
setOthersRevoking(true);
|
|
612
|
-
try {
|
|
613
|
-
await client.sessions.revokeOthers();
|
|
614
|
-
await loadSessions(0);
|
|
615
|
-
} finally {
|
|
616
|
-
setOthersRevoking(false);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
async function onDeleteAccount() {
|
|
621
|
-
setDeleteError(null);
|
|
622
|
-
setDeleteBusy(true);
|
|
623
|
-
try {
|
|
624
|
-
await client.users.delete();
|
|
625
|
-
await client.auth.signOut().catch(() => undefined);
|
|
626
|
-
onDeleted?.();
|
|
627
|
-
} catch (err) {
|
|
628
|
-
setDeleteError(
|
|
629
|
-
err instanceof Error ? err.message : 'Failed to delete account'
|
|
630
|
-
);
|
|
631
|
-
} finally {
|
|
632
|
-
setDeleteBusy(false);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
async function handleCheckout(plan: Plan) {
|
|
637
|
-
setCheckoutId(plan.id);
|
|
638
|
-
setCheckoutDone(false);
|
|
639
|
-
try {
|
|
640
|
-
// Always use Paddle.js overlay checkout
|
|
641
|
-
// authApiUrl is available via client apiUrl
|
|
642
|
-
const rawApiUrl =
|
|
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;
|
|
679
|
-
} catch (err) {
|
|
680
|
-
console.error('[SlyxUp] checkout failed', err);
|
|
681
|
-
} finally {
|
|
682
|
-
setCheckoutId(null);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
|
|
687
|
-
if (!user) return null;
|
|
688
|
-
|
|
689
|
-
const emailVerified = user.emailVerified;
|
|
690
|
-
const name = displayName(
|
|
691
|
-
user as { firstName: string | null; lastName: string | null; email: string }
|
|
692
|
-
);
|
|
693
|
-
|
|
694
|
-
// Resolve current plan name via plans lookup if subscription has planId but no planName
|
|
695
|
-
const currentPlan = subscription
|
|
696
|
-
? (plans.find((p) => p.id === subscription.planId) ?? null)
|
|
697
|
-
: null;
|
|
698
|
-
const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
|
|
699
|
-
|
|
700
|
-
const bodyInner = (
|
|
701
|
-
<>
|
|
702
|
-
<div className="slx-profile-head">
|
|
703
|
-
<h2 className="slx-profile-title">Account settings</h2>
|
|
704
|
-
{modal && (
|
|
705
|
-
<button
|
|
706
|
-
type="button"
|
|
707
|
-
className="slx-profile-close"
|
|
708
|
-
onClick={(e) => {
|
|
709
|
-
e.stopPropagation();
|
|
710
|
-
onClose?.();
|
|
711
|
-
}}
|
|
712
|
-
aria-label="Close account settings"
|
|
713
|
-
>
|
|
714
|
-
✕
|
|
715
|
-
</button>
|
|
716
|
-
)}
|
|
717
|
-
</div>
|
|
718
|
-
|
|
719
|
-
<div className="slx-profile-body">
|
|
720
|
-
<nav className="slx-profile-nav" aria-label="Settings sections">
|
|
721
|
-
<button
|
|
722
|
-
type="button"
|
|
723
|
-
className={`slx-profile-nav-btn${tab === 'profile' ? ' on' : ''}`}
|
|
724
|
-
onClick={() => setTab('profile')}
|
|
725
|
-
aria-current={tab === 'profile' ? 'page' : undefined}
|
|
726
|
-
>
|
|
727
|
-
<ProfileIcon /> Profile
|
|
728
|
-
</button>
|
|
729
|
-
<button
|
|
730
|
-
type="button"
|
|
731
|
-
className={`slx-profile-nav-btn${tab === 'security' ? ' on' : ''}`}
|
|
732
|
-
onClick={() => setTab('security')}
|
|
733
|
-
aria-current={tab === 'security' ? 'page' : undefined}
|
|
734
|
-
>
|
|
735
|
-
<ShieldIcon /> Security
|
|
736
|
-
</button>
|
|
737
|
-
<button
|
|
738
|
-
type="button"
|
|
739
|
-
className={`slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`}
|
|
740
|
-
onClick={() => setTab('billing')}
|
|
741
|
-
aria-current={tab === 'billing' ? 'page' : undefined}
|
|
742
|
-
>
|
|
743
|
-
<CreditCardIcon /> Billing
|
|
744
|
-
</button>
|
|
745
|
-
</nav>
|
|
746
|
-
|
|
747
|
-
<div className="slx-profile-content">
|
|
748
|
-
{/* ── Profile Tab ── */}
|
|
749
|
-
{tab === 'profile' && (
|
|
750
|
-
<>
|
|
751
|
-
<section className="slx-profile-sec">
|
|
752
|
-
<div className="slx-avatar-row">
|
|
753
|
-
<div className="slx-avatar-lg" aria-hidden="true">
|
|
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
|
-
)}
|
|
765
|
-
</div>
|
|
766
|
-
<div style={{ minWidth: 0 }}>
|
|
767
|
-
<p
|
|
768
|
-
className="slx-row-value"
|
|
769
|
-
style={{ margin: 0, wordBreak: 'break-word' }}
|
|
770
|
-
>
|
|
771
|
-
{name}
|
|
772
|
-
</p>
|
|
773
|
-
<p
|
|
774
|
-
className="slx-row-label"
|
|
775
|
-
style={{ wordBreak: 'break-all' }}
|
|
776
|
-
>
|
|
777
|
-
{user.email}
|
|
778
|
-
</p>
|
|
779
|
-
</div>
|
|
780
|
-
</div>
|
|
781
|
-
|
|
782
|
-
{saved && (
|
|
783
|
-
<p
|
|
784
|
-
className="slx-error-text"
|
|
785
|
-
style={{ color: 'var(--slx-success)' }}
|
|
786
|
-
>
|
|
787
|
-
Profile saved.
|
|
788
|
-
</p>
|
|
789
|
-
)}
|
|
790
|
-
|
|
791
|
-
<form onSubmit={onProfileSubmit}>
|
|
792
|
-
<div className="slx-field">
|
|
793
|
-
<label className="slx-label" htmlFor="slx-up-first">
|
|
794
|
-
First name
|
|
795
|
-
</label>
|
|
796
|
-
<input
|
|
797
|
-
id="slx-up-first"
|
|
798
|
-
className="slx-input"
|
|
799
|
-
type="text"
|
|
800
|
-
autoComplete="given-name"
|
|
801
|
-
value={firstName}
|
|
802
|
-
onChange={(e) => setFirstName(e.target.value)}
|
|
803
|
-
/>
|
|
804
|
-
</div>
|
|
805
|
-
<div className="slx-field">
|
|
806
|
-
<label className="slx-label" htmlFor="slx-up-last">
|
|
807
|
-
Last name
|
|
808
|
-
</label>
|
|
809
|
-
<input
|
|
810
|
-
id="slx-up-last"
|
|
811
|
-
className="slx-input"
|
|
812
|
-
type="text"
|
|
813
|
-
autoComplete="family-name"
|
|
814
|
-
value={lastName}
|
|
815
|
-
onChange={(e) => setLastName(e.target.value)}
|
|
816
|
-
/>
|
|
817
|
-
</div>
|
|
818
|
-
<div className="slx-field">
|
|
819
|
-
<label className="slx-label" htmlFor="slx-up-avatar">
|
|
820
|
-
Avatar URL
|
|
821
|
-
</label>
|
|
822
|
-
<input
|
|
823
|
-
id="slx-up-avatar"
|
|
824
|
-
className="slx-input"
|
|
825
|
-
type="url"
|
|
826
|
-
placeholder="https://…"
|
|
827
|
-
value={avatarUrl}
|
|
828
|
-
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
829
|
-
/>
|
|
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>
|
|
851
|
-
</div>
|
|
852
|
-
<button className="slx-btn" type="submit" disabled={busy}>
|
|
853
|
-
{busy ? 'Saving…' : 'Save changes'}
|
|
854
|
-
</button>
|
|
855
|
-
</form>
|
|
856
|
-
</section>
|
|
857
|
-
|
|
858
|
-
<section className="slx-profile-sec">
|
|
859
|
-
<h3 className="slx-sec-title">Email</h3>
|
|
860
|
-
<div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
|
|
861
|
-
<div style={{ minWidth: 0 }}>
|
|
862
|
-
<p
|
|
863
|
-
className="slx-row-value"
|
|
864
|
-
style={{ wordBreak: 'break-all' }}
|
|
865
|
-
>
|
|
866
|
-
{user.email}
|
|
867
|
-
</p>
|
|
868
|
-
<p className="slx-row-label">Primary email</p>
|
|
869
|
-
</div>
|
|
870
|
-
{emailVerified ? (
|
|
871
|
-
<span className="slx-badge slx-badge-ok">Verified</span>
|
|
872
|
-
) : (
|
|
873
|
-
<span
|
|
874
|
-
style={{
|
|
875
|
-
display: 'inline-flex',
|
|
876
|
-
alignItems: 'center',
|
|
877
|
-
gap: 8,
|
|
878
|
-
flexWrap: 'wrap',
|
|
879
|
-
}}
|
|
880
|
-
>
|
|
881
|
-
<span className="slx-badge slx-badge-warn">
|
|
882
|
-
Unverified
|
|
883
|
-
</span>
|
|
884
|
-
<button
|
|
885
|
-
type="button"
|
|
886
|
-
className="slx-link"
|
|
887
|
-
onClick={onResendVerification}
|
|
888
|
-
disabled={resending}
|
|
889
|
-
>
|
|
890
|
-
{resending ? 'Sending…' : resent ? 'Sent ✓' : 'Resend'}
|
|
891
|
-
</button>
|
|
892
|
-
</span>
|
|
893
|
-
)}
|
|
894
|
-
</div>
|
|
895
|
-
</section>
|
|
896
|
-
</>
|
|
897
|
-
)}
|
|
898
|
-
|
|
899
|
-
{/* ── Security Tab ── */}
|
|
900
|
-
{tab === 'security' && (
|
|
901
|
-
<>
|
|
902
|
-
<section className="slx-profile-sec">
|
|
903
|
-
<h3 className="slx-sec-title">Change password</h3>
|
|
904
|
-
{pwSaved && (
|
|
905
|
-
<p
|
|
906
|
-
className="slx-error-text"
|
|
907
|
-
style={{ color: 'var(--slx-success)' }}
|
|
908
|
-
>
|
|
909
|
-
Password updated.
|
|
910
|
-
</p>
|
|
911
|
-
)}
|
|
912
|
-
{pwError && <p className="slx-error-text">{pwError}</p>}
|
|
913
|
-
<form onSubmit={onPasswordSubmit}>
|
|
914
|
-
<div className="slx-field">
|
|
915
|
-
<label className="slx-label" htmlFor="slx-pw-current">
|
|
916
|
-
Current password
|
|
917
|
-
</label>
|
|
918
|
-
<input
|
|
919
|
-
id="slx-pw-current"
|
|
920
|
-
className="slx-input"
|
|
921
|
-
type="password"
|
|
922
|
-
autoComplete="current-password"
|
|
923
|
-
value={currentPassword}
|
|
924
|
-
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
925
|
-
required
|
|
926
|
-
/>
|
|
927
|
-
</div>
|
|
928
|
-
<div className="slx-field">
|
|
929
|
-
<label className="slx-label" htmlFor="slx-pw-new">
|
|
930
|
-
New password
|
|
931
|
-
</label>
|
|
932
|
-
<input
|
|
933
|
-
id="slx-pw-new"
|
|
934
|
-
className="slx-input"
|
|
935
|
-
type="password"
|
|
936
|
-
autoComplete="new-password"
|
|
937
|
-
minLength={8}
|
|
938
|
-
value={newPassword}
|
|
939
|
-
onChange={(e) => setNewPassword(e.target.value)}
|
|
940
|
-
required
|
|
941
|
-
/>
|
|
942
|
-
<p className="slx-hint">At least 8 characters.</p>
|
|
943
|
-
</div>
|
|
944
|
-
<div className="slx-field">
|
|
945
|
-
<label className="slx-label" htmlFor="slx-pw-confirm">
|
|
946
|
-
Confirm new password
|
|
947
|
-
</label>
|
|
948
|
-
<input
|
|
949
|
-
id="slx-pw-confirm"
|
|
950
|
-
className="slx-input"
|
|
951
|
-
type="password"
|
|
952
|
-
autoComplete="new-password"
|
|
953
|
-
value={confirmPassword}
|
|
954
|
-
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
955
|
-
required
|
|
956
|
-
/>
|
|
957
|
-
</div>
|
|
958
|
-
<button className="slx-btn" type="submit" disabled={pwBusy}>
|
|
959
|
-
{pwBusy ? 'Updating…' : 'Update password'}
|
|
960
|
-
</button>
|
|
961
|
-
</form>
|
|
962
|
-
</section>
|
|
963
|
-
|
|
964
|
-
<section className="slx-profile-sec">
|
|
965
|
-
<h3 className="slx-sec-title">Active sessions</h3>
|
|
966
|
-
{sessionsLoading ? (
|
|
967
|
-
<p className="slx-hint">Loading sessions…</p>
|
|
968
|
-
) : sessions.length === 0 ? (
|
|
969
|
-
<p className="slx-hint">No active sessions.</p>
|
|
970
|
-
) : (
|
|
971
|
-
<>
|
|
972
|
-
{sessions.map((s) => (
|
|
973
|
-
<div key={s.id} className="slx-session">
|
|
974
|
-
<div className="slx-session-meta">
|
|
975
|
-
<p className="slx-session-device">
|
|
976
|
-
{deviceLabel(s.userAgent)}
|
|
977
|
-
{s.isCurrent && (
|
|
978
|
-
<span className="slx-badge slx-badge-accent">
|
|
979
|
-
This device
|
|
980
|
-
</span>
|
|
981
|
-
)}
|
|
982
|
-
</p>
|
|
983
|
-
<p className="slx-session-sub">
|
|
984
|
-
{[
|
|
985
|
-
s.ipAddress,
|
|
986
|
-
`created ${formatDate(s.createdAt)}`,
|
|
987
|
-
`expires ${formatDate(s.expiresAt)}`,
|
|
988
|
-
]
|
|
989
|
-
.filter(Boolean)
|
|
990
|
-
.join(' · ')}
|
|
991
|
-
</p>
|
|
992
|
-
</div>
|
|
993
|
-
{!s.isCurrent && (
|
|
994
|
-
<button
|
|
995
|
-
type="button"
|
|
996
|
-
className="slx-btn-danger-outline"
|
|
997
|
-
onClick={() => onRevoke(s.id)}
|
|
998
|
-
disabled={revokingId === s.id}
|
|
999
|
-
>
|
|
1000
|
-
{revokingId === s.id ? '…' : 'Revoke'}
|
|
1001
|
-
</button>
|
|
1002
|
-
)}
|
|
1003
|
-
</div>
|
|
1004
|
-
))}
|
|
1005
|
-
{sessions.some((s) => !s.isCurrent) && (
|
|
1006
|
-
<button
|
|
1007
|
-
type="button"
|
|
1008
|
-
className="slx-btn-danger-outline"
|
|
1009
|
-
style={{ width: '100%', marginTop: 4 }}
|
|
1010
|
-
onClick={onRevokeOthers}
|
|
1011
|
-
disabled={othersRevoking}
|
|
1012
|
-
>
|
|
1013
|
-
{othersRevoking
|
|
1014
|
-
? 'Signing out…'
|
|
1015
|
-
: 'Sign out other devices'}
|
|
1016
|
-
</button>
|
|
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>
|
|
1296
|
-
</>
|
|
1297
|
-
)}
|
|
1298
|
-
</section>
|
|
1299
|
-
|
|
1300
|
-
<section className="slx-danger-zone">
|
|
1301
|
-
<p className="slx-danger-title">Danger zone</p>
|
|
1302
|
-
<p className="slx-danger-desc">
|
|
1303
|
-
Permanently deletes your account and all associated data.
|
|
1304
|
-
Active sessions are revoked immediately. This cannot be
|
|
1305
|
-
undone.
|
|
1306
|
-
</p>
|
|
1307
|
-
{deleteError && <p className="slx-error-text">{deleteError}</p>}
|
|
1308
|
-
<div className="slx-field">
|
|
1309
|
-
<label className="slx-label" htmlFor="slx-del-confirm">
|
|
1310
|
-
Type <strong>DELETE</strong> to confirm
|
|
1311
|
-
</label>
|
|
1312
|
-
<input
|
|
1313
|
-
id="slx-del-confirm"
|
|
1314
|
-
className="slx-input"
|
|
1315
|
-
type="text"
|
|
1316
|
-
value={confirmText}
|
|
1317
|
-
onChange={(e) => setConfirmText(e.target.value)}
|
|
1318
|
-
placeholder="DELETE"
|
|
1319
|
-
/>
|
|
1320
|
-
</div>
|
|
1321
|
-
<button
|
|
1322
|
-
type="button"
|
|
1323
|
-
className="slx-btn"
|
|
1324
|
-
style={{
|
|
1325
|
-
background: 'var(--slx-danger)',
|
|
1326
|
-
borderColor: 'var(--slx-danger)',
|
|
1327
|
-
}}
|
|
1328
|
-
onClick={onDeleteAccount}
|
|
1329
|
-
disabled={confirmText !== 'DELETE' || deleteBusy}
|
|
1330
|
-
>
|
|
1331
|
-
{deleteBusy ? 'Deleting…' : 'Delete my account forever'}
|
|
1332
|
-
</button>
|
|
1333
|
-
</section>
|
|
1334
|
-
</>
|
|
1335
|
-
)}
|
|
1336
|
-
|
|
1337
|
-
{/* ── Billing Tab ── */}
|
|
1338
|
-
{tab === 'billing' &&
|
|
1339
|
-
(billingLoading ? (
|
|
1340
|
-
<section className="slx-profile-sec">
|
|
1341
|
-
<p className="slx-hint">Loading billing information…</p>
|
|
1342
|
-
</section>
|
|
1343
|
-
) : !subscription ? (
|
|
1344
|
-
<>
|
|
1345
|
-
<section className="slx-profile-sec">
|
|
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
|
-
)}
|
|
1363
|
-
<div className="slx-billing-card">
|
|
1364
|
-
<p className="slx-billing-plan">No active subscription</p>
|
|
1365
|
-
<p className="slx-billing-detail">
|
|
1366
|
-
You don't have a subscription yet. Choose a plan to
|
|
1367
|
-
get started.
|
|
1368
|
-
</p>
|
|
1369
|
-
</div>
|
|
1370
|
-
</section>
|
|
1371
|
-
|
|
1372
|
-
<section className="slx-profile-sec">
|
|
1373
|
-
<h3 className="slx-sec-title">Available plans</h3>
|
|
1374
|
-
{plansLoading ? (
|
|
1375
|
-
<p className="slx-hint">Loading plans…</p>
|
|
1376
|
-
) : plans.length === 0 ? (
|
|
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>
|
|
1384
|
-
<p className="slx-hint" style={{ marginTop: 6 }}>
|
|
1385
|
-
Ask your admin to create a plan in billing.
|
|
1386
|
-
</p>
|
|
1387
|
-
</div>
|
|
1388
|
-
) : (
|
|
1389
|
-
<div className="slx-billing-plans">
|
|
1390
|
-
{plans.map((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
|
-
)}
|
|
1398
|
-
<p className="slx-plan-name">{plan.name}</p>
|
|
1399
|
-
<p style={{ margin: '2px 0 0' }}>
|
|
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>
|
|
1406
|
-
</p>
|
|
1407
|
-
{plan.trialDays ? (
|
|
1408
|
-
<p
|
|
1409
|
-
className="slx-billing-detail"
|
|
1410
|
-
style={{
|
|
1411
|
-
color: 'var(--slx-accent)',
|
|
1412
|
-
marginTop: 4,
|
|
1413
|
-
}}
|
|
1414
|
-
>
|
|
1415
|
-
{plan.trialDays} day free trial
|
|
1416
|
-
</p>
|
|
1417
|
-
) : (
|
|
1418
|
-
<p
|
|
1419
|
-
className="slx-billing-detail"
|
|
1420
|
-
style={{ visibility: 'hidden', marginTop: 4 }}
|
|
1421
|
-
>
|
|
1422
|
-
|
|
1423
|
-
</p>
|
|
1424
|
-
)}
|
|
1425
|
-
<ul className="slx-plan-features">
|
|
1426
|
-
{(plan.features ?? []).map((f) => (
|
|
1427
|
-
<li key={f}>{f}</li>
|
|
1428
|
-
))}
|
|
1429
|
-
</ul>
|
|
1430
|
-
<button
|
|
1431
|
-
type="button"
|
|
1432
|
-
className="slx-btn slx-plan-cta"
|
|
1433
|
-
onClick={() => handleCheckout(plan)}
|
|
1434
|
-
disabled={checkoutId === plan.id}
|
|
1435
|
-
>
|
|
1436
|
-
{checkoutId === plan.id
|
|
1437
|
-
? 'Redirecting…'
|
|
1438
|
-
: 'Choose plan'}
|
|
1439
|
-
</button>
|
|
1440
|
-
</div>
|
|
1441
|
-
))}
|
|
1442
|
-
</div>
|
|
1443
|
-
)}
|
|
1444
|
-
</section>
|
|
1445
|
-
|
|
1446
|
-
{invoices.length > 0 && (
|
|
1447
|
-
<section className="slx-profile-sec">
|
|
1448
|
-
<h3 className="slx-sec-title">Invoices</h3>
|
|
1449
|
-
{invoices.map((inv) => (
|
|
1450
|
-
<div key={inv.id} className="slx-invoice-row">
|
|
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>
|
|
1457
|
-
<span
|
|
1458
|
-
className={`slx-badge ${
|
|
1459
|
-
inv.status === 'paid'
|
|
1460
|
-
? 'slx-badge-ok'
|
|
1461
|
-
: inv.status === 'overdue'
|
|
1462
|
-
? 'slx-badge-warn'
|
|
1463
|
-
: 'slx-badge-accent'
|
|
1464
|
-
}`}
|
|
1465
|
-
>
|
|
1466
|
-
{inv.status}
|
|
1467
|
-
</span>
|
|
1468
|
-
</div>
|
|
1469
|
-
))}
|
|
1470
|
-
</section>
|
|
1471
|
-
)}
|
|
1472
|
-
</>
|
|
1473
|
-
) : (
|
|
1474
|
-
<>
|
|
1475
|
-
<section className="slx-profile-sec">
|
|
1476
|
-
<h3 className="slx-sec-title">Current plan</h3>
|
|
1477
|
-
<div className="slx-billing-card">
|
|
1478
|
-
<div
|
|
1479
|
-
style={{
|
|
1480
|
-
display: 'flex',
|
|
1481
|
-
justifyContent: 'space-between',
|
|
1482
|
-
alignItems: 'flex-start',
|
|
1483
|
-
gap: 12,
|
|
1484
|
-
flexWrap: 'wrap',
|
|
1485
|
-
}}
|
|
1486
|
-
>
|
|
1487
|
-
<div style={{ minWidth: 0 }}>
|
|
1488
|
-
<p className="slx-billing-plan">
|
|
1489
|
-
{resolvedPlanName ?? 'Subscription'}
|
|
1490
|
-
</p>
|
|
1491
|
-
<p className="slx-billing-detail">
|
|
1492
|
-
Status:{' '}
|
|
1493
|
-
<span
|
|
1494
|
-
className={`slx-billing-status slx-billing-status-${subscription.status}`}
|
|
1495
|
-
>
|
|
1496
|
-
{subscription.status}
|
|
1497
|
-
</span>
|
|
1498
|
-
</p>
|
|
1499
|
-
{subscription.currentPeriodEnd && (
|
|
1500
|
-
<p className="slx-billing-detail">
|
|
1501
|
-
{subscription.cancelAtPeriodEnd
|
|
1502
|
-
? `Cancels ${formatDate(subscription.currentPeriodEnd)}`
|
|
1503
|
-
: `Renews ${formatDate(subscription.currentPeriodEnd)}`}
|
|
1504
|
-
</p>
|
|
1505
|
-
)}
|
|
1506
|
-
{subscription.cancelAtPeriodEnd && (
|
|
1507
|
-
<p
|
|
1508
|
-
className="slx-billing-detail"
|
|
1509
|
-
style={{
|
|
1510
|
-
color: 'var(--slx-danger)',
|
|
1511
|
-
fontWeight: 600,
|
|
1512
|
-
}}
|
|
1513
|
-
>
|
|
1514
|
-
Scheduled to cancel at period end
|
|
1515
|
-
</p>
|
|
1516
|
-
)}
|
|
1517
|
-
</div>
|
|
1518
|
-
{currentPlan && (
|
|
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}
|
|
1528
|
-
</span>
|
|
1529
|
-
)}
|
|
1530
|
-
</div>
|
|
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
|
-
)}
|
|
1542
|
-
<div className="slx-billing-actions">
|
|
1543
|
-
<button
|
|
1544
|
-
type="button"
|
|
1545
|
-
className="slx-btn-secondary"
|
|
1546
|
-
onClick={() => void loadBilling()}
|
|
1547
|
-
>
|
|
1548
|
-
Refresh
|
|
1549
|
-
</button>
|
|
1550
|
-
</div>
|
|
1551
|
-
</div>
|
|
1552
|
-
</section>
|
|
1553
|
-
|
|
1554
|
-
{/* Upgrade / Downgrade plans */}
|
|
1555
|
-
{plansLoading ? (
|
|
1556
|
-
<section className="slx-profile-sec">
|
|
1557
|
-
<p className="slx-hint">Loading available plans…</p>
|
|
1558
|
-
</section>
|
|
1559
|
-
) : plans.length > 0 ? (
|
|
1560
|
-
<section className="slx-profile-sec">
|
|
1561
|
-
<h3 className="slx-sec-title">Available plans</h3>
|
|
1562
|
-
<p className="slx-hint" style={{ marginBottom: 8 }}>
|
|
1563
|
-
Switch plans anytime. Changes apply at the next billing
|
|
1564
|
-
cycle.
|
|
1565
|
-
</p>
|
|
1566
|
-
<div className="slx-billing-plans">
|
|
1567
|
-
{plans.map((plan) => {
|
|
1568
|
-
const isCurrent = subscription.planId
|
|
1569
|
-
? subscription.planId === plan.id
|
|
1570
|
-
: resolvedPlanName === plan.name;
|
|
1571
|
-
const currentAmount = currentPlan?.amount ?? 0;
|
|
1572
|
-
const isUpgrade = plan.amount > currentAmount;
|
|
1573
|
-
const isDowngrade =
|
|
1574
|
-
plan.amount < currentAmount && !isCurrent;
|
|
1575
|
-
return (
|
|
1576
|
-
<div
|
|
1577
|
-
key={plan.id}
|
|
1578
|
-
className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
|
|
1579
|
-
style={isCurrent ? { opacity: 0.92 } : undefined}
|
|
1580
|
-
>
|
|
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
|
-
)}
|
|
1592
|
-
<p className="slx-plan-name">{plan.name}</p>
|
|
1593
|
-
<p style={{ margin: '2px 0 0' }}>
|
|
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>
|
|
1600
|
-
</p>
|
|
1601
|
-
{plan.trialDays ? (
|
|
1602
|
-
<p
|
|
1603
|
-
className="slx-billing-detail"
|
|
1604
|
-
style={{
|
|
1605
|
-
color: 'var(--slx-accent)',
|
|
1606
|
-
marginTop: 4,
|
|
1607
|
-
}}
|
|
1608
|
-
>
|
|
1609
|
-
{plan.trialDays} day trial
|
|
1610
|
-
</p>
|
|
1611
|
-
) : (
|
|
1612
|
-
<p
|
|
1613
|
-
className="slx-billing-detail"
|
|
1614
|
-
style={{ visibility: 'hidden', marginTop: 4 }}
|
|
1615
|
-
>
|
|
1616
|
-
|
|
1617
|
-
</p>
|
|
1618
|
-
)}
|
|
1619
|
-
<ul className="slx-plan-features">
|
|
1620
|
-
{(plan.features ?? []).map((f) => (
|
|
1621
|
-
<li key={f}>{f}</li>
|
|
1622
|
-
))}
|
|
1623
|
-
</ul>
|
|
1624
|
-
<button
|
|
1625
|
-
type="button"
|
|
1626
|
-
className={
|
|
1627
|
-
isCurrent
|
|
1628
|
-
? 'slx-btn-secondary slx-plan-cta'
|
|
1629
|
-
: 'slx-btn slx-plan-cta'
|
|
1630
|
-
}
|
|
1631
|
-
disabled={isCurrent || checkoutId === plan.id}
|
|
1632
|
-
onClick={() => handleCheckout(plan)}
|
|
1633
|
-
>
|
|
1634
|
-
{isCurrent
|
|
1635
|
-
? 'Current plan'
|
|
1636
|
-
: checkoutId === plan.id
|
|
1637
|
-
? 'Redirecting…'
|
|
1638
|
-
: isUpgrade
|
|
1639
|
-
? 'Upgrade'
|
|
1640
|
-
: isDowngrade
|
|
1641
|
-
? 'Downgrade'
|
|
1642
|
-
: 'Switch plan'}
|
|
1643
|
-
</button>
|
|
1644
|
-
</div>
|
|
1645
|
-
);
|
|
1646
|
-
})}
|
|
1647
|
-
</div>
|
|
1648
|
-
</section>
|
|
1649
|
-
) : null}
|
|
1650
|
-
|
|
1651
|
-
{invoices.length > 0 && (
|
|
1652
|
-
<section className="slx-profile-sec">
|
|
1653
|
-
<h3 className="slx-sec-title">Invoices</h3>
|
|
1654
|
-
{invoices.map((inv) => (
|
|
1655
|
-
<div key={inv.id} className="slx-invoice-row">
|
|
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>
|
|
1662
|
-
<span
|
|
1663
|
-
className={`slx-badge ${
|
|
1664
|
-
inv.status === 'paid'
|
|
1665
|
-
? 'slx-badge-ok'
|
|
1666
|
-
: inv.status === 'overdue'
|
|
1667
|
-
? 'slx-badge-warn'
|
|
1668
|
-
: 'slx-badge-accent'
|
|
1669
|
-
}`}
|
|
1670
|
-
>
|
|
1671
|
-
{inv.status}
|
|
1672
|
-
</span>
|
|
1673
|
-
</div>
|
|
1674
|
-
))}
|
|
1675
|
-
</section>
|
|
1676
|
-
)}
|
|
1677
|
-
</>
|
|
1678
|
-
))}
|
|
1679
|
-
</div>
|
|
1680
|
-
</div>
|
|
1681
|
-
</>
|
|
1682
|
-
);
|
|
1683
|
-
|
|
1684
|
-
if (!modal) {
|
|
1685
|
-
return (
|
|
1686
|
-
<div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
|
|
1687
|
-
{bodyInner}
|
|
1688
|
-
</div>
|
|
1689
|
-
);
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
// Modal overlay — click on backdrop closes, click inside modal does not.
|
|
1693
|
-
// Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
|
|
1694
|
-
return (
|
|
1695
|
-
// biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
|
|
1696
|
-
<div
|
|
1697
|
-
className="slx-overlay"
|
|
1698
|
-
onMouseDown={(e) => {
|
|
1699
|
-
if (e.target === e.currentTarget) onClose?.();
|
|
1700
|
-
}}
|
|
1701
|
-
onClick={(e) => {
|
|
1702
|
-
if (e.target === e.currentTarget) onClose?.();
|
|
1703
|
-
}}
|
|
1704
|
-
role="presentation"
|
|
1705
|
-
>
|
|
1706
|
-
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, no keyboard action needed */}
|
|
1707
|
-
{/* biome-ignore lint/a11y/useSemanticElements: dialog is correct for modal */}
|
|
1708
|
-
<div
|
|
1709
|
-
role="dialog"
|
|
1710
|
-
aria-modal="true"
|
|
1711
|
-
aria-label="Account settings"
|
|
1712
|
-
className="slx-profile-modal"
|
|
1713
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
1714
|
-
onClick={(e) => e.stopPropagation()}
|
|
1715
|
-
>
|
|
1716
|
-
{bodyInner}
|
|
1717
|
-
</div>
|
|
1718
|
-
</div>
|
|
1719
|
-
);
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
function ProfileIcon() {
|
|
1723
|
-
return (
|
|
1724
|
-
<svg
|
|
1725
|
-
width="15"
|
|
1726
|
-
height="15"
|
|
1727
|
-
viewBox="0 0 24 24"
|
|
1728
|
-
fill="none"
|
|
1729
|
-
stroke="currentColor"
|
|
1730
|
-
strokeWidth="2"
|
|
1731
|
-
strokeLinecap="round"
|
|
1732
|
-
aria-hidden="true"
|
|
1733
|
-
>
|
|
1734
|
-
<circle cx="12" cy="8" r="4" />
|
|
1735
|
-
<path d="M5 21c0-3.9 3.1-7 7-7s7 3.1 7 7" />
|
|
1736
|
-
</svg>
|
|
1737
|
-
);
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
function ShieldIcon() {
|
|
1741
|
-
return (
|
|
1742
|
-
<svg
|
|
1743
|
-
width="15"
|
|
1744
|
-
height="15"
|
|
1745
|
-
viewBox="0 0 24 24"
|
|
1746
|
-
fill="none"
|
|
1747
|
-
stroke="currentColor"
|
|
1748
|
-
strokeWidth="2"
|
|
1749
|
-
strokeLinecap="round"
|
|
1750
|
-
strokeLinejoin="round"
|
|
1751
|
-
aria-hidden="true"
|
|
1752
|
-
>
|
|
1753
|
-
<path d="M12 22s8-3.6 8-10V5l-8-3-8 3v7c0 6.4 8 10 8 10z" />
|
|
1754
|
-
<path d="m9 12 2 2 4-4" />
|
|
1755
|
-
</svg>
|
|
1756
|
-
);
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
function CreditCardIcon() {
|
|
1760
|
-
return (
|
|
1761
|
-
<svg
|
|
1762
|
-
width="15"
|
|
1763
|
-
height="15"
|
|
1764
|
-
viewBox="0 0 24 24"
|
|
1765
|
-
fill="none"
|
|
1766
|
-
stroke="currentColor"
|
|
1767
|
-
strokeWidth="2"
|
|
1768
|
-
strokeLinecap="round"
|
|
1769
|
-
strokeLinejoin="round"
|
|
1770
|
-
aria-hidden="true"
|
|
1771
|
-
>
|
|
1772
|
-
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
|
1773
|
-
<line x1="1" y1="10" x2="23" y2="10" />
|
|
1774
|
-
</svg>
|
|
1775
|
-
);
|
|
1776
|
-
}
|