@guard-rail/core 0.7.1 → 0.9.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 (51) hide show
  1. package/README.md +86 -41
  2. package/dist/adapters/supabase/auth.d.ts +13 -1
  3. package/dist/adapters/supabase/auth.js +42 -25
  4. package/dist/adapters/supabase/auth.js.map +1 -1
  5. package/dist/adapters/supabase/client.d.ts +16 -1
  6. package/dist/adapters/supabase/client.js +9 -0
  7. package/dist/adapters/supabase/client.js.map +1 -1
  8. package/dist/adapters/supabase/confirm.d.ts +5 -0
  9. package/dist/adapters/supabase/confirm.js +12 -1
  10. package/dist/adapters/supabase/confirm.js.map +1 -1
  11. package/dist/adapters/supabase/members.js +13 -2
  12. package/dist/adapters/supabase/members.js.map +1 -1
  13. package/dist/core/adapter-types.d.ts +80 -12
  14. package/dist/core/config.d.ts +2 -1
  15. package/dist/core/config.js +4 -0
  16. package/dist/core/config.js.map +1 -1
  17. package/dist/core/defs.d.ts +11 -1
  18. package/dist/core/defs.js +11 -1
  19. package/dist/core/defs.js.map +1 -1
  20. package/dist/core/types.d.ts +6 -0
  21. package/dist/email/resend.d.ts +80 -0
  22. package/dist/email/resend.js +118 -0
  23. package/dist/email/resend.js.map +1 -0
  24. package/dist/index.client.d.ts +6 -3
  25. package/dist/index.client.js +14 -0
  26. package/dist/index.client.js.map +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/react/Admin.js +105 -107
  31. package/dist/react/Admin.js.map +1 -1
  32. package/dist/react/AuthDrawer.d.ts +23 -0
  33. package/dist/react/AuthDrawer.js +165 -0
  34. package/dist/react/AuthDrawer.js.map +1 -0
  35. package/dist/react/AuthRedirectSync.js +1 -1
  36. package/dist/react/AuthRedirectSync.js.map +1 -1
  37. package/dist/react/Icons.d.ts +2 -1
  38. package/dist/react/Icons.js +2 -0
  39. package/dist/react/Icons.js.map +1 -1
  40. package/dist/react/Team.d.ts +7 -1
  41. package/dist/react/Team.js +23 -3
  42. package/dist/react/Team.js.map +1 -1
  43. package/dist/server/access.d.ts +3 -1
  44. package/dist/server/access.js +5 -3
  45. package/dist/server/access.js.map +1 -1
  46. package/dist/server/actions.d.ts +25 -4
  47. package/dist/server/actions.js +118 -11
  48. package/dist/server/actions.js.map +1 -1
  49. package/dist/server/wrapper.js +2 -1
  50. package/dist/server/wrapper.js.map +1 -1
  51. package/package.json +8 -10
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n\nexport const OpenIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n <polyline points=\"15 3 21 3 21 9\" />\n <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\" />\n </svg>\n);\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/react/Icons.tsx"],"sourcesContent":["import React from \"react\";\n\n/**\n * Inline SVG rather than an icon package: Guardrail's chrome should not add a\n * runtime dependency to every consuming app.\n */\nconst base = {\n width: 14,\n height: 14,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 3,\n strokeLinecap: \"round\" as const,\n strokeLinejoin: \"round\" as const,\n};\n\nexport const CheckIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <polyline points=\"20 6 9 17 4 12\" />\n </svg>\n);\n\nexport const CloseIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n);\n\nexport const PencilIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M12 20h9\" />\n <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\" />\n </svg>\n);\n\nexport const PeopleIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" />\n <circle cx=\"9\" cy=\"7\" r=\"4\" />\n <path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" />\n <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />\n </svg>\n);\n\nexport const PlusIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n);\n\nexport const TrashIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n </svg>\n);\n\nexport const ArrowUpIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\" />\n <polyline points=\"6 11 12 5 18 11\" />\n </svg>\n);\n\nexport const ArrowDownIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <polyline points=\"18 13 12 19 6 13\" />\n </svg>\n);\n\nexport const LogOutIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" />\n <polyline points=\"16 17 21 12 16 7\" />\n <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\" />\n </svg>\n);\n\nexport const SpinnerIcon = () => (\n <svg {...base} aria-hidden=\"true\">\n <path d=\"M21 12a9 9 0 1 1-6.2-8.6\">\n <animateTransform\n attributeName=\"transform\"\n type=\"rotate\"\n from=\"0 12 12\"\n to=\"360 12 12\"\n dur=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </path>\n </svg>\n);\n\nexport const OpenIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n <polyline points=\"15 3 21 3 21 9\" />\n <line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\" />\n </svg>\n);\n\nexport const KeyIcon = () => (\n <svg {...base} strokeWidth={2} aria-hidden=\"true\">\n <path d=\"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4\" />\n </svg>\n);\n"],"mappings":"AAmBI,cAKF,YALE;AAnBJ,OAAO,WAAW;AAMlB,MAAM,OAAO;AAAA,EACX,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,eAAe;AAAA,EACf,gBAAgB;AAClB;AAEO,MAAM,YAAY,MACvB,oBAAC,SAAK,GAAG,MAAM,eAAY,QACzB,8BAAC,cAAS,QAAO,kBAAiB,GACpC;AAGK,MAAM,YAAY,MACvB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,KAAI,IAAG,MAAK;AAAA,EACpC,oBAAC,UAAK,IAAG,KAAI,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,GACtC;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,YAAW;AAAA,EACnB,oBAAC,UAAK,GAAE,2DAA0D;AAAA,GACpE;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,6CAA4C;AAAA,EACpD,oBAAC,YAAO,IAAG,KAAI,IAAG,KAAI,GAAE,KAAI;AAAA,EAC5B,oBAAC,UAAK,GAAE,8BAA6B;AAAA,EACrC,oBAAC,UAAK,GAAE,6BAA4B;AAAA,GACtC;AAGK,MAAM,WAAW,MACtB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,EACrC,oBAAC,UAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK;AAAA,GACvC;AAGK,MAAM,YAAY,MACvB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,cAAS,QAAO,gBAAe;AAAA,EAChC,oBAAC,UAAK,GAAE,iDAAgD;AAAA,EACxD,oBAAC,UAAK,GAAE,0DAAyD;AAAA,GACnE;AAGK,MAAM,cAAc,MACzB,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK,IAAG,KAAI;AAAA,EACrC,oBAAC,cAAS,QAAO,mBAAkB;AAAA,GACrC;AAGK,MAAM,gBAAgB,MAC3B,qBAAC,SAAK,GAAG,MAAM,eAAY,QACzB;AAAA,sBAAC,UAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK,IAAG,MAAK;AAAA,EACrC,oBAAC,cAAS,QAAO,oBAAmB;AAAA,GACtC;AAGK,MAAM,aAAa,MACxB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,2CAA0C;AAAA,EAClD,oBAAC,cAAS,QAAO,oBAAmB;AAAA,EACpC,oBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,KAAI,IAAG,MAAK;AAAA,GACvC;AAGK,MAAM,cAAc,MACzB,oBAAC,SAAK,GAAG,MAAM,eAAY,QACzB,8BAAC,UAAK,GAAE,4BACN;AAAA,EAAC;AAAA;AAAA,IACC,eAAc;AAAA,IACd,MAAK;AAAA,IACL,MAAK;AAAA,IACL,IAAG;AAAA,IACH,KAAI;AAAA,IACJ,aAAY;AAAA;AACd,GACF,GACF;AAGK,MAAM,WAAW,MACtB,qBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC;AAAA,sBAAC,UAAK,GAAE,4DAA2D;AAAA,EACnE,oBAAC,cAAS,QAAO,kBAAiB;AAAA,EAClC,oBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK,IAAG,KAAI;AAAA,GACvC;AAGK,MAAM,UAAU,MACrB,oBAAC,SAAK,GAAG,MAAM,aAAa,GAAG,eAAY,QACzC,8BAAC,UAAK,GAAE,2HAA0H,GACpI;","names":[]}
@@ -1,7 +1,13 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
 
3
+ /**
4
+ * Keyframes cannot be expressed as inline styles, so the drawer ships the few
5
+ * it needs. Scoped to [data-guardrail-team] to stay clear of app styles, and
6
+ * disabled under prefers-reduced-motion.
7
+ */
8
+ declare const ANIMATION_CSS = "\n@keyframes gr-fade-in { from { opacity: 0 } to { opacity: 1 } }\n@keyframes gr-slide-in {\n from { transform: translateX(100%) }\n to { transform: translateX(0) }\n}\n[data-guardrail-scrim] { animation: gr-fade-in 160ms ease-out both }\n[data-guardrail-team] { animation: gr-slide-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both }\n[data-guardrail-team] [data-gr-row] { animation: gr-fade-in 240ms ease-out both }\n@keyframes gr-fade-out { from { opacity: 1 } to { opacity: 0 } }\n@keyframes gr-slide-out {\n from { transform: translateX(0) }\n to { transform: translateX(100%) }\n}\n[data-guardrail-scrim][data-closing] { animation: gr-fade-out 180ms ease-in both }\n[data-guardrail-team][data-closing] { animation: gr-slide-out 180ms cubic-bezier(0.4, 0, 1, 1) both }\n@media (prefers-reduced-motion: reduce) {\n [data-guardrail-scrim], [data-guardrail-team], [data-guardrail-team] [data-gr-row] {\n animation: none\n }\n}\n";
3
9
  declare function TeamDrawer({ onClose }: {
4
10
  onClose: () => void;
5
11
  }): react_jsx_runtime.JSX.Element;
6
12
 
7
- export { TeamDrawer };
13
+ export { ANIMATION_CSS, TeamDrawer };
@@ -5,10 +5,11 @@ import {
5
5
  inviteMember,
6
6
  listTeam,
7
7
  removeMember,
8
+ sendPasswordResetAction,
8
9
  updateMemberRole
9
10
  } from "../server/actions";
10
11
  import { useGRail } from "./Provider";
11
- import { CloseIcon } from "./Icons";
12
+ import { CloseIcon, KeyIcon } from "./Icons";
12
13
  import { S, v } from "./styles";
13
14
  const ROLES = ["admin", "editor"];
14
15
  const ANIMATION_CSS = `
@@ -39,6 +40,7 @@ function TeamDrawer({ onClose }) {
39
40
  const [members, setMembers] = useState([]);
40
41
  const [loading, setLoading] = useState(true);
41
42
  const [error, setError] = useState(null);
43
+ const [notice, setNotice] = useState(null);
42
44
  const [inviteEmail, setInviteEmail] = useState("");
43
45
  const [inviteRole, setInviteRole] = useState("editor");
44
46
  const [pending, startTransition] = useTransition();
@@ -51,8 +53,10 @@ function TeamDrawer({ onClose }) {
51
53
  }, []);
52
54
  const run = (fn) => startTransition(async () => {
53
55
  setError(null);
56
+ setNotice(null);
54
57
  const res = await fn();
55
58
  if (!res.success) setError(res.error ?? "Something went wrong.");
59
+ else if (res.notice) setNotice(res.notice);
56
60
  await refresh();
57
61
  });
58
62
  const [closing, setClosing] = useState(false);
@@ -102,6 +106,7 @@ function TeamDrawer({ onClose }) {
102
106
  ] }),
103
107
  /* @__PURE__ */ jsxs("div", { style: S.drawerBody, children: [
104
108
  error && /* @__PURE__ */ jsx("div", { style: { ...S.error, marginBottom: 12 }, children: error }),
109
+ notice && /* @__PURE__ */ jsx("div", { style: { fontSize: 12, color: v("muted"), marginBottom: 12 }, role: "status", children: notice }),
105
110
  loading ? /* @__PURE__ */ jsx("p", { style: { fontSize: 13, color: v("muted") }, children: "Loading\u2026" }) : members.map((m) => /* @__PURE__ */ jsxs("div", { "data-gr-row": true, style: S.memberRow, children: [
106
111
  /* @__PURE__ */ jsxs("span", { style: S.memberEmail, title: m.email, children: [
107
112
  m.email,
@@ -120,6 +125,17 @@ function TeamDrawer({ onClose }) {
120
125
  children: ROLES.map((r) => /* @__PURE__ */ jsx("option", { value: r, children: r }, r))
121
126
  }
122
127
  ) : /* @__PURE__ */ jsx("span", { style: { fontSize: 12, color: v("muted") }, children: m.role }),
128
+ isAdmin && /* @__PURE__ */ jsx(
129
+ "button",
130
+ {
131
+ style: { ...S.iconBtn, color: v("muted"), width: 22, height: 22 },
132
+ disabled: pending,
133
+ onClick: () => run(() => sendPasswordResetAction(m.email)),
134
+ "aria-label": `Send password reset to ${m.email}`,
135
+ title: "Send password reset",
136
+ children: /* @__PURE__ */ jsx(KeyIcon, {})
137
+ }
138
+ ),
123
139
  isAdmin && /* @__PURE__ */ jsx(
124
140
  "button",
125
141
  {
@@ -173,9 +189,12 @@ function TeamDrawer({ onClose }) {
173
189
  }
174
190
  ),
175
191
  /* @__PURE__ */ jsxs("p", { style: { fontSize: 11, color: v("subtle"), marginTop: 10 }, children: [
176
- "Guardrail records the invite. Sending the email is your app's job \u2014 wire the ",
192
+ "New people get an email to set a password. Invites and resets go out through your ",
177
193
  /* @__PURE__ */ jsx("code", { children: "onInvite" }),
178
- " handler."
194
+ " / ",
195
+ /* @__PURE__ */ jsx("code", { children: "onPasswordReset" }),
196
+ " ",
197
+ "handlers, or your auth provider's own emails if you haven't set them."
179
198
  ] })
180
199
  ] })
181
200
  ] })
@@ -185,6 +204,7 @@ function TeamDrawer({ onClose }) {
185
204
  ] });
186
205
  }
187
206
  export {
207
+ ANIMATION_CSS,
188
208
  TeamDrawer
189
209
  };
190
210
  //# sourceMappingURL=Team.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/Team.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useCallback, useEffect, useState, useTransition } from \"react\";\nimport {\n inviteMember,\n listTeam,\n removeMember,\n updateMemberRole,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { CloseIcon } from \"./Icons\";\nimport { S, v } from \"./styles\";\nimport type { GRMember, GRRole } from \"../core/types\";\n\nconst ROLES: GRRole[] = [\"admin\", \"editor\"];\n\n/**\n * Keyframes cannot be expressed as inline styles, so the drawer ships the few\n * it needs. Scoped to [data-guardrail-team] to stay clear of app styles, and\n * disabled under prefers-reduced-motion.\n */\nconst ANIMATION_CSS = `\n@keyframes gr-fade-in { from { opacity: 0 } to { opacity: 1 } }\n@keyframes gr-slide-in {\n from { transform: translateX(100%) }\n to { transform: translateX(0) }\n}\n[data-guardrail-scrim] { animation: gr-fade-in 160ms ease-out both }\n[data-guardrail-team] { animation: gr-slide-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both }\n[data-guardrail-team] [data-gr-row] { animation: gr-fade-in 240ms ease-out both }\n@keyframes gr-fade-out { from { opacity: 1 } to { opacity: 0 } }\n@keyframes gr-slide-out {\n from { transform: translateX(0) }\n to { transform: translateX(100%) }\n}\n[data-guardrail-scrim][data-closing] { animation: gr-fade-out 180ms ease-in both }\n[data-guardrail-team][data-closing] { animation: gr-slide-out 180ms cubic-bezier(0.4, 0, 1, 1) both }\n@media (prefers-reduced-motion: reduce) {\n [data-guardrail-scrim], [data-guardrail-team], [data-guardrail-team] [data-gr-row] {\n animation: none\n }\n}\n`;\n\nexport function TeamDrawer({ onClose }: { onClose: () => void }) {\n const { role, email: currentEmail } = useGRail();\n const isAdmin = role === \"admin\";\n\n const [members, setMembers] = useState<GRMember[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [inviteEmail, setInviteEmail] = useState(\"\");\n const [inviteRole, setInviteRole] = useState<GRRole>(\"editor\");\n const [pending, startTransition] = useTransition();\n\n const refresh = () =>\n listTeam().then((rows) => {\n setMembers(rows);\n setLoading(false);\n });\n\n useEffect(() => {\n void refresh();\n }, []);\n\n // Server actions re-check the role; this only decides what to render.\n // Always refresh: a failure can still have changed the roster (an invite\n // that saved but whose email failed, for instance).\n const run = (fn: () => Promise<{ success: boolean; error?: string }>) =>\n startTransition(async () => {\n setError(null);\n const res = await fn();\n if (!res.success) setError(res.error ?? \"Something went wrong.\");\n await refresh();\n });\n\n /**\n * Unmounting on close skips the exit animation entirely. Stay mounted, play\n * it, and let the parent unmount once it finishes.\n */\n const [closing, setClosing] = useState(false);\n const requestClose = useCallback(() => {\n const reduced =\n typeof window !== \"undefined\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n if (reduced) return onClose(); // No animation, so no animationend to wait for.\n setClosing(true);\n }, [onClose]);\n\n useEffect(() => {\n if (!closing) return;\n // Safety net if animationend never arrives (tab hidden, styles stripped).\n const t = window.setTimeout(onClose, 400);\n return () => window.clearTimeout(t);\n }, [closing, onClose]);\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && requestClose();\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [requestClose]);\n\n return (\n <>\n {/* Hoisted to <head> — see the note on the theme <style> in Admin.tsx. */}\n <style href=\"guardrail-team-animation\" precedence=\"default\">\n {ANIMATION_CSS}\n </style>\n <div\n data-guardrail\n data-guardrail-scrim\n {...(closing ? { \"data-closing\": \"\" } : {})}\n style={S.scrim}\n onClick={requestClose}\n />\n <aside\n data-guardrail\n data-guardrail-team\n {...(closing ? { \"data-closing\": \"\" } : {})}\n onAnimationEnd={(e) => {\n // Row fade-ins bubble up here too; only the drawer's own exit counts.\n if (closing && e.target === e.currentTarget) onClose();\n }}\n style={S.drawer}\n role=\"dialog\"\n aria-label=\"Team\"\n >\n <header style={S.drawerHead}>\n <h2 style={S.drawerTitle}>Team</h2>\n <button style={{ ...S.iconBtn, color: v(\"muted\") }} onClick={requestClose} aria-label=\"Close\">\n <CloseIcon />\n </button>\n </header>\n\n <div style={S.drawerBody}>\n {error && <div style={{ ...S.error, marginBottom: 12 }}>{error}</div>}\n\n {loading ? (\n <p style={{ fontSize: 13, color: v(\"muted\") }}>Loading…</p>\n ) : (\n members.map((m) => (\n <div key={m.email} data-gr-row style={S.memberRow}>\n <span style={S.memberEmail} title={m.email}>\n {m.email}\n {m.email === currentEmail && \" (you)\"}\n </span>\n\n {m.status === \"pending\" && <span style={S.badge}>Invited</span>}\n\n {isAdmin ? (\n <select\n style={S.select}\n value={m.role}\n disabled={pending}\n onChange={(e) =>\n run(() =>\n updateMemberRole(m.email, e.target.value as GRRole),\n )\n }\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>{m.role}</span>\n )}\n\n {isAdmin && (\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 22, height: 22 }}\n disabled={pending}\n onClick={() => run(() => removeMember(m.email))}\n aria-label={`Remove ${m.email}`}\n title=\"Remove\"\n >\n <CloseIcon />\n </button>\n )}\n </div>\n ))\n )}\n\n {isAdmin && (\n <>\n <p style={S.sectionLabel}>Invite someone</p>\n <form\n onSubmit={(e) => {\n e.preventDefault();\n run(async () => {\n const res = await inviteMember(inviteEmail, inviteRole);\n if (res.success) setInviteEmail(\"\");\n return res;\n });\n }}\n >\n <input\n style={S.field}\n type=\"email\"\n placeholder=\"name@company.com\"\n value={inviteEmail}\n onChange={(e) => setInviteEmail(e.target.value)}\n required\n />\n <div style={{ display: \"flex\", gap: 8 }}>\n <select\n style={{ ...S.select, flex: 1 }}\n value={inviteRole}\n onChange={(e) => setInviteRole(e.target.value as GRRole)}\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button style={S.primaryBtn} type=\"submit\" disabled={pending}>\n {pending ? \"Working…\" : \"Invite\"}\n </button>\n </div>\n </form>\n <p style={{ fontSize: 11, color: v(\"subtle\"), marginTop: 10 }}>\n Guardrail records the invite. Sending the email is your app&apos;s\n job — wire the <code>onInvite</code> handler.\n </p>\n </>\n )}\n </div>\n </aside>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/react/Team.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useCallback, useEffect, useState, useTransition } from \"react\";\nimport {\n inviteMember,\n listTeam,\n removeMember,\n sendPasswordResetAction,\n updateMemberRole,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { CloseIcon, KeyIcon } from \"./Icons\";\nimport { S, v } from \"./styles\";\nimport type { GRMember, GRRole } from \"../core/types\";\n\nconst ROLES: GRRole[] = [\"admin\", \"editor\"];\n\n/**\n * Keyframes cannot be expressed as inline styles, so the drawer ships the few\n * it needs. Scoped to [data-guardrail-team] to stay clear of app styles, and\n * disabled under prefers-reduced-motion.\n */\nexport const ANIMATION_CSS = `\n@keyframes gr-fade-in { from { opacity: 0 } to { opacity: 1 } }\n@keyframes gr-slide-in {\n from { transform: translateX(100%) }\n to { transform: translateX(0) }\n}\n[data-guardrail-scrim] { animation: gr-fade-in 160ms ease-out both }\n[data-guardrail-team] { animation: gr-slide-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both }\n[data-guardrail-team] [data-gr-row] { animation: gr-fade-in 240ms ease-out both }\n@keyframes gr-fade-out { from { opacity: 1 } to { opacity: 0 } }\n@keyframes gr-slide-out {\n from { transform: translateX(0) }\n to { transform: translateX(100%) }\n}\n[data-guardrail-scrim][data-closing] { animation: gr-fade-out 180ms ease-in both }\n[data-guardrail-team][data-closing] { animation: gr-slide-out 180ms cubic-bezier(0.4, 0, 1, 1) both }\n@media (prefers-reduced-motion: reduce) {\n [data-guardrail-scrim], [data-guardrail-team], [data-guardrail-team] [data-gr-row] {\n animation: none\n }\n}\n`;\n\nexport function TeamDrawer({ onClose }: { onClose: () => void }) {\n const { role, email: currentEmail } = useGRail();\n const isAdmin = role === \"admin\";\n\n const [members, setMembers] = useState<GRMember[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [notice, setNotice] = useState<string | null>(null);\n const [inviteEmail, setInviteEmail] = useState(\"\");\n const [inviteRole, setInviteRole] = useState<GRRole>(\"editor\");\n const [pending, startTransition] = useTransition();\n\n const refresh = () =>\n listTeam().then((rows) => {\n setMembers(rows);\n setLoading(false);\n });\n\n useEffect(() => {\n void refresh();\n }, []);\n\n // Server actions re-check the role; this only decides what to render.\n // Always refresh: a failure can still have changed the roster (an invite\n // that saved but whose email failed, for instance).\n const run = (fn: () => Promise<{ success: boolean; error?: string; notice?: string }>) =>\n startTransition(async () => {\n setError(null);\n setNotice(null);\n const res = await fn();\n if (!res.success) setError(res.error ?? \"Something went wrong.\");\n else if (res.notice) setNotice(res.notice);\n await refresh();\n });\n\n /**\n * Unmounting on close skips the exit animation entirely. Stay mounted, play\n * it, and let the parent unmount once it finishes.\n */\n const [closing, setClosing] = useState(false);\n const requestClose = useCallback(() => {\n const reduced =\n typeof window !== \"undefined\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n if (reduced) return onClose(); // No animation, so no animationend to wait for.\n setClosing(true);\n }, [onClose]);\n\n useEffect(() => {\n if (!closing) return;\n // Safety net if animationend never arrives (tab hidden, styles stripped).\n const t = window.setTimeout(onClose, 400);\n return () => window.clearTimeout(t);\n }, [closing, onClose]);\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && requestClose();\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [requestClose]);\n\n return (\n <>\n {/* Hoisted to <head> — see the note on the theme <style> in Admin.tsx. */}\n <style href=\"guardrail-team-animation\" precedence=\"default\">\n {ANIMATION_CSS}\n </style>\n <div\n data-guardrail\n data-guardrail-scrim\n {...(closing ? { \"data-closing\": \"\" } : {})}\n style={S.scrim}\n onClick={requestClose}\n />\n <aside\n data-guardrail\n data-guardrail-team\n {...(closing ? { \"data-closing\": \"\" } : {})}\n onAnimationEnd={(e) => {\n // Row fade-ins bubble up here too; only the drawer's own exit counts.\n if (closing && e.target === e.currentTarget) onClose();\n }}\n style={S.drawer}\n role=\"dialog\"\n aria-label=\"Team\"\n >\n <header style={S.drawerHead}>\n <h2 style={S.drawerTitle}>Team</h2>\n <button style={{ ...S.iconBtn, color: v(\"muted\") }} onClick={requestClose} aria-label=\"Close\">\n <CloseIcon />\n </button>\n </header>\n\n <div style={S.drawerBody}>\n {error && <div style={{ ...S.error, marginBottom: 12 }}>{error}</div>}\n {notice && (\n <div style={{ fontSize: 12, color: v(\"muted\"), marginBottom: 12 }} role=\"status\">\n {notice}\n </div>\n )}\n\n {loading ? (\n <p style={{ fontSize: 13, color: v(\"muted\") }}>Loading…</p>\n ) : (\n members.map((m) => (\n <div key={m.email} data-gr-row style={S.memberRow}>\n <span style={S.memberEmail} title={m.email}>\n {m.email}\n {m.email === currentEmail && \" (you)\"}\n </span>\n\n {m.status === \"pending\" && <span style={S.badge}>Invited</span>}\n\n {isAdmin ? (\n <select\n style={S.select}\n value={m.role}\n disabled={pending}\n onChange={(e) =>\n run(() =>\n updateMemberRole(m.email, e.target.value as GRRole),\n )\n }\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n ) : (\n <span style={{ fontSize: 12, color: v(\"muted\") }}>{m.role}</span>\n )}\n\n {isAdmin && (\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 22, height: 22 }}\n disabled={pending}\n onClick={() => run(() => sendPasswordResetAction(m.email))}\n aria-label={`Send password reset to ${m.email}`}\n title=\"Send password reset\"\n >\n <KeyIcon />\n </button>\n )}\n\n {isAdmin && (\n <button\n style={{ ...S.iconBtn, color: v(\"muted\"), width: 22, height: 22 }}\n disabled={pending}\n onClick={() => run(() => removeMember(m.email))}\n aria-label={`Remove ${m.email}`}\n title=\"Remove\"\n >\n <CloseIcon />\n </button>\n )}\n </div>\n ))\n )}\n\n {isAdmin && (\n <>\n <p style={S.sectionLabel}>Invite someone</p>\n <form\n onSubmit={(e) => {\n e.preventDefault();\n run(async () => {\n const res = await inviteMember(inviteEmail, inviteRole);\n if (res.success) setInviteEmail(\"\");\n return res;\n });\n }}\n >\n <input\n style={S.field}\n type=\"email\"\n placeholder=\"name@company.com\"\n value={inviteEmail}\n onChange={(e) => setInviteEmail(e.target.value)}\n required\n />\n <div style={{ display: \"flex\", gap: 8 }}>\n <select\n style={{ ...S.select, flex: 1 }}\n value={inviteRole}\n onChange={(e) => setInviteRole(e.target.value as GRRole)}\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button style={S.primaryBtn} type=\"submit\" disabled={pending}>\n {pending ? \"Working…\" : \"Invite\"}\n </button>\n </div>\n </form>\n <p style={{ fontSize: 11, color: v(\"subtle\"), marginTop: 10 }}>\n New people get an email to set a password. Invites and resets go\n out through your <code>onInvite</code> / <code>onPasswordReset</code>{\" \"}\n handlers, or your auth provider&apos;s own emails if you haven&apos;t\n set them.\n </p>\n </>\n )}\n </div>\n </aside>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -62,8 +62,9 @@ declare function canEdit(): Promise<boolean>;
62
62
  *
63
63
  * needsPassword is set by completeAuthRedirectAction the moment an
64
64
  * invite/recovery link finishes — it forces chrome on regardless of the
65
- * edit-mode cookie, since the whole point is to show the set-password prompt
65
+ * edit-mode cookie, since the whole point is to show the set-password step
66
66
  * right away, not require also having ?edit=true or an unsuppressed cookie.
67
+ * authError (a link that failed) does the same, to explain what happened.
67
68
  */
68
69
  declare function resolveEditMode(viewer: GRViewer | null, cookieStore: {
69
70
  get(name: string): {
@@ -72,6 +73,7 @@ declare function resolveEditMode(viewer: GRViewer | null, cookieStore: {
72
73
  }): {
73
74
  isEditMode: boolean;
74
75
  needsPassword: boolean;
76
+ authError?: "expired" | "failed";
75
77
  };
76
78
 
77
79
  export { canEdit, isAdmin, isEditor, requireRole, resolveEditMode };
@@ -19,9 +19,11 @@ async function canEdit() {
19
19
  }
20
20
  function resolveEditMode(viewer, cookieStore) {
21
21
  const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;
22
- const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === "true";
23
- const isEditMode = needsPassword || (editCookie === "false" ? false : editCookie === "true" || viewer !== null);
24
- return { isEditMode, needsPassword };
22
+ const needsPassword = viewer !== null && cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === "true";
23
+ const rawError = cookieStore.get(GR_COOKIES.AUTH_ERROR)?.value;
24
+ const authError = rawError === "expired" || rawError === "failed" ? rawError : void 0;
25
+ const isEditMode = needsPassword || authError !== void 0 || (editCookie === "false" ? false : editCookie === "true" || viewer !== null);
26
+ return { isEditMode, needsPassword, ...authError ? { authError } : {} };
25
27
  }
26
28
  export {
27
29
  canEdit,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/access.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { getCachedViewer } from \"./session\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport type { GRRole, GRViewer } from \"../core/types\";\n\n/**\n * Role checks, server-side only.\n *\n * Deliberately NOT in a \"use server\" file: every export of one of those is a\n * callable Server Action endpoint, which is the wrong shape for an\n * authorization helper — it should only ever run inside the server code that\n * calls it.\n *\n * The client's copy of the role (useGRail().role) decides what to *show*; it\n * never decides what is *allowed*. A hidden button is not authorization, so\n * any Server Action behind a custom admin UI must call one of these itself.\n */\n\n/**\n * Returns the signed-in viewer if their role is in `allowed`, otherwise null.\n * The building block for the two helpers below; reach for this directly only\n * when you need the viewer itself (their email, say), not just a yes/no.\n */\nexport async function requireRole(\n allowed: readonly GRRole[],\n): Promise<GRViewer | null> {\n const viewer = await getCachedViewer();\n if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;\n return viewer;\n}\n\n/** True for a signed-in admin of this site — team management, and everything an editor can do. */\nexport async function isAdmin(): Promise<boolean> {\n return (await requireRole([\"admin\"])) !== null;\n}\n\n/**\n * True for anyone who may edit this site's content: an editor, or an admin\n * (admins can do everything editors can). The check for a custom drawer's\n * Server Action that changes content. Use isAdmin() instead for anything that\n * touches the team.\n */\nexport async function isEditor(): Promise<boolean> {\n return (await requireRole([\"admin\", \"editor\"])) !== null;\n}\n\n/**\n * True when this viewer may edit AND the site is currently in edit mode — the\n * one check for \"should I show editing UI / do edit-only work right now.\"\n * Admin vs editor doesn't matter; use isAdmin() when it does.\n *\n * For a custom drawer's trigger rendered in a Server Component. In a Client\n * Component, read `canEdit` from useGRail() instead — same answer, same rule.\n * Don't use it as the only authorization on a Server Action: edit mode is a\n * display preference (a cookie the visitor controls), so the action should\n * call isEditor() — this answers \"show it,\" that answers \"allow it.\"\n *\n * Reads cookies, which makes the calling route dynamic — nothing new in\n * practice, since withGuardrail() already does the same in the root layout.\n */\nexport async function canEdit(): Promise<boolean> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return false;\n return resolveEditMode(viewer, await cookies()).isEditMode;\n}\n\n/**\n * The one definition of \"is the site in edit mode,\" shared by withGuardrail()\n * (which hands it to the client as useGRail().isEditMode) and canEdit() so the\n * two can't drift apart.\n *\n * \"false\" is an explicit, deliberate suppression (the toolbar's exit button,\n * or ?edit=false, while still signed in) and wins outright — it lasts until\n * the next ?edit=true, and ends only the editing session, not the sign-in. Otherwise, a real\n * session is enough on its own — an authenticated editor's chrome persists for\n * as long as that session does, with no separate cookie timer to fall out of\n * sync with it. \"true\" only matters for someone not yet authenticated: a\n * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach\n * the sign-in form, not a standing invitation — the previous design kept this\n * cookie alive indefinitely once set, which meant a stray copy of it kept\n * showing the sign-in bar to whoever's browser it was sitting in, for as long\n * as that browser stayed open.\n *\n * needsPassword is set by completeAuthRedirectAction the moment an\n * invite/recovery link finishes — it forces chrome on regardless of the\n * edit-mode cookie, since the whole point is to show the set-password prompt\n * right away, not require also having ?edit=true or an unsuppressed cookie.\n */\nexport function resolveEditMode(\n viewer: GRViewer | null,\n cookieStore: { get(name: string): { value: string } | undefined },\n): { isEditMode: boolean; needsPassword: boolean } {\n const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;\n const needsPassword = cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === \"true\";\n const isEditMode =\n needsPassword ||\n (editCookie === \"false\" ? false : editCookie === \"true\" || viewer !== null);\n return { isEditMode, needsPassword };\n}\n"],"mappings":"AAAA,SAAS,eAAe;AACxB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAqB3B,eAAsB,YACpB,SAC0B;AAC1B,QAAM,SAAS,MAAM,gBAAgB;AACrC,MAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,QAAQ,SAAS,OAAO,IAAI,EAAG,QAAO;AACtE,SAAO;AACT;AAGA,eAAsB,UAA4B;AAChD,SAAQ,MAAM,YAAY,CAAC,OAAO,CAAC,MAAO;AAC5C;AAQA,eAAsB,WAA6B;AACjD,SAAQ,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC,MAAO;AACtD;AAgBA,eAAsB,UAA4B;AAChD,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,gBAAgB,QAAQ,MAAM,QAAQ,CAAC,EAAE;AAClD;AAwBO,SAAS,gBACd,QACA,aACiD;AACjD,QAAM,aAAa,YAAY,IAAI,WAAW,SAAS,GAAG;AAC1D,QAAM,gBAAgB,YAAY,IAAI,WAAW,cAAc,GAAG,UAAU;AAC5E,QAAM,aACJ,kBACC,eAAe,UAAU,QAAQ,eAAe,UAAU,WAAW;AACxE,SAAO,EAAE,YAAY,cAAc;AACrC;","names":[]}
1
+ {"version":3,"sources":["../../src/server/access.ts"],"sourcesContent":["import { cookies } from \"next/headers\";\nimport { getCachedViewer } from \"./session\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport type { GRRole, GRViewer } from \"../core/types\";\n\n/**\n * Role checks, server-side only.\n *\n * Deliberately NOT in a \"use server\" file: every export of one of those is a\n * callable Server Action endpoint, which is the wrong shape for an\n * authorization helper — it should only ever run inside the server code that\n * calls it.\n *\n * The client's copy of the role (useGRail().role) decides what to *show*; it\n * never decides what is *allowed*. A hidden button is not authorization, so\n * any Server Action behind a custom admin UI must call one of these itself.\n */\n\n/**\n * Returns the signed-in viewer if their role is in `allowed`, otherwise null.\n * The building block for the two helpers below; reach for this directly only\n * when you need the viewer itself (their email, say), not just a yes/no.\n */\nexport async function requireRole(\n allowed: readonly GRRole[],\n): Promise<GRViewer | null> {\n const viewer = await getCachedViewer();\n if (!viewer || !viewer.role || !allowed.includes(viewer.role)) return null;\n return viewer;\n}\n\n/** True for a signed-in admin of this site — team management, and everything an editor can do. */\nexport async function isAdmin(): Promise<boolean> {\n return (await requireRole([\"admin\"])) !== null;\n}\n\n/**\n * True for anyone who may edit this site's content: an editor, or an admin\n * (admins can do everything editors can). The check for a custom drawer's\n * Server Action that changes content. Use isAdmin() instead for anything that\n * touches the team.\n */\nexport async function isEditor(): Promise<boolean> {\n return (await requireRole([\"admin\", \"editor\"])) !== null;\n}\n\n/**\n * True when this viewer may edit AND the site is currently in edit mode — the\n * one check for \"should I show editing UI / do edit-only work right now.\"\n * Admin vs editor doesn't matter; use isAdmin() when it does.\n *\n * For a custom drawer's trigger rendered in a Server Component. In a Client\n * Component, read `canEdit` from useGRail() instead — same answer, same rule.\n * Don't use it as the only authorization on a Server Action: edit mode is a\n * display preference (a cookie the visitor controls), so the action should\n * call isEditor() — this answers \"show it,\" that answers \"allow it.\"\n *\n * Reads cookies, which makes the calling route dynamic — nothing new in\n * practice, since withGuardrail() already does the same in the root layout.\n */\nexport async function canEdit(): Promise<boolean> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return false;\n return resolveEditMode(viewer, await cookies()).isEditMode;\n}\n\n/**\n * The one definition of \"is the site in edit mode,\" shared by withGuardrail()\n * (which hands it to the client as useGRail().isEditMode) and canEdit() so the\n * two can't drift apart.\n *\n * \"false\" is an explicit, deliberate suppression (the toolbar's exit button,\n * or ?edit=false, while still signed in) and wins outright — it lasts until\n * the next ?edit=true, and ends only the editing session, not the sign-in. Otherwise, a real\n * session is enough on its own — an authenticated editor's chrome persists for\n * as long as that session does, with no separate cookie timer to fall out of\n * sync with it. \"true\" only matters for someone not yet authenticated: a\n * short-lived grace window (see GR_EDIT_MODE_TTL_SECONDS) to actually reach\n * the sign-in form, not a standing invitation — the previous design kept this\n * cookie alive indefinitely once set, which meant a stray copy of it kept\n * showing the sign-in bar to whoever's browser it was sitting in, for as long\n * as that browser stayed open.\n *\n * needsPassword is set by completeAuthRedirectAction the moment an\n * invite/recovery link finishes — it forces chrome on regardless of the\n * edit-mode cookie, since the whole point is to show the set-password step\n * right away, not require also having ?edit=true or an unsuppressed cookie.\n * authError (a link that failed) does the same, to explain what happened.\n */\nexport function resolveEditMode(\n viewer: GRViewer | null,\n cookieStore: { get(name: string): { value: string } | undefined },\n): { isEditMode: boolean; needsPassword: boolean; authError?: \"expired\" | \"failed\" } {\n const editCookie = cookieStore.get(GR_COOKIES.EDIT_MODE)?.value;\n // Only means anything with a session to set a password on. A leftover flag —\n // from a link whose session has since been signed out or expired — must not\n // be able to force the chrome on for someone nobody is signed in as, which no\n // \"off\" setting could undo.\n const needsPassword =\n viewer !== null && cookieStore.get(GR_COOKIES.NEEDS_PASSWORD)?.value === \"true\";\n const rawError = cookieStore.get(GR_COOKIES.AUTH_ERROR)?.value;\n const authError = rawError === \"expired\" || rawError === \"failed\" ? rawError : undefined;\n // A failed link forces the chrome on for the same reason a pending password\n // does: the whole point is to tell the person right now, even if they arrived\n // with no ?edit=true and no session.\n const isEditMode =\n needsPassword ||\n authError !== undefined ||\n (editCookie === \"false\" ? false : editCookie === \"true\" || viewer !== null);\n return { isEditMode, needsPassword, ...(authError ? { authError } : {}) };\n}\n"],"mappings":"AAAA,SAAS,eAAe;AACxB,SAAS,uBAAuB;AAChC,SAAS,kBAAkB;AAqB3B,eAAsB,YACpB,SAC0B;AAC1B,QAAM,SAAS,MAAM,gBAAgB;AACrC,MAAI,CAAC,UAAU,CAAC,OAAO,QAAQ,CAAC,QAAQ,SAAS,OAAO,IAAI,EAAG,QAAO;AACtE,SAAO;AACT;AAGA,eAAsB,UAA4B;AAChD,SAAQ,MAAM,YAAY,CAAC,OAAO,CAAC,MAAO;AAC5C;AAQA,eAAsB,WAA6B;AACjD,SAAQ,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC,MAAO;AACtD;AAgBA,eAAsB,UAA4B;AAChD,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,gBAAgB,QAAQ,MAAM,QAAQ,CAAC,EAAE;AAClD;AAyBO,SAAS,gBACd,QACA,aACmF;AACnF,QAAM,aAAa,YAAY,IAAI,WAAW,SAAS,GAAG;AAK1D,QAAM,gBACJ,WAAW,QAAQ,YAAY,IAAI,WAAW,cAAc,GAAG,UAAU;AAC3E,QAAM,WAAW,YAAY,IAAI,WAAW,UAAU,GAAG;AACzD,QAAM,YAAY,aAAa,aAAa,aAAa,WAAW,WAAW;AAI/E,QAAM,aACJ,iBACA,cAAc,WACb,eAAe,UAAU,QAAQ,eAAe,UAAU,WAAW;AACxE,SAAO,EAAE,YAAY,eAAe,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC,EAAG;AAC1E;","names":[]}
@@ -2,9 +2,11 @@ import { GRRole, GRAsset, GRMember, OverrideValue, SetPasswordState, ActionState
2
2
  import { GRUploadTarget, AuthRedirectParams } from '../core/adapter-types.js';
3
3
  import 'react';
4
4
 
5
+ /** `notice` is for a success the admin should still read, e.g. "no email was sent because...". */
5
6
  type Result = {
6
7
  success: boolean;
7
8
  error?: string;
9
+ notice?: string;
8
10
  };
9
11
  declare function saveOverride(row: OverrideRow): Promise<Result>;
10
12
  /**
@@ -46,13 +48,26 @@ declare function listTeam(): Promise<GRMember[]>;
46
48
  declare function inviteMember(email: string, role: GRRole): Promise<Result>;
47
49
  declare function updateMemberRole(email: string, role: GRRole): Promise<Result>;
48
50
  declare function removeMember(email: string): Promise<Result>;
51
+ /**
52
+ * "Forgot password?" from the sign-in bar. Called with no session, so it says
53
+ * the same thing whether or not the address belongs to anyone: a member gets an
54
+ * email, a stranger gets nothing, and neither can tell which happened from the
55
+ * response or how long it took. Only a malformed address is rejected, since
56
+ * that says nothing about anyone.
57
+ */
58
+ declare function forgotPasswordAction(email: string): Promise<Result>;
59
+ /**
60
+ * An admin sending a password reset to someone on the team, from the Team
61
+ * drawer. Unlike the self-service request this reports what happened — an admin
62
+ * is allowed to know, and needs to when it didn't work.
63
+ */
64
+ declare function sendPasswordResetAction(email: string): Promise<Result>;
49
65
  declare function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState>;
50
66
  declare function logoutAction(): Promise<{
51
67
  success: boolean;
52
68
  }>;
53
69
  /**
54
- * Completes a session that arrived via an invite, magic-link, or
55
- * password-recovery redirect. Called by AuthRedirectSync (react/), the
70
+ * Completes a session that arrived via an invite or password-recovery link. Called by AuthRedirectSync (react/), the
56
71
  * generic client component that notices this kind of link and hands off
57
72
  * whatever it found in the URL — the actual token exchange is entirely the
58
73
  * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only
@@ -74,9 +89,15 @@ declare function completeAuthRedirectAction(params: AuthRedirectParams): Promise
74
89
  * resolving them, which already happens independently of this.
75
90
  */
76
91
  declare function setPasswordAction(_prev: SetPasswordState, formData: FormData): Promise<SetPasswordState>;
77
- /** Dismisses the set-password prompt without setting one — see Admin.tsx. */
92
+ /**
93
+ * Dismisses the set-password step without setting a password.
94
+ *
95
+ * @deprecated Guardrail's own UI no longer offers this: passwords are the only
96
+ * way back in, so the step can't be skipped. Kept so an app's own UI built on
97
+ * it keeps working.
98
+ */
78
99
  declare function skipSetPasswordAction(): Promise<{
79
100
  success: boolean;
80
101
  }>;
81
102
 
82
- export { completeAuthRedirectAction, createAssetUpload, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, saveOverride, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole };
103
+ export { completeAuthRedirectAction, createAssetUpload, forgotPasswordAction, inviteMember, listAssets, listTeam, loginAction, logoutAction, removeMember, saveOverride, sendPasswordResetAction, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole };
@@ -6,13 +6,18 @@ import {
6
6
  getDB,
7
7
  getInviteHandler,
8
8
  getMembers,
9
+ getPasswordResetHandler,
9
10
  getStorage,
10
11
  resolveSiteId
11
12
  } from "../core/config";
12
13
  import { getCachedViewer } from "./session";
13
14
  import { requireRole } from "./access";
14
15
  import { rethrowIfBailout } from "../core/bailout";
15
- import { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from "../core/defs";
16
+ import {
17
+ GR_AUTH_ERROR_TTL_SECONDS,
18
+ GR_COOKIES,
19
+ GR_NEEDS_PASSWORD_TTL_SECONDS
20
+ } from "../core/defs";
16
21
  const DENIED = { success: false, error: "Unauthorized." };
17
22
  async function saveOverride(row) {
18
23
  const viewer = await requireRole(["admin", "editor"]);
@@ -110,29 +115,43 @@ async function inviteMember(email, role) {
110
115
  if (!res.success) return res;
111
116
  }
112
117
  const auth = getAuth();
113
- if (auth.inviteUser) {
114
- const sent = await auth.inviteUser(normalized, {
115
- redirectTo: `${await requestOrigin()}/?edit=true`
116
- });
118
+ const onInvite = getInviteHandler();
119
+ const origin = await requestOrigin();
120
+ const made = auth.createInviteLink ? await auth.createInviteLink(normalized, { origin }) : null;
121
+ if (made && !made.success) {
122
+ revalidatePath("/", "layout");
123
+ return {
124
+ success: false,
125
+ error: `Invite saved, but the sign-in link couldn't be created: ${made.error}`
126
+ };
127
+ }
128
+ if (!onInvite) {
117
129
  revalidatePath("/", "layout");
118
- return sent.success ? { success: true } : {
130
+ return {
119
131
  success: false,
120
- error: `Invite saved, but the email failed: ${sent.error}`
132
+ error: "Invite saved, but this site has nothing set up to email it. Add resendEmails() (or your own onInvite) to withGuardrail() \u2014 see the README."
121
133
  };
122
134
  }
123
135
  try {
124
- await getInviteHandler()?.({
136
+ await onInvite({
125
137
  email: normalized,
126
138
  role: existing?.role ?? role,
127
139
  siteId,
128
- invitedBy: viewer.email ?? viewer.userId
140
+ invitedBy: viewer.email ?? viewer.userId,
141
+ ...made ? { link: made.link, isNewUser: made.isNewUser } : {}
129
142
  });
130
143
  } catch (e) {
131
144
  console.error("Guardrail: onInvite handler threw", e);
145
+ revalidatePath("/", "layout");
146
+ return {
147
+ success: false,
148
+ error: `Invite saved, but the email failed: ${e instanceof Error ? e.message : "unknown error"}`
149
+ };
132
150
  }
133
151
  revalidatePath("/", "layout");
134
- return { success: true };
152
+ return made && !made.isNewUser ? { success: true, notice: ALREADY_HAS_ACCOUNT(normalized) } : { success: true };
135
153
  }
154
+ const ALREADY_HAS_ACCOUNT = (email) => `${email} already has an account, so they were told they've been added rather than sent a link \u2014 they sign in with their existing password.`;
136
155
  async function updateMemberRole(email, role) {
137
156
  const viewer = await requireRole(["admin"]);
138
157
  if (!viewer) return DENIED;
@@ -167,6 +186,74 @@ async function removeMember(email) {
167
186
  if (res.success) revalidatePath("/", "layout");
168
187
  return res;
169
188
  }
189
+ const RESET_COOLDOWN_MS = 6e4;
190
+ const lastResetAt = /* @__PURE__ */ new Map();
191
+ const RESET_MIN_DURATION_MS = 600;
192
+ const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
193
+ async function dispatchPasswordReset(email, requestedBy) {
194
+ const auth = getAuth();
195
+ const handler = getPasswordResetHandler();
196
+ if (!auth.createPasswordResetLink) {
197
+ return {
198
+ success: false,
199
+ error: "this site's auth setup can't create reset links \u2014 give the Supabase adapter a serviceRoleKey."
200
+ };
201
+ }
202
+ if (!handler) {
203
+ return {
204
+ success: false,
205
+ error: "this site has no onPasswordReset handler \u2014 add resendEmails() (or your own) to withGuardrail()."
206
+ };
207
+ }
208
+ const made = await auth.createPasswordResetLink(email, { origin: await requestOrigin() });
209
+ if (!made.success) return { success: false, error: made.error };
210
+ try {
211
+ await handler({ email, siteId: await resolveSiteId(), link: made.link, requestedBy });
212
+ return { success: true };
213
+ } catch (e) {
214
+ console.error("Guardrail: onPasswordReset handler threw", e);
215
+ return { success: false, error: e instanceof Error ? e.message : "unknown error" };
216
+ }
217
+ }
218
+ async function forgotPasswordAction(email) {
219
+ const started = Date.now();
220
+ const normalized = String(email ?? "").toLowerCase().trim();
221
+ if (!EMAIL_PATTERN.test(normalized)) {
222
+ return { success: false, error: "Enter a valid email address." };
223
+ }
224
+ try {
225
+ const siteId = await resolveSiteId();
226
+ const key = `${siteId}:${normalized}`;
227
+ const last = lastResetAt.get(key);
228
+ if (!last || started - last >= RESET_COOLDOWN_MS) {
229
+ lastResetAt.set(key, started);
230
+ if (lastResetAt.size > 500) {
231
+ for (const [k, t] of lastResetAt) if (started - t >= RESET_COOLDOWN_MS) lastResetAt.delete(k);
232
+ }
233
+ const members = getMembers();
234
+ const known = members.emailHasAccess ? await members.emailHasAccess(siteId, normalized) : true;
235
+ if (known) {
236
+ const res = await dispatchPasswordReset(normalized, "self");
237
+ if (!res.success) console.error("Guardrail: password reset failed:", res.error);
238
+ }
239
+ }
240
+ } catch (e) {
241
+ rethrowIfBailout(e);
242
+ console.error("Guardrail: password reset failed", e);
243
+ }
244
+ const wait = RESET_MIN_DURATION_MS - (Date.now() - started);
245
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
246
+ return { success: true };
247
+ }
248
+ async function sendPasswordResetAction(email) {
249
+ const viewer = await requireRole(["admin"]);
250
+ if (!viewer) return DENIED;
251
+ const normalized = String(email ?? "").toLowerCase().trim();
252
+ const target = await getMembers().getMember(await resolveSiteId(), { email: normalized });
253
+ if (!target) return { success: false, error: "No such member." };
254
+ const res = await dispatchPasswordReset(normalized, viewer.email ?? viewer.userId);
255
+ return res.success ? { success: true, notice: `Password reset email sent to ${normalized}.` } : { success: false, error: `The reset email couldn't be sent: ${res.error}` };
256
+ }
170
257
  async function requestOrigin() {
171
258
  const h = await headers();
172
259
  const origin = h.get("origin");
@@ -210,6 +297,8 @@ async function logoutAction() {
210
297
  await auth.logout();
211
298
  const cookieStore = await cookies();
212
299
  cookieStore.delete(GR_COOKIES.EDIT_MODE);
300
+ cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);
301
+ cookieStore.delete(GR_COOKIES.AUTH_ERROR);
213
302
  revalidatePath("/", "layout");
214
303
  return { success: true };
215
304
  }
@@ -217,15 +306,24 @@ async function completeAuthRedirectAction(params) {
217
306
  const auth = getAuth();
218
307
  if (!auth.completeAuthRedirect) return { success: false };
219
308
  const res = await auth.completeAuthRedirect(params);
309
+ const cookieStore = await cookies();
220
310
  if (res.success) {
311
+ cookieStore.delete(GR_COOKIES.AUTH_ERROR);
221
312
  if (res.needsPassword) {
222
- const cookieStore = await cookies();
223
313
  cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, "true", {
224
314
  path: "/",
225
315
  maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS
226
316
  });
227
317
  }
228
318
  revalidatePath("/", "layout");
319
+ } else {
320
+ console.error("Guardrail: couldn't complete an emailed link:", res.error);
321
+ cookieStore.set(
322
+ GR_COOKIES.AUTH_ERROR,
323
+ /expired|invalid|already|used|not found/i.test(res.error ?? "") ? "expired" : "failed",
324
+ { path: "/", maxAge: GR_AUTH_ERROR_TTL_SECONDS }
325
+ );
326
+ revalidatePath("/", "layout");
229
327
  }
230
328
  return res;
231
329
  }
@@ -237,6 +335,13 @@ async function setPasswordAction(_prev, formData) {
237
335
  return { success: false, error: "This site's auth provider does not support setting a password." };
238
336
  }
239
337
  const password = String(formData.get("password") ?? "");
338
+ const confirm = formData.get("confirm");
339
+ if (password.length < 8) {
340
+ return { success: false, error: "Use at least 8 characters." };
341
+ }
342
+ if (confirm !== null && String(confirm) !== password) {
343
+ return { success: false, error: "The two passwords don't match." };
344
+ }
240
345
  const res = await auth.setPassword(password);
241
346
  if (res.success) {
242
347
  const cookieStore = await cookies();
@@ -254,6 +359,7 @@ async function skipSetPasswordAction() {
254
359
  export {
255
360
  completeAuthRedirectAction,
256
361
  createAssetUpload,
362
+ forgotPasswordAction,
257
363
  inviteMember,
258
364
  listAssets,
259
365
  listTeam,
@@ -261,6 +367,7 @@ export {
261
367
  logoutAction,
262
368
  removeMember,
263
369
  saveOverride,
370
+ sendPasswordResetAction,
264
371
  setFieldValue,
265
372
  setPasswordAction,
266
373
  skipSetPasswordAction,