@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,562 @@
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
+ ApiError,
14
+ createInvite,
15
+ type InviteResult,
16
+ listInvites,
17
+ listOrgMembers,
18
+ type OrgMember,
19
+ type PendingInvite,
20
+ removeMember,
21
+ revokeInvite,
22
+ updateMemberRole,
23
+ } from "../lib/api";
24
+ import { cn } from "../lib/cn";
25
+
26
+ export interface InviteMembersProps {
27
+ /** Org to manage. Defaults to the user's active tenant. */
28
+ orgId?: string;
29
+ /** Roles offered in the role picker. Default: ["member", "admin"]. */
30
+ roles?: string[];
31
+ /** Hide the members roster (focus solely on the invite form + pending list). */
32
+ hideMembers?: boolean;
33
+ /** Hide the pending invites list. */
34
+ hidePending?: boolean;
35
+ /** Optional callback after a new invite is sent. */
36
+ onInvited?: (invite: InviteResult) => void;
37
+ className?: string;
38
+ }
39
+
40
+ /**
41
+ * Drop-in members + invites surface. Owner / admin only — non-managers
42
+ * see a forbidden notice. Composes three concerns:
43
+ *
44
+ * - Invite form (POST /api/auth/orgs/:id/invites)
45
+ * - Pending invites with revoke (GET + DELETE on the same path)
46
+ * - Member roster with role change + remove (GET/PUT/DELETE on members)
47
+ *
48
+ * For a focused subset, set `hideMembers` or `hidePending`.
49
+ */
50
+ export function InviteMembers({
51
+ orgId: orgIdOverride,
52
+ roles = ["member", "admin"],
53
+ hideMembers,
54
+ hidePending,
55
+ onInvited,
56
+ className,
57
+ }: InviteMembersProps) {
58
+ const { isSignedIn, tenantId, userId, session } = useAuth();
59
+ const orgId = orgIdOverride ?? tenantId;
60
+
61
+ const [members, setMembers] = useState<OrgMember[] | null>(null);
62
+ const [invites, setInvites] = useState<PendingInvite[] | null>(null);
63
+ const [loadError, setLoadError] = useState<string | null>(null);
64
+
65
+ const refresh = useCallback(async () => {
66
+ if (!orgId) return;
67
+ setLoadError(null);
68
+ try {
69
+ const [m, i] = await Promise.all([
70
+ listOrgMembers(orgId),
71
+ listInvites(orgId),
72
+ ]);
73
+ setMembers(m);
74
+ setInvites(i);
75
+ } catch (err) {
76
+ setLoadError(messageFromError(err));
77
+ }
78
+ }, [orgId]);
79
+
80
+ useEffect(() => {
81
+ if (!isSignedIn || !orgId) return;
82
+ void refresh();
83
+ }, [isSignedIn, orgId, refresh]);
84
+
85
+ if (!isSignedIn) return null;
86
+
87
+ if (!orgId) {
88
+ return (
89
+ <EmptyShell className={className}>
90
+ <p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
91
+ Select an organization to manage members.
92
+ </p>
93
+ </EmptyShell>
94
+ );
95
+ }
96
+
97
+ // Manager role gating mirrors what the server enforces — keeps the
98
+ // UI honest instead of showing inputs that 403 on submit.
99
+ const callerRoles = session?.roles ?? [];
100
+ const canManage =
101
+ callerRoles.includes("owner") || callerRoles.includes("admin");
102
+ if (!canManage) {
103
+ return (
104
+ <EmptyShell className={className}>
105
+ <p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
106
+ You need owner or admin role to invite members.
107
+ </p>
108
+ </EmptyShell>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div
114
+ className={cn(
115
+ "mx-auto w-full max-w-xl space-y-6 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-6 shadow-sm",
116
+ className,
117
+ )}
118
+ >
119
+ <InviteForm
120
+ orgId={orgId}
121
+ roles={roles}
122
+ onInvited={(inv) => {
123
+ onInvited?.(inv);
124
+ void refresh();
125
+ }}
126
+ />
127
+
128
+ {!hidePending ? (
129
+ <PendingList
130
+ invites={invites}
131
+ orgId={orgId}
132
+ onRevoked={refresh}
133
+ />
134
+ ) : null}
135
+
136
+ {!hideMembers ? (
137
+ <MemberRoster
138
+ members={members}
139
+ orgId={orgId}
140
+ roles={roles}
141
+ currentUserId={userId}
142
+ currentUserRoles={callerRoles}
143
+ onChanged={refresh}
144
+ />
145
+ ) : null}
146
+
147
+ {loadError ? <ErrorBanner message={loadError} /> : null}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Invite form
154
+ // ---------------------------------------------------------------------------
155
+
156
+ function InviteForm({
157
+ orgId,
158
+ roles,
159
+ onInvited,
160
+ }: {
161
+ orgId: string;
162
+ roles: string[];
163
+ onInvited: (invite: InviteResult) => void;
164
+ }) {
165
+ const [email, setEmail] = useState("");
166
+ const [role, setRole] = useState(roles[0] ?? "member");
167
+ const [pending, setPending] = useState(false);
168
+ const [error, setError] = useState<string | null>(null);
169
+ const [lastInvite, setLastInvite] = useState<InviteResult | null>(null);
170
+
171
+ async function onSubmit(e: FormEvent) {
172
+ e.preventDefault();
173
+ setError(null);
174
+ setPending(true);
175
+ try {
176
+ const invite = await createInvite(orgId, email.trim(), role);
177
+ setEmail("");
178
+ setLastInvite(invite);
179
+ onInvited(invite);
180
+ } catch (err) {
181
+ setError(messageFromError(err));
182
+ } finally {
183
+ setPending(false);
184
+ }
185
+ }
186
+
187
+ return (
188
+ <div className="space-y-3">
189
+ <SectionLabel>Invite by email</SectionLabel>
190
+ <form onSubmit={onSubmit} className="flex gap-2">
191
+ <input
192
+ type="email"
193
+ value={email}
194
+ onChange={(e) => setEmail(e.target.value)}
195
+ required
196
+ placeholder="teammate@acme.com"
197
+ 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)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
198
+ />
199
+ <select
200
+ value={role}
201
+ onChange={(e) => setRole(e.target.value)}
202
+ className="rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-2 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
203
+ >
204
+ {roles.map((r) => (
205
+ <option key={r} value={r}>
206
+ {capitalize(r)}
207
+ </option>
208
+ ))}
209
+ </select>
210
+ <button
211
+ type="submit"
212
+ disabled={pending}
213
+ 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"
214
+ >
215
+ {pending ? "…" : "Invite"}
216
+ </button>
217
+ </form>
218
+ {error ? <ErrorBanner message={error} /> : null}
219
+ {lastInvite?.token ? (
220
+ <div className="rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper-2,#f4f4f5)] p-3 text-xs text-[var(--pylon-ink-2,#52525b)]">
221
+ <p className="mb-1.5 font-medium text-[var(--pylon-ink,#0a0a0a)]">
222
+ Dev mode — share this link manually
223
+ </p>
224
+ <code className="block break-all rounded bg-[var(--pylon-paper,#ffffff)] px-2 py-1 text-[var(--pylon-ink,#0a0a0a)]">
225
+ {lastInvite.accept_url}
226
+ </code>
227
+ </div>
228
+ ) : null}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Pending invites
235
+ // ---------------------------------------------------------------------------
236
+
237
+ function PendingList({
238
+ invites,
239
+ orgId,
240
+ onRevoked,
241
+ }: {
242
+ invites: PendingInvite[] | null;
243
+ orgId: string;
244
+ onRevoked: () => void;
245
+ }) {
246
+ if (!invites) {
247
+ return <SectionLabel muted>Loading pending invites…</SectionLabel>;
248
+ }
249
+ if (invites.length === 0) {
250
+ return (
251
+ <div className="space-y-2">
252
+ <SectionLabel>Pending invites</SectionLabel>
253
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
254
+ No invites waiting.
255
+ </p>
256
+ </div>
257
+ );
258
+ }
259
+ return (
260
+ <div className="space-y-2">
261
+ <SectionLabel>Pending invites</SectionLabel>
262
+ <ul className="divide-y divide-[var(--pylon-rule,#e5e7eb)] rounded-md border border-[var(--pylon-rule,#e5e7eb)]">
263
+ {invites.map((inv) => (
264
+ <InviteRow
265
+ key={inv.id}
266
+ invite={inv}
267
+ orgId={orgId}
268
+ onRevoked={onRevoked}
269
+ />
270
+ ))}
271
+ </ul>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ function InviteRow({
277
+ invite,
278
+ orgId,
279
+ onRevoked,
280
+ }: {
281
+ invite: PendingInvite;
282
+ orgId: string;
283
+ onRevoked: () => void;
284
+ }) {
285
+ const [pending, setPending] = useState(false);
286
+ async function onRevoke() {
287
+ setPending(true);
288
+ try {
289
+ await revokeInvite(orgId, invite.id);
290
+ onRevoked();
291
+ } finally {
292
+ setPending(false);
293
+ }
294
+ }
295
+ return (
296
+ <li className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
297
+ <span className="min-w-0">
298
+ <span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
299
+ {invite.email}
300
+ </span>
301
+ <span className="block text-[11px] text-[var(--pylon-ink-3,#71717a)]">
302
+ {capitalize(invite.role)} · expires{" "}
303
+ {formatRelative(invite.expires_at)}
304
+ </span>
305
+ </span>
306
+ <button
307
+ type="button"
308
+ onClick={onRevoke}
309
+ disabled={pending}
310
+ className="rounded-md px-2 py-1 text-xs text-[var(--pylon-error-ink,#b91c1c)] transition-colors hover:bg-[var(--pylon-error-bg,#fef2f2)] disabled:opacity-50"
311
+ >
312
+ {pending ? "…" : "Revoke"}
313
+ </button>
314
+ </li>
315
+ );
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Member roster
320
+ // ---------------------------------------------------------------------------
321
+
322
+ function MemberRoster({
323
+ members,
324
+ orgId,
325
+ roles,
326
+ currentUserId,
327
+ currentUserRoles,
328
+ onChanged,
329
+ }: {
330
+ members: OrgMember[] | null;
331
+ orgId: string;
332
+ roles: string[];
333
+ currentUserId: string | null;
334
+ currentUserRoles: string[];
335
+ onChanged: () => void;
336
+ }) {
337
+ if (!members) {
338
+ return <SectionLabel muted>Loading members…</SectionLabel>;
339
+ }
340
+ const isOwner = currentUserRoles.includes("owner");
341
+ // Owners are allowed to promote/demote anyone; admins can't touch
342
+ // owners. Pre-compute "owner picker" so the dropdown only includes
343
+ // "owner" when the caller is allowed to assign it.
344
+ const assignableRoles = isOwner ? [...roles, "owner"] : roles;
345
+ const deduped = Array.from(new Set(assignableRoles));
346
+
347
+ return (
348
+ <div className="space-y-2">
349
+ <SectionLabel>Members</SectionLabel>
350
+ <ul className="divide-y divide-[var(--pylon-rule,#e5e7eb)] rounded-md border border-[var(--pylon-rule,#e5e7eb)]">
351
+ {members.map((m) => (
352
+ <MemberRow
353
+ key={m.user_id}
354
+ member={m}
355
+ orgId={orgId}
356
+ roles={deduped}
357
+ isSelf={m.user_id === currentUserId}
358
+ canManageRoles={
359
+ isOwner || (currentUserRoles.includes("admin") && m.role !== "owner")
360
+ }
361
+ onChanged={onChanged}
362
+ />
363
+ ))}
364
+ </ul>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ function MemberRow({
370
+ member,
371
+ orgId,
372
+ roles,
373
+ isSelf,
374
+ canManageRoles,
375
+ onChanged,
376
+ }: {
377
+ member: OrgMember;
378
+ orgId: string;
379
+ roles: string[];
380
+ isSelf: boolean;
381
+ canManageRoles: boolean;
382
+ onChanged: () => void;
383
+ }) {
384
+ const [pending, setPending] = useState(false);
385
+ const [error, setError] = useState<string | null>(null);
386
+
387
+ async function onRoleChange(role: string) {
388
+ setError(null);
389
+ setPending(true);
390
+ try {
391
+ await updateMemberRole(orgId, member.user_id, role);
392
+ onChanged();
393
+ } catch (err) {
394
+ setError(messageFromError(err));
395
+ } finally {
396
+ setPending(false);
397
+ }
398
+ }
399
+
400
+ async function onRemove() {
401
+ setError(null);
402
+ setPending(true);
403
+ try {
404
+ await removeMember(orgId, member.user_id);
405
+ onChanged();
406
+ } catch (err) {
407
+ setError(messageFromError(err));
408
+ } finally {
409
+ setPending(false);
410
+ }
411
+ }
412
+
413
+ return (
414
+ <li className="flex flex-col gap-2 px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between">
415
+ <span className="min-w-0">
416
+ <span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
417
+ {member.user_id}
418
+ {isSelf ? (
419
+ <span className="ml-1 text-[10px] uppercase tracking-wider text-[var(--pylon-ink-3,#71717a)]">
420
+ you
421
+ </span>
422
+ ) : null}
423
+ </span>
424
+ <span className="block text-[11px] text-[var(--pylon-ink-3,#71717a)]">
425
+ Joined {formatRelative(member.joined_at)}
426
+ </span>
427
+ </span>
428
+ <div className="flex items-center gap-2">
429
+ {canManageRoles ? (
430
+ <select
431
+ value={member.role}
432
+ onChange={(e) => onRoleChange(e.target.value)}
433
+ disabled={pending}
434
+ className="rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-2 py-1 text-xs text-[var(--pylon-ink,#0a0a0a)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
435
+ >
436
+ {roles.map((r) => (
437
+ <option key={r} value={r}>
438
+ {capitalize(r)}
439
+ </option>
440
+ ))}
441
+ </select>
442
+ ) : (
443
+ <span className="rounded-md border border-[var(--pylon-rule,#e5e7eb)] px-2 py-0.5 text-[11px] text-[var(--pylon-ink-2,#52525b)]">
444
+ {capitalize(member.role)}
445
+ </span>
446
+ )}
447
+ {!isSelf && canManageRoles ? (
448
+ <button
449
+ type="button"
450
+ onClick={onRemove}
451
+ disabled={pending}
452
+ className="rounded-md px-2 py-1 text-xs text-[var(--pylon-error-ink,#b91c1c)] transition-colors hover:bg-[var(--pylon-error-bg,#fef2f2)] disabled:opacity-50"
453
+ >
454
+ Remove
455
+ </button>
456
+ ) : null}
457
+ </div>
458
+ {error ? (
459
+ <p className="basis-full text-[11px] text-[var(--pylon-error-ink,#b91c1c)]">
460
+ {error}
461
+ </p>
462
+ ) : null}
463
+ </li>
464
+ );
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Helpers
469
+ // ---------------------------------------------------------------------------
470
+
471
+ function EmptyShell({
472
+ children,
473
+ className,
474
+ }: {
475
+ children: ReactNode;
476
+ className?: string;
477
+ }) {
478
+ return (
479
+ <div
480
+ className={cn(
481
+ "mx-auto w-full max-w-xl rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-6 shadow-sm",
482
+ className,
483
+ )}
484
+ >
485
+ {children}
486
+ </div>
487
+ );
488
+ }
489
+
490
+ function SectionLabel({
491
+ children,
492
+ muted,
493
+ }: {
494
+ children: ReactNode;
495
+ muted?: boolean;
496
+ }) {
497
+ return (
498
+ <p
499
+ className={cn(
500
+ "text-xs font-medium uppercase tracking-wider",
501
+ muted
502
+ ? "text-[var(--pylon-ink-3,#71717a)]"
503
+ : "text-[var(--pylon-ink-2,#52525b)]",
504
+ )}
505
+ >
506
+ {children}
507
+ </p>
508
+ );
509
+ }
510
+
511
+ function ErrorBanner({ message }: { message: string }) {
512
+ return (
513
+ <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)]">
514
+ {message}
515
+ </p>
516
+ );
517
+ }
518
+
519
+ function capitalize(s: string): string {
520
+ if (!s) return "";
521
+ return s.charAt(0).toUpperCase() + s.slice(1);
522
+ }
523
+
524
+ function formatRelative(unixSecs: number): string {
525
+ if (!unixSecs) return "—";
526
+ const ms = unixSecs * 1000;
527
+ const diff = ms - Date.now();
528
+ const abs = Math.abs(diff);
529
+ const day = 86_400_000;
530
+ const hour = 3_600_000;
531
+ const min = 60_000;
532
+ const sign = diff < 0 ? "ago" : "from now";
533
+ if (abs > day) return `${Math.round(abs / day)}d ${sign}`;
534
+ if (abs > hour) return `${Math.round(abs / hour)}h ${sign}`;
535
+ if (abs > min) return `${Math.round(abs / min)}m ${sign}`;
536
+ return diff < 0 ? "just now" : "soon";
537
+ }
538
+
539
+ function messageFromError(err: unknown): string {
540
+ if (err instanceof ApiError) {
541
+ switch (err.code) {
542
+ case "INVALID_EMAIL":
543
+ return "Enter a valid email address.";
544
+ case "BAD_ROLE":
545
+ return "Role must be owner, admin, or member.";
546
+ case "LAST_OWNER":
547
+ return "Can't demote or remove the last owner. Promote someone else first.";
548
+ case "FORBIDDEN":
549
+ return "You don't have permission to do that.";
550
+ case "NOT_A_MEMBER":
551
+ return "That user isn't a member of this org.";
552
+ case "INVITE_CREATE_FAILED":
553
+ return "Couldn't create invite — try again.";
554
+ case "API_KEY_AUTH_FORBIDDEN":
555
+ return "Member management needs a session, not an API key.";
556
+ default:
557
+ return err.message;
558
+ }
559
+ }
560
+ if (err instanceof Error) return err.message;
561
+ return "Something went wrong.";
562
+ }