@slyxup/ui 0.2.2 → 0.2.4
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 +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +67 -3
- package/dist/components/SignIn/SignIn.d.ts +3 -1
- package/dist/components/SignIn/SignIn.d.ts.map +1 -1
- package/dist/components/SignIn/SignIn.js +2 -10
- 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 +1 -9
- package/dist/components/SignUp/SignUp.js.map +1 -1
- package/dist/components/UserProfile/UserProfile.d.ts +8 -2
- package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/components/UserProfile/UserProfile.js +200 -6
- package/dist/components/UserProfile/UserProfile.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.d.ts +6 -3
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +194 -73
- package/dist/styles.js.map +1 -1
- package/package.json +2 -2
- package/src/components/SignIn/SignIn.tsx +18 -15
- package/src/components/SignUp/SignUp.tsx +1 -11
- package/src/components/UserProfile/UserProfile.tsx +536 -56
- package/src/index.ts +4 -1
- package/src/styles.ts +194 -73
|
@@ -1,17 +1,99 @@
|
|
|
1
|
+
import type { SlyxupSessionInfo } from '@slyxup/core';
|
|
1
2
|
import { useAuth, useUser } from '@slyxup/react';
|
|
2
|
-
import { type FormEvent, useEffect, useState } from 'react';
|
|
3
|
+
import { type FormEvent, useCallback, useEffect, useState } from 'react';
|
|
3
4
|
import { injectStyles } from '../../styles';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
export interface UserProfileProps {
|
|
7
|
+
/** Render as a centered modal overlay (default). Set false for inline usage. */
|
|
8
|
+
modal?: boolean;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
/** Called after the account is deleted — redirect or reset app state here. */
|
|
11
|
+
onDeleted?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Tab = 'profile' | 'security';
|
|
15
|
+
|
|
16
|
+
function initials(user: { firstName: string | null; email: string }): string {
|
|
17
|
+
const n = user.firstName?.trim();
|
|
18
|
+
if (n) return n.slice(0, 1).toUpperCase();
|
|
19
|
+
return user.email.slice(0, 1).toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Best-effort device label from a user agent string. */
|
|
23
|
+
function deviceLabel(ua: string | null): string {
|
|
24
|
+
if (!ua) return 'Unknown device';
|
|
25
|
+
const os = /Windows/i.test(ua)
|
|
26
|
+
? 'Windows'
|
|
27
|
+
: /Mac OS X|Macintosh/i.test(ua)
|
|
28
|
+
? 'macOS'
|
|
29
|
+
: /Android/i.test(ua)
|
|
30
|
+
? 'Android'
|
|
31
|
+
: /iPhone|iPad|iOS/i.test(ua)
|
|
32
|
+
? 'iOS'
|
|
33
|
+
: /Linux/i.test(ua)
|
|
34
|
+
? 'Linux'
|
|
35
|
+
: 'Unknown OS';
|
|
36
|
+
const browser = /Edg\//i.test(ua)
|
|
37
|
+
? 'Edge'
|
|
38
|
+
: /Chrome\//i.test(ua)
|
|
39
|
+
? 'Chrome'
|
|
40
|
+
: /Safari\//i.test(ua)
|
|
41
|
+
? 'Safari'
|
|
42
|
+
: /Firefox\//i.test(ua)
|
|
43
|
+
? 'Firefox'
|
|
44
|
+
: 'Browser';
|
|
45
|
+
return `${browser} · ${os}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatDate(iso: string): string {
|
|
49
|
+
const d = new Date(iso);
|
|
50
|
+
return Number.isNaN(d.getTime())
|
|
51
|
+
? iso
|
|
52
|
+
: d.toLocaleDateString(undefined, {
|
|
53
|
+
month: 'short',
|
|
54
|
+
day: 'numeric',
|
|
55
|
+
year: 'numeric',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function UserProfile({
|
|
60
|
+
modal = true,
|
|
61
|
+
onClose,
|
|
62
|
+
onDeleted,
|
|
63
|
+
}: UserProfileProps) {
|
|
7
64
|
injectStyles();
|
|
8
65
|
const { isLoaded, user, reload } = useUser();
|
|
9
66
|
const { client } = useAuth();
|
|
67
|
+
|
|
68
|
+
const [tab, setTab] = useState<Tab>('profile');
|
|
69
|
+
|
|
70
|
+
// ── Profile form state ──
|
|
10
71
|
const [firstName, setFirstName] = useState('');
|
|
11
72
|
const [lastName, setLastName] = useState('');
|
|
12
73
|
const [avatarUrl, setAvatarUrl] = useState('');
|
|
13
74
|
const [busy, setBusy] = useState(false);
|
|
14
75
|
const [saved, setSaved] = useState(false);
|
|
76
|
+
const [resending, setResending] = useState(false);
|
|
77
|
+
const [resent, setResent] = useState(false);
|
|
78
|
+
|
|
79
|
+
// ── Password form state ──
|
|
80
|
+
const [currentPassword, setCurrentPassword] = useState('');
|
|
81
|
+
const [newPassword, setNewPassword] = useState('');
|
|
82
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
83
|
+
const [pwBusy, setPwBusy] = useState(false);
|
|
84
|
+
const [pwError, setPwError] = useState<string | null>(null);
|
|
85
|
+
const [pwSaved, setPwSaved] = useState(false);
|
|
86
|
+
|
|
87
|
+
// ── Sessions state ──
|
|
88
|
+
const [sessions, setSessions] = useState<SlyxupSessionInfo[]>([]);
|
|
89
|
+
const [sessionsLoading, setSessionsLoading] = useState(true);
|
|
90
|
+
const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
91
|
+
const [othersRevoking, setOthersRevoking] = useState(false);
|
|
92
|
+
|
|
93
|
+
// ── Danger zone state ──
|
|
94
|
+
const [confirmText, setConfirmText] = useState('');
|
|
95
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
96
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
15
97
|
|
|
16
98
|
useEffect(() => {
|
|
17
99
|
if (user) {
|
|
@@ -21,7 +103,31 @@ export function UserProfile() {
|
|
|
21
103
|
}
|
|
22
104
|
}, [user]);
|
|
23
105
|
|
|
24
|
-
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
function onKey(e: KeyboardEvent) {
|
|
108
|
+
if (e.key === 'Escape' && modal) onClose?.();
|
|
109
|
+
}
|
|
110
|
+
document.addEventListener('keydown', onKey);
|
|
111
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
112
|
+
}, [modal, onClose]);
|
|
113
|
+
|
|
114
|
+
const loadSessions = useCallback(async () => {
|
|
115
|
+
setSessionsLoading(true);
|
|
116
|
+
try {
|
|
117
|
+
const res = await client.sessions.list();
|
|
118
|
+
setSessions(res.sessions);
|
|
119
|
+
} catch {
|
|
120
|
+
setSessions([]);
|
|
121
|
+
} finally {
|
|
122
|
+
setSessionsLoading(false);
|
|
123
|
+
}
|
|
124
|
+
}, [client]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (tab === 'security') void loadSessions();
|
|
128
|
+
}, [tab, loadSessions]);
|
|
129
|
+
|
|
130
|
+
async function onProfileSubmit(e: FormEvent) {
|
|
25
131
|
e.preventDefault();
|
|
26
132
|
setBusy(true);
|
|
27
133
|
setSaved(false);
|
|
@@ -35,61 +141,435 @@ export function UserProfile() {
|
|
|
35
141
|
}
|
|
36
142
|
}
|
|
37
143
|
|
|
144
|
+
async function onResendVerification() {
|
|
145
|
+
if (!user) return;
|
|
146
|
+
setResending(true);
|
|
147
|
+
try {
|
|
148
|
+
await client.auth.resendVerification(user.email);
|
|
149
|
+
setResent(true);
|
|
150
|
+
setTimeout(() => setResent(false), 4000);
|
|
151
|
+
} finally {
|
|
152
|
+
setResending(false);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function onPasswordSubmit(e: FormEvent) {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
setPwError(null);
|
|
159
|
+
setPwSaved(false);
|
|
160
|
+
if (newPassword.length < 8) {
|
|
161
|
+
setPwError('New password must be at least 8 characters.');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (newPassword !== confirmPassword) {
|
|
165
|
+
setPwError('Passwords do not match.');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setPwBusy(true);
|
|
169
|
+
try {
|
|
170
|
+
await client.password.change({ currentPassword, newPassword });
|
|
171
|
+
setPwSaved(true);
|
|
172
|
+
setCurrentPassword('');
|
|
173
|
+
setNewPassword('');
|
|
174
|
+
setConfirmPassword('');
|
|
175
|
+
setTimeout(() => setPwSaved(false), 3000);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
setPwError(
|
|
178
|
+
err instanceof Error ? err.message : 'Failed to change password'
|
|
179
|
+
);
|
|
180
|
+
} finally {
|
|
181
|
+
setPwBusy(false);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function onRevoke(id: string) {
|
|
186
|
+
setRevokingId(id);
|
|
187
|
+
try {
|
|
188
|
+
await client.sessions.revoke(id);
|
|
189
|
+
await loadSessions();
|
|
190
|
+
} finally {
|
|
191
|
+
setRevokingId(null);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function onRevokeOthers() {
|
|
196
|
+
setOthersRevoking(true);
|
|
197
|
+
try {
|
|
198
|
+
await client.sessions.revokeOthers();
|
|
199
|
+
await loadSessions();
|
|
200
|
+
} finally {
|
|
201
|
+
setOthersRevoking(false);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function onDeleteAccount() {
|
|
206
|
+
setDeleteError(null);
|
|
207
|
+
setDeleteBusy(true);
|
|
208
|
+
try {
|
|
209
|
+
await client.users.delete();
|
|
210
|
+
await client.auth.signOut().catch(() => undefined);
|
|
211
|
+
onDeleted?.();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
setDeleteError(
|
|
214
|
+
err instanceof Error ? err.message : 'Failed to delete account'
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
setDeleteBusy(false);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
38
221
|
if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
|
|
222
|
+
if (!user) return null;
|
|
39
223
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
224
|
+
const emailVerified = user.emailVerified;
|
|
225
|
+
|
|
226
|
+
const body = (
|
|
227
|
+
<div
|
|
228
|
+
// biome-ignore lint/a11y/useSemanticElements: rendered inside a popover, not a top-level dialog
|
|
229
|
+
role="dialog"
|
|
230
|
+
aria-modal={modal || undefined}
|
|
231
|
+
aria-label="Account settings"
|
|
232
|
+
>
|
|
233
|
+
<div className="slx-profile-head">
|
|
234
|
+
<h2 className="slx-profile-title">Account settings</h2>
|
|
235
|
+
{modal && (
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
className="slx-profile-close"
|
|
239
|
+
onClick={onClose}
|
|
240
|
+
aria-label="Close account settings"
|
|
241
|
+
>
|
|
242
|
+
✕
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="slx-profile-body">
|
|
248
|
+
<nav className="slx-profile-nav" aria-label="Settings sections">
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
className={`slx-profile-nav-btn${tab === 'profile' ? ' on' : ''}`}
|
|
252
|
+
onClick={() => setTab('profile')}
|
|
253
|
+
aria-current={tab === 'profile' ? 'page' : undefined}
|
|
254
|
+
>
|
|
255
|
+
<ProfileIcon /> Profile
|
|
256
|
+
</button>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
className={`slx-profile-nav-btn${tab === 'security' ? ' on' : ''}`}
|
|
260
|
+
onClick={() => setTab('security')}
|
|
261
|
+
aria-current={tab === 'security' ? 'page' : undefined}
|
|
262
|
+
>
|
|
263
|
+
<ShieldIcon /> Security
|
|
264
|
+
</button>
|
|
265
|
+
</nav>
|
|
266
|
+
|
|
267
|
+
<div className="slx-profile-content">
|
|
268
|
+
{tab === 'profile' ? (
|
|
269
|
+
<>
|
|
270
|
+
<section className="slx-profile-sec">
|
|
271
|
+
<div className="slx-avatar-row">
|
|
272
|
+
<div className="slx-avatar-lg" aria-hidden="true">
|
|
273
|
+
{user.avatarUrl ? (
|
|
274
|
+
<img src={user.avatarUrl} alt="" />
|
|
275
|
+
) : (
|
|
276
|
+
initials(user)
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
<p className="slx-hint" style={{ margin: 0 }}>
|
|
280
|
+
Your avatar is loaded from its URL. Paste any public image
|
|
281
|
+
link below.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{saved && (
|
|
286
|
+
<p className="slx-error-text" style={{ color: '#34a853' }}>
|
|
287
|
+
Profile saved.
|
|
288
|
+
</p>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
<form onSubmit={onProfileSubmit}>
|
|
292
|
+
<div className="slx-field">
|
|
293
|
+
<label className="slx-label" htmlFor="slx-up-first">
|
|
294
|
+
First name
|
|
295
|
+
</label>
|
|
296
|
+
<input
|
|
297
|
+
id="slx-up-first"
|
|
298
|
+
className="slx-input"
|
|
299
|
+
type="text"
|
|
300
|
+
value={firstName}
|
|
301
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
<div className="slx-field">
|
|
305
|
+
<label className="slx-label" htmlFor="slx-up-last">
|
|
306
|
+
Last name
|
|
307
|
+
</label>
|
|
308
|
+
<input
|
|
309
|
+
id="slx-up-last"
|
|
310
|
+
className="slx-input"
|
|
311
|
+
type="text"
|
|
312
|
+
value={lastName}
|
|
313
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
<div className="slx-field">
|
|
317
|
+
<label className="slx-label" htmlFor="slx-up-avatar">
|
|
318
|
+
Avatar URL
|
|
319
|
+
</label>
|
|
320
|
+
<input
|
|
321
|
+
id="slx-up-avatar"
|
|
322
|
+
className="slx-input"
|
|
323
|
+
type="url"
|
|
324
|
+
placeholder="https://…"
|
|
325
|
+
value={avatarUrl}
|
|
326
|
+
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
<button className="slx-btn" type="submit" disabled={busy}>
|
|
330
|
+
{busy ? 'Saving…' : 'Save changes'}
|
|
331
|
+
</button>
|
|
332
|
+
</form>
|
|
333
|
+
</section>
|
|
334
|
+
|
|
335
|
+
<section className="slx-profile-sec">
|
|
336
|
+
<h3 className="slx-sec-title">Email</h3>
|
|
337
|
+
<div className="slx-row">
|
|
338
|
+
<div>
|
|
339
|
+
<p className="slx-row-value">{user.email}</p>
|
|
340
|
+
<p className="slx-row-label">Primary email</p>
|
|
341
|
+
</div>
|
|
342
|
+
{emailVerified ? (
|
|
343
|
+
<span className="slx-badge slx-badge-ok">✓ Verified</span>
|
|
344
|
+
) : (
|
|
345
|
+
<span
|
|
346
|
+
style={{
|
|
347
|
+
display: 'inline-flex',
|
|
348
|
+
alignItems: 'center',
|
|
349
|
+
gap: 8,
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<span className="slx-badge slx-badge-warn">
|
|
353
|
+
Unverified
|
|
354
|
+
</span>
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
className="slx-link"
|
|
358
|
+
onClick={onResendVerification}
|
|
359
|
+
disabled={resending}
|
|
360
|
+
>
|
|
361
|
+
{resending ? 'Sending…' : resent ? 'Sent ✓' : 'Resend'}
|
|
362
|
+
</button>
|
|
363
|
+
</span>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</section>
|
|
367
|
+
</>
|
|
368
|
+
) : (
|
|
369
|
+
<>
|
|
370
|
+
<section className="slx-profile-sec">
|
|
371
|
+
<h3 className="slx-sec-title">Change password</h3>
|
|
372
|
+
{pwSaved && (
|
|
373
|
+
<p className="slx-error-text" style={{ color: '#34a853' }}>
|
|
374
|
+
Password updated.
|
|
375
|
+
</p>
|
|
376
|
+
)}
|
|
377
|
+
{pwError && <p className="slx-error-text">{pwError}</p>}
|
|
378
|
+
<form onSubmit={onPasswordSubmit}>
|
|
379
|
+
<div className="slx-field">
|
|
380
|
+
<label className="slx-label" htmlFor="slx-pw-current">
|
|
381
|
+
Current password
|
|
382
|
+
</label>
|
|
383
|
+
<input
|
|
384
|
+
id="slx-pw-current"
|
|
385
|
+
className="slx-input"
|
|
386
|
+
type="password"
|
|
387
|
+
autoComplete="current-password"
|
|
388
|
+
value={currentPassword}
|
|
389
|
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
390
|
+
required
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
<div className="slx-field">
|
|
394
|
+
<label className="slx-label" htmlFor="slx-pw-new">
|
|
395
|
+
New password
|
|
396
|
+
</label>
|
|
397
|
+
<input
|
|
398
|
+
id="slx-pw-new"
|
|
399
|
+
className="slx-input"
|
|
400
|
+
type="password"
|
|
401
|
+
autoComplete="new-password"
|
|
402
|
+
minLength={8}
|
|
403
|
+
value={newPassword}
|
|
404
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
405
|
+
required
|
|
406
|
+
/>
|
|
407
|
+
<p className="slx-hint">At least 8 characters.</p>
|
|
408
|
+
</div>
|
|
409
|
+
<div className="slx-field">
|
|
410
|
+
<label className="slx-label" htmlFor="slx-pw-confirm">
|
|
411
|
+
Confirm new password
|
|
412
|
+
</label>
|
|
413
|
+
<input
|
|
414
|
+
id="slx-pw-confirm"
|
|
415
|
+
className="slx-input"
|
|
416
|
+
type="password"
|
|
417
|
+
autoComplete="new-password"
|
|
418
|
+
value={confirmPassword}
|
|
419
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
420
|
+
required
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
<button className="slx-btn" type="submit" disabled={pwBusy}>
|
|
424
|
+
{pwBusy ? 'Updating…' : 'Update password'}
|
|
425
|
+
</button>
|
|
426
|
+
</form>
|
|
427
|
+
</section>
|
|
428
|
+
|
|
429
|
+
<section className="slx-profile-sec">
|
|
430
|
+
<h3 className="slx-sec-title">Active sessions</h3>
|
|
431
|
+
{sessionsLoading ? (
|
|
432
|
+
<p className="slx-hint">Loading sessions…</p>
|
|
433
|
+
) : (
|
|
434
|
+
<>
|
|
435
|
+
{sessions.map((s) => (
|
|
436
|
+
<div key={s.id} className="slx-session">
|
|
437
|
+
<div className="slx-session-meta">
|
|
438
|
+
<p className="slx-session-device">
|
|
439
|
+
{deviceLabel(s.userAgent)}
|
|
440
|
+
{s.isCurrent && (
|
|
441
|
+
<span className="slx-badge slx-badge-accent">
|
|
442
|
+
This device
|
|
443
|
+
</span>
|
|
444
|
+
)}
|
|
445
|
+
</p>
|
|
446
|
+
<p className="slx-session-sub">
|
|
447
|
+
{[
|
|
448
|
+
s.ipAddress,
|
|
449
|
+
`created ${formatDate(s.createdAt)}`,
|
|
450
|
+
`expires ${formatDate(s.expiresAt)}`,
|
|
451
|
+
]
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.join(' · ')}
|
|
454
|
+
</p>
|
|
455
|
+
</div>
|
|
456
|
+
{!s.isCurrent && (
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
className="slx-btn-danger-outline"
|
|
460
|
+
onClick={() => onRevoke(s.id)}
|
|
461
|
+
disabled={revokingId === s.id}
|
|
462
|
+
>
|
|
463
|
+
{revokingId === s.id ? '…' : 'Revoke'}
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
))}
|
|
468
|
+
{sessions.some((s) => !s.isCurrent) && (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
className="slx-btn-danger-outline"
|
|
472
|
+
style={{ width: '100%', padding: '9px 11px' }}
|
|
473
|
+
onClick={onRevokeOthers}
|
|
474
|
+
disabled={othersRevoking}
|
|
475
|
+
>
|
|
476
|
+
{othersRevoking
|
|
477
|
+
? 'Signing out…'
|
|
478
|
+
: 'Sign out other devices'}
|
|
479
|
+
</button>
|
|
480
|
+
)}
|
|
481
|
+
</>
|
|
482
|
+
)}
|
|
483
|
+
</section>
|
|
484
|
+
|
|
485
|
+
<section className="slx-danger-zone">
|
|
486
|
+
<p className="slx-danger-title">Danger zone</p>
|
|
487
|
+
<p className="slx-danger-desc">
|
|
488
|
+
Permanently deletes your account and all associated data.
|
|
489
|
+
Active sessions are revoked immediately. This cannot be
|
|
490
|
+
undone.
|
|
491
|
+
</p>
|
|
492
|
+
{deleteError && <p className="slx-error-text">{deleteError}</p>}
|
|
493
|
+
<div className="slx-field">
|
|
494
|
+
<label className="slx-label" htmlFor="slx-del-confirm">
|
|
495
|
+
Type <strong>DELETE</strong> to confirm
|
|
496
|
+
</label>
|
|
497
|
+
<input
|
|
498
|
+
id="slx-del-confirm"
|
|
499
|
+
className="slx-input"
|
|
500
|
+
type="text"
|
|
501
|
+
value={confirmText}
|
|
502
|
+
onChange={(e) => setConfirmText(e.target.value)}
|
|
503
|
+
placeholder="DELETE"
|
|
504
|
+
/>
|
|
505
|
+
</div>
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
className="slx-btn"
|
|
509
|
+
style={{
|
|
510
|
+
background: 'var(--slx-danger)',
|
|
511
|
+
borderColor: 'var(--slx-danger)',
|
|
512
|
+
}}
|
|
513
|
+
onClick={onDeleteAccount}
|
|
514
|
+
disabled={confirmText !== 'DELETE' || deleteBusy}
|
|
515
|
+
>
|
|
516
|
+
{deleteBusy ? 'Deleting…' : 'Delete my account forever'}
|
|
517
|
+
</button>
|
|
518
|
+
</section>
|
|
519
|
+
</>
|
|
520
|
+
)}
|
|
88
521
|
</div>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
if (!modal) return body;
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<div
|
|
530
|
+
className="slx-overlay"
|
|
531
|
+
onMouseDown={(e) => {
|
|
532
|
+
if (e.target === e.currentTarget) onClose?.();
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
{body}
|
|
93
536
|
</div>
|
|
94
537
|
);
|
|
95
538
|
}
|
|
539
|
+
|
|
540
|
+
function ProfileIcon() {
|
|
541
|
+
return (
|
|
542
|
+
<svg
|
|
543
|
+
width="15"
|
|
544
|
+
height="15"
|
|
545
|
+
viewBox="0 0 24 24"
|
|
546
|
+
fill="none"
|
|
547
|
+
stroke="currentColor"
|
|
548
|
+
strokeWidth="2"
|
|
549
|
+
strokeLinecap="round"
|
|
550
|
+
aria-hidden="true"
|
|
551
|
+
>
|
|
552
|
+
<circle cx="12" cy="8" r="4" />
|
|
553
|
+
<path d="M5 21c0-3.9 3.1-7 7-7s7 3.1 7 7" />
|
|
554
|
+
</svg>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function ShieldIcon() {
|
|
559
|
+
return (
|
|
560
|
+
<svg
|
|
561
|
+
width="15"
|
|
562
|
+
height="15"
|
|
563
|
+
viewBox="0 0 24 24"
|
|
564
|
+
fill="none"
|
|
565
|
+
stroke="currentColor"
|
|
566
|
+
strokeWidth="2"
|
|
567
|
+
strokeLinecap="round"
|
|
568
|
+
strokeLinejoin="round"
|
|
569
|
+
aria-hidden="true"
|
|
570
|
+
>
|
|
571
|
+
<path d="M12 22s8-3.6 8-10V5l-8-3-8 3v7c0 6.4 8 10 8 10z" />
|
|
572
|
+
<path d="m9 12 2 2 4-4" />
|
|
573
|
+
</svg>
|
|
574
|
+
);
|
|
575
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,10 @@ export { KeyholeMark, GoogleIcon, GitHubIcon, CheckIcon } from './icons';
|
|
|
4
4
|
export { SignIn, type SignInProps } from './components/SignIn/SignIn';
|
|
5
5
|
export { SignUp, type SignUpProps } from './components/SignUp/SignUp';
|
|
6
6
|
export { UserButton } from './components/UserButton/UserButton';
|
|
7
|
-
export {
|
|
7
|
+
export {
|
|
8
|
+
UserProfile,
|
|
9
|
+
type UserProfileProps,
|
|
10
|
+
} from './components/UserProfile/UserProfile';
|
|
8
11
|
export {
|
|
9
12
|
ForgotPassword,
|
|
10
13
|
type ForgotPasswordProps,
|