@guard-rail/core 0.5.0 → 0.7.0

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.
Files changed (43) hide show
  1. package/README.md +71 -9
  2. package/dist/core/defs.d.ts +13 -1
  3. package/dist/core/defs.js +2 -0
  4. package/dist/core/defs.js.map +1 -1
  5. package/dist/core/types.d.ts +8 -0
  6. package/dist/index.client.d.ts +5 -2
  7. package/dist/index.client.js +18 -1
  8. package/dist/index.client.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +7 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/react/Admin.js +14 -12
  13. package/dist/react/Admin.js.map +1 -1
  14. package/dist/react/GRail/client.js +43 -3
  15. package/dist/react/GRail/client.js.map +1 -1
  16. package/dist/react/Icons.d.ts +2 -1
  17. package/dist/react/Icons.js +6 -0
  18. package/dist/react/Icons.js.map +1 -1
  19. package/dist/react/Repeater/client.d.ts +2 -0
  20. package/dist/react/Repeater/client.js +15 -1
  21. package/dist/react/Repeater/client.js.map +1 -1
  22. package/dist/react/Repeater/index.d.ts +8 -1
  23. package/dist/react/Repeater/index.js +19 -1
  24. package/dist/react/Repeater/index.js.map +1 -1
  25. package/dist/react/Syncer.d.ts +3 -2
  26. package/dist/react/Syncer.js +6 -2
  27. package/dist/react/Syncer.js.map +1 -1
  28. package/dist/react/openHref.d.ts +23 -0
  29. package/dist/react/openHref.js +25 -0
  30. package/dist/react/openHref.js.map +1 -0
  31. package/dist/server/access.d.ts +77 -0
  32. package/dist/server/access.js +33 -0
  33. package/dist/server/access.js.map +1 -0
  34. package/dist/server/actions.d.ts +20 -7
  35. package/dist/server/actions.js +20 -9
  36. package/dist/server/actions.js.map +1 -1
  37. package/dist/server/fieldData.d.ts +4 -2
  38. package/dist/server/fieldData.js.map +1 -1
  39. package/dist/server/repeaterActions.js +1 -1
  40. package/dist/server/repeaterActions.js.map +1 -1
  41. package/dist/server/wrapper.js +5 -5
  42. package/dist/server/wrapper.js.map +1 -1
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -119,16 +119,26 @@ Links take `as="a"` (so `href` type-checks) and edit both the text and the URL:
119
119
  <GRail id="nav.cta" as="a" type="link" href="/contact">Get in touch</GRail>
120
120
  ```
121
121
 
122
+ Clicking a link in edit mode opens its editor rather than following it, so the
123
+ editor has an **Open** button to go to the page — same tab for your own pages
124
+ (you stay signed in and in edit mode), a new tab for other sites. That's how an
125
+ editor moves around a site whose navigation is all `<GRail>` links.
126
+
122
127
  To edit, visit any page with `?edit=true`, sign in with a Supabase user, and
123
128
  click a field. `?edit=true` itself only grants a short (~10 minute) window to
124
129
  reach the sign-in form — a normal visitor never sees any Guardrail chrome, and
125
130
  a stray copy of that window (a shared computer, an old tab) self-expires
126
131
  rather than showing a sign-in bar indefinitely. It's a dismissable prompt, not
127
132
  a standing invitation: a signed-in editor's toolbar isn't on that timer at
128
- all, and persists for as long as their real session does, with no need to
129
- revisit `?edit=true` again after the first sign-in. `?edit=false` explicitly
130
- hides the toolbar (also for ~10 minutes) if you want to preview the site
131
- without it while still signed in.
133
+ all, and persists for as long as their real session does.
134
+
135
+ The toolbar's exit button ends the editing session, not the sign-in: the
136
+ overlay goes away, and `?edit=true` brings it straight back with no password.
137
+ The sign-in form only reappears once the session itself is gone (expired, or
138
+ ended elsewhere). `?edit=false` does the same exit from the URL, and an exit
139
+ holds until the next `?edit=true` rather than lapsing on a timer. (Someone
140
+ signed in to an account with no role on the site gets a real **Sign out**
141
+ instead, to switch accounts.)
132
142
 
133
143
  ### In Client Components
134
144
 
@@ -344,9 +354,13 @@ Editors get add/remove/reorder controls; anonymous visitors get the template
344
354
  rendered N times with no admin JS, same SSR/CSR split as `<GRail>` itself.
345
355
 
346
356
  **Routed items** — a product, a blog post — additionally get their own page.
347
- Guardrail never owns routing: pass `routed`, which prompts for a slug when an
348
- item is added, and the developer writes an ordinary dynamic route that
349
- resolves the item by that slug:
357
+ Guardrail never owns routing: pass `routed` with the base path your dynamic
358
+ route lives at (`routed="/products"` for `app/products/[slug]/page.tsx`). That
359
+ prompts for a slug when an item is added, and gives each item's editing toolbar
360
+ an **Open** button to its page — otherwise an editor could only reach it by
361
+ typing the URL, since clicking its fields edits them. (`routed` alone, without
362
+ a path, still works; it just has no Open button.) The developer writes an
363
+ ordinary dynamic route that resolves the item by that slug:
350
364
 
351
365
  ```tsx
352
366
  // app/products/[slug]/page.tsx
@@ -389,6 +403,52 @@ scratch. A visual page builder (Puck, say) is a better fit for that and can
389
403
  sit alongside `<GRepeater>` later — it would decide composition, while
390
404
  `<GRail>` still owns each block's leaf content.
391
405
 
406
+ ## Custom editing UI
407
+
408
+ `<GRail>` edits what's on the page. Some fields have nothing to click — an SEO
409
+ title, a meta description, an og:image — and some data isn't copy at all, like
410
+ stock or price. For those, build a small drawer of your own and keep the
411
+ authorization in Guardrail's hands:
412
+
413
+ - **`getFieldValue(id)`** reads any field (server-side).
414
+ - **`setFieldValue(id, value)`** writes one. A string is shorthand for a text
415
+ field; pass `{ type: "image", src, alt }` etc. for the others. It checks the
416
+ caller is an editor or admin itself, and writes the same row a `<GRail>` with
417
+ that id would, so the two stay interchangeable.
418
+ - **`isEditor()` / `isAdmin()`** are the role checks for your *own* Server
419
+ Actions. `isEditor()` is true for editors and admins; `isAdmin()` only for
420
+ admins (team management).
421
+ - **`canEdit()`** is true when the viewer holds a role *and* the site is in
422
+ edit mode — the one check for "show my editing UI." Use it in Server
423
+ Components; Client Components read the same flag as `useGRail().canEdit`.
424
+
425
+ ```tsx
426
+ // app/products/[slug]/actions.ts — a hand-rolled save for your own data
427
+ "use server";
428
+ import { isEditor } from "@guard-rail/core";
429
+
430
+ export async function restock(sku: string, quantity: number) {
431
+ if (!(await isEditor())) return { success: false, error: "Unauthorized." };
432
+ // ...write to your own stock table
433
+ }
434
+ ```
435
+
436
+ Show the trigger only when `canEdit` is true — not `isEditMode` alone, since
437
+ edit mode can be switched on by anyone visiting `?edit=true`; `canEdit` also
438
+ requires a role on the site. In a small client component:
439
+
440
+ ```tsx
441
+ "use client";
442
+ function StockEditTrigger({ children }: { children: React.ReactNode }) {
443
+ const { canEdit } = useGRail();
444
+ if (!canEdit) return <>{children}</>;
445
+ return <button onClick={openDrawer}>{children}</button>;
446
+ }
447
+ ```
448
+
449
+ `canEdit` only decides what to *show*: every action behind it must still call
450
+ `isEditor()` itself, because a hidden button is not authorization.
451
+
392
452
  ## Appearance
393
453
 
394
454
  Guardrail's own chrome (sign-in bar, toolbar, edit popups, image picker, team
@@ -406,7 +466,7 @@ surrounding `<Link>`).
406
466
 
407
467
  ## Extending the toolbar
408
468
 
409
- The bundled toolbar (edit indicator, Team, Log out) has one extension point:
469
+ The bundled toolbar (edit indicator, Team, Exit edit mode) has one extension point:
410
470
 
411
471
  ```tsx
412
472
  withGuardrail(RootLayout, {
@@ -450,7 +510,9 @@ export default withGuardrail(RootLayout, {
450
510
  });
451
511
  ```
452
512
 
453
- Only `getSession()` and `logout()` are required. `authenticate` is optional —
513
+ Only `getSession()` and `logout()` are required (`logout()` backs the **Sign out** shown to
514
+ an account with no role on the site — the toolbar's normal exit button ends edit mode but
515
+ keeps the session, so it doesn't call it). `authenticate` is optional —
454
516
  adapters built on magic links, SSO, or a hosted sign-in page omit it and set
455
517
  `signInUrl`, and Guardrail links there instead of rendering a password form it
456
518
  cannot fulfil. `inviteUser` is optional too; without it, invites fall back to
@@ -36,6 +36,18 @@ declare const GR_COOKIES: {
36
36
  * logout.
37
37
  */
38
38
  declare const GR_EDIT_MODE_TTL_SECONDS: number;
39
+ /**
40
+ * How long an explicit "off" (the toolbar's exit button, ?edit=false, or the
41
+ * sign-in bar's dismiss) is remembered. Long, unlike "true" above, because the
42
+ * two fail differently: a stale "true" can put a sign-in bar in front of
43
+ * someone who never asked for it, so it's kept short — a stale "false" only
44
+ * ever hides chrome, so there's nothing to bound. It has to last as long as
45
+ * the session it sits on top of: an editor who exited stays exited (instead of
46
+ * the overlay reappearing by itself once a short timer lapsed) until they ask
47
+ * for edit mode again with ?edit=true. Matches the Supabase session cookie's
48
+ * 400 days.
49
+ */
50
+ declare const GR_EDIT_MODE_OFF_TTL_SECONDS: number;
39
51
  /**
40
52
  * How long the set-a-password prompt stays up after an invite/recovery link
41
53
  * completes. Longer than the edit-mode window above — picking a password
@@ -60,4 +72,4 @@ declare const GR_TABLE = "gr_content";
60
72
  declare const GR_MEMBERS_TABLE = "gr_members";
61
73
  declare const GR_REPEATER_TABLE = "gr_repeater_items";
62
74
 
63
- export { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS, GR_MEMBERS_TABLE, GR_NEEDS_PASSWORD_TTL_SECONDS, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
75
+ export { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS, GR_EDIT_MODE_TTL_SECONDS, GR_MEMBERS_TABLE, GR_NEEDS_PASSWORD_TTL_SECONDS, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
package/dist/core/defs.js CHANGED
@@ -26,6 +26,7 @@ const GR_COOKIES = {
26
26
  NEEDS_PASSWORD: "gr_needs_password"
27
27
  };
28
28
  const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;
29
+ const GR_EDIT_MODE_OFF_TTL_SECONDS = 400 * 24 * 60 * 60;
29
30
  const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;
30
31
  const GR_SCHEMA = "guardrail";
31
32
  const GR_TABLE = "gr_content";
@@ -33,6 +34,7 @@ const GR_MEMBERS_TABLE = "gr_members";
33
34
  const GR_REPEATER_TABLE = "gr_repeater_items";
34
35
  export {
35
36
  GR_COOKIES,
37
+ GR_EDIT_MODE_OFF_TTL_SECONDS,
36
38
  GR_EDIT_MODE_TTL_SECONDS,
37
39
  GR_MEMBERS_TABLE,
38
40
  GR_NEEDS_PASSWORD_TTL_SECONDS,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /**\n * Signals intent to edit. Carries no authority on its own — every write\n * still goes through a real, server-verified role check, independent of\n * this — but it does decide whether the admin chrome (the sign-in bar, or\n * the toolbar) renders at all, so it is not harmless either: a stale copy\n * of it is a real, if low-stakes, exposure (\"there's a CMS here\") to\n * whoever's browser it's sitting in next, not just its original visitor.\n *\n * Values: \"true\" (show chrome — the short-lived grace window below, for\n * someone not yet authenticated to reach the sign-in form), \"false\"\n * (explicitly suppressed, e.g. ?edit=false previewing without chrome while\n * still signed in), or absent. Absent defers entirely to whether a real\n * session exists (see withGuardrail) — an authenticated editor's chrome\n * persists for as long as their actual session does, with no separate\n * timer of its own to fall out of sync with it.\n */\n EDIT_MODE: \"gr_edit_mode\",\n /**\n * Set server-side the moment a redirect-carried session (an invite or\n * password-recovery link) completes, so the next render — which has no\n * other way to know that just happened — prompts to set a password\n * instead of showing the normal toolbar. Cleared once they do, or once\n * they explicitly skip.\n */\n NEEDS_PASSWORD: \"gr_needs_password\",\n} as const;\n\n/**\n * How long the edit-mode cookie's \"true\"/\"false\" values last before\n * reverting to the session-derived default. Short on purpose: it only ever\n * needs to outlive the handful of clicks between landing on ?edit=true and\n * actually typing a password (or, for ?edit=false, a quick chrome-free\n * preview) — not a whole working session, which persists through real auth\n * instead. Bounds how long a stray copy of this cookie can show anything to\n * whoever's browser it's left in, self-healing without needing an explicit\n * logout.\n */\nexport const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;\n\n/**\n * How long the set-a-password prompt stays up after an invite/recovery link\n * completes. Longer than the edit-mode window above — picking a password\n * takes more than one click — but still bounded, rather than nagging\n * indefinitely if it's skipped and the cookie is somehow never cleared.\n */\nexport const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBxB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQX,gBAAgB;AAClB;AAYO,MAAM,2BAA2B,KAAK;AAQtC,MAAM,gCAAgC,KAAK;AAc3C,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
1
+ {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /**\n * Signals intent to edit. Carries no authority on its own — every write\n * still goes through a real, server-verified role check, independent of\n * this — but it does decide whether the admin chrome (the sign-in bar, or\n * the toolbar) renders at all, so it is not harmless either: a stale copy\n * of it is a real, if low-stakes, exposure (\"there's a CMS here\") to\n * whoever's browser it's sitting in next, not just its original visitor.\n *\n * Values: \"true\" (show chrome — the short-lived grace window below, for\n * someone not yet authenticated to reach the sign-in form), \"false\"\n * (explicitly suppressed, e.g. ?edit=false previewing without chrome while\n * still signed in), or absent. Absent defers entirely to whether a real\n * session exists (see withGuardrail) — an authenticated editor's chrome\n * persists for as long as their actual session does, with no separate\n * timer of its own to fall out of sync with it.\n */\n EDIT_MODE: \"gr_edit_mode\",\n /**\n * Set server-side the moment a redirect-carried session (an invite or\n * password-recovery link) completes, so the next render — which has no\n * other way to know that just happened — prompts to set a password\n * instead of showing the normal toolbar. Cleared once they do, or once\n * they explicitly skip.\n */\n NEEDS_PASSWORD: \"gr_needs_password\",\n} as const;\n\n/**\n * How long the edit-mode cookie's \"true\"/\"false\" values last before\n * reverting to the session-derived default. Short on purpose: it only ever\n * needs to outlive the handful of clicks between landing on ?edit=true and\n * actually typing a password (or, for ?edit=false, a quick chrome-free\n * preview) — not a whole working session, which persists through real auth\n * instead. Bounds how long a stray copy of this cookie can show anything to\n * whoever's browser it's left in, self-healing without needing an explicit\n * logout.\n */\nexport const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;\n\n/**\n * How long an explicit \"off\" (the toolbar's exit button, ?edit=false, or the\n * sign-in bar's dismiss) is remembered. Long, unlike \"true\" above, because the\n * two fail differently: a stale \"true\" can put a sign-in bar in front of\n * someone who never asked for it, so it's kept short — a stale \"false\" only\n * ever hides chrome, so there's nothing to bound. It has to last as long as\n * the session it sits on top of: an editor who exited stays exited (instead of\n * the overlay reappearing by itself once a short timer lapsed) until they ask\n * for edit mode again with ?edit=true. Matches the Supabase session cookie's\n * 400 days.\n */\nexport const GR_EDIT_MODE_OFF_TTL_SECONDS = 400 * 24 * 60 * 60;\n\n/**\n * How long the set-a-password prompt stays up after an invite/recovery link\n * completes. Longer than the edit-mode window above — picking a password\n * takes more than one click — but still bounded, rather than nagging\n * indefinitely if it's skipped and the cookie is somehow never cleared.\n */\nexport const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBxB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQX,gBAAgB;AAClB;AAYO,MAAM,2BAA2B,KAAK;AAatC,MAAM,+BAA+B,MAAM,KAAK,KAAK;AAQrD,MAAM,gCAAgC,KAAK;AAc3C,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
@@ -119,6 +119,14 @@ interface GRState {
119
119
  siteId: string;
120
120
  /** Null when the signed-in user holds no role on this site. */
121
121
  role: GRRole | null;
122
+ /**
123
+ * The viewer holds a role on this site AND the site is in edit mode — the
124
+ * one flag to gate custom editing UI on. Equivalent to
125
+ * `isEditMode && role !== null`; server-side, the same answer is canEdit().
126
+ * Decides what to *show* only — a Server Action behind it must still call
127
+ * isEditor() itself.
128
+ */
129
+ canEdit: boolean;
122
130
  email?: string;
123
131
  auth: GRAuthCapabilities;
124
132
  /** A storage adapter is configured, so images can be uploaded and picked. */
@@ -1,6 +1,6 @@
1
1
  export { GRail } from './react/GRail/universal.js';
2
2
  export { useGRail } from './react/Provider.js';
3
- export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
3
+ export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
4
4
  export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
5
5
  export { GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
6
6
  export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
@@ -14,5 +14,8 @@ declare const GRepeater: (..._args: unknown[]) => never;
14
14
  declare const listRepeaterItems: (..._args: unknown[]) => never;
15
15
  declare const getRepeaterItemBySlug: (..._args: unknown[]) => never;
16
16
  declare const getFieldValue: (..._args: unknown[]) => never;
17
+ declare const isAdmin: (..._args: unknown[]) => never;
18
+ declare const isEditor: (..._args: unknown[]) => never;
19
+ declare const canEdit: (..._args: unknown[]) => never;
17
20
 
18
- export { GRepeater, configureGuardrail, getFieldValue, getRepeaterItemBySlug, listRepeaterItems, withGuardrail };
21
+ export { GRepeater, canEdit, configureGuardrail, getFieldValue, getRepeaterItemBySlug, isAdmin, isEditor, listRepeaterItems, withGuardrail };
@@ -8,7 +8,8 @@ import {
8
8
  createAssetUpload,
9
9
  listAssets,
10
10
  setPasswordAction,
11
- skipSetPasswordAction
11
+ skipSetPasswordAction,
12
+ setFieldValue
12
13
  } from "./server/actions";
13
14
  import {
14
15
  addRepeaterItem,
@@ -46,21 +47,37 @@ const getFieldValue = serverOnly(
46
47
  "getFieldValue()",
47
48
  "Read the value in a Server Component and pass it down as a prop."
48
49
  );
50
+ const isAdmin = serverOnly(
51
+ "isAdmin()",
52
+ "Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show."
53
+ );
54
+ const isEditor = serverOnly(
55
+ "isEditor()",
56
+ "Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show."
57
+ );
58
+ const canEdit = serverOnly(
59
+ "canEdit()",
60
+ "Call it in a Server Component; in a Client Component, read `canEdit` from useGRail()."
61
+ );
49
62
  export {
50
63
  GRail,
51
64
  GRepeater,
52
65
  addRepeaterItem,
66
+ canEdit,
53
67
  configureGuardrail,
54
68
  createAssetUpload,
55
69
  getFieldValue,
56
70
  getRepeaterItemBySlug,
57
71
  inviteMember,
72
+ isAdmin,
73
+ isEditor,
58
74
  listAssets,
59
75
  listRepeaterItems,
60
76
  listTeam,
61
77
  removeMember,
62
78
  removeRepeaterItem,
63
79
  reorderRepeaterItems,
80
+ setFieldValue,
64
81
  setPasswordAction,
65
82
  skipSetPasswordAction,
66
83
  updateMemberRole,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n setFieldValue,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\nexport const isAdmin = serverOnly(\n \"isAdmin()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const isEditor = serverOnly(\n \"isEditor()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const canEdit = serverOnly(\n \"canEdit()\",\n \"Call it in a Server Component; in a Client Component, read `canEdit` from useGRail().\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,WAAW;AAAA,EACtB;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export { withGuardrail } from './server/wrapper.js';
2
- export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
2
+ export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
3
3
  export { GRail } from './react/GRail/index.js';
4
4
  export { GRepeater, GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
5
5
  export { configureGuardrail } from './core/config.js';
6
6
  export { getRepeaterItemBySlug, listRepeaterItems } from './server/repeaterData.js';
7
7
  export { getFieldValue } from './server/fieldData.js';
8
+ export { canEdit, isAdmin, isEditor } from './server/access.js';
8
9
  export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
9
10
  export { useGRail } from './react/Provider.js';
10
11
  export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  createAssetUpload,
8
8
  listAssets,
9
9
  setPasswordAction,
10
- skipSetPasswordAction
10
+ skipSetPasswordAction,
11
+ setFieldValue
11
12
  } from "./server/actions";
12
13
  import { GRail } from "./react/GRail";
13
14
  import { GRepeater } from "./react/Repeater";
@@ -17,6 +18,7 @@ import {
17
18
  getRepeaterItemBySlug
18
19
  } from "./server/repeaterData";
19
20
  import { getFieldValue } from "./server/fieldData";
21
+ import { isAdmin, isEditor, canEdit } from "./server/access";
20
22
  import {
21
23
  addRepeaterItem,
22
24
  removeRepeaterItem,
@@ -27,17 +29,21 @@ export {
27
29
  GRail,
28
30
  GRepeater,
29
31
  addRepeaterItem,
32
+ canEdit,
30
33
  configureGuardrail,
31
34
  createAssetUpload,
32
35
  getFieldValue,
33
36
  getRepeaterItemBySlug,
34
37
  inviteMember,
38
+ isAdmin,
39
+ isEditor,
35
40
  listAssets,
36
41
  listRepeaterItems,
37
42
  listTeam,
38
43
  removeMember,
39
44
  removeRepeaterItem,
40
45
  reorderRepeaterItems,
46
+ setFieldValue,
41
47
  setPasswordAction,
42
48
  skipSetPasswordAction,
43
49
  updateMemberRole,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n setFieldValue,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Role / edit-mode checks for a custom admin UI and the Server Actions behind\n// it. Plain server functions, not actions — see access.ts for why.\nexport { isAdmin, isEditor, canEdit } from \"./server/access\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B,SAAS,SAAS,UAAU,eAAe;AAG3C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
@@ -12,7 +12,7 @@ import { useGRail } from "./Provider";
12
12
  import { TeamDrawer } from "./Team";
13
13
  import { CloseIcon, LogOutIcon, PeopleIcon } from "./Icons";
14
14
  import { S, THEME_CSS, v } from "./styles";
15
- import { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from "../core/defs";
15
+ import { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS } from "../core/defs";
16
16
  const initialState = { email: "", password: "" };
17
17
  const initialPasswordState = {};
18
18
  function GRailAdmin(props) {
@@ -46,13 +46,13 @@ function AdminBody({ toolbarExtra }) {
46
46
  await skipSetPasswordAction();
47
47
  router.refresh();
48
48
  };
49
- const signOut = async () => {
50
- await logoutAction();
49
+ const exitEditMode = () => {
50
+ document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;
51
51
  router.replace(pathname);
52
52
  router.refresh();
53
53
  };
54
- const dismissEditMode = () => {
55
- document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;
54
+ const signOut = async () => {
55
+ await logoutAction();
56
56
  router.replace(pathname);
57
57
  router.refresh();
58
58
  };
@@ -70,7 +70,7 @@ function AdminBody({ toolbarExtra }) {
70
70
  {
71
71
  type: "button",
72
72
  style: { ...S.iconBtn, color: v("muted") },
73
- onClick: dismissEditMode,
73
+ onClick: exitEditMode,
74
74
  title: "Not editing? Dismiss",
75
75
  "aria-label": "Dismiss",
76
76
  children: /* @__PURE__ */ jsx(CloseIcon, {})
@@ -88,6 +88,7 @@ function AdminBody({ toolbarExtra }) {
88
88
  {
89
89
  name: "email",
90
90
  type: "email",
91
+ autoComplete: "email",
91
92
  placeholder: "Email",
92
93
  defaultValue: formState.email,
93
94
  required: true,
@@ -99,6 +100,7 @@ function AdminBody({ toolbarExtra }) {
99
100
  {
100
101
  name: "password",
101
102
  type: "password",
103
+ autoComplete: "current-password",
102
104
  placeholder: "Password",
103
105
  required: true,
104
106
  style: S.input
@@ -110,7 +112,7 @@ function AdminBody({ toolbarExtra }) {
110
112
  {
111
113
  type: "button",
112
114
  style: { ...S.iconBtn, color: v("muted") },
113
- onClick: dismissEditMode,
115
+ onClick: exitEditMode,
114
116
  title: "Not editing? Dismiss",
115
117
  "aria-label": "Dismiss",
116
118
  children: /* @__PURE__ */ jsx(CloseIcon, {})
@@ -158,8 +160,8 @@ function AdminBody({ toolbarExtra }) {
158
160
  {
159
161
  style: { ...S.iconBtn, color: v("muted") },
160
162
  onClick: signOut,
161
- title: "Log out",
162
- "aria-label": "Log out",
163
+ title: "Sign out",
164
+ "aria-label": "Sign out",
163
165
  children: /* @__PURE__ */ jsx(LogOutIcon, {})
164
166
  }
165
167
  )
@@ -190,9 +192,9 @@ function AdminBody({ toolbarExtra }) {
190
192
  "button",
191
193
  {
192
194
  style: { ...S.iconBtn, color: v("muted") },
193
- onClick: signOut,
194
- title: "Log out",
195
- "aria-label": "Log out",
195
+ onClick: exitEditMode,
196
+ title: "Exit edit mode",
197
+ "aria-label": "Exit edit mode",
196
198
  children: /* @__PURE__ */ jsx(LogOutIcon, {})
197
199
  }
198
200
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Admin.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useActionState, useEffect, useState } from \"react\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport {\n loginAction,\n logoutAction,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { TeamDrawer } from \"./Team\";\nimport { CloseIcon, LogOutIcon, PeopleIcon } from \"./Icons\";\nimport { S, THEME_CSS, v } from \"./styles\";\nimport { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS } from \"../core/defs\";\nimport type { ActionState, SetPasswordState } from \"../core/types\";\n\nconst initialState: ActionState = { email: \"\", password: \"\" };\nconst initialPasswordState: SetPasswordState = {};\n\n/**\n * Owns the theme stylesheet so every piece of chrome — the sign-in bar, the\n * toolbar, and the field popups portaled to <body> — picks up the same\n * light/dark variables from one place.\n */\nexport function GRailAdmin(props: { toolbarExtra?: React.ReactNode }) {\n const { isEditMode } = useGRail();\n if (!isEditMode) return null;\n return (\n <>\n {/*\n precedence + href make React hoist this into <head> and dedupe it.\n Required, not cosmetic: this whole component renders as a sibling of\n <html> (withGuardrail can't reach inside the app's own layout), and\n React 19 rejects a bare <style> anywhere outside the main document.\n */}\n <style href=\"guardrail-theme\" precedence=\"default\">\n {THEME_CSS}\n </style>\n <AdminBody {...props} />\n </>\n );\n}\n\nfunction AdminBody({ toolbarExtra }: { toolbarExtra?: React.ReactNode }) {\n const { isAuth, isEditMode, needsPassword, role, auth } = useGRail();\n const [teamOpen, setTeamOpen] = useState(false);\n const [formState, formAction, isPending] = useActionState(\n loginAction,\n initialState,\n );\n const [passwordState, passwordFormAction, isSettingPassword] = useActionState(\n setPasswordAction,\n initialPasswordState,\n );\n const router = useRouter();\n const pathname = usePathname();\n\n useEffect(() => {\n if (formState.success) router.refresh();\n }, [formState.success, router]);\n\n useEffect(() => {\n if (passwordState.success) router.refresh();\n }, [passwordState.success, router]);\n\n const skipSetPassword = async () => {\n await skipSetPasswordAction();\n router.refresh();\n };\n\n const signOut = async () => {\n // logoutAction() now clears the edit-mode cookie itself — see the\n // comment there for why a client-side hard navigation right after it\n // used to race Next's own automatic post-action route refresh and lose.\n // router.replace + refresh works with that refresh instead of against\n // it: replace drops ?edit=true from the URL (a bare reload would leave\n // it there, since the cookie, not the query, is what withGuardrail\n // reads), and refresh re-renders the tree against the now-cleared\n // cookies without a full page reload.\n await logoutAction();\n router.replace(pathname);\n router.refresh();\n };\n\n // For someone who was never authenticated and doesn't want to be here at\n // all — a stray or expired-but-not-yet-cleared cookie, say. Explicit\n // \"false\" rather than deleting: a plain delete would do nothing for an\n // authenticated visitor (their session alone is enough to keep chrome\n // visible), but this button only ever renders in the not-yet-authenticated\n // branches below, where the two are equivalent — \"false\" is used anyway\n // for consistency with the one cookie-writing convention (see Syncer.tsx).\n const dismissEditMode = () => {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_TTL_SECONDS}; SameSite=Lax`;\n router.replace(pathname);\n router.refresh();\n };\n\n // No intent signalled — render nothing at all.\n if (!isEditMode) return null;\n\n if (!isAuth) {\n // The adapter cannot take a password here, so hand sign-in off rather than\n // showing a form that could never succeed.\n if (!auth.password) {\n return (\n <div data-guardrail style={S.banner}>\n <div style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {!auth.signInUrl && (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n No sign-in method is configured for this site.\n </span>\n )}\n </div>\n {auth.signInUrl && (\n <a href={auth.signInUrl} style={{ ...S.primaryBtn, textDecoration: \"none\" }}>\n Sign in\n </a>\n )}\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={dismissEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </div>\n </div>\n );\n }\n\n return (\n <div data-guardrail style={S.banner}>\n <form action={formAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {formState.error && <span style={S.error}>{formState.error}</span>}\n </div>\n <input\n name=\"email\"\n type=\"email\"\n placeholder=\"Email\"\n defaultValue={formState.email}\n required\n style={S.input}\n />\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"Password\"\n required\n style={S.input}\n />\n <button type=\"submit\" disabled={isPending} style={S.primaryBtn}>\n {isPending ? \"Authenticating…\" : \"Sign In\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={dismissEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // A redirect-carried session (an invite or password-recovery link) just\n // completed — they're genuinely signed in, but have no password yet.\n // Ahead of the role check below: getCachedViewer() already claims a\n // pending invite as a side effect of resolving them, so role is normally\n // already set by the time this renders — this is purely about the\n // password, which is orthogonal to that. Skippable rather than a hard\n // gate: magic-link sign-in already works with no password at all, so\n // someone can dismiss this and keep using invite/magic links indefinitely.\n if (needsPassword) {\n return (\n <div data-guardrail style={S.banner}>\n <form action={passwordFormAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {passwordState.error ? (\n <span style={S.error}>{passwordState.error}</span>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n Set a password to sign in next time\n </span>\n )}\n </div>\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"New password\"\n required\n minLength={6}\n style={S.input}\n />\n <button type=\"submit\" disabled={isSettingPassword} style={S.primaryBtn}>\n {isSettingPassword ? \"Saving…\" : \"Set Password\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={skipSetPassword}\n title=\"Skip for now\"\n aria-label=\"Skip\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // Signed in, but holds no role on this site.\n if (!role) {\n return (\n <div data-guardrail style={S.toolbar}>\n <span style={S.toolbarLabel}>No access to this site</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Log out\"\n aria-label=\"Log out\"\n >\n <LogOutIcon />\n </button>\n </div>\n );\n }\n\n return (\n <>\n {teamOpen && <TeamDrawer onClose={() => setTeamOpen(false)} />}\n <div data-guardrail style={S.toolbar}>\n <span style={S.dot} />\n <span style={S.toolbarLabel}>Edit Mode</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 24, height: 24 }}\n onClick={() => setTeamOpen(true)}\n title=\"Team\"\n aria-label=\"Team\"\n >\n <PeopleIcon />\n </button>\n\n {toolbarExtra && (\n <>\n <span style={S.divider} />\n {toolbarExtra}\n </>\n )}\n\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Log out\"\n aria-label=\"Log out\"\n >\n <LogOutIcon />\n </button>\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/react/Admin.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useActionState, useEffect, useState } from \"react\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport {\n loginAction,\n logoutAction,\n setPasswordAction,\n skipSetPasswordAction,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { TeamDrawer } from \"./Team\";\nimport { CloseIcon, LogOutIcon, PeopleIcon } from \"./Icons\";\nimport { S, THEME_CSS, v } from \"./styles\";\nimport { GR_COOKIES, GR_EDIT_MODE_OFF_TTL_SECONDS } from \"../core/defs\";\nimport type { ActionState, SetPasswordState } from \"../core/types\";\n\nconst initialState: ActionState = { email: \"\", password: \"\" };\nconst initialPasswordState: SetPasswordState = {};\n\n/**\n * Owns the theme stylesheet so every piece of chrome — the sign-in bar, the\n * toolbar, and the field popups portaled to <body> — picks up the same\n * light/dark variables from one place.\n */\nexport function GRailAdmin(props: { toolbarExtra?: React.ReactNode }) {\n const { isEditMode } = useGRail();\n if (!isEditMode) return null;\n return (\n <>\n {/*\n precedence + href make React hoist this into <head> and dedupe it.\n Required, not cosmetic: this whole component renders as a sibling of\n <html> (withGuardrail can't reach inside the app's own layout), and\n React 19 rejects a bare <style> anywhere outside the main document.\n */}\n <style href=\"guardrail-theme\" precedence=\"default\">\n {THEME_CSS}\n </style>\n <AdminBody {...props} />\n </>\n );\n}\n\nfunction AdminBody({ toolbarExtra }: { toolbarExtra?: React.ReactNode }) {\n const { isAuth, isEditMode, needsPassword, role, auth } = useGRail();\n const [teamOpen, setTeamOpen] = useState(false);\n const [formState, formAction, isPending] = useActionState(\n loginAction,\n initialState,\n );\n const [passwordState, passwordFormAction, isSettingPassword] = useActionState(\n setPasswordAction,\n initialPasswordState,\n );\n const router = useRouter();\n const pathname = usePathname();\n\n useEffect(() => {\n if (formState.success) router.refresh();\n }, [formState.success, router]);\n\n useEffect(() => {\n if (passwordState.success) router.refresh();\n }, [passwordState.success, router]);\n\n const skipSetPassword = async () => {\n await skipSetPasswordAction();\n router.refresh();\n };\n\n // Ends the editing session, not the sign-in: the overlay goes away and the\n // person's session stays, so ?edit=true brings it straight back with no\n // credentials. The sign-in form only returns once the session itself is\n // gone (expired, or ended elsewhere). Explicit \"false\" rather than deleting\n // the cookie: with a live session, deleting it would leave edit mode on —\n // see resolveEditMode. router.replace drops ?edit=true from the URL (a bare\n // refresh would leave it there), and refresh re-renders without chrome.\n //\n // Also what the sign-in bar's dismiss button does, for someone who was never\n // authenticated and doesn't want to be here at all.\n const exitEditMode = () => {\n document.cookie = `${GR_COOKIES.EDIT_MODE}=false; path=/; max-age=${GR_EDIT_MODE_OFF_TTL_SECONDS}; SameSite=Lax`;\n router.replace(pathname);\n router.refresh();\n };\n\n // A real sign-out, kept only for the \"no access\" bar below: someone on the\n // wrong account has no other way to switch to the right one. logoutAction()\n // clears the edit-mode cookie itself — a client-side navigation right after\n // it used to race Next's own post-action route refresh and lose.\n const signOut = async () => {\n await logoutAction();\n router.replace(pathname);\n router.refresh();\n };\n\n // No intent signalled — render nothing at all.\n if (!isEditMode) return null;\n\n if (!isAuth) {\n // The adapter cannot take a password here, so hand sign-in off rather than\n // showing a form that could never succeed.\n if (!auth.password) {\n return (\n <div data-guardrail style={S.banner}>\n <div style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {!auth.signInUrl && (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n No sign-in method is configured for this site.\n </span>\n )}\n </div>\n {auth.signInUrl && (\n <a href={auth.signInUrl} style={{ ...S.primaryBtn, textDecoration: \"none\" }}>\n Sign in\n </a>\n )}\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </div>\n </div>\n );\n }\n\n return (\n <div data-guardrail style={S.banner}>\n <form action={formAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {formState.error && <span style={S.error}>{formState.error}</span>}\n </div>\n <input\n name=\"email\"\n type=\"email\"\n autoComplete=\"email\"\n placeholder=\"Email\"\n defaultValue={formState.email}\n required\n style={S.input}\n />\n <input\n name=\"password\"\n type=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Password\"\n required\n style={S.input}\n />\n <button type=\"submit\" disabled={isPending} style={S.primaryBtn}>\n {isPending ? \"Authenticating…\" : \"Sign In\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Not editing? Dismiss\"\n aria-label=\"Dismiss\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // A redirect-carried session (an invite or password-recovery link) just\n // completed — they're genuinely signed in, but have no password yet.\n // Ahead of the role check below: getCachedViewer() already claims a\n // pending invite as a side effect of resolving them, so role is normally\n // already set by the time this renders — this is purely about the\n // password, which is orthogonal to that. Skippable rather than a hard\n // gate: magic-link sign-in already works with no password at all, so\n // someone can dismiss this and keep using invite/magic links indefinitely.\n if (needsPassword) {\n return (\n <div data-guardrail style={S.banner}>\n <form action={passwordFormAction} style={S.bannerForm}>\n <div style={S.brandStack}>\n <span style={S.brand}>Guardrail</span>\n {passwordState.error ? (\n <span style={S.error}>{passwordState.error}</span>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>\n Set a password to sign in next time\n </span>\n )}\n </div>\n <input\n name=\"password\"\n type=\"password\"\n placeholder=\"New password\"\n required\n minLength={6}\n style={S.input}\n />\n <button type=\"submit\" disabled={isSettingPassword} style={S.primaryBtn}>\n {isSettingPassword ? \"Saving…\" : \"Set Password\"}\n </button>\n <button\n type=\"button\"\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={skipSetPassword}\n title=\"Skip for now\"\n aria-label=\"Skip\"\n >\n <CloseIcon />\n </button>\n </form>\n </div>\n );\n }\n\n // Signed in, but holds no role on this site.\n if (!role) {\n return (\n <div data-guardrail style={S.toolbar}>\n <span style={S.toolbarLabel}>No access to this site</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={signOut}\n title=\"Sign out\"\n aria-label=\"Sign out\"\n >\n <LogOutIcon />\n </button>\n </div>\n );\n }\n\n return (\n <>\n {teamOpen && <TeamDrawer onClose={() => setTeamOpen(false)} />}\n <div data-guardrail style={S.toolbar}>\n <span style={S.dot} />\n <span style={S.toolbarLabel}>Edit Mode</span>\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 24, height: 24 }}\n onClick={() => setTeamOpen(true)}\n title=\"Team\"\n aria-label=\"Team\"\n >\n <PeopleIcon />\n </button>\n\n {toolbarExtra && (\n <>\n <span style={S.divider} />\n {toolbarExtra}\n </>\n )}\n\n <span style={S.divider} />\n <button\n style={{ ...S.iconBtn, color: v(\"muted\") }}\n onClick={exitEditMode}\n title=\"Exit edit mode\"\n aria-label=\"Exit edit mode\"\n >\n <LogOutIcon />\n </button>\n </div>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -2,11 +2,13 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import React, { useEffect, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { useRouter } from "next/navigation";
5
6
  import { saveOverride } from "../../server/actions";
6
7
  import { GRailRender } from "./render";
7
8
  import { useGRail } from "../Provider";
8
9
  import { useAnchorRect } from "../useAnchorRect";
9
- import { CheckIcon, CloseIcon, PencilIcon, SpinnerIcon } from "../Icons";
10
+ import { CheckIcon, CloseIcon, OpenIcon, PencilIcon, SpinnerIcon } from "../Icons";
11
+ import { openHref } from "../openHref";
10
12
  import { S } from "../styles";
11
13
  import { MediaPicker } from "../MediaPicker";
12
14
  function GRailClient({
@@ -209,12 +211,15 @@ function Chrome({
209
211
  onCancel
210
212
  }) {
211
213
  const { storage } = useGRail();
214
+ const router = useRouter();
212
215
  const [pickerOpen, setPickerOpen] = useState(false);
213
216
  const GAP = 8;
214
217
  const below = rect.bottom + GAP;
215
218
  const flip = typeof window !== "undefined" && below > window.innerHeight - 120;
219
+ const PANEL_MIN_WIDTH = 260;
220
+ const maxLeft = typeof window !== "undefined" ? window.innerWidth - (isInline ? 0 : PANEL_MIN_WIDTH) - 8 : Infinity;
216
221
  const position = {
217
- left: Math.max(8, rect.left),
222
+ left: Math.max(8, Math.min(rect.left, maxLeft)),
218
223
  ...flip ? { top: Math.max(8, rect.top - GAP - 44) } : { top: below }
219
224
  };
220
225
  const actions = /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -317,7 +322,42 @@ function Chrome({
317
322
  )
318
323
  ] }),
319
324
  error && /* @__PURE__ */ jsx("div", { style: { ...S.error, marginBottom: 8 }, children: error }),
320
- /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 4, justifyContent: "flex-end" }, children: actions })
325
+ /* @__PURE__ */ jsxs(
326
+ "div",
327
+ {
328
+ style: {
329
+ display: "flex",
330
+ gap: 4,
331
+ alignItems: "center",
332
+ justifyContent: row.value.type === "link" ? "space-between" : "flex-end"
333
+ },
334
+ children: [
335
+ row.value.type === "link" && /* @__PURE__ */ jsxs(
336
+ "button",
337
+ {
338
+ type: "button",
339
+ style: {
340
+ ...S.secondaryBtn,
341
+ width: "auto",
342
+ marginBottom: 0,
343
+ display: "inline-flex",
344
+ alignItems: "center",
345
+ gap: 6
346
+ },
347
+ disabled: !row.value.href?.trim(),
348
+ onClick: () => openHref(row.value.href ?? "", (path) => router.push(path)),
349
+ title: "Open this link",
350
+ "aria-label": "Open link",
351
+ children: [
352
+ /* @__PURE__ */ jsx(OpenIcon, {}),
353
+ " Open"
354
+ ]
355
+ }
356
+ ),
357
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 4 }, children: actions })
358
+ ]
359
+ }
360
+ )
321
361
  ]
322
362
  }
323
363
  );