@pylonsync/client 0.3.268 → 0.3.270

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.268",
6
+ "version": "0.3.270",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -19,8 +19,8 @@
19
19
  "check": "tsc -p tsconfig.json --noEmit"
20
20
  },
21
21
  "dependencies": {
22
- "@pylonsync/react": "0.3.268",
23
- "@pylonsync/sync": "0.3.268",
22
+ "@pylonsync/react": "0.3.270",
23
+ "@pylonsync/sync": "0.3.270",
24
24
  "clsx": "^2.1.1"
25
25
  },
26
26
  "peerDependencies": {
@@ -27,6 +27,14 @@ export interface OrganizationSwitcherProps {
27
27
  onSwitched?: (orgId: string | null) => void;
28
28
  /** Called after a new org is created (post-creation, pre-switch). */
29
29
  onCreated?: (org: OrgSummary) => void;
30
+ /**
31
+ * Active org name resolved on the server (e.g. `serverData.get("Org", …)`
32
+ * in an SSR layout). Seeds the trigger label so it renders the real name on
33
+ * the first paint — no "Personal" → real-name flash, and no pop-in waiting
34
+ * for `useAuth()`/`listOrgs()` to hydrate. The dropdown list still loads
35
+ * client-side, but it's hidden until opened.
36
+ */
37
+ initialActiveName?: string;
30
38
  className?: string;
31
39
  }
32
40
 
@@ -44,6 +52,7 @@ export function OrganizationSwitcher({
44
52
  hideCreate,
45
53
  onSwitched,
46
54
  onCreated,
55
+ initialActiveName,
47
56
  className,
48
57
  }: OrganizationSwitcherProps) {
49
58
  const { isSignedIn, tenantId, selectOrg, clearOrg } = useAuth();
@@ -88,10 +97,14 @@ export function OrganizationSwitcher({
88
97
  };
89
98
  }, [open]);
90
99
 
91
- if (!isSignedIn) return null;
100
+ // A server-seeded active name means the SSR layout already rendered this in
101
+ // an authenticated shell — render immediately instead of waiting for
102
+ // `isSignedIn` to hydrate (which causes a pop-in).
103
+ if (!isSignedIn && !initialActiveName) return null;
92
104
 
93
105
  const active = orgs?.find((o) => o.id === tenantId) ?? null;
94
- const label = active?.name ?? (hidePersonal ? "Select org" : "Personal");
106
+ const label =
107
+ active?.name ?? initialActiveName ?? (hidePersonal ? "Select org" : "Personal");
95
108
 
96
109
  async function switchTo(orgId: string | null) {
97
110
  setSwitching(true);
package/src/index.ts CHANGED
@@ -99,6 +99,8 @@ export {
99
99
  createApiKey,
100
100
  createInvite,
101
101
  createOrg,
102
+ deleteOrg,
103
+ renameOrg,
102
104
  listActiveSessions,
103
105
  listApiKeys,
104
106
  listAuthProviders,
package/src/lib/api.ts CHANGED
@@ -30,6 +30,29 @@ class ApiError extends Error {
30
30
  }
31
31
  }
32
32
 
33
+ // Turn a non-2xx Response into an ApiError, pulling the stable error `code` and
34
+ // human `message` out of Pylon's error envelope. The server returns
35
+ // `{ "error": { "code": "...", "message": "..." } }`; older/other endpoints may
36
+ // send a flat `{ "error": "CODE", "message": "..." }`. Handle both so callers
37
+ // always get a real `.code` to branch on (not "Bad Request").
38
+ async function throwApiError(res: Response): Promise<never> {
39
+ const payload = (await res.json().catch(() => ({}))) as {
40
+ error?: { code?: string; message?: string } | string;
41
+ code?: string;
42
+ message?: string;
43
+ };
44
+ const nested =
45
+ payload && typeof payload.error === "object" ? payload.error : null;
46
+ const code =
47
+ nested?.code ??
48
+ (typeof payload?.error === "string" ? payload.error : undefined) ??
49
+ payload?.code ??
50
+ `HTTP_${res.status}`;
51
+ const message =
52
+ nested?.message ?? payload?.message ?? res.statusText ?? "request failed";
53
+ throw new ApiError(code, message, res.status);
54
+ }
55
+
33
56
  async function post<T>(path: string, body: unknown): Promise<T> {
34
57
  const res = await fetch(`${getBaseUrl()}${path}`, {
35
58
  method: "POST",
@@ -37,13 +60,7 @@ async function post<T>(path: string, body: unknown): Promise<T> {
37
60
  headers: { "content-type": "application/json" },
38
61
  body: JSON.stringify(body),
39
62
  });
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
- }
63
+ if (!res.ok) await throwApiError(res);
47
64
  return res.json() as Promise<T>;
48
65
  }
49
66
 
@@ -52,7 +69,7 @@ async function get<T>(path: string): Promise<T> {
52
69
  method: "GET",
53
70
  credentials: "include",
54
71
  });
55
- if (!res.ok) throw new ApiError(`HTTP_${res.status}`, res.statusText, res.status);
72
+ if (!res.ok) await throwApiError(res);
56
73
  return res.json() as Promise<T>;
57
74
  }
58
75
 
@@ -154,10 +171,31 @@ export async function createOrg(name: string): Promise<OrgSummary> {
154
171
  return post<OrgSummary>("/api/auth/orgs", { name });
155
172
  }
156
173
 
174
+ /** Rename an org. Server gates this to owners + admins (`can_manage_members`). */
175
+ export async function renameOrg(
176
+ orgId: string,
177
+ name: string,
178
+ ): Promise<{ id: string; name: string }> {
179
+ return req<{ id: string; name: string }>("PATCH", `/api/auth/orgs/${orgId}`, {
180
+ name,
181
+ });
182
+ }
183
+
184
+ /** Delete an org and its memberships + invites. Server gates this to owners
185
+ * (`can_delete_org`). Irreversible. */
186
+ export async function deleteOrg(orgId: string): Promise<{ deleted: boolean }> {
187
+ return req<{ deleted: boolean }>("DELETE", `/api/auth/orgs/${orgId}`);
188
+ }
189
+
157
190
  export interface OrgMember {
158
191
  user_id: string;
159
192
  role: string;
160
193
  joined_at: number;
194
+ /** Member's email, joined server-side from the User entity. `null` if the
195
+ * User row is missing or has no email. */
196
+ email: string | null;
197
+ /** Member's display name (displayName / name / fullName), if set. */
198
+ name: string | null;
161
199
  }
162
200
 
163
201
  export async function listOrgMembers(orgId: string): Promise<OrgMember[]> {
@@ -345,7 +383,7 @@ export async function revokeApiKey(
345
383
  }
346
384
 
347
385
  async function req<T>(
348
- method: "PUT" | "DELETE",
386
+ method: "PUT" | "PATCH" | "DELETE",
349
387
  path: string,
350
388
  body?: unknown,
351
389
  ): Promise<T> {
@@ -355,13 +393,7 @@ async function req<T>(
355
393
  headers: body ? { "content-type": "application/json" } : undefined,
356
394
  body: body ? JSON.stringify(body) : undefined,
357
395
  });
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
- }
396
+ if (!res.ok) await throwApiError(res);
365
397
  return res.json() as Promise<T>;
366
398
  }
367
399