@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,515 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type FormEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
ApiError,
|
|
12
|
+
type AuthProvider,
|
|
13
|
+
listAuthProviders,
|
|
14
|
+
passwordLogin,
|
|
15
|
+
persistSession,
|
|
16
|
+
sendMagicLink,
|
|
17
|
+
verifyMagicLink,
|
|
18
|
+
} from "../lib/api";
|
|
19
|
+
import { cn } from "../lib/cn";
|
|
20
|
+
|
|
21
|
+
export interface SignInProps {
|
|
22
|
+
/** Auth method to lead with. Default: "magic" (most secure default). */
|
|
23
|
+
method?: "magic" | "password";
|
|
24
|
+
/** Where the dashboard sends the user after sign-in. */
|
|
25
|
+
afterSignInUrl?: string;
|
|
26
|
+
/** Optional callback once a session is minted. */
|
|
27
|
+
onSignedIn?: () => void;
|
|
28
|
+
/** Forgot-password landing route shown when the password tab is active. */
|
|
29
|
+
forgotPasswordUrl?: string;
|
|
30
|
+
/** Text shown above the form. */
|
|
31
|
+
title?: ReactNode;
|
|
32
|
+
/** Subtitle / call-to-action. */
|
|
33
|
+
subtitle?: ReactNode;
|
|
34
|
+
/** Tailwind class merged onto the card. */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SignIn({
|
|
39
|
+
method = "magic",
|
|
40
|
+
afterSignInUrl,
|
|
41
|
+
onSignedIn,
|
|
42
|
+
forgotPasswordUrl = "/forgot-password",
|
|
43
|
+
title = "Sign in",
|
|
44
|
+
subtitle,
|
|
45
|
+
className,
|
|
46
|
+
}: SignInProps) {
|
|
47
|
+
const [tab, setTab] = useState<"magic" | "password">(method);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Card className={className}>
|
|
51
|
+
<Heading title={title} subtitle={subtitle} />
|
|
52
|
+
<TabBar value={tab} onChange={setTab} />
|
|
53
|
+
{tab === "magic" ? (
|
|
54
|
+
<MagicLinkPanel
|
|
55
|
+
afterSignInUrl={afterSignInUrl}
|
|
56
|
+
onSignedIn={onSignedIn}
|
|
57
|
+
/>
|
|
58
|
+
) : (
|
|
59
|
+
<PasswordPanel
|
|
60
|
+
mode="login"
|
|
61
|
+
afterSignInUrl={afterSignInUrl}
|
|
62
|
+
onSignedIn={onSignedIn}
|
|
63
|
+
forgotPasswordUrl={forgotPasswordUrl}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
<OAuthButtons />
|
|
67
|
+
<Switcher
|
|
68
|
+
prompt="No account?"
|
|
69
|
+
cta="Create one"
|
|
70
|
+
href={afterSignInUrl ? `?signup=1` : "#signup"}
|
|
71
|
+
/>
|
|
72
|
+
</Card>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SignUpProps extends SignInProps {}
|
|
77
|
+
|
|
78
|
+
export function SignUp({
|
|
79
|
+
afterSignInUrl,
|
|
80
|
+
onSignedIn,
|
|
81
|
+
title = "Create your account",
|
|
82
|
+
subtitle,
|
|
83
|
+
className,
|
|
84
|
+
}: SignUpProps) {
|
|
85
|
+
return (
|
|
86
|
+
<Card className={className}>
|
|
87
|
+
<Heading title={title} subtitle={subtitle} />
|
|
88
|
+
<PasswordPanel
|
|
89
|
+
mode="register"
|
|
90
|
+
afterSignInUrl={afterSignInUrl}
|
|
91
|
+
onSignedIn={onSignedIn}
|
|
92
|
+
/>
|
|
93
|
+
<OAuthButtons />
|
|
94
|
+
<Switcher
|
|
95
|
+
prompt="Already have an account?"
|
|
96
|
+
cta="Sign in"
|
|
97
|
+
href={afterSignInUrl ? `?signin=1` : "#signin"}
|
|
98
|
+
/>
|
|
99
|
+
</Card>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Internals
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function Card({ className, children }: { className?: string; children: ReactNode }) {
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className={cn(
|
|
111
|
+
"pylon-auth-card",
|
|
112
|
+
"mx-auto w-full max-w-sm space-y-5 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-7 shadow-sm",
|
|
113
|
+
className,
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Heading({ title, subtitle }: { title: ReactNode; subtitle?: ReactNode }) {
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-1.5 text-center">
|
|
124
|
+
<h2 className="text-lg font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
125
|
+
{title}
|
|
126
|
+
</h2>
|
|
127
|
+
{subtitle ? (
|
|
128
|
+
<p className="text-sm text-[var(--pylon-ink-2,#52525b)]">{subtitle}</p>
|
|
129
|
+
) : null}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function TabBar({
|
|
135
|
+
value,
|
|
136
|
+
onChange,
|
|
137
|
+
}: {
|
|
138
|
+
value: "magic" | "password";
|
|
139
|
+
onChange: (v: "magic" | "password") => void;
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex rounded-md border border-[var(--pylon-rule,#e5e7eb)] p-0.5 text-sm">
|
|
143
|
+
<TabButton active={value === "magic"} onClick={() => onChange("magic")}>
|
|
144
|
+
Magic link
|
|
145
|
+
</TabButton>
|
|
146
|
+
<TabButton
|
|
147
|
+
active={value === "password"}
|
|
148
|
+
onClick={() => onChange("password")}
|
|
149
|
+
>
|
|
150
|
+
Password
|
|
151
|
+
</TabButton>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function TabButton({
|
|
157
|
+
active,
|
|
158
|
+
onClick,
|
|
159
|
+
children,
|
|
160
|
+
}: {
|
|
161
|
+
active: boolean;
|
|
162
|
+
onClick: () => void;
|
|
163
|
+
children: ReactNode;
|
|
164
|
+
}) {
|
|
165
|
+
return (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={onClick}
|
|
169
|
+
className={cn(
|
|
170
|
+
"flex-1 rounded-[5px] py-1.5 text-center font-medium transition-colors",
|
|
171
|
+
active
|
|
172
|
+
? "bg-[var(--pylon-ink,#0a0a0a)] text-[var(--pylon-paper,#ffffff)]"
|
|
173
|
+
: "text-[var(--pylon-ink-2,#52525b)] hover:text-[var(--pylon-ink,#0a0a0a)]",
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
{children}
|
|
177
|
+
</button>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function MagicLinkPanel({
|
|
182
|
+
afterSignInUrl,
|
|
183
|
+
onSignedIn,
|
|
184
|
+
}: {
|
|
185
|
+
afterSignInUrl?: string;
|
|
186
|
+
onSignedIn?: () => void;
|
|
187
|
+
}) {
|
|
188
|
+
const [email, setEmail] = useState("");
|
|
189
|
+
const [code, setCode] = useState("");
|
|
190
|
+
const [step, setStep] = useState<"email" | "code">("email");
|
|
191
|
+
const [error, setError] = useState<string | null>(null);
|
|
192
|
+
const [pending, setPending] = useState(false);
|
|
193
|
+
|
|
194
|
+
async function onRequest(e: FormEvent) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
setError(null);
|
|
197
|
+
setPending(true);
|
|
198
|
+
try {
|
|
199
|
+
await sendMagicLink(email);
|
|
200
|
+
setStep("code");
|
|
201
|
+
} catch (err) {
|
|
202
|
+
setError(messageFromError(err));
|
|
203
|
+
} finally {
|
|
204
|
+
setPending(false);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function onVerify(e: FormEvent) {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
setError(null);
|
|
211
|
+
setPending(true);
|
|
212
|
+
try {
|
|
213
|
+
const session = await verifyMagicLink(email, code);
|
|
214
|
+
persistSession(session);
|
|
215
|
+
onSignedIn?.();
|
|
216
|
+
if (afterSignInUrl && typeof window !== "undefined") {
|
|
217
|
+
window.location.assign(afterSignInUrl);
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
setError(messageFromError(err));
|
|
221
|
+
} finally {
|
|
222
|
+
setPending(false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (step === "email") {
|
|
227
|
+
return (
|
|
228
|
+
<form onSubmit={onRequest} className="space-y-3">
|
|
229
|
+
<Field
|
|
230
|
+
label="Email"
|
|
231
|
+
type="email"
|
|
232
|
+
value={email}
|
|
233
|
+
onChange={setEmail}
|
|
234
|
+
required
|
|
235
|
+
autoComplete="email"
|
|
236
|
+
placeholder="you@example.com"
|
|
237
|
+
/>
|
|
238
|
+
<SubmitButton pending={pending} label="Send code" />
|
|
239
|
+
<ErrorText message={error} />
|
|
240
|
+
</form>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<form onSubmit={onVerify} className="space-y-3">
|
|
246
|
+
<p className="text-center text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
247
|
+
We sent a code to <span className="font-medium">{email}</span>.
|
|
248
|
+
</p>
|
|
249
|
+
<Field
|
|
250
|
+
label="Code"
|
|
251
|
+
value={code}
|
|
252
|
+
onChange={setCode}
|
|
253
|
+
required
|
|
254
|
+
autoComplete="one-time-code"
|
|
255
|
+
inputMode="numeric"
|
|
256
|
+
placeholder="123456"
|
|
257
|
+
/>
|
|
258
|
+
<SubmitButton pending={pending} label="Sign in" />
|
|
259
|
+
<ErrorText message={error} />
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={() => {
|
|
263
|
+
setStep("email");
|
|
264
|
+
setCode("");
|
|
265
|
+
setError(null);
|
|
266
|
+
}}
|
|
267
|
+
className="block w-full text-center text-xs text-[var(--pylon-ink-3,#71717a)] hover:underline"
|
|
268
|
+
>
|
|
269
|
+
Use a different email
|
|
270
|
+
</button>
|
|
271
|
+
</form>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function PasswordPanel({
|
|
276
|
+
mode,
|
|
277
|
+
afterSignInUrl,
|
|
278
|
+
onSignedIn,
|
|
279
|
+
forgotPasswordUrl,
|
|
280
|
+
}: {
|
|
281
|
+
mode: "login" | "register";
|
|
282
|
+
afterSignInUrl?: string;
|
|
283
|
+
onSignedIn?: () => void;
|
|
284
|
+
forgotPasswordUrl?: string;
|
|
285
|
+
}) {
|
|
286
|
+
const [email, setEmail] = useState("");
|
|
287
|
+
const [password, setPassword] = useState("");
|
|
288
|
+
const [displayName, setDisplayName] = useState("");
|
|
289
|
+
const [error, setError] = useState<string | null>(null);
|
|
290
|
+
const [pending, setPending] = useState(false);
|
|
291
|
+
|
|
292
|
+
async function onSubmit(e: FormEvent) {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
setError(null);
|
|
295
|
+
setPending(true);
|
|
296
|
+
try {
|
|
297
|
+
const session =
|
|
298
|
+
mode === "login"
|
|
299
|
+
? await passwordLogin({ email, password })
|
|
300
|
+
: await (
|
|
301
|
+
await import("../lib/api")
|
|
302
|
+
).passwordRegister({
|
|
303
|
+
email,
|
|
304
|
+
password,
|
|
305
|
+
displayName: displayName || undefined,
|
|
306
|
+
});
|
|
307
|
+
persistSession(session);
|
|
308
|
+
onSignedIn?.();
|
|
309
|
+
if (afterSignInUrl && typeof window !== "undefined") {
|
|
310
|
+
window.location.assign(afterSignInUrl);
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
setError(messageFromError(err));
|
|
314
|
+
} finally {
|
|
315
|
+
setPending(false);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<form onSubmit={onSubmit} className="space-y-3">
|
|
321
|
+
{mode === "register" ? (
|
|
322
|
+
<Field
|
|
323
|
+
label="Name"
|
|
324
|
+
value={displayName}
|
|
325
|
+
onChange={setDisplayName}
|
|
326
|
+
autoComplete="name"
|
|
327
|
+
placeholder="optional"
|
|
328
|
+
/>
|
|
329
|
+
) : null}
|
|
330
|
+
<Field
|
|
331
|
+
label="Email"
|
|
332
|
+
type="email"
|
|
333
|
+
value={email}
|
|
334
|
+
onChange={setEmail}
|
|
335
|
+
required
|
|
336
|
+
autoComplete="email"
|
|
337
|
+
placeholder="you@example.com"
|
|
338
|
+
/>
|
|
339
|
+
<Field
|
|
340
|
+
label="Password"
|
|
341
|
+
type="password"
|
|
342
|
+
value={password}
|
|
343
|
+
onChange={setPassword}
|
|
344
|
+
required
|
|
345
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
346
|
+
/>
|
|
347
|
+
<SubmitButton
|
|
348
|
+
pending={pending}
|
|
349
|
+
label={mode === "login" ? "Sign in" : "Create account"}
|
|
350
|
+
/>
|
|
351
|
+
{mode === "login" && forgotPasswordUrl ? (
|
|
352
|
+
<a
|
|
353
|
+
href={forgotPasswordUrl}
|
|
354
|
+
className="block text-center text-xs text-[var(--pylon-ink-3,#71717a)] hover:underline"
|
|
355
|
+
>
|
|
356
|
+
Forgot password?
|
|
357
|
+
</a>
|
|
358
|
+
) : null}
|
|
359
|
+
<ErrorText message={error} />
|
|
360
|
+
</form>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function OAuthButtons() {
|
|
365
|
+
const [providers, setProviders] = useState<AuthProvider[] | null>(null);
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
let cancelled = false;
|
|
368
|
+
void listAuthProviders().then((p) => {
|
|
369
|
+
if (!cancelled) setProviders(p);
|
|
370
|
+
});
|
|
371
|
+
return () => {
|
|
372
|
+
cancelled = true;
|
|
373
|
+
};
|
|
374
|
+
}, []);
|
|
375
|
+
if (!providers || providers.length === 0) return null;
|
|
376
|
+
return (
|
|
377
|
+
<div className="space-y-2">
|
|
378
|
+
<Divider label="or" />
|
|
379
|
+
{providers.map((p) => (
|
|
380
|
+
<button
|
|
381
|
+
key={p.provider}
|
|
382
|
+
type="button"
|
|
383
|
+
onClick={() => {
|
|
384
|
+
if (typeof window !== "undefined") {
|
|
385
|
+
const callback = encodeURIComponent(window.location.href);
|
|
386
|
+
window.location.assign(`${p.auth_url}?callback=${callback}`);
|
|
387
|
+
}
|
|
388
|
+
}}
|
|
389
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm font-medium text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]"
|
|
390
|
+
>
|
|
391
|
+
Continue with {labelFor(p.provider)}
|
|
392
|
+
</button>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function Switcher({
|
|
399
|
+
prompt,
|
|
400
|
+
cta,
|
|
401
|
+
href,
|
|
402
|
+
}: {
|
|
403
|
+
prompt: string;
|
|
404
|
+
cta: string;
|
|
405
|
+
href: string;
|
|
406
|
+
}) {
|
|
407
|
+
return (
|
|
408
|
+
<p className="text-center text-xs text-[var(--pylon-ink-2,#52525b)]">
|
|
409
|
+
{prompt}{" "}
|
|
410
|
+
<a
|
|
411
|
+
href={href}
|
|
412
|
+
className="font-medium text-[var(--pylon-ink,#0a0a0a)] hover:underline"
|
|
413
|
+
>
|
|
414
|
+
{cta}
|
|
415
|
+
</a>
|
|
416
|
+
</p>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function Field({
|
|
421
|
+
label,
|
|
422
|
+
value,
|
|
423
|
+
onChange,
|
|
424
|
+
type = "text",
|
|
425
|
+
required,
|
|
426
|
+
autoComplete,
|
|
427
|
+
placeholder,
|
|
428
|
+
inputMode,
|
|
429
|
+
}: {
|
|
430
|
+
label: string;
|
|
431
|
+
value: string;
|
|
432
|
+
onChange: (v: string) => void;
|
|
433
|
+
type?: string;
|
|
434
|
+
required?: boolean;
|
|
435
|
+
autoComplete?: string;
|
|
436
|
+
placeholder?: string;
|
|
437
|
+
inputMode?: "text" | "numeric";
|
|
438
|
+
}) {
|
|
439
|
+
return (
|
|
440
|
+
<label className="block space-y-1.5">
|
|
441
|
+
<span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
|
|
442
|
+
{label}
|
|
443
|
+
</span>
|
|
444
|
+
<input
|
|
445
|
+
type={type}
|
|
446
|
+
value={value}
|
|
447
|
+
onChange={(e) => onChange(e.target.value)}
|
|
448
|
+
required={required}
|
|
449
|
+
autoComplete={autoComplete}
|
|
450
|
+
placeholder={placeholder}
|
|
451
|
+
inputMode={inputMode}
|
|
452
|
+
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)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
|
|
453
|
+
/>
|
|
454
|
+
</label>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function SubmitButton({ pending, label }: { pending: boolean; label: string }) {
|
|
459
|
+
return (
|
|
460
|
+
<button
|
|
461
|
+
type="submit"
|
|
462
|
+
disabled={pending}
|
|
463
|
+
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"
|
|
464
|
+
>
|
|
465
|
+
{pending ? "…" : label}
|
|
466
|
+
</button>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function ErrorText({ message }: { message: string | null }) {
|
|
471
|
+
if (!message) return null;
|
|
472
|
+
return (
|
|
473
|
+
<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)]">
|
|
474
|
+
{message}
|
|
475
|
+
</p>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function Divider({ label }: { label: string }) {
|
|
480
|
+
return (
|
|
481
|
+
<div className="flex items-center gap-3 text-[10px] uppercase tracking-wider text-[var(--pylon-ink-3,#a1a1aa)]">
|
|
482
|
+
<span className="h-px flex-1 bg-[var(--pylon-rule,#e5e7eb)]" />
|
|
483
|
+
{label}
|
|
484
|
+
<span className="h-px flex-1 bg-[var(--pylon-rule,#e5e7eb)]" />
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function messageFromError(err: unknown): string {
|
|
490
|
+
if (err instanceof ApiError) {
|
|
491
|
+
switch (err.code) {
|
|
492
|
+
case "INVALID_CREDENTIALS":
|
|
493
|
+
return "Wrong email or password.";
|
|
494
|
+
case "USER_EXISTS":
|
|
495
|
+
return "That email is already in use.";
|
|
496
|
+
case "WEAK_PASSWORD":
|
|
497
|
+
return "Pick a stronger password.";
|
|
498
|
+
case "RATE_LIMITED":
|
|
499
|
+
return "Too many attempts — try again in a minute.";
|
|
500
|
+
case "CAPTCHA_REQUIRED":
|
|
501
|
+
return "Captcha verification failed.";
|
|
502
|
+
case "INVALID_CODE":
|
|
503
|
+
return "That code didn't match. Try again.";
|
|
504
|
+
default:
|
|
505
|
+
return err.message;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (err instanceof Error) return err.message;
|
|
509
|
+
return "Something went wrong.";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function labelFor(provider: string): string {
|
|
513
|
+
if (!provider) return "provider";
|
|
514
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
515
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ReactNode } from "react";
|
|
5
|
+
import { useAuth } from "../hooks/useAuth";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
|
|
8
|
+
export interface SignOutButtonProps {
|
|
9
|
+
afterSignOutUrl?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Headless-friendly sign-out button. Pass children to fully customize
|
|
16
|
+
* the rendered label/UI; default renders a plain "Sign out" button.
|
|
17
|
+
*/
|
|
18
|
+
export function SignOutButton({
|
|
19
|
+
afterSignOutUrl,
|
|
20
|
+
className,
|
|
21
|
+
children,
|
|
22
|
+
}: SignOutButtonProps) {
|
|
23
|
+
const { signOut } = useAuth();
|
|
24
|
+
async function onClick() {
|
|
25
|
+
await signOut();
|
|
26
|
+
if (afterSignOutUrl && typeof window !== "undefined") {
|
|
27
|
+
window.location.assign(afterSignOutUrl);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={onClick}
|
|
34
|
+
className={cn(
|
|
35
|
+
"inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium text-[var(--pylon-ink-2,#52525b)] transition-colors hover:text-[var(--pylon-ink,#0a0a0a)]",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{children ?? "Sign out"}
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useAuth } from "../hooks/useAuth";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
|
|
13
|
+
export interface UserButtonProps {
|
|
14
|
+
/** Where to send the user after sign-out. Default: current page reload. */
|
|
15
|
+
afterSignOutUrl?: string;
|
|
16
|
+
/** Show the user's name next to the avatar (Clerk's `showName`). */
|
|
17
|
+
showName?: boolean;
|
|
18
|
+
/** Extra menu items rendered above the built-in entries. */
|
|
19
|
+
menuItems?: Array<{
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
onClick?: () => void;
|
|
22
|
+
href?: string;
|
|
23
|
+
}>;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Avatar dropdown with profile/sign-out, mirroring Clerk's `<UserButton />`.
|
|
29
|
+
* Renders nothing when the user isn't signed in — wrap the call site in
|
|
30
|
+
* `<SignedIn>` or check `useAuth()` if you want a sign-in CTA instead.
|
|
31
|
+
*/
|
|
32
|
+
export function UserButton({
|
|
33
|
+
afterSignOutUrl,
|
|
34
|
+
showName,
|
|
35
|
+
menuItems,
|
|
36
|
+
className,
|
|
37
|
+
}: UserButtonProps) {
|
|
38
|
+
const { isSignedIn, userId, session, signOut } = useAuth();
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
function onDocClick(e: MouseEvent) {
|
|
45
|
+
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
|
46
|
+
}
|
|
47
|
+
function onKey(e: KeyboardEvent) {
|
|
48
|
+
if (e.key === "Escape") setOpen(false);
|
|
49
|
+
}
|
|
50
|
+
document.addEventListener("mousedown", onDocClick);
|
|
51
|
+
document.addEventListener("keydown", onKey);
|
|
52
|
+
return () => {
|
|
53
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
54
|
+
document.removeEventListener("keydown", onKey);
|
|
55
|
+
};
|
|
56
|
+
}, [open]);
|
|
57
|
+
|
|
58
|
+
if (!isSignedIn) return null;
|
|
59
|
+
|
|
60
|
+
// ResolvedSession is intentionally minimal (userId / tenantId / roles).
|
|
61
|
+
// Apps that want a real name / email shown on the avatar pass it in
|
|
62
|
+
// via `menuItems` or wrap their own component over the User entity.
|
|
63
|
+
const label = userId ?? "Account";
|
|
64
|
+
const initials = initialsFor(label);
|
|
65
|
+
// session reserved for future extension hooks
|
|
66
|
+
void session;
|
|
67
|
+
|
|
68
|
+
async function onSignOut() {
|
|
69
|
+
setOpen(false);
|
|
70
|
+
await signOut();
|
|
71
|
+
if (afterSignOutUrl && typeof window !== "undefined") {
|
|
72
|
+
window.location.assign(afterSignOutUrl);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div ref={rootRef} className={cn("relative inline-flex", className)}>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => setOpen((o) => !o)}
|
|
81
|
+
aria-haspopup="menu"
|
|
82
|
+
aria-expanded={open}
|
|
83
|
+
className="inline-flex items-center gap-2 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--pylon-ink,#0a0a0a)]"
|
|
84
|
+
>
|
|
85
|
+
<span
|
|
86
|
+
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--pylon-ink,#0a0a0a)] text-xs font-semibold text-[var(--pylon-paper,#ffffff)]"
|
|
87
|
+
aria-hidden
|
|
88
|
+
>
|
|
89
|
+
{initials}
|
|
90
|
+
</span>
|
|
91
|
+
{showName ? (
|
|
92
|
+
<span className="text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
|
|
93
|
+
{label}
|
|
94
|
+
</span>
|
|
95
|
+
) : null}
|
|
96
|
+
</button>
|
|
97
|
+
{open ? (
|
|
98
|
+
<div
|
|
99
|
+
role="menu"
|
|
100
|
+
className="absolute right-0 top-full z-50 mt-2 w-56 overflow-hidden rounded-lg border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] shadow-lg"
|
|
101
|
+
>
|
|
102
|
+
<div className="border-b border-[var(--pylon-rule,#e5e7eb)] px-3 py-2.5">
|
|
103
|
+
<p className="truncate text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
|
|
104
|
+
{label}
|
|
105
|
+
</p>
|
|
106
|
+
{userId ? (
|
|
107
|
+
<p className="truncate text-xs text-[var(--pylon-ink-3,#71717a)]">
|
|
108
|
+
{userId}
|
|
109
|
+
</p>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
<div className="py-1">
|
|
113
|
+
{(menuItems ?? []).map((item, i) => (
|
|
114
|
+
<MenuItem
|
|
115
|
+
key={i}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
item.onClick?.();
|
|
118
|
+
setOpen(false);
|
|
119
|
+
}}
|
|
120
|
+
href={item.href}
|
|
121
|
+
>
|
|
122
|
+
{item.label}
|
|
123
|
+
</MenuItem>
|
|
124
|
+
))}
|
|
125
|
+
<MenuItem onClick={onSignOut}>Sign out</MenuItem>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function MenuItem({
|
|
134
|
+
onClick,
|
|
135
|
+
href,
|
|
136
|
+
children,
|
|
137
|
+
}: {
|
|
138
|
+
onClick?: () => void;
|
|
139
|
+
href?: string;
|
|
140
|
+
children: ReactNode;
|
|
141
|
+
}) {
|
|
142
|
+
const cls =
|
|
143
|
+
"block w-full px-3 py-2 text-left text-sm text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]";
|
|
144
|
+
if (href) {
|
|
145
|
+
return (
|
|
146
|
+
<a href={href} role="menuitem" className={cls} onClick={onClick}>
|
|
147
|
+
{children}
|
|
148
|
+
</a>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return (
|
|
152
|
+
<button type="button" role="menuitem" onClick={onClick} className={cls}>
|
|
153
|
+
{children}
|
|
154
|
+
</button>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function initialsFor(label: string): string {
|
|
159
|
+
const parts = label.split(/\s+/).filter(Boolean);
|
|
160
|
+
if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
|
|
161
|
+
const seed = parts[0] ?? label;
|
|
162
|
+
return seed.slice(0, 2).toUpperCase();
|
|
163
|
+
}
|