@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.
@@ -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
- /** Edit first/last name + avatar URL. */
6
- export function UserProfile() {
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
- async function onSubmit(e: FormEvent) {
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
- return (
41
- <div className="slx-card">
42
- <h1 className="slx-title">Profile</h1>
43
- <p className="slx-subtitle">Signed in as {user?.email}</p>
44
-
45
- {saved && (
46
- <p className="slx-error-text" style={{ color: '#34a853' }}>
47
- Profile saved.
48
- </p>
49
- )}
50
-
51
- <form onSubmit={onSubmit}>
52
- <div className="slx-field">
53
- <label className="slx-label" htmlFor="slx-profile-first">
54
- First name
55
- </label>
56
- <input
57
- id="slx-profile-first"
58
- className="slx-input"
59
- type="text"
60
- value={firstName}
61
- onChange={(e) => setFirstName(e.target.value)}
62
- />
63
- </div>
64
- <div className="slx-field">
65
- <label className="slx-label" htmlFor="slx-profile-last">
66
- Last name
67
- </label>
68
- <input
69
- id="slx-profile-last"
70
- className="slx-input"
71
- type="text"
72
- value={lastName}
73
- onChange={(e) => setLastName(e.target.value)}
74
- />
75
- </div>
76
- <div className="slx-field">
77
- <label className="slx-label" htmlFor="slx-profile-avatar">
78
- Avatar URL
79
- </label>
80
- <input
81
- id="slx-profile-avatar"
82
- className="slx-input"
83
- type="url"
84
- placeholder="https://…"
85
- value={avatarUrl}
86
- onChange={(e) => setAvatarUrl(e.target.value)}
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
- <button className="slx-btn" type="submit" disabled={busy}>
90
- {busy ? 'Saving…' : 'Save changes'}
91
- </button>
92
- </form>
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 { UserProfile } from './components/UserProfile/UserProfile';
7
+ export {
8
+ UserProfile,
9
+ type UserProfileProps,
10
+ } from './components/UserProfile/UserProfile';
8
11
  export {
9
12
  ForgotPassword,
10
13
  type ForgotPasswordProps,