@pylonsync/client 0.3.267

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.
@@ -0,0 +1,485 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import {
5
+ type FormEvent,
6
+ type ReactNode,
7
+ useCallback,
8
+ useEffect,
9
+ useState,
10
+ } from "react";
11
+ import { useAuth } from "../hooks/useAuth";
12
+ import {
13
+ type ActiveSession,
14
+ type ApiKeyCreated,
15
+ ApiError,
16
+ type ApiKeySummary,
17
+ changePassword,
18
+ createApiKey,
19
+ listActiveSessions,
20
+ listApiKeys,
21
+ revokeAllSessions,
22
+ revokeApiKey,
23
+ } from "../lib/api";
24
+ import { cn } from "../lib/cn";
25
+
26
+ export interface UserProfileProps {
27
+ /** Hide sections you don't need. */
28
+ hidePassword?: boolean;
29
+ hideSessions?: boolean;
30
+ hideApiKeys?: boolean;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * Drop-in account management surface — Clerk's `<UserProfile />` shape.
36
+ * Sections:
37
+ * - Identity card (user_id, active org)
38
+ * - Password change (skipped for OAuth-only accounts)
39
+ * - Active sessions + "Sign out other devices"
40
+ * - API keys (create / list / revoke)
41
+ *
42
+ * Each section can be hidden via props if the consuming app handles it
43
+ * elsewhere. Renders nothing for signed-out users.
44
+ */
45
+ export function UserProfile({
46
+ hidePassword,
47
+ hideSessions,
48
+ hideApiKeys,
49
+ className,
50
+ }: UserProfileProps) {
51
+ const { isSignedIn, userId, tenantId } = useAuth();
52
+ if (!isSignedIn) return null;
53
+ return (
54
+ <div
55
+ className={cn(
56
+ "mx-auto w-full max-w-xl space-y-6 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-7 shadow-sm",
57
+ className,
58
+ )}
59
+ >
60
+ <IdentityCard userId={userId} tenantId={tenantId} />
61
+ {!hidePassword ? <PasswordChange /> : null}
62
+ {!hideSessions ? <Sessions /> : null}
63
+ {!hideApiKeys ? <ApiKeys /> : null}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Identity
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function IdentityCard({
73
+ userId,
74
+ tenantId,
75
+ }: {
76
+ userId: string | null;
77
+ tenantId: string | null;
78
+ }) {
79
+ return (
80
+ <section className="space-y-1">
81
+ <SectionLabel>Account</SectionLabel>
82
+ <div className="rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper-2,#f4f4f5)] p-3">
83
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">User ID</p>
84
+ <p className="break-all font-mono text-sm text-[var(--pylon-ink,#0a0a0a)]">
85
+ {userId ?? "—"}
86
+ </p>
87
+ {tenantId ? (
88
+ <>
89
+ <p className="mt-2 text-xs text-[var(--pylon-ink-3,#71717a)]">
90
+ Active org
91
+ </p>
92
+ <p className="break-all font-mono text-sm text-[var(--pylon-ink,#0a0a0a)]">
93
+ {tenantId}
94
+ </p>
95
+ </>
96
+ ) : null}
97
+ </div>
98
+ </section>
99
+ );
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Password change
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function PasswordChange() {
107
+ const [current, setCurrent] = useState("");
108
+ const [next, setNext] = useState("");
109
+ const [confirm, setConfirm] = useState("");
110
+ const [pending, setPending] = useState(false);
111
+ const [status, setStatus] =
112
+ useState<{ ok: boolean; message: string } | null>(null);
113
+
114
+ async function onSubmit(e: FormEvent) {
115
+ e.preventDefault();
116
+ setStatus(null);
117
+ if (next !== confirm) {
118
+ setStatus({ ok: false, message: "New passwords don't match." });
119
+ return;
120
+ }
121
+ setPending(true);
122
+ try {
123
+ await changePassword({ currentPassword: current, newPassword: next });
124
+ setStatus({ ok: true, message: "Password updated." });
125
+ setCurrent("");
126
+ setNext("");
127
+ setConfirm("");
128
+ } catch (err) {
129
+ setStatus({ ok: false, message: messageFromError(err) });
130
+ } finally {
131
+ setPending(false);
132
+ }
133
+ }
134
+
135
+ return (
136
+ <section className="space-y-3">
137
+ <SectionLabel>Change password</SectionLabel>
138
+ <form onSubmit={onSubmit} className="space-y-2">
139
+ <PasswordField
140
+ label="Current"
141
+ value={current}
142
+ onChange={setCurrent}
143
+ autoComplete="current-password"
144
+ />
145
+ <PasswordField
146
+ label="New"
147
+ value={next}
148
+ onChange={setNext}
149
+ autoComplete="new-password"
150
+ />
151
+ <PasswordField
152
+ label="Confirm"
153
+ value={confirm}
154
+ onChange={setConfirm}
155
+ autoComplete="new-password"
156
+ />
157
+ <button
158
+ type="submit"
159
+ disabled={pending || !current || !next}
160
+ className="w-full rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
161
+ >
162
+ {pending ? "…" : "Update password"}
163
+ </button>
164
+ {status ? (
165
+ <p
166
+ className={cn(
167
+ "rounded-md border px-3 py-2 text-xs",
168
+ status.ok
169
+ ? "border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper-2,#f4f4f5)] text-[var(--pylon-ink-2,#52525b)]"
170
+ : "border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] text-[var(--pylon-error-ink,#b91c1c)]",
171
+ )}
172
+ >
173
+ {status.message}
174
+ </p>
175
+ ) : null}
176
+ </form>
177
+ </section>
178
+ );
179
+ }
180
+
181
+ function PasswordField({
182
+ label,
183
+ value,
184
+ onChange,
185
+ autoComplete,
186
+ }: {
187
+ label: string;
188
+ value: string;
189
+ onChange: (v: string) => void;
190
+ autoComplete: string;
191
+ }) {
192
+ return (
193
+ <label className="block space-y-1">
194
+ <span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
195
+ {label}
196
+ </span>
197
+ <input
198
+ type="password"
199
+ value={value}
200
+ onChange={(e) => onChange(e.target.value)}
201
+ autoComplete={autoComplete}
202
+ className="w-full rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
203
+ />
204
+ </label>
205
+ );
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Sessions
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function Sessions() {
213
+ const [sessions, setSessions] = useState<ActiveSession[] | null>(null);
214
+ const [pending, setPending] = useState(false);
215
+ const [error, setError] = useState<string | null>(null);
216
+
217
+ const refresh = useCallback(async () => {
218
+ setSessions(await listActiveSessions());
219
+ }, []);
220
+
221
+ useEffect(() => {
222
+ void refresh();
223
+ }, [refresh]);
224
+
225
+ async function onSignOutOthers() {
226
+ setError(null);
227
+ setPending(true);
228
+ try {
229
+ await revokeAllSessions();
230
+ void refresh();
231
+ } catch (err) {
232
+ setError(messageFromError(err));
233
+ } finally {
234
+ setPending(false);
235
+ }
236
+ }
237
+
238
+ return (
239
+ <section className="space-y-3">
240
+ <div className="flex items-baseline justify-between gap-2">
241
+ <SectionLabel>Active sessions</SectionLabel>
242
+ {sessions && sessions.length > 1 ? (
243
+ <button
244
+ type="button"
245
+ onClick={onSignOutOthers}
246
+ disabled={pending}
247
+ className="text-xs font-medium text-[var(--pylon-error-ink,#b91c1c)] hover:underline disabled:opacity-50"
248
+ >
249
+ Sign out all devices
250
+ </button>
251
+ ) : null}
252
+ </div>
253
+ {!sessions ? (
254
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
255
+ Loading…
256
+ </p>
257
+ ) : sessions.length === 0 ? (
258
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
259
+ No active sessions.
260
+ </p>
261
+ ) : (
262
+ <ul className="divide-y divide-[var(--pylon-rule,#e5e7eb)] rounded-md border border-[var(--pylon-rule,#e5e7eb)]">
263
+ {sessions.map((s) => (
264
+ <li
265
+ key={s.token_prefix + s.created_at}
266
+ className="flex items-center justify-between gap-2 px-3 py-2 text-sm"
267
+ >
268
+ <span className="min-w-0">
269
+ <span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
270
+ {s.device || "Unknown device"}
271
+ </span>
272
+ <span className="block text-[11px] text-[var(--pylon-ink-3,#71717a)]">
273
+ Token …{s.token_prefix} · expires{" "}
274
+ {formatRelative(s.expires_at)}
275
+ </span>
276
+ </span>
277
+ </li>
278
+ ))}
279
+ </ul>
280
+ )}
281
+ {error ? <ErrorBanner message={error} /> : null}
282
+ </section>
283
+ );
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // API keys
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function ApiKeys() {
291
+ const [keys, setKeys] = useState<ApiKeySummary[] | null>(null);
292
+ const [creating, setCreating] = useState(false);
293
+ const [newName, setNewName] = useState("");
294
+ const [lastCreated, setLastCreated] = useState<ApiKeyCreated | null>(null);
295
+ const [pending, setPending] = useState(false);
296
+ const [error, setError] = useState<string | null>(null);
297
+
298
+ const refresh = useCallback(async () => {
299
+ setKeys(await listApiKeys());
300
+ }, []);
301
+
302
+ useEffect(() => {
303
+ void refresh();
304
+ }, [refresh]);
305
+
306
+ async function onCreate(e: FormEvent) {
307
+ e.preventDefault();
308
+ setError(null);
309
+ setPending(true);
310
+ try {
311
+ const created = await createApiKey({ name: newName.trim() });
312
+ setLastCreated(created);
313
+ setNewName("");
314
+ setCreating(false);
315
+ void refresh();
316
+ } catch (err) {
317
+ setError(messageFromError(err));
318
+ } finally {
319
+ setPending(false);
320
+ }
321
+ }
322
+
323
+ async function onRevoke(id: string) {
324
+ setError(null);
325
+ try {
326
+ await revokeApiKey(id);
327
+ void refresh();
328
+ } catch (err) {
329
+ setError(messageFromError(err));
330
+ }
331
+ }
332
+
333
+ return (
334
+ <section className="space-y-3">
335
+ <div className="flex items-baseline justify-between gap-2">
336
+ <SectionLabel>API keys</SectionLabel>
337
+ {!creating ? (
338
+ <button
339
+ type="button"
340
+ onClick={() => setCreating(true)}
341
+ className="text-xs font-medium text-[var(--pylon-ink,#0a0a0a)] hover:underline"
342
+ >
343
+ New key
344
+ </button>
345
+ ) : null}
346
+ </div>
347
+ {creating ? (
348
+ <form onSubmit={onCreate} className="flex gap-2">
349
+ <input
350
+ value={newName}
351
+ onChange={(e) => setNewName(e.target.value)}
352
+ placeholder="Key name"
353
+ required
354
+ autoFocus
355
+ className="flex-1 rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
356
+ />
357
+ <button
358
+ type="submit"
359
+ disabled={pending || !newName.trim()}
360
+ className="rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
361
+ >
362
+ {pending ? "…" : "Create"}
363
+ </button>
364
+ <button
365
+ type="button"
366
+ onClick={() => setCreating(false)}
367
+ className="rounded-md px-3 py-2 text-xs text-[var(--pylon-ink-2,#52525b)] hover:underline"
368
+ >
369
+ Cancel
370
+ </button>
371
+ </form>
372
+ ) : null}
373
+ {lastCreated ? (
374
+ <div className="space-y-1.5 rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper-2,#f4f4f5)] p-3">
375
+ <p className="text-[11px] font-medium uppercase tracking-wider text-[var(--pylon-ink-2,#52525b)]">
376
+ Copy now — won't be shown again
377
+ </p>
378
+ <code className="block break-all rounded bg-[var(--pylon-paper,#ffffff)] px-2 py-1 font-mono text-xs text-[var(--pylon-ink,#0a0a0a)]">
379
+ {lastCreated.key}
380
+ </code>
381
+ <button
382
+ type="button"
383
+ onClick={() => setLastCreated(null)}
384
+ className="text-[11px] text-[var(--pylon-ink-3,#71717a)] hover:underline"
385
+ >
386
+ Dismiss
387
+ </button>
388
+ </div>
389
+ ) : null}
390
+ {!keys ? (
391
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
392
+ Loading…
393
+ </p>
394
+ ) : keys.length === 0 ? (
395
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
396
+ No keys yet.
397
+ </p>
398
+ ) : (
399
+ <ul className="divide-y divide-[var(--pylon-rule,#e5e7eb)] rounded-md border border-[var(--pylon-rule,#e5e7eb)]">
400
+ {keys.map((k) => (
401
+ <li
402
+ key={k.id}
403
+ className="flex items-center justify-between gap-2 px-3 py-2 text-sm"
404
+ >
405
+ <span className="min-w-0">
406
+ <span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
407
+ {k.name}
408
+ </span>
409
+ <span className="block text-[11px] text-[var(--pylon-ink-3,#71717a)]">
410
+ {k.prefix}… · last used{" "}
411
+ {k.last_used_at ? formatRelative(k.last_used_at) : "never"}
412
+ </span>
413
+ </span>
414
+ <button
415
+ type="button"
416
+ onClick={() => onRevoke(k.id)}
417
+ className="rounded-md px-2 py-1 text-xs text-[var(--pylon-error-ink,#b91c1c)] transition-colors hover:bg-[var(--pylon-error-bg,#fef2f2)]"
418
+ >
419
+ Revoke
420
+ </button>
421
+ </li>
422
+ ))}
423
+ </ul>
424
+ )}
425
+ {error ? <ErrorBanner message={error} /> : null}
426
+ </section>
427
+ );
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Shared helpers
432
+ // ---------------------------------------------------------------------------
433
+
434
+ function SectionLabel({ children }: { children: ReactNode }) {
435
+ return (
436
+ <p className="text-xs font-medium uppercase tracking-wider text-[var(--pylon-ink-2,#52525b)]">
437
+ {children}
438
+ </p>
439
+ );
440
+ }
441
+
442
+ function ErrorBanner({ message }: { message: string }) {
443
+ return (
444
+ <p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
445
+ {message}
446
+ </p>
447
+ );
448
+ }
449
+
450
+ function formatRelative(unixSecs: number): string {
451
+ if (!unixSecs) return "—";
452
+ const ms = unixSecs * 1000;
453
+ const diff = ms - Date.now();
454
+ const abs = Math.abs(diff);
455
+ const day = 86_400_000;
456
+ const hour = 3_600_000;
457
+ const min = 60_000;
458
+ const sign = diff < 0 ? "ago" : "from now";
459
+ if (abs > day) return `${Math.round(abs / day)}d ${sign}`;
460
+ if (abs > hour) return `${Math.round(abs / hour)}h ${sign}`;
461
+ if (abs > min) return `${Math.round(abs / min)}m ${sign}`;
462
+ return diff < 0 ? "just now" : "soon";
463
+ }
464
+
465
+ function messageFromError(err: unknown): string {
466
+ if (err instanceof ApiError) {
467
+ switch (err.code) {
468
+ case "INVALID_CREDENTIALS":
469
+ case "BAD_CURRENT_PASSWORD":
470
+ return "Current password is wrong.";
471
+ case "WEAK_PASSWORD":
472
+ return "Pick a stronger password.";
473
+ case "MISSING_PASSWORD":
474
+ return "Enter a new password.";
475
+ case "API_KEY_AUTH_FORBIDDEN":
476
+ return "This action needs a session, not an API key.";
477
+ case "AUTH_REQUIRED":
478
+ return "Sign in to manage your account.";
479
+ default:
480
+ return err.message;
481
+ }
482
+ }
483
+ if (err instanceof Error) return err.message;
484
+ return "Something went wrong.";
485
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { db, useSession } from "@pylonsync/react";
4
+
5
+ /**
6
+ * Drop-in hook that wraps `useSession(db.sync)` so components in
7
+ * `@pylonsync/client` don't have to thread a SyncEngine through props.
8
+ * Apps that init() the global engine (the common case) get a Clerk-style
9
+ * `const { isSignedIn, user } = useAuth()` surface.
10
+ */
11
+ export function useAuth() {
12
+ const session = useSession(db.sync);
13
+ return {
14
+ isSignedIn: session.isAuthenticated,
15
+ isLoaded: true,
16
+ userId: session.userId,
17
+ tenantId: session.tenantId,
18
+ isAdmin: session.isAdmin,
19
+ session: session.session,
20
+ signOut: session.signOut,
21
+ selectOrg: session.selectOrg,
22
+ clearOrg: session.clearOrg,
23
+ refresh: session.refresh,
24
+ };
25
+ }
26
+
27
+ export type UseAuthReturn = ReturnType<typeof useAuth>;
package/src/index.ts ADDED
@@ -0,0 +1,130 @@
1
+ export { SignIn, SignUp } from "./components/SignIn";
2
+ export type { SignInProps, SignUpProps } from "./components/SignIn";
3
+
4
+ export {
5
+ SignedIn,
6
+ SignedOut,
7
+ Protect,
8
+ HasRole,
9
+ InOrg,
10
+ NoOrg,
11
+ RedirectToSignIn,
12
+ } from "./components/Gates";
13
+ export type {
14
+ ProtectProps,
15
+ HasRoleProps,
16
+ RedirectToSignInProps,
17
+ } from "./components/Gates";
18
+
19
+ export { UserButton } from "./components/UserButton";
20
+ export type { UserButtonProps } from "./components/UserButton";
21
+
22
+ export { SignOutButton } from "./components/SignOutButton";
23
+ export type { SignOutButtonProps } from "./components/SignOutButton";
24
+
25
+ export { EnsureGuest } from "./components/EnsureGuest";
26
+ export type { EnsureGuestProps } from "./components/EnsureGuest";
27
+
28
+ export {
29
+ OrganizationSwitcher,
30
+ CreateOrganization,
31
+ } from "./components/OrganizationSwitcher";
32
+ export type {
33
+ OrganizationSwitcherProps,
34
+ CreateOrganizationProps,
35
+ } from "./components/OrganizationSwitcher";
36
+
37
+ export { InviteMembers } from "./components/InviteMembers";
38
+ export type { InviteMembersProps } from "./components/InviteMembers";
39
+
40
+ export { AcceptInvite } from "./components/AcceptInvite";
41
+ export type { AcceptInviteProps } from "./components/AcceptInvite";
42
+
43
+ export { ConnectAccount } from "./components/ConnectAccount";
44
+ export type { ConnectAccountProps } from "./components/ConnectAccount";
45
+
46
+ export { FileUpload } from "./components/FileUpload";
47
+ export type {
48
+ FileUploadProps,
49
+ FileUploadResult,
50
+ } from "./components/FileUpload";
51
+
52
+ export { UserProfile } from "./components/UserProfile";
53
+ export type { UserProfileProps } from "./components/UserProfile";
54
+
55
+ export { ForgotPassword, ResetPassword } from "./components/PasswordReset";
56
+ export type {
57
+ ForgotPasswordProps,
58
+ ResetPasswordProps,
59
+ } from "./components/PasswordReset";
60
+
61
+ export { ChatBot } from "./components/ChatBot";
62
+ export type { ChatBotProps, ChatMessage } from "./components/ChatBot";
63
+
64
+ export { EntityList } from "./components/EntityList";
65
+ export type {
66
+ EntityListProps,
67
+ EntityListColumn,
68
+ } from "./components/EntityList";
69
+
70
+ export { EntityForm } from "./components/EntityForm";
71
+ export type {
72
+ EntityFormProps,
73
+ EntityFormField,
74
+ } from "./components/EntityForm";
75
+
76
+ export { Router, Link, Outlet } from "./router/Router";
77
+ export type { RouterProps, LinkProps } from "./router/Router";
78
+ export type { RouteSpec, MatchedRoute } from "./router/match";
79
+ export {
80
+ useRouter,
81
+ useParams,
82
+ useSearchParams,
83
+ usePathname,
84
+ } from "./router/useRouter";
85
+
86
+ export { useAuth } from "./hooks/useAuth";
87
+ export type { UseAuthReturn } from "./hooks/useAuth";
88
+
89
+ // Re-export the lower-level API helpers so apps building custom auth
90
+ // surfaces can drive the same endpoints without duplicating the fetch
91
+ // + error-mapping logic the built-in components use.
92
+ export {
93
+ acceptInvite,
94
+ ApiError,
95
+ changePassword,
96
+ completePasswordReset,
97
+ ensureGuestSession,
98
+ connectionAuthUrl,
99
+ createApiKey,
100
+ createInvite,
101
+ createOrg,
102
+ listActiveSessions,
103
+ listApiKeys,
104
+ listAuthProviders,
105
+ listInvites,
106
+ listOrgMembers,
107
+ listOrgs,
108
+ passwordLogin,
109
+ passwordRegister,
110
+ persistSession,
111
+ removeMember,
112
+ requestPasswordReset,
113
+ revokeAllSessions,
114
+ revokeApiKey,
115
+ revokeInvite,
116
+ sendMagicLink,
117
+ updateMemberRole,
118
+ verifyMagicLink,
119
+ } from "./lib/api";
120
+ export type {
121
+ ActiveSession,
122
+ ApiKeyCreated,
123
+ ApiKeySummary,
124
+ AuthProvider,
125
+ InviteResult,
126
+ OrgMember,
127
+ OrgSummary,
128
+ PendingInvite,
129
+ SessionResponse,
130
+ } from "./lib/api";