@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
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { db, getBaseUrl, storageKey } from "@pylonsync/react";
|
|
4
|
+
|
|
5
|
+
export interface AuthProvider {
|
|
6
|
+
provider: string;
|
|
7
|
+
auth_url: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionResponse {
|
|
11
|
+
token: string;
|
|
12
|
+
user_id: string;
|
|
13
|
+
expires_at?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OrgSummary {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
role: "owner" | "admin" | "member" | string;
|
|
20
|
+
created_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class ApiError extends Error {
|
|
24
|
+
code: string;
|
|
25
|
+
status: number;
|
|
26
|
+
constructor(code: string, message: string, status: number) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.status = status;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
34
|
+
const res = await fetch(`${getBaseUrl()}${path}`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
credentials: "include",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const payload = await res.json().catch(() => ({}) as Record<string, unknown>);
|
|
42
|
+
const code = (payload?.error as string) ?? `HTTP_${res.status}`;
|
|
43
|
+
const message =
|
|
44
|
+
(payload?.message as string) ?? res.statusText ?? "request failed";
|
|
45
|
+
throw new ApiError(code, message, res.status);
|
|
46
|
+
}
|
|
47
|
+
return res.json() as Promise<T>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function get<T>(path: string): Promise<T> {
|
|
51
|
+
const res = await fetch(`${getBaseUrl()}${path}`, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
credentials: "include",
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) throw new ApiError(`HTTP_${res.status}`, res.statusText, res.status);
|
|
56
|
+
return res.json() as Promise<T>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mint a guest session if the browser doesn't already have a Pylon
|
|
61
|
+
* token. Idempotent — calling repeatedly is a no-op once a session
|
|
62
|
+
* exists. Designed for zero-auth demos (`<EnsureGuest>` is the
|
|
63
|
+
* component wrapper); apps that need real users should use `<SignIn>`
|
|
64
|
+
* instead.
|
|
65
|
+
*
|
|
66
|
+
* Each browser gets its own anonymous `user_id`, so multi-tab demos
|
|
67
|
+
* still observe live sync across tabs sharing the same guest. To
|
|
68
|
+
* reset (e.g. "switch identity"), clear the token from storage and
|
|
69
|
+
* call this again.
|
|
70
|
+
*/
|
|
71
|
+
export async function ensureGuestSession(): Promise<SessionResponse | null> {
|
|
72
|
+
if (typeof window === "undefined") return null;
|
|
73
|
+
const existing = window.localStorage?.getItem(storageKey("token"));
|
|
74
|
+
if (existing) return null;
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`${getBaseUrl()}/api/auth/guest`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
credentials: "include",
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) return null;
|
|
81
|
+
const body = (await res.json()) as SessionResponse;
|
|
82
|
+
persistSession(body);
|
|
83
|
+
return body;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function listAuthProviders(): Promise<AuthProvider[]> {
|
|
90
|
+
try {
|
|
91
|
+
return await get<AuthProvider[]>("/api/auth/providers");
|
|
92
|
+
} catch {
|
|
93
|
+
// Older binaries may not expose this — render with no OAuth buttons.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function sendMagicLink(
|
|
99
|
+
email: string,
|
|
100
|
+
captchaToken?: string,
|
|
101
|
+
): Promise<{ ok: true }> {
|
|
102
|
+
return post("/api/auth/magic/send", { email, captchaToken });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function verifyMagicLink(
|
|
106
|
+
email: string,
|
|
107
|
+
code: string,
|
|
108
|
+
): Promise<SessionResponse> {
|
|
109
|
+
return post("/api/auth/magic/verify", { email, code });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function passwordRegister(input: {
|
|
113
|
+
email: string;
|
|
114
|
+
password: string;
|
|
115
|
+
displayName?: string;
|
|
116
|
+
captchaToken?: string;
|
|
117
|
+
}): Promise<SessionResponse> {
|
|
118
|
+
return post("/api/auth/password/register", input);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function passwordLogin(input: {
|
|
122
|
+
email: string;
|
|
123
|
+
password: string;
|
|
124
|
+
}): Promise<SessionResponse> {
|
|
125
|
+
return post("/api/auth/password/login", input);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persist a freshly-minted session locally + tell the sync engine to
|
|
130
|
+
* re-fetch /api/auth/me so cached `useSession()` consumers re-render
|
|
131
|
+
* immediately. Components call this after successful sign-in/sign-up.
|
|
132
|
+
*/
|
|
133
|
+
export function persistSession(session: SessionResponse): void {
|
|
134
|
+
try {
|
|
135
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
136
|
+
window.localStorage.setItem(storageKey("token"), session.token);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// localStorage can throw (private mode, quota, etc.) — fall through
|
|
140
|
+
// and let the sync engine pick up the token on next start.
|
|
141
|
+
}
|
|
142
|
+
void db.sync.notifySessionChanged();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function listOrgs(): Promise<OrgSummary[]> {
|
|
146
|
+
try {
|
|
147
|
+
return await get<OrgSummary[]>("/api/auth/orgs");
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function createOrg(name: string): Promise<OrgSummary> {
|
|
154
|
+
return post<OrgSummary>("/api/auth/orgs", { name });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface OrgMember {
|
|
158
|
+
user_id: string;
|
|
159
|
+
role: string;
|
|
160
|
+
joined_at: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function listOrgMembers(orgId: string): Promise<OrgMember[]> {
|
|
164
|
+
try {
|
|
165
|
+
return await get<OrgMember[]>(`/api/auth/orgs/${orgId}/members`);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function updateMemberRole(
|
|
172
|
+
orgId: string,
|
|
173
|
+
userId: string,
|
|
174
|
+
role: string,
|
|
175
|
+
): Promise<{ updated: boolean }> {
|
|
176
|
+
return req<{ updated: boolean }>(
|
|
177
|
+
"PUT",
|
|
178
|
+
`/api/auth/orgs/${orgId}/members/${userId}`,
|
|
179
|
+
{ role },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function removeMember(
|
|
184
|
+
orgId: string,
|
|
185
|
+
userId: string,
|
|
186
|
+
): Promise<{ removed: boolean }> {
|
|
187
|
+
return req<{ removed: boolean }>(
|
|
188
|
+
"DELETE",
|
|
189
|
+
`/api/auth/orgs/${orgId}/members/${userId}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface PendingInvite {
|
|
194
|
+
id: string;
|
|
195
|
+
email: string;
|
|
196
|
+
role: string;
|
|
197
|
+
token_prefix: string;
|
|
198
|
+
invited_by: string;
|
|
199
|
+
created_at: number;
|
|
200
|
+
expires_at: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface InviteResult {
|
|
204
|
+
id: string;
|
|
205
|
+
email: string;
|
|
206
|
+
role: string;
|
|
207
|
+
expires_at: number;
|
|
208
|
+
accept_url: string;
|
|
209
|
+
/** Dev mode only — full token so the inviter can copy/paste when
|
|
210
|
+
* the email transport isn't configured. */
|
|
211
|
+
token?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function listInvites(orgId: string): Promise<PendingInvite[]> {
|
|
215
|
+
try {
|
|
216
|
+
return await get<PendingInvite[]>(`/api/auth/orgs/${orgId}/invites`);
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function createInvite(
|
|
223
|
+
orgId: string,
|
|
224
|
+
email: string,
|
|
225
|
+
role: string,
|
|
226
|
+
): Promise<InviteResult> {
|
|
227
|
+
return post<InviteResult>(`/api/auth/orgs/${orgId}/invites`, { email, role });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function revokeInvite(
|
|
231
|
+
orgId: string,
|
|
232
|
+
inviteId: string,
|
|
233
|
+
): Promise<{ revoked: boolean }> {
|
|
234
|
+
return req<{ revoked: boolean }>(
|
|
235
|
+
"DELETE",
|
|
236
|
+
`/api/auth/orgs/${orgId}/invites/${inviteId}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function acceptInvite(
|
|
241
|
+
token: string,
|
|
242
|
+
): Promise<{ org_id: string; role: string }> {
|
|
243
|
+
return post<{ org_id: string; role: string }>(
|
|
244
|
+
`/api/auth/invites/${encodeURIComponent(token)}/accept`,
|
|
245
|
+
{},
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ConnectionAuthUrl {
|
|
250
|
+
url: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function connectionAuthUrl(
|
|
254
|
+
name: string,
|
|
255
|
+
postRedirect?: string,
|
|
256
|
+
): Promise<ConnectionAuthUrl> {
|
|
257
|
+
return post<ConnectionAuthUrl>(
|
|
258
|
+
`/api/connections/${encodeURIComponent(name)}/auth-url`,
|
|
259
|
+
postRedirect ? { post_redirect: postRedirect } : {},
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface ActiveSession {
|
|
264
|
+
token_prefix: string;
|
|
265
|
+
user_id: string;
|
|
266
|
+
device?: string;
|
|
267
|
+
created_at: number;
|
|
268
|
+
expires_at: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function listActiveSessions(): Promise<ActiveSession[]> {
|
|
272
|
+
try {
|
|
273
|
+
return await get<ActiveSession[]>("/api/auth/sessions");
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function revokeAllSessions(): Promise<{ revoked_count: number }> {
|
|
280
|
+
return req<{ revoked_count: number }>("DELETE", "/api/auth/sessions");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function changePassword(input: {
|
|
284
|
+
currentPassword: string;
|
|
285
|
+
newPassword: string;
|
|
286
|
+
}): Promise<{ ok: true }> {
|
|
287
|
+
return post("/api/auth/password/change", input);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function requestPasswordReset(
|
|
291
|
+
email: string,
|
|
292
|
+
): Promise<{ sent: true }> {
|
|
293
|
+
return post("/api/auth/password/reset/request", { email });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function completePasswordReset(input: {
|
|
297
|
+
token: string;
|
|
298
|
+
newPassword: string;
|
|
299
|
+
}): Promise<{ ok: true }> {
|
|
300
|
+
return post("/api/auth/password/reset/complete", input);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface ApiKeySummary {
|
|
304
|
+
id: string;
|
|
305
|
+
prefix: string;
|
|
306
|
+
name: string;
|
|
307
|
+
scopes?: string | null;
|
|
308
|
+
expires_at?: number | null;
|
|
309
|
+
last_used_at?: number | null;
|
|
310
|
+
created_at: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface ApiKeyCreated extends ApiKeySummary {
|
|
314
|
+
/** Shown once on creation. Never returned again. */
|
|
315
|
+
key: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function listApiKeys(): Promise<ApiKeySummary[]> {
|
|
319
|
+
try {
|
|
320
|
+
return await get<ApiKeySummary[]>("/api/auth/api-keys");
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function createApiKey(input: {
|
|
327
|
+
name: string;
|
|
328
|
+
scopes?: string;
|
|
329
|
+
expiresAt?: number;
|
|
330
|
+
}): Promise<ApiKeyCreated> {
|
|
331
|
+
return post<ApiKeyCreated>("/api/auth/api-keys", {
|
|
332
|
+
name: input.name,
|
|
333
|
+
scopes: input.scopes,
|
|
334
|
+
expires_at: input.expiresAt,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function revokeApiKey(
|
|
339
|
+
id: string,
|
|
340
|
+
): Promise<{ revoked: boolean }> {
|
|
341
|
+
return req<{ revoked: boolean }>(
|
|
342
|
+
"DELETE",
|
|
343
|
+
`/api/auth/api-keys/${encodeURIComponent(id)}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function req<T>(
|
|
348
|
+
method: "PUT" | "DELETE",
|
|
349
|
+
path: string,
|
|
350
|
+
body?: unknown,
|
|
351
|
+
): Promise<T> {
|
|
352
|
+
const res = await fetch(`${getBaseUrl()}${path}`, {
|
|
353
|
+
method,
|
|
354
|
+
credentials: "include",
|
|
355
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
356
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
357
|
+
});
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
const payload = await res.json().catch(() => ({}) as Record<string, unknown>);
|
|
360
|
+
const code = (payload?.error as string) ?? `HTTP_${res.status}`;
|
|
361
|
+
const message =
|
|
362
|
+
(payload?.message as string) ?? res.statusText ?? "request failed";
|
|
363
|
+
throw new ApiError(code, message, res.status);
|
|
364
|
+
}
|
|
365
|
+
return res.json() as Promise<T>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export { ApiError };
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type AnchorHTMLAttributes,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { useAuth } from "../hooks/useAuth";
|
|
14
|
+
import { RouterContext, RouterDepthContext } from "./context";
|
|
15
|
+
import { matchRoutes, type MatchedRoute, type RouteSpec } from "./match";
|
|
16
|
+
|
|
17
|
+
export interface RouterProps {
|
|
18
|
+
routes: RouteSpec[];
|
|
19
|
+
/** Render when no route matches. Default: a minimal 404 line. */
|
|
20
|
+
notFound?: ReactNode;
|
|
21
|
+
/** Initial pathname for SSR / testing. Default: window.location.pathname. */
|
|
22
|
+
initialUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Top-level SPA router. Subscribes to `popstate` + intercepted clicks
|
|
27
|
+
* from `<Link />`, matches the URL against the `routes` tree, and
|
|
28
|
+
* renders the matched chain with nested `<Outlet />` support.
|
|
29
|
+
*
|
|
30
|
+
* Place once near the root of the app:
|
|
31
|
+
*
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <Router routes={[
|
|
34
|
+
* { path: "/", component: HomePage },
|
|
35
|
+
* {
|
|
36
|
+
* path: "/dashboard",
|
|
37
|
+
* component: DashboardLayout,
|
|
38
|
+
* requireAuth: true,
|
|
39
|
+
* children: [
|
|
40
|
+
* { path: "/", component: DashboardHome },
|
|
41
|
+
* { path: "/settings", component: SettingsPage },
|
|
42
|
+
* ],
|
|
43
|
+
* },
|
|
44
|
+
* ]} />
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function Router({ routes, notFound, initialUrl }: RouterProps) {
|
|
48
|
+
const [url, setUrl] = useState<URL>(() => initialURL(initialUrl));
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (typeof window === "undefined") return;
|
|
52
|
+
// Pick up the live URL on mount in case the SSR-painted snapshot
|
|
53
|
+
// is stale (hashchange, etc).
|
|
54
|
+
setUrl(new URL(window.location.href));
|
|
55
|
+
function onPop() {
|
|
56
|
+
setUrl(new URL(window.location.href));
|
|
57
|
+
}
|
|
58
|
+
window.addEventListener("popstate", onPop);
|
|
59
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const navigate = useCallback(
|
|
63
|
+
(href: string, mode: "push" | "replace") => {
|
|
64
|
+
if (typeof window === "undefined") return;
|
|
65
|
+
const next = new URL(href, window.location.origin);
|
|
66
|
+
if (mode === "push") {
|
|
67
|
+
window.history.pushState({}, "", next.href);
|
|
68
|
+
} else {
|
|
69
|
+
window.history.replaceState({}, "", next.href);
|
|
70
|
+
}
|
|
71
|
+
setUrl(next);
|
|
72
|
+
// Scroll to top on push, matching what users expect from a
|
|
73
|
+
// SPA navigation. Replace skips this — usually a side-effect
|
|
74
|
+
// of in-place state (e.g., filter toggles).
|
|
75
|
+
if (mode === "push") {
|
|
76
|
+
window.scrollTo(0, 0);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const chain = useMemo(
|
|
83
|
+
() => matchRoutes(routes, url.pathname),
|
|
84
|
+
[routes, url.pathname],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const params = useMemo(() => {
|
|
88
|
+
const merged: Record<string, string> = {};
|
|
89
|
+
for (const m of chain ?? []) Object.assign(merged, m.params);
|
|
90
|
+
return merged;
|
|
91
|
+
}, [chain]);
|
|
92
|
+
|
|
93
|
+
const query = useMemo(() => parseQuery(url.search), [url.search]);
|
|
94
|
+
|
|
95
|
+
const value = useMemo(
|
|
96
|
+
() => ({
|
|
97
|
+
pathname: url.pathname,
|
|
98
|
+
search: url.search,
|
|
99
|
+
hash: url.hash,
|
|
100
|
+
query,
|
|
101
|
+
params,
|
|
102
|
+
matches: chain ?? [],
|
|
103
|
+
push: (href: string) => navigate(href, "push"),
|
|
104
|
+
replace: (href: string) => navigate(href, "replace"),
|
|
105
|
+
back: () => {
|
|
106
|
+
if (typeof window !== "undefined") window.history.back();
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
[url, query, params, chain, navigate],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<RouterContext.Provider value={value}>
|
|
114
|
+
<RouterDepthContext.Provider value={0}>
|
|
115
|
+
{chain ? <RouteRenderer chain={chain} depth={0} /> : (notFound ?? <NotFound />)}
|
|
116
|
+
</RouterDepthContext.Provider>
|
|
117
|
+
</RouterContext.Provider>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function RouteRenderer({
|
|
122
|
+
chain,
|
|
123
|
+
depth,
|
|
124
|
+
}: {
|
|
125
|
+
chain: MatchedRoute[];
|
|
126
|
+
depth: number;
|
|
127
|
+
}) {
|
|
128
|
+
const m = chain[depth];
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
const node = renderRoute(m.route);
|
|
131
|
+
return (
|
|
132
|
+
<RouterDepthContext.Provider value={depth}>
|
|
133
|
+
<AuthGate route={m.route}>{node}</AuthGate>
|
|
134
|
+
</RouterDepthContext.Provider>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function AuthGate({
|
|
139
|
+
route,
|
|
140
|
+
children,
|
|
141
|
+
}: {
|
|
142
|
+
route: RouteSpec;
|
|
143
|
+
children: ReactNode;
|
|
144
|
+
}) {
|
|
145
|
+
const { isSignedIn, isAdmin } = useAuth();
|
|
146
|
+
const needsAdmin = route.requireAuth === "admin";
|
|
147
|
+
const allowed = !route.requireAuth
|
|
148
|
+
? true
|
|
149
|
+
: needsAdmin
|
|
150
|
+
? isSignedIn && isAdmin
|
|
151
|
+
: isSignedIn;
|
|
152
|
+
// Effect must be unconditional to honor rules of hooks. The redirect
|
|
153
|
+
// only fires when both (a) the route is auth-gated and (b) the
|
|
154
|
+
// caller isn't allowed.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!route.requireAuth || allowed) return;
|
|
157
|
+
if (typeof window === "undefined") return;
|
|
158
|
+
const signInUrl = route.signInUrl ?? "/sign-in";
|
|
159
|
+
const next = encodeURIComponent(
|
|
160
|
+
window.location.pathname + window.location.search,
|
|
161
|
+
);
|
|
162
|
+
window.location.assign(`${signInUrl}?next=${next}`);
|
|
163
|
+
}, [allowed, route.requireAuth, route.signInUrl]);
|
|
164
|
+
return allowed ? <>{children}</> : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderRoute(route: RouteSpec): ReactNode {
|
|
168
|
+
if (route.element !== undefined) return route.element;
|
|
169
|
+
if (route.component) {
|
|
170
|
+
const C = route.component;
|
|
171
|
+
return <C />;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Renders the next match in the chain — the page nested under the
|
|
178
|
+
* current layout. Without an `<Outlet />` a layout component's
|
|
179
|
+
* children never appear.
|
|
180
|
+
*/
|
|
181
|
+
export function Outlet() {
|
|
182
|
+
const router = useContext(RouterContext);
|
|
183
|
+
const depth = useContext(RouterDepthContext);
|
|
184
|
+
if (!router) return null;
|
|
185
|
+
if (depth + 1 >= router.matches.length) return null;
|
|
186
|
+
return <RouteRenderer chain={router.matches} depth={depth + 1} />;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
190
|
+
href: string;
|
|
191
|
+
/** Use replaceState instead of pushState (no history entry added). */
|
|
192
|
+
replace?: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* SPA link. Renders a real `<a>` so middle-click / cmd-click / right-
|
|
197
|
+
* click → "open in new tab" all behave correctly. Plain left-click is
|
|
198
|
+
* intercepted and routed via the in-page Router.
|
|
199
|
+
*/
|
|
200
|
+
export function Link({
|
|
201
|
+
href,
|
|
202
|
+
replace,
|
|
203
|
+
onClick,
|
|
204
|
+
target,
|
|
205
|
+
rel,
|
|
206
|
+
children,
|
|
207
|
+
...rest
|
|
208
|
+
}: LinkProps) {
|
|
209
|
+
const router = useContext(RouterContext);
|
|
210
|
+
function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
|
|
211
|
+
onClick?.(e);
|
|
212
|
+
if (e.defaultPrevented) return;
|
|
213
|
+
if (
|
|
214
|
+
e.button !== 0 ||
|
|
215
|
+
e.metaKey ||
|
|
216
|
+
e.ctrlKey ||
|
|
217
|
+
e.shiftKey ||
|
|
218
|
+
e.altKey ||
|
|
219
|
+
target === "_blank"
|
|
220
|
+
)
|
|
221
|
+
return;
|
|
222
|
+
if (!router) return;
|
|
223
|
+
// Only intercept same-origin relative paths.
|
|
224
|
+
try {
|
|
225
|
+
const u = new URL(href, window.location.origin);
|
|
226
|
+
if (u.origin !== window.location.origin) return;
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
if (replace) router.replace(u.pathname + u.search + u.hash);
|
|
229
|
+
else router.push(u.pathname + u.search + u.hash);
|
|
230
|
+
} catch {
|
|
231
|
+
/* malformed href — let the browser handle it */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return (
|
|
235
|
+
<a
|
|
236
|
+
href={href}
|
|
237
|
+
target={target}
|
|
238
|
+
rel={target === "_blank" ? (rel ?? "noopener noreferrer") : rel}
|
|
239
|
+
onClick={handleClick}
|
|
240
|
+
{...rest}
|
|
241
|
+
>
|
|
242
|
+
{children}
|
|
243
|
+
</a>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function NotFound() {
|
|
248
|
+
return (
|
|
249
|
+
<div className="mx-auto max-w-md px-6 py-16 text-center">
|
|
250
|
+
<p className="text-4xl font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
251
|
+
404
|
|
252
|
+
</p>
|
|
253
|
+
<p className="mt-2 text-sm text-[var(--pylon-ink-2,#52525b)]">
|
|
254
|
+
Nothing here.
|
|
255
|
+
</p>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function initialURL(initialUrl?: string): URL {
|
|
261
|
+
if (initialUrl) {
|
|
262
|
+
try {
|
|
263
|
+
return new URL(initialUrl, "http://localhost");
|
|
264
|
+
} catch {
|
|
265
|
+
/* fall through */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (typeof window !== "undefined") {
|
|
269
|
+
return new URL(window.location.href);
|
|
270
|
+
}
|
|
271
|
+
return new URL("http://localhost/");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseQuery(search: string): Record<string, string> {
|
|
275
|
+
const out: Record<string, string> = {};
|
|
276
|
+
if (!search) return out;
|
|
277
|
+
const params = new URLSearchParams(
|
|
278
|
+
search.startsWith("?") ? search.slice(1) : search,
|
|
279
|
+
);
|
|
280
|
+
for (const [k, v] of params.entries()) out[k] = v;
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext } from "react";
|
|
4
|
+
import type { MatchedRoute } from "./match";
|
|
5
|
+
|
|
6
|
+
export interface RouterContextValue {
|
|
7
|
+
pathname: string;
|
|
8
|
+
search: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
query: Record<string, string>;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
/** The matched chain — root layout to leaf page. */
|
|
13
|
+
matches: MatchedRoute[];
|
|
14
|
+
push: (href: string) => void;
|
|
15
|
+
replace: (href: string) => void;
|
|
16
|
+
back: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RouterContext = createContext<RouterContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Used internally so a layout's `<Outlet />` knows which depth in the
|
|
23
|
+
* match chain to render next. Apps don't touch this directly.
|
|
24
|
+
*/
|
|
25
|
+
export const RouterDepthContext = createContext<number>(0);
|