@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.
- package/README.md +125 -0
- package/package.json +32 -0
- package/src/components/AcceptInvite.tsx +160 -0
- package/src/components/ChatBot.tsx +228 -0
- package/src/components/ConnectAccount.tsx +119 -0
- package/src/components/EnsureGuest.tsx +49 -0
- package/src/components/EntityForm.tsx +308 -0
- package/src/components/EntityList.tsx +203 -0
- package/src/components/FileUpload.tsx +213 -0
- package/src/components/Gates.tsx +139 -0
- package/src/components/InviteMembers.tsx +562 -0
- package/src/components/OrganizationSwitcher.tsx +417 -0
- package/src/components/PasswordReset.tsx +302 -0
- package/src/components/SignIn.tsx +515 -0
- package/src/components/SignOutButton.tsx +42 -0
- package/src/components/UserButton.tsx +163 -0
- package/src/components/UserProfile.tsx +485 -0
- package/src/hooks/useAuth.ts +27 -0
- package/src/index.ts +130 -0
- package/src/lib/api.ts +368 -0
- package/src/lib/cn.ts +7 -0
- package/src/router/Router.tsx +282 -0
- package/src/router/context.ts +25 -0
- package/src/router/match.ts +106 -0
- package/src/router/useRouter.ts +40 -0
- package/src/theme.css +30 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
}
|