@guard-rail/core 0.7.0 → 0.8.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.
- package/README.md +90 -5
- package/dist/adapters/supabase/auth.d.ts +19 -1
- package/dist/adapters/supabase/auth.js +35 -9
- package/dist/adapters/supabase/auth.js.map +1 -1
- package/dist/adapters/supabase/client.d.ts +16 -1
- package/dist/adapters/supabase/client.js +9 -0
- package/dist/adapters/supabase/client.js.map +1 -1
- package/dist/adapters/supabase/confirm.d.ts +5 -0
- package/dist/adapters/supabase/confirm.js +13 -2
- package/dist/adapters/supabase/confirm.js.map +1 -1
- package/dist/core/adapter-types.d.ts +36 -0
- package/dist/email/resend.d.ts +51 -0
- package/dist/email/resend.js +75 -0
- package/dist/email/resend.js.map +1 -0
- package/dist/index.client.d.ts +3 -1
- package/dist/index.client.js +5 -0
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/react/Repeater/index.js +2 -3
- package/dist/react/Repeater/index.js.map +1 -1
- package/dist/server/actions.js +33 -2
- package/dist/server/actions.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,10 +223,14 @@ notices the link's tokens (in the URL hash, a `code`, or a `token_hash` —
|
|
|
223
223
|
whichever style your project's templates happen to produce, including the
|
|
224
224
|
unedited default) and completes the session automatically.
|
|
225
225
|
|
|
226
|
-
Someone arriving this way has a valid session but no
|
|
227
|
-
get a **Set Password** prompt in place of the usual
|
|
228
|
-
since a magic link works fine on its own indefinitely if
|
|
229
|
-
The same prompt appears after a password-recovery link.
|
|
226
|
+
Someone arriving this way for the first time has a valid session but no
|
|
227
|
+
password yet, so they get a **Set Password** prompt in place of the usual
|
|
228
|
+
toolbar — skippable, since a magic link works fine on its own indefinitely if
|
|
229
|
+
they'd rather not. The same prompt appears after a password-recovery link.
|
|
230
|
+
Someone who already had an account (from an earlier invite, or from another
|
|
231
|
+
site on the same Supabase project) is just signed in: auth users are
|
|
232
|
+
project-wide, so a second prompt would have them overwrite a password they use
|
|
233
|
+
everywhere.
|
|
230
234
|
|
|
231
235
|
Two things still worth knowing:
|
|
232
236
|
|
|
@@ -251,7 +255,88 @@ export const GET = supabaseConfirmRoute({ url, anonKey });
|
|
|
251
255
|
|
|
252
256
|
The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in the
|
|
253
257
|
browser that requested it — the admin's, not the invitee's — so a custom
|
|
254
|
-
template needs the token hash form above either way.
|
|
258
|
+
template needs the token hash form above either way. Arriving through this
|
|
259
|
+
route shows the same **Set Password** prompt as the default flow.
|
|
260
|
+
|
|
261
|
+
**Customizing the invite email.** Guardrail sends invites as Supabase *magic
|
|
262
|
+
link* emails, so the wording is Supabase's **Magic Link** template — and with
|
|
263
|
+
the Supabase CLI it can live in your repo instead of the dashboard. Declare it
|
|
264
|
+
in `supabase/config.toml`, then push it:
|
|
265
|
+
|
|
266
|
+
```toml
|
|
267
|
+
[auth.email.template.magic_link]
|
|
268
|
+
subject = "You've been invited"
|
|
269
|
+
content_path = "./supabase/templates/magic_link.html"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```html
|
|
273
|
+
<!-- supabase/templates/magic_link.html -->
|
|
274
|
+
<h2>You've been invited</h2>
|
|
275
|
+
<p><a href="{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email">Set up your account</a></p>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
supabase config diff # preview
|
|
280
|
+
supabase config push # apply to the linked project
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Templates belong to the Supabase *project*, so sites sharing one project share
|
|
284
|
+
this wording — and `supabase config push` writes the project's auth settings,
|
|
285
|
+
so don't run it against a project other sites depend on. To make the email
|
|
286
|
+
entirely your own instead, send it yourself:
|
|
287
|
+
|
|
288
|
+
**Branded invite emails (Resend).** Give the Supabase adapter a service-role
|
|
289
|
+
key and an `onInvite` handler, and Guardrail creates the account and mints the
|
|
290
|
+
sign-in link while *your* site sends the email — your domain, your template, no
|
|
291
|
+
dependence on the project's templates, Site URL, or redirect allowlist:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// lib/guardrail.ts
|
|
295
|
+
import { resendInvite } from "@guard-rail/core";
|
|
296
|
+
import { supabaseAdapters } from "@guard-rail/core/adapters/supabase";
|
|
297
|
+
|
|
298
|
+
const supabase = supabaseAdapters({
|
|
299
|
+
url: process.env.SUPABASE_URL!,
|
|
300
|
+
anonKey: process.env.SUPABASE_ANON_KEY!,
|
|
301
|
+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, // server-only
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
withGuardrail(RootLayout, {
|
|
305
|
+
...supabase,
|
|
306
|
+
siteId: "my-site",
|
|
307
|
+
onInvite: resendInvite({
|
|
308
|
+
from: "My Site <hello@mysite.com>", // a domain verified in Resend
|
|
309
|
+
siteName: "My Site",
|
|
310
|
+
// apiKey defaults to process.env.RESEND_API_KEY
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
New addresses get a "Set up your account" email, and addresses that already
|
|
316
|
+
have an account get "Sign in". The link points at your own site and is
|
|
317
|
+
completed there, so nothing needs mounting, and an email scanner that
|
|
318
|
+
pre-fetches links can't use it up. If sending fails — Resend rejecting the
|
|
319
|
+
sender domain, say — the admin who clicked Invite sees why, and the invite stays
|
|
320
|
+
recorded so they can resend it.
|
|
321
|
+
|
|
322
|
+
For your own markup, pass `render`; for another provider, write your own
|
|
323
|
+
`onInvite` — it receives the same event, including `link`:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
resendInvite({
|
|
327
|
+
from: "My Site <hello@mysite.com>",
|
|
328
|
+
render: ({ link, siteName, isNewUser }) => ({
|
|
329
|
+
subject: `Welcome to ${siteName}`,
|
|
330
|
+
html: `<a href="${link}">${isNewUser ? "Set up your account" : "Sign in"}</a>`,
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The service-role key is project-wide by Supabase's design — it bypasses RLS and
|
|
336
|
+
reaches every schema, not just Guardrail's — so keep it in a server-only
|
|
337
|
+
environment variable (never `NEXT_PUBLIC_`). It is used for exactly one call,
|
|
338
|
+
creating the invite link. Leave it out and Guardrail sends Supabase's own
|
|
339
|
+
magic-link email as before; both settings are needed to opt in.
|
|
255
340
|
|
|
256
341
|
**Bootstrapping.** A site with no members has nobody who can add members, and
|
|
257
342
|
there is deliberately no in-app way to grant yourself a role — that would mean
|
|
@@ -4,6 +4,24 @@ import '@supabase/supabase-js';
|
|
|
4
4
|
import 'react';
|
|
5
5
|
import '../../core/types.js';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Whether this link signs in someone who still needs to choose a password.
|
|
9
|
+
*
|
|
10
|
+
* "invite" and "recovery" always do: one is a brand-new account, the other is
|
|
11
|
+
* someone who asked to reset theirs. Every other kind — a magic link, its
|
|
12
|
+
* token_hash form ("email"), a PKCE code with no type at all — is decided by
|
|
13
|
+
* the account instead: it needs a password if this very link is what confirmed
|
|
14
|
+
* its email address, i.e. a first sign-in. That is what "an invite sent as a
|
|
15
|
+
* magic link" looks like, and it deliberately leaves out someone who already
|
|
16
|
+
* has an account, which matters most on a Supabase project shared by several
|
|
17
|
+
* sites: auth users are project-wide, so they have one password everywhere,
|
|
18
|
+
* and a prompt on a second site would have them overwrite it.
|
|
19
|
+
*
|
|
20
|
+
* Skippable either way — a magic link keeps working without a password.
|
|
21
|
+
*/
|
|
22
|
+
declare function needsPasswordFor(type: string | null | undefined, user: {
|
|
23
|
+
email_confirmed_at?: string | null;
|
|
24
|
+
} | null | undefined): boolean;
|
|
7
25
|
declare function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter;
|
|
8
26
|
|
|
9
|
-
export { supabaseAuth };
|
|
27
|
+
export { needsPasswordFor, supabaseAuth };
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { anonClient, assertCredentials, sessionClient } from "./client";
|
|
2
|
-
|
|
1
|
+
import { adminClient, anonClient, assertCredentials, sessionClient } from "./client";
|
|
2
|
+
function needsPasswordFor(type, user) {
|
|
3
|
+
if (type === "invite" || type === "recovery") return true;
|
|
4
|
+
const confirmedAt = user?.email_confirmed_at ? Date.parse(user.email_confirmed_at) : NaN;
|
|
5
|
+
return Number.isFinite(confirmedAt) && Date.now() - confirmedAt < JUST_CONFIRMED_MS;
|
|
6
|
+
}
|
|
7
|
+
const JUST_CONFIRMED_MS = 2 * 60 * 1e3;
|
|
3
8
|
function supabaseAuth(creds) {
|
|
4
9
|
assertCredentials(creds);
|
|
5
|
-
|
|
10
|
+
const adapter = {
|
|
6
11
|
async authenticate(email, password) {
|
|
7
12
|
const client = await sessionClient(creds);
|
|
8
13
|
const { data, error } = await client.auth.signInWithPassword({
|
|
@@ -75,22 +80,22 @@ function supabaseAuth(creds) {
|
|
|
75
80
|
if (!access_token || !refresh_token) {
|
|
76
81
|
return { success: false, error: "Redirect link is missing its tokens." };
|
|
77
82
|
}
|
|
78
|
-
const { error } = await client.auth.setSession({ access_token, refresh_token });
|
|
83
|
+
const { data, error } = await client.auth.setSession({ access_token, refresh_token });
|
|
79
84
|
if (error) return { success: false, error: error.message };
|
|
80
|
-
return { success: true, needsPassword:
|
|
85
|
+
return { success: true, needsPassword: needsPasswordFor(hashType, data.user) };
|
|
81
86
|
}
|
|
82
87
|
if (code) {
|
|
83
|
-
const { error } = await client.auth.exchangeCodeForSession(code);
|
|
88
|
+
const { data, error } = await client.auth.exchangeCodeForSession(code);
|
|
84
89
|
if (error) return { success: false, error: error.message };
|
|
85
|
-
return { success: true, needsPassword:
|
|
90
|
+
return { success: true, needsPassword: needsPasswordFor(type, data.user) };
|
|
86
91
|
}
|
|
87
92
|
if (tokenHash && type) {
|
|
88
|
-
const { error } = await client.auth.verifyOtp({
|
|
93
|
+
const { data, error } = await client.auth.verifyOtp({
|
|
89
94
|
type,
|
|
90
95
|
token_hash: tokenHash
|
|
91
96
|
});
|
|
92
97
|
if (error) return { success: false, error: error.message };
|
|
93
|
-
return { success: true, needsPassword:
|
|
98
|
+
return { success: true, needsPassword: needsPasswordFor(type, data.user) };
|
|
94
99
|
}
|
|
95
100
|
return { success: false, error: "No redirect data to complete." };
|
|
96
101
|
},
|
|
@@ -100,8 +105,29 @@ function supabaseAuth(creds) {
|
|
|
100
105
|
return error ? { success: false, error: error.message } : { success: true };
|
|
101
106
|
}
|
|
102
107
|
};
|
|
108
|
+
if (creds.serviceRoleKey) {
|
|
109
|
+
adapter.createInviteLink = async (email, { origin }) => {
|
|
110
|
+
const admin = adminClient(creds);
|
|
111
|
+
let isNewUser = true;
|
|
112
|
+
let res = await admin.auth.admin.generateLink({ type: "invite", email });
|
|
113
|
+
if (res.error) {
|
|
114
|
+
isNewUser = false;
|
|
115
|
+
res = await admin.auth.admin.generateLink({ type: "magiclink", email });
|
|
116
|
+
}
|
|
117
|
+
if (res.error || !res.data?.properties?.hashed_token) {
|
|
118
|
+
return { success: false, error: res.error?.message ?? "No sign-in link was returned." };
|
|
119
|
+
}
|
|
120
|
+
const { hashed_token, verification_type } = res.data.properties;
|
|
121
|
+
const link = new URL("/", origin);
|
|
122
|
+
link.searchParams.set("token_hash", hashed_token);
|
|
123
|
+
link.searchParams.set("type", verification_type);
|
|
124
|
+
return { success: true, link: link.toString(), isNewUser };
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return adapter;
|
|
103
128
|
}
|
|
104
129
|
export {
|
|
130
|
+
needsPasswordFor,
|
|
105
131
|
supabaseAuth
|
|
106
132
|
};
|
|
107
133
|
//# sourceMappingURL=auth.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type {\n AuthRedirectParams,\n GRAuthAdapter,\n GRSession,\n} from \"../../core/adapter-types\";\n\n// \"invite\" covers Supabase's own admin-API invite flow; \"recovery\" is\n// forgot-password. \"magiclink\" belongs here too — deliberately, not an\n// oversight — because inviteUser() below sends one (see its own comment for\n// why) rather than an \"invite\"-typed email, so this is the type Guardrail's\n// actual, real invite flow produces. A returning magic-link user who already\n// has a password sees this redundantly, but it's skippable, and the\n// alternative — never prompting the person this feature exists for — isn't\n// an acceptable trade to avoid that.\nconst NEEDS_PASSWORD_TYPES = new Set([\"invite\", \"recovery\", \"magiclink\"]);\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n return {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n\n /**\n * Whichever of hash/code/tokenHash is present decides the exchange —\n * these correspond to Supabase's three redirect styles (legacy implicit\n * grant, PKCE, and the token_hash style Guardrail's own confirm route\n * already used), so this handles a link regardless of which one a\n * project's email templates happen to produce, including the\n * unconfigured default (implicit grant, hash-based — what an unedited\n * Supabase project actually sends).\n *\n * Goes through sessionClient(), the same cookie-writing client\n * everything else in this adapter uses — setSession/exchangeCodeForSession/\n * verifyOtp all persist the resulting session through its existing\n * getAll/setAll bridge, so this needs no browser-side Supabase client of\n * its own.\n */\n async completeAuthRedirect({ hash, code, tokenHash, type }: AuthRedirectParams) {\n const client = await sessionClient(creds);\n\n if (hash) {\n const hashParams = new URLSearchParams(hash);\n const access_token = hashParams.get(\"access_token\");\n const refresh_token = hashParams.get(\"refresh_token\");\n const hashType = hashParams.get(\"type\") ?? type;\n if (!access_token || !refresh_token) {\n return { success: false, error: \"Redirect link is missing its tokens.\" };\n }\n const { error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(hashType ?? \"\") };\n }\n\n if (code) {\n const { error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type ?? \"\") };\n }\n\n if (tokenHash && type) {\n const { error } = await client.auth.verifyOtp({\n type: type as EmailOtpType,\n token_hash: tokenHash,\n });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type) };\n }\n\n return { success: false, error: \"No redirect data to complete.\" };\n },\n\n async setPassword(password: string) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.updateUser({ password });\n return error ? { success: false, error: error.message } : { success: true };\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { adminClient, anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type {\n AuthRedirectParams,\n GRAuthAdapter,\n GRSession,\n} from \"../../core/adapter-types\";\n\n/**\n * Whether this link signs in someone who still needs to choose a password.\n *\n * \"invite\" and \"recovery\" always do: one is a brand-new account, the other is\n * someone who asked to reset theirs. Every other kind — a magic link, its\n * token_hash form (\"email\"), a PKCE code with no type at all — is decided by\n * the account instead: it needs a password if this very link is what confirmed\n * its email address, i.e. a first sign-in. That is what \"an invite sent as a\n * magic link\" looks like, and it deliberately leaves out someone who already\n * has an account, which matters most on a Supabase project shared by several\n * sites: auth users are project-wide, so they have one password everywhere,\n * and a prompt on a second site would have them overwrite it.\n *\n * Skippable either way — a magic link keeps working without a password.\n */\nexport function needsPasswordFor(\n type: string | null | undefined,\n user: { email_confirmed_at?: string | null } | null | undefined,\n): boolean {\n if (type === \"invite\" || type === \"recovery\") return true;\n const confirmedAt = user?.email_confirmed_at ? Date.parse(user.email_confirmed_at) : NaN;\n return Number.isFinite(confirmedAt) && Date.now() - confirmedAt < JUST_CONFIRMED_MS;\n}\n\n/**\n * \"Just now\" for a first sign-in. Supabase stamps email_confirmed_at when the\n * link is clicked, not when it's sent, so this only has to span the seconds\n * between that click and this check — a longer window would just start\n * catching accounts created or confirmed a few minutes earlier.\n */\nconst JUST_CONFIRMED_MS = 2 * 60 * 1000;\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n const adapter: GRAuthAdapter = {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n\n /**\n * Whichever of hash/code/tokenHash is present decides the exchange —\n * these correspond to Supabase's three redirect styles (legacy implicit\n * grant, PKCE, and the token_hash style Guardrail's own confirm route\n * already used), so this handles a link regardless of which one a\n * project's email templates happen to produce, including the\n * unconfigured default (implicit grant, hash-based — what an unedited\n * Supabase project actually sends).\n *\n * Goes through sessionClient(), the same cookie-writing client\n * everything else in this adapter uses — setSession/exchangeCodeForSession/\n * verifyOtp all persist the resulting session through its existing\n * getAll/setAll bridge, so this needs no browser-side Supabase client of\n * its own.\n */\n async completeAuthRedirect({ hash, code, tokenHash, type }: AuthRedirectParams) {\n const client = await sessionClient(creds);\n\n if (hash) {\n const hashParams = new URLSearchParams(hash);\n const access_token = hashParams.get(\"access_token\");\n const refresh_token = hashParams.get(\"refresh_token\");\n const hashType = hashParams.get(\"type\") ?? type;\n if (!access_token || !refresh_token) {\n return { success: false, error: \"Redirect link is missing its tokens.\" };\n }\n const { data, error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(hashType, data.user) };\n }\n\n if (code) {\n const { data, error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type, data.user) };\n }\n\n if (tokenHash && type) {\n const { data, error } = await client.auth.verifyOtp({\n type: type as EmailOtpType,\n token_hash: tokenHash,\n });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type, data.user) };\n }\n\n return { success: false, error: \"No redirect data to complete.\" };\n },\n\n async setPassword(password: string) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.updateUser({ password });\n return error ? { success: false, error: error.message } : { success: true };\n },\n };\n\n // Only offered when a service-role key was configured: minting a sign-in link\n // server-side is an admin operation, and an adapter without the key simply\n // can't do it (Guardrail then falls back to inviteUser).\n if (creds.serviceRoleKey) {\n /**\n * Creates the account and returns a one-time link, sending nothing — the\n * site's own onInvite handler delivers it (see resendInvite).\n *\n * The link points at the site's own root with the token in the query\n * string, where the always-mounted AuthRedirectSync completes it. That\n * needs no route, no Supabase Site URL / redirect allowlist entry (settings\n * that are project-wide on a shared project), and — unlike a GET handler\n * that verifies the token — survives an email scanner that pre-fetches\n * links, since nothing is verified until the page's JavaScript runs.\n */\n adapter.createInviteLink = async (email, { origin }) => {\n const admin = adminClient(creds);\n\n // A new address gets a real invite; one that already has an account (here\n // or on another site on the same project) gets a sign-in link instead.\n let isNewUser = true;\n let res = await admin.auth.admin.generateLink({ type: \"invite\", email });\n if (res.error) {\n isNewUser = false;\n res = await admin.auth.admin.generateLink({ type: \"magiclink\", email });\n }\n if (res.error || !res.data?.properties?.hashed_token) {\n return { success: false, error: res.error?.message ?? \"No sign-in link was returned.\" };\n }\n\n const { hashed_token, verification_type } = res.data.properties;\n const link = new URL(\"/\", origin);\n link.searchParams.set(\"token_hash\", hashed_token);\n link.searchParams.set(\"type\", verification_type);\n return { success: true, link: link.toString(), isNewUser };\n };\n }\n\n return adapter;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -11,6 +11,14 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
|
|
11
11
|
interface SupabaseCredentials {
|
|
12
12
|
url: string;
|
|
13
13
|
anonKey: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional, and only for branded invite emails (see createInviteLink): it
|
|
16
|
+
* lets Guardrail create an account and mint a sign-in link server-side, so
|
|
17
|
+
* the site can send the email itself. Project-wide by Supabase's design —
|
|
18
|
+
* it bypasses RLS and reaches every schema — so read it from a server-only
|
|
19
|
+
* environment variable (never NEXT_PUBLIC_), and it is used for nothing else.
|
|
20
|
+
*/
|
|
21
|
+
serviceRoleKey?: string;
|
|
14
22
|
}
|
|
15
23
|
declare function assertCredentials(creds: SupabaseCredentials): void;
|
|
16
24
|
/**
|
|
@@ -24,5 +32,12 @@ declare function anonClient(creds: SupabaseCredentials): SupabaseClient;
|
|
|
24
32
|
* the signed-in user and RLS can enforce authorization independently.
|
|
25
33
|
*/
|
|
26
34
|
declare function sessionClient(creds: SupabaseCredentials): Promise<SupabaseClient>;
|
|
35
|
+
/**
|
|
36
|
+
* Service-role client, for the one admin call Guardrail makes with it
|
|
37
|
+
* (generating an invite link). Throws if no key was configured — callers are
|
|
38
|
+
* expected to have checked, since the adapter only exposes that capability when
|
|
39
|
+
* one was.
|
|
40
|
+
*/
|
|
41
|
+
declare function adminClient(creds: SupabaseCredentials): SupabaseClient;
|
|
27
42
|
|
|
28
|
-
export { type SupabaseCredentials, anonClient, assertCredentials, sessionClient };
|
|
43
|
+
export { type SupabaseCredentials, adminClient, anonClient, assertCredentials, sessionClient };
|
|
@@ -36,7 +36,16 @@ async function sessionClient(creds) {
|
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
function adminClient(creds) {
|
|
40
|
+
if (!creds.serviceRoleKey) {
|
|
41
|
+
throw new Error("Guardrail (Supabase): serviceRoleKey is not configured.");
|
|
42
|
+
}
|
|
43
|
+
return createClient(creds.url, creds.serviceRoleKey, {
|
|
44
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
39
47
|
export {
|
|
48
|
+
adminClient,
|
|
40
49
|
anonClient,
|
|
41
50
|
assertCredentials,
|
|
42
51
|
sessionClient
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n /**\n * Optional, and only for branded invite emails (see createInviteLink): it\n * lets Guardrail create an account and mint a sign-in link server-side, so\n * the site can send the email itself. Project-wide by Supabase's design —\n * it bypasses RLS and reaches every schema — so read it from a server-only\n * environment variable (never NEXT_PUBLIC_), and it is used for nothing else.\n */\n serviceRoleKey?: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n\n/**\n * Service-role client, for the one admin call Guardrail makes with it\n * (generating an invite link). Throws if no key was configured — callers are\n * expected to have checked, since the adapter only exposes that capability when\n * one was.\n */\nexport function adminClient(creds: SupabaseCredentials): SupabaseClient {\n if (!creds.serviceRoleKey) {\n throw new Error(\"Guardrail (Supabase): serviceRoleKey is not configured.\");\n }\n return createClient(creds.url, creds.serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAuBjB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AACF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO,OAAO;AAAA,UACtC;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAQO,SAAS,YAAY,OAA4C;AACtE,MAAI,CAAC,MAAM,gBAAgB;AACzB,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,SAAO,aAAa,MAAM,KAAK,MAAM,gBAAgB;AAAA,IACnD,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,EACzD,CAAC;AACH;","names":[]}
|
|
@@ -11,6 +11,11 @@ import '@supabase/supabase-js';
|
|
|
11
11
|
* verifyOtp with a token_hash works in whichever browser opens the link, unlike
|
|
12
12
|
* a PKCE code exchange. Once the session cookie is set, the next render claims
|
|
13
13
|
* any pending invitation addressed to that email.
|
|
14
|
+
*
|
|
15
|
+
* An invite, magic link, or recovery link also sets the same "needs a
|
|
16
|
+
* password" flag the in-page redirect handler does, so the person lands on the
|
|
17
|
+
* Set Password prompt rather than silently signed in with no way to log in
|
|
18
|
+
* next time — both ways of completing a link behave the same.
|
|
14
19
|
*/
|
|
15
20
|
declare function supabaseConfirmRoute(creds: SupabaseCredentials, options?: {
|
|
16
21
|
next?: string;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { cookies } from "next/headers";
|
|
1
2
|
import { redirect } from "next/navigation";
|
|
2
3
|
import { assertCredentials, sessionClient } from "./client";
|
|
4
|
+
import { needsPasswordFor } from "./auth";
|
|
5
|
+
import { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from "../../core/defs";
|
|
3
6
|
function safePath(value, fallback) {
|
|
4
7
|
if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
|
|
5
8
|
return value;
|
|
@@ -14,8 +17,16 @@ function supabaseConfirmRoute(creds, options = {}) {
|
|
|
14
17
|
const next = safePath(url.searchParams.get("next"), fallback);
|
|
15
18
|
if (tokenHash && type) {
|
|
16
19
|
const client = await sessionClient(creds);
|
|
17
|
-
const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
|
|
18
|
-
if (!error)
|
|
20
|
+
const { data, error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
|
|
21
|
+
if (!error) {
|
|
22
|
+
if (needsPasswordFor(type, data.user)) {
|
|
23
|
+
(await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, "true", {
|
|
24
|
+
path: "/",
|
|
25
|
+
maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
redirect(next);
|
|
29
|
+
}
|
|
19
30
|
console.error("Guardrail (Supabase): link verification failed", error.message);
|
|
20
31
|
}
|
|
21
32
|
redirect(`${fallback}${fallback.includes("?") ? "&" : "?"}gr_auth_error=link`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n return value;\n}\n\n/**\n * Route handler that turns an emailed link into a session.\n *\n * Mount it in the app:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n *\n * verifyOtp with a token_hash works in whichever browser opens the link, unlike\n * a PKCE code exchange. Once the session cookie is set, the next render claims\n * any pending invitation addressed to that email.\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\") as EmailOtpType | null;\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) redirect(next);\n console.error(\"Guardrail (Supabase): link verification failed\", error.message);\n }\n\n redirect(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`);\n };\n}\n"],"mappings":"AACA,SAAS,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { needsPasswordFor } from \"./auth\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../../core/defs\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n return value;\n}\n\n/**\n * Route handler that turns an emailed link into a session.\n *\n * Mount it in the app:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n *\n * verifyOtp with a token_hash works in whichever browser opens the link, unlike\n * a PKCE code exchange. Once the session cookie is set, the next render claims\n * any pending invitation addressed to that email.\n *\n * An invite, magic link, or recovery link also sets the same \"needs a\n * password\" flag the in-page redirect handler does, so the person lands on the\n * Set Password prompt rather than silently signed in with no way to log in\n * next time — both ways of completing a link behave the same.\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\") as EmailOtpType | null;\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) {\n if (needsPasswordFor(type, data.user)) {\n (await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n redirect(next);\n }\n console.error(\"Guardrail (Supabase): link verification failed\", error.message);\n }\n\n redirect(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`);\n };\n}\n"],"mappings":"AACA,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;AAEjD,SAAS,wBAAwB;AACjC,SAAS,YAAY,qCAAqC;AAM1D,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AACvE,SAAO;AACT;AAkBO,SAAS,qBACd,OACA,UAA6B,CAAC,GAC9B;AACA,oBAAkB,KAAK;AACvB,QAAM,WAAW,QAAQ,QAAQ;AAEjC,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,YAAY,IAAI,aAAa,IAAI,YAAY;AACnD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,UAAM,OAAO,SAAS,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ;AAE5D,QAAI,aAAa,MAAM;AACrB,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU,EAAE,MAAM,YAAY,UAAU,CAAC;AACnF,UAAI,CAAC,OAAO;AACV,YAAI,iBAAiB,MAAM,KAAK,IAAI,GAAG;AACrC,WAAC,MAAM,QAAQ,GAAG,IAAI,WAAW,gBAAgB,QAAQ;AAAA,YACvD,MAAM;AAAA,YACN,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AACA,iBAAS,IAAI;AAAA,MACf;AACA,cAAQ,MAAM,kDAAkD,MAAM,OAAO;AAAA,IAC/E;AAEA,aAAS,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,oBAAoB;AAAA,EAC/E;AACF;","names":[]}
|
|
@@ -89,6 +89,28 @@ interface GRAuthAdapter {
|
|
|
89
89
|
success: boolean;
|
|
90
90
|
error?: string;
|
|
91
91
|
}>;
|
|
92
|
+
/**
|
|
93
|
+
* Create the account and return a one-time sign-in link, without sending
|
|
94
|
+
* anything — for an app that delivers its own invitation email (see
|
|
95
|
+
* onInvite). Optional, and only worth implementing when the provider can
|
|
96
|
+
* mint a link server-side; Guardrail uses it only when the app also
|
|
97
|
+
* provides onInvite, and otherwise falls back to inviteUser.
|
|
98
|
+
*
|
|
99
|
+
* `origin` is the site's own origin, so the link points back at the site
|
|
100
|
+
* (where AuthRedirectSync completes it) rather than at the provider.
|
|
101
|
+
* `isNewUser` says whether this created an account or the address already
|
|
102
|
+
* had one — worth a different email ("set up" vs "sign in").
|
|
103
|
+
*/
|
|
104
|
+
createInviteLink?: (email: string, options: {
|
|
105
|
+
origin: string;
|
|
106
|
+
}) => Promise<{
|
|
107
|
+
success: true;
|
|
108
|
+
link: string;
|
|
109
|
+
isNewUser: boolean;
|
|
110
|
+
} | {
|
|
111
|
+
success: false;
|
|
112
|
+
error: string;
|
|
113
|
+
}>;
|
|
92
114
|
/**
|
|
93
115
|
* Return the current verified session, or null.
|
|
94
116
|
*
|
|
@@ -247,6 +269,15 @@ interface GRInviteEvent {
|
|
|
247
269
|
role: GRRole;
|
|
248
270
|
siteId: string;
|
|
249
271
|
invitedBy: string;
|
|
272
|
+
/**
|
|
273
|
+
* A one-time sign-in link for the invitee, present when the auth adapter can
|
|
274
|
+
* create one (see createInviteLink). Put it in the email. Absent for an
|
|
275
|
+
* adapter that can't, in which case delivering access is entirely the app's
|
|
276
|
+
* job.
|
|
277
|
+
*/
|
|
278
|
+
link?: string;
|
|
279
|
+
/** Whether the invite created an account (true) or the address already had one. Set alongside `link`. */
|
|
280
|
+
isNewUser?: boolean;
|
|
250
281
|
}
|
|
251
282
|
interface GRConfig {
|
|
252
283
|
db: GRDBAdapter;
|
|
@@ -262,6 +293,11 @@ interface GRConfig {
|
|
|
262
293
|
* membership; delivering the invitation is the app's job, using whatever
|
|
263
294
|
* mail provider it already has. In-process by design — a library running
|
|
264
295
|
* inside the developer's server has no need for webhooks.
|
|
296
|
+
*
|
|
297
|
+
* When the auth adapter can create sign-in links (createInviteLink), this is
|
|
298
|
+
* also how an app owns the email itself: the event carries `link`, and
|
|
299
|
+
* `resendInvite()` is a ready-made handler. Throwing reports the failure to
|
|
300
|
+
* the admin who sent the invite.
|
|
265
301
|
*/
|
|
266
302
|
onInvite?: (event: GRInviteEvent) => void | Promise<void>;
|
|
267
303
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { GRInviteEvent } from '../core/adapter-types.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
import '../core/types.js';
|
|
4
|
+
|
|
5
|
+
/** What gets sent: the pieces of one email. */
|
|
6
|
+
interface ResendInviteContent {
|
|
7
|
+
subject: string;
|
|
8
|
+
html: string;
|
|
9
|
+
/** Plain-text alternative. Worth providing — some clients and spam filters prefer it. */
|
|
10
|
+
text?: string;
|
|
11
|
+
}
|
|
12
|
+
/** What a custom template is given: the invite, plus the link and name to put in it. */
|
|
13
|
+
type ResendInviteTemplateEvent = GRInviteEvent & {
|
|
14
|
+
link: string;
|
|
15
|
+
siteName: string;
|
|
16
|
+
};
|
|
17
|
+
interface ResendInviteOptions {
|
|
18
|
+
/**
|
|
19
|
+
* "Name <hello@yourdomain.com>". The domain has to be verified in Resend, or
|
|
20
|
+
* Resend rejects the send.
|
|
21
|
+
*/
|
|
22
|
+
from: string;
|
|
23
|
+
/** Resend API key. Defaults to `process.env.RESEND_API_KEY`, read when an invite is sent. */
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
/** Named in the default email. Defaults to the site id. */
|
|
26
|
+
siteName?: string;
|
|
27
|
+
/** Replaces the default email entirely — your own subject, markup, brand. */
|
|
28
|
+
render?: (event: ResendInviteTemplateEvent) => ResendInviteContent | Promise<ResendInviteContent>;
|
|
29
|
+
/** Override the endpoint, e.g. for a proxy or a test server. */
|
|
30
|
+
apiUrl?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* An `onInvite` handler that emails the invitee through Resend.
|
|
34
|
+
*
|
|
35
|
+
* withGuardrail(Layout, {
|
|
36
|
+
* ...supabaseAdapters({ url, anonKey, serviceRoleKey }),
|
|
37
|
+
* onInvite: resendInvite({ from: "Acme <hello@acme.com>", siteName: "Acme" }),
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* Needs an auth adapter that can create sign-in links (the Supabase adapter
|
|
41
|
+
* can once it's given a serviceRoleKey); without a link there is nothing to
|
|
42
|
+
* send, and it throws a message saying so. Throwing is how failures reach the
|
|
43
|
+
* admin who clicked Invite — Resend rejecting the sender domain, say.
|
|
44
|
+
*
|
|
45
|
+
* Calls Resend's HTTP API directly, so there is no SDK to install. To send
|
|
46
|
+
* through anything else, write your own onInvite — the event carries the same
|
|
47
|
+
* `link`.
|
|
48
|
+
*/
|
|
49
|
+
declare function resendInvite(options: ResendInviteOptions): (event: GRInviteEvent) => Promise<void>;
|
|
50
|
+
|
|
51
|
+
export { type ResendInviteContent, type ResendInviteOptions, type ResendInviteTemplateEvent, resendInvite };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const RESEND_URL = "https://api.resend.com/emails";
|
|
2
|
+
function resendInvite(options) {
|
|
3
|
+
return async (event) => {
|
|
4
|
+
if (!event.link) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"resendInvite needs a sign-in link, but the auth adapter didn't provide one \u2014 pass serviceRoleKey to supabaseAdapters() so it can create them."
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
const apiKey = options.apiKey ?? process.env.RESEND_API_KEY;
|
|
10
|
+
if (!apiKey) throw new Error("RESEND_API_KEY is not set.");
|
|
11
|
+
const templateEvent = {
|
|
12
|
+
...event,
|
|
13
|
+
link: event.link,
|
|
14
|
+
siteName: options.siteName ?? event.siteId
|
|
15
|
+
};
|
|
16
|
+
const content = await (options.render ?? defaultInviteEmail)(templateEvent);
|
|
17
|
+
const res = await fetch(options.apiUrl ?? RESEND_URL, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${apiKey}`,
|
|
21
|
+
"Content-Type": "application/json"
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
from: options.from,
|
|
25
|
+
to: [event.email],
|
|
26
|
+
subject: content.subject,
|
|
27
|
+
html: content.html,
|
|
28
|
+
...content.text ? { text: content.text } : {}
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const detail = await res.json().then(
|
|
33
|
+
(body) => body?.message,
|
|
34
|
+
() => void 0
|
|
35
|
+
);
|
|
36
|
+
throw new Error(`Resend rejected the email (${res.status})${detail ? `: ${detail}` : "."}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function escapeHtml(value) {
|
|
41
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
42
|
+
}
|
|
43
|
+
function defaultInviteEmail(event) {
|
|
44
|
+
const site = event.siteName;
|
|
45
|
+
const who = event.invitedBy.includes("@") ? event.invitedBy : "An admin";
|
|
46
|
+
const intro = `${who} invited you to edit ${site} as an ${event.role}.`;
|
|
47
|
+
const action = event.isNewUser === false ? "Sign in" : "Set up your account";
|
|
48
|
+
const subject = event.isNewUser === false ? `Sign in to edit ${site}` : `You're invited to edit ${site}`;
|
|
49
|
+
const html = `<!doctype html>
|
|
50
|
+
<html>
|
|
51
|
+
<body style="margin:0;padding:24px;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111;">
|
|
52
|
+
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;">
|
|
53
|
+
<h1 style="margin:0 0 12px;font-size:20px;">${escapeHtml(subject)}</h1>
|
|
54
|
+
<p style="margin:0 0 24px;line-height:1.5;">${escapeHtml(intro)}</p>
|
|
55
|
+
<p style="margin:0 0 24px;">
|
|
56
|
+
<a href="${escapeHtml(event.link)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 20px;border-radius:6px;font-weight:600;">${escapeHtml(action)}</a>
|
|
57
|
+
</p>
|
|
58
|
+
<p style="margin:0;font-size:13px;line-height:1.5;color:#666;">This link works once and expires, so open it on the device you'll use. If you weren't expecting this, you can ignore the email.</p>
|
|
59
|
+
</div>
|
|
60
|
+
</body>
|
|
61
|
+
</html>`;
|
|
62
|
+
const text = `${subject}
|
|
63
|
+
|
|
64
|
+
${intro}
|
|
65
|
+
|
|
66
|
+
${action}: ${event.link}
|
|
67
|
+
|
|
68
|
+
This link works once and expires. If you weren't expecting this, you can ignore the email.
|
|
69
|
+
`;
|
|
70
|
+
return { subject, html, text };
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
resendInvite
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=resend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/email/resend.ts"],"sourcesContent":["import type { GRInviteEvent } from \"../core/adapter-types\";\n\n/** What gets sent: the pieces of one email. */\nexport interface ResendInviteContent {\n subject: string;\n html: string;\n /** Plain-text alternative. Worth providing — some clients and spam filters prefer it. */\n text?: string;\n}\n\n/** What a custom template is given: the invite, plus the link and name to put in it. */\nexport type ResendInviteTemplateEvent = GRInviteEvent & {\n link: string;\n siteName: string;\n};\n\nexport interface ResendInviteOptions {\n /**\n * \"Name <hello@yourdomain.com>\". The domain has to be verified in Resend, or\n * Resend rejects the send.\n */\n from: string;\n /** Resend API key. Defaults to `process.env.RESEND_API_KEY`, read when an invite is sent. */\n apiKey?: string;\n /** Named in the default email. Defaults to the site id. */\n siteName?: string;\n /** Replaces the default email entirely — your own subject, markup, brand. */\n render?: (event: ResendInviteTemplateEvent) => ResendInviteContent | Promise<ResendInviteContent>;\n /** Override the endpoint, e.g. for a proxy or a test server. */\n apiUrl?: string;\n}\n\nconst RESEND_URL = \"https://api.resend.com/emails\";\n\n/**\n * An `onInvite` handler that emails the invitee through Resend.\n *\n * withGuardrail(Layout, {\n * ...supabaseAdapters({ url, anonKey, serviceRoleKey }),\n * onInvite: resendInvite({ from: \"Acme <hello@acme.com>\", siteName: \"Acme\" }),\n * });\n *\n * Needs an auth adapter that can create sign-in links (the Supabase adapter\n * can once it's given a serviceRoleKey); without a link there is nothing to\n * send, and it throws a message saying so. Throwing is how failures reach the\n * admin who clicked Invite — Resend rejecting the sender domain, say.\n *\n * Calls Resend's HTTP API directly, so there is no SDK to install. To send\n * through anything else, write your own onInvite — the event carries the same\n * `link`.\n */\nexport function resendInvite(options: ResendInviteOptions) {\n return async (event: GRInviteEvent): Promise<void> => {\n if (!event.link) {\n throw new Error(\n \"resendInvite needs a sign-in link, but the auth adapter didn't provide one — \" +\n \"pass serviceRoleKey to supabaseAdapters() so it can create them.\",\n );\n }\n const apiKey = options.apiKey ?? process.env.RESEND_API_KEY;\n if (!apiKey) throw new Error(\"RESEND_API_KEY is not set.\");\n\n const templateEvent: ResendInviteTemplateEvent = {\n ...event,\n link: event.link,\n siteName: options.siteName ?? event.siteId,\n };\n const content = await (options.render ?? defaultInviteEmail)(templateEvent);\n\n const res = await fetch(options.apiUrl ?? RESEND_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n from: options.from,\n to: [event.email],\n subject: content.subject,\n html: content.html,\n ...(content.text ? { text: content.text } : {}),\n }),\n });\n\n if (!res.ok) {\n const detail = await res.json().then(\n (body: { message?: string }) => body?.message,\n () => undefined,\n );\n throw new Error(`Resend rejected the email (${res.status})${detail ? `: ${detail}` : \".\"}`);\n }\n };\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * The email used when no `render` is given: plain, one button, readable\n * everywhere. Interpolated values are escaped — the site name and inviter come\n * from config and from a person, and neither belongs in markup unescaped.\n */\nfunction defaultInviteEmail(event: ResendInviteTemplateEvent): ResendInviteContent {\n const site = event.siteName;\n const who = event.invitedBy.includes(\"@\") ? event.invitedBy : \"An admin\";\n const intro = `${who} invited you to edit ${site} as an ${event.role}.`;\n const action = event.isNewUser === false ? \"Sign in\" : \"Set up your account\";\n const subject = event.isNewUser === false ? `Sign in to edit ${site}` : `You're invited to edit ${site}`;\n\n const html = `<!doctype html>\n<html>\n <body style=\"margin:0;padding:24px;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111;\">\n <div style=\"max-width:480px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;\">\n <h1 style=\"margin:0 0 12px;font-size:20px;\">${escapeHtml(subject)}</h1>\n <p style=\"margin:0 0 24px;line-height:1.5;\">${escapeHtml(intro)}</p>\n <p style=\"margin:0 0 24px;\">\n <a href=\"${escapeHtml(event.link)}\" style=\"display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 20px;border-radius:6px;font-weight:600;\">${escapeHtml(action)}</a>\n </p>\n <p style=\"margin:0;font-size:13px;line-height:1.5;color:#666;\">This link works once and expires, so open it on the device you'll use. If you weren't expecting this, you can ignore the email.</p>\n </div>\n </body>\n</html>`;\n\n const text = `${subject}\\n\\n${intro}\\n\\n${action}: ${event.link}\\n\\nThis link works once and expires. If you weren't expecting this, you can ignore the email.\\n`;\n return { subject, html, text };\n}\n"],"mappings":"AAgCA,MAAM,aAAa;AAmBZ,SAAS,aAAa,SAA8B;AACzD,SAAO,OAAO,UAAwC;AACpD,QAAI,CAAC,MAAM,MAAM;AACf,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,UAAU,QAAQ,IAAI;AAC7C,QAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4BAA4B;AAEzD,UAAM,gBAA2C;AAAA,MAC/C,GAAG;AAAA,MACH,MAAM,MAAM;AAAA,MACZ,UAAU,QAAQ,YAAY,MAAM;AAAA,IACtC;AACA,UAAM,UAAU,OAAO,QAAQ,UAAU,oBAAoB,aAAa;AAE1E,UAAM,MAAM,MAAM,MAAM,QAAQ,UAAU,YAAY;AAAA,MACpD,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,eAAe,UAAU,MAAM;AAAA,QAC/B,gBAAgB;AAAA,MAClB;AAAA,MACA,MAAM,KAAK,UAAU;AAAA,QACnB,MAAM,QAAQ;AAAA,QACd,IAAI,CAAC,MAAM,KAAK;AAAA,QAChB,SAAS,QAAQ;AAAA,QACjB,MAAM,QAAQ;AAAA,QACd,GAAI,QAAQ,OAAO,EAAE,MAAM,QAAQ,KAAK,IAAI,CAAC;AAAA,MAC/C,CAAC;AAAA,IACH,CAAC;AAED,QAAI,CAAC,IAAI,IAAI;AACX,YAAM,SAAS,MAAM,IAAI,KAAK,EAAE;AAAA,QAC9B,CAAC,SAA+B,MAAM;AAAA,QACtC,MAAM;AAAA,MACR;AACA,YAAM,IAAI,MAAM,8BAA8B,IAAI,MAAM,IAAI,SAAS,KAAK,MAAM,KAAK,GAAG,EAAE;AAAA,IAC5F;AAAA,EACF;AACF;AAEA,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAOA,SAAS,mBAAmB,OAAuD;AACjF,QAAM,OAAO,MAAM;AACnB,QAAM,MAAM,MAAM,UAAU,SAAS,GAAG,IAAI,MAAM,YAAY;AAC9D,QAAM,QAAQ,GAAG,GAAG,wBAAwB,IAAI,UAAU,MAAM,IAAI;AACpE,QAAM,SAAS,MAAM,cAAc,QAAQ,YAAY;AACvD,QAAM,UAAU,MAAM,cAAc,QAAQ,mBAAmB,IAAI,KAAK,0BAA0B,IAAI;AAEtG,QAAM,OAAO;AAAA;AAAA;AAAA;AAAA,oDAIqC,WAAW,OAAO,CAAC;AAAA,oDACnB,WAAW,KAAK,CAAC;AAAA;AAAA,mBAElD,WAAW,MAAM,IAAI,CAAC,uIAAuI,WAAW,MAAM,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAOhM,QAAM,OAAO,GAAG,OAAO;AAAA;AAAA,EAAO,KAAK;AAAA;AAAA,EAAO,MAAM,KAAK,MAAM,IAAI;AAAA;AAAA;AAAA;AAC/D,SAAO,EAAE,SAAS,MAAM,KAAK;AAC/B;","names":[]}
|
package/dist/index.client.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { useGRail } from './react/Provider.js';
|
|
|
3
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
|
+
export { ResendInviteContent, ResendInviteOptions, ResendInviteTemplateEvent } from './email/resend.js';
|
|
6
7
|
export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
|
|
7
8
|
export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
|
|
8
9
|
import 'react/jsx-runtime';
|
|
@@ -17,5 +18,6 @@ declare const getFieldValue: (..._args: unknown[]) => never;
|
|
|
17
18
|
declare const isAdmin: (..._args: unknown[]) => never;
|
|
18
19
|
declare const isEditor: (..._args: unknown[]) => never;
|
|
19
20
|
declare const canEdit: (..._args: unknown[]) => never;
|
|
21
|
+
declare const resendInvite: (..._args: unknown[]) => never;
|
|
20
22
|
|
|
21
|
-
export { GRepeater, canEdit, configureGuardrail, getFieldValue, getRepeaterItemBySlug, isAdmin, isEditor, listRepeaterItems, withGuardrail };
|
|
23
|
+
export { GRepeater, canEdit, configureGuardrail, getFieldValue, getRepeaterItemBySlug, isAdmin, isEditor, listRepeaterItems, resendInvite, withGuardrail };
|
package/dist/index.client.js
CHANGED
|
@@ -59,6 +59,10 @@ const canEdit = serverOnly(
|
|
|
59
59
|
"canEdit()",
|
|
60
60
|
"Call it in a Server Component; in a Client Component, read `canEdit` from useGRail()."
|
|
61
61
|
);
|
|
62
|
+
const resendInvite = serverOnly(
|
|
63
|
+
"resendInvite()",
|
|
64
|
+
"Pass it as onInvite in withGuardrail(), which runs in your root layout."
|
|
65
|
+
);
|
|
62
66
|
export {
|
|
63
67
|
GRail,
|
|
64
68
|
GRepeater,
|
|
@@ -77,6 +81,7 @@ export {
|
|
|
77
81
|
removeMember,
|
|
78
82
|
removeRepeaterItem,
|
|
79
83
|
reorderRepeaterItems,
|
|
84
|
+
resendInvite,
|
|
80
85
|
setFieldValue,
|
|
81
86
|
setPasswordAction,
|
|
82
87
|
skipSetPasswordAction,
|
package/dist/index.client.js.map
CHANGED
|
@@ -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 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":[]}
|
|
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);\nexport const resendInvite = serverOnly(\n \"resendInvite()\",\n \"Pass it as onInvite in withGuardrail(), which runs in your root layout.\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n ResendInviteOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n} from \"./email/resend\";\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;AACO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { configureGuardrail } from './core/config.js';
|
|
|
6
6
|
export { getRepeaterItemBySlug, listRepeaterItems } from './server/repeaterData.js';
|
|
7
7
|
export { getFieldValue } from './server/fieldData.js';
|
|
8
8
|
export { canEdit, isAdmin, isEditor } from './server/access.js';
|
|
9
|
+
export { ResendInviteContent, ResendInviteOptions, ResendInviteTemplateEvent, resendInvite } from './email/resend.js';
|
|
9
10
|
export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
|
|
10
11
|
export { useGRail } from './react/Provider.js';
|
|
11
12
|
export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./server/repeaterData";
|
|
20
20
|
import { getFieldValue } from "./server/fieldData";
|
|
21
21
|
import { isAdmin, isEditor, canEdit } from "./server/access";
|
|
22
|
+
import { resendInvite } from "./email/resend";
|
|
22
23
|
import {
|
|
23
24
|
addRepeaterItem,
|
|
24
25
|
removeRepeaterItem,
|
|
@@ -43,6 +44,7 @@ export {
|
|
|
43
44
|
removeMember,
|
|
44
45
|
removeRepeaterItem,
|
|
45
46
|
reorderRepeaterItems,
|
|
47
|
+
resendInvite,
|
|
46
48
|
setFieldValue,
|
|
47
49
|
setPasswordAction,
|
|
48
50
|
skipSetPasswordAction,
|
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 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;
|
|
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// Invite delivery through Resend, as an onInvite handler — see email/resend.ts.\nexport { resendInvite } from \"./email/resend\";\nexport type {\n ResendInviteOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n} from \"./email/resend\";\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;AAE3C,SAAS,oBAAoB;AAQ7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment, jsx } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { listRepeaterItems } from "../../server/repeaterData";
|
|
4
|
-
import {
|
|
4
|
+
import { canEdit } from "../../server/access";
|
|
5
5
|
import { RepeaterClient } from "./client";
|
|
6
6
|
function itemHref(routed, slug) {
|
|
7
7
|
if (typeof routed !== "string" || !routed.startsWith("/") || !slug) return void 0;
|
|
@@ -14,8 +14,7 @@ async function GRepeater({ id, as: Item, routed, children }) {
|
|
|
14
14
|
);
|
|
15
15
|
}
|
|
16
16
|
const items = await listRepeaterItems(id);
|
|
17
|
-
|
|
18
|
-
if (viewer?.role) {
|
|
17
|
+
if (await canEdit()) {
|
|
19
18
|
const rendered = items.map((item) => ({
|
|
20
19
|
id: item.id,
|
|
21
20
|
href: itemHref(routed, item.slug),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/react/Repeater/index.tsx"],"sourcesContent":["import React from \"react\";\nimport { listRepeaterItems } from \"../../server/repeaterData\";\nimport {
|
|
1
|
+
{"version":3,"sources":["../../../src/react/Repeater/index.tsx"],"sourcesContent":["import React from \"react\";\nimport { listRepeaterItems } from \"../../server/repeaterData\";\nimport { canEdit } from \"../../server/access\";\nimport { RepeaterClient } from \"./client\";\nimport type { GRRepeaterItem } from \"../../core/types\";\n\nexport interface RepeaterItemProps {\n item: GRRepeaterItem;\n}\n\nexport interface GRepeaterProps {\n /** The repeater's own key. Item fields are namespaced under it: `${id}.${item.id}.*`. */\n id: string;\n /** The developer's own component — the one place the item's shape is defined. */\n as: React.ComponentType<RepeaterItemProps>;\n /**\n * Marks this repeater as routed: each item gets its own URL (a product, a\n * post), so adding one prompts for a slug. Omit for a plain row of items on\n * one page (service cards) — those need no slug at all.\n *\n * Pass the base path your dynamic route lives at (`routed=\"/products\"` for\n * `app/products/[slug]/page.tsx`) and each item's editing toolbar gets an\n * Open button to its page — without it, an editor could only reach an item's\n * page by typing the URL, since clicking its fields edits them. Guardrail\n * never owns routing, so it can't work that path out for itself; `true`\n * works as before, just without the button.\n */\n routed?: boolean | string;\n /** Shown in place of the list when it has no items yet. */\n children?: React.ReactNode;\n}\n\n/** `/products` + `wool-throw` -> `/products/wool-throw`; undefined when the path can't be built. */\nfunction itemHref(routed: GRepeaterProps[\"routed\"], slug?: string): string | undefined {\n if (typeof routed !== \"string\" || !routed.startsWith(\"/\") || !slug) return undefined;\n return `${routed.replace(/\\/+$/, \"\")}/${slug}`;\n}\n\n/**\n * Renders N instances of one developer-authored template component, with the\n * marketer controlling how many and in what order — not what shape they take.\n * That's the whole primitive: no schema, no field-type registry, no relations.\n * An item's own fields are just <GRail> fields living under the item's id,\n * identical in kind to every other field on the page.\n *\n * Server Component by default, same split as <GRail>: anonymous visitors get\n * the template rendered N times with no admin JS; the add/remove/reorder\n * chrome only loads for someone who can actually write.\n */\nexport async function GRepeater({ id, as: Item, routed, children }: GRepeaterProps) {\n if (typeof routed === \"string\" && !routed.startsWith(\"/\")) {\n console.warn(\n `Guardrail: <GRepeater id=\"${id}\" routed=\"${routed}\"> — the base path must start with \"/\" (e.g. \"/products\"); no Open button will be shown.`,\n );\n }\n const items = await listRepeaterItems(id);\n\n // canEdit(), not just \"has a role\": an editor who has exited edit mode is\n // still signed in, and must get the plain list a visitor sees — no\n // add/remove/reorder chrome, no admin JS — until they re-enter edit mode.\n if (await canEdit()) {\n // Rendered here, on the server, and handed down as already-resolved\n // elements — never as the Item component itself. Item almost certainly\n // renders <GRail>, an async Server Component; passing the function across\n // the client boundary would let RepeaterClient try to invoke it in the\n // browser, which cannot execute a Server Component at all. An element\n // (the *result* of calling it) is plain serializable RSC content and\n // composes into a Client Component exactly like `children` does.\n const rendered = items.map((item) => ({\n id: item.id,\n href: itemHref(routed, item.slug),\n node: <Item key={item.id} item={item} />,\n }));\n\n return (\n <RepeaterClient\n repeaterId={id}\n routed={routed !== undefined && routed !== false}\n items={rendered}\n >\n {children}\n </RepeaterClient>\n );\n }\n\n if (items.length === 0) return <>{children ?? null}</>;\n return (\n <>\n {items.map((item) => (\n <Item key={item.id} item={item} />\n ))}\n </>\n );\n}\n"],"mappings":"AAuEY,SAcqB,UAdrB;AAvEZ,OAAO,WAAW;AAClB,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB,SAAS,sBAAsB;AA8B/B,SAAS,SAAS,QAAkC,MAAmC;AACrF,MAAI,OAAO,WAAW,YAAY,CAAC,OAAO,WAAW,GAAG,KAAK,CAAC,KAAM,QAAO;AAC3E,SAAO,GAAG,OAAO,QAAQ,QAAQ,EAAE,CAAC,IAAI,IAAI;AAC9C;AAaA,eAAsB,UAAU,EAAE,IAAI,IAAI,MAAM,QAAQ,SAAS,GAAmB;AAClF,MAAI,OAAO,WAAW,YAAY,CAAC,OAAO,WAAW,GAAG,GAAG;AACzD,YAAQ;AAAA,MACN,6BAA6B,EAAE,aAAa,MAAM;AAAA,IACpD;AAAA,EACF;AACA,QAAM,QAAQ,MAAM,kBAAkB,EAAE;AAKxC,MAAI,MAAM,QAAQ,GAAG;AAQnB,UAAM,WAAW,MAAM,IAAI,CAAC,UAAU;AAAA,MACpC,IAAI,KAAK;AAAA,MACT,MAAM,SAAS,QAAQ,KAAK,IAAI;AAAA,MAChC,MAAM,oBAAC,QAAmB,QAAT,KAAK,EAAgB;AAAA,IACxC,EAAE;AAEF,WACE;AAAA,MAAC;AAAA;AAAA,QACC,YAAY;AAAA,QACZ,QAAQ,WAAW,UAAa,WAAW;AAAA,QAC3C,OAAO;AAAA,QAEN;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,MAAI,MAAM,WAAW,EAAG,QAAO,gCAAG,sBAAY,MAAK;AACnD,SACE,gCACG,gBAAM,IAAI,CAAC,SACV,oBAAC,QAAmB,QAAT,KAAK,EAAgB,CACjC,GACH;AAEJ;","names":[]}
|
package/dist/server/actions.js
CHANGED
|
@@ -110,6 +110,37 @@ async function inviteMember(email, role) {
|
|
|
110
110
|
if (!res.success) return res;
|
|
111
111
|
}
|
|
112
112
|
const auth = getAuth();
|
|
113
|
+
const onInvite = getInviteHandler();
|
|
114
|
+
const invitedBy = viewer.email ?? viewer.userId;
|
|
115
|
+
if (auth.createInviteLink && onInvite) {
|
|
116
|
+
const made = await auth.createInviteLink(normalized, { origin: await requestOrigin() });
|
|
117
|
+
if (!made.success) {
|
|
118
|
+
revalidatePath("/", "layout");
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
error: `Invite saved, but the sign-in link couldn't be created: ${made.error}`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await onInvite({
|
|
126
|
+
email: normalized,
|
|
127
|
+
role: existing?.role ?? role,
|
|
128
|
+
siteId,
|
|
129
|
+
invitedBy,
|
|
130
|
+
link: made.link,
|
|
131
|
+
isNewUser: made.isNewUser
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error("Guardrail: onInvite handler threw", e);
|
|
135
|
+
revalidatePath("/", "layout");
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
error: `Invite saved, but the email failed: ${e instanceof Error ? e.message : "unknown error"}`
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
revalidatePath("/", "layout");
|
|
142
|
+
return { success: true };
|
|
143
|
+
}
|
|
113
144
|
if (auth.inviteUser) {
|
|
114
145
|
const sent = await auth.inviteUser(normalized, {
|
|
115
146
|
redirectTo: `${await requestOrigin()}/?edit=true`
|
|
@@ -121,11 +152,11 @@ async function inviteMember(email, role) {
|
|
|
121
152
|
};
|
|
122
153
|
}
|
|
123
154
|
try {
|
|
124
|
-
await
|
|
155
|
+
await onInvite?.({
|
|
125
156
|
email: normalized,
|
|
126
157
|
role: existing?.role ?? role,
|
|
127
158
|
siteId,
|
|
128
|
-
invitedBy
|
|
159
|
+
invitedBy
|
|
129
160
|
});
|
|
130
161
|
} catch (e) {
|
|
131
162
|
console.error("Guardrail: onInvite handler threw", e);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n OverrideRow,\n OverrideType,\n OverrideValue,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n// Every mutation below re-resolves the viewer server-side through\n// requireRole() (see access.ts) — the client's copy of the role decides what\n// to *show*, never what is *allowed*.\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n return writeOverride(row.pathKey, row.value);\n}\n\nconst OVERRIDE_TYPES: readonly OverrideType[] = [\"text\", \"link\", \"image\", \"button\"];\n\n/**\n * Writes one field's value by id, with no <GRail> involved — the setter that\n * pairs with getFieldValue(). It exists for fields with nothing on the page to\n * click: an SEO title or meta description, an og:image, a product's stock\n * note. Build a drawer or form of your own, and have it (or a Server Action of\n * yours) call this.\n *\n * A bare string is shorthand for a text field. Pass an OverrideValue for the\n * other types ({ type: \"image\", src, alt }, a link, a button).\n *\n * Does its own role check — an editor or admin — so it is safe to call\n * directly from a Client Component, the same as saveOverride. If your own\n * action does other work first, check isEditor() there too.\n *\n * Writes the same row a <GRail> with this id would, so the two stay\n * interchangeable: a field first written here can later be given a visible\n * <GRail>, and vice versa.\n */\nexport async function setFieldValue(\n id: string,\n value: string | OverrideValue,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const pathKey = id.trim();\n if (!pathKey) return { success: false, error: \"A field id is required.\" };\n\n const normalized: OverrideValue =\n typeof value === \"string\" ? { type: \"text\", text: value } : value;\n if (!OVERRIDE_TYPES.includes(normalized.type)) {\n return { success: false, error: `Unknown field type \"${normalized.type}\".` };\n }\n\n return writeOverride(pathKey, normalized);\n}\n\n/** The write shared by saveOverride and setFieldValue; the caller has already authorized. */\nasync function writeOverride(\n pathKey: string,\n value: OverrideValue,\n): Promise<Result> {\n const rowToSend: GRStorageRow = {\n pathKey,\n siteId: await resolveSiteId(),\n type: value.type,\n value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = email.toLowerCase().trim();\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n if (auth.inviteUser) {\n // The membership is already written; a failed send must not undo it.\n const sent = await auth.inviteUser(normalized, {\n redirectTo: `${await requestOrigin()}/?edit=true`,\n });\n revalidatePath(\"/\", \"layout\");\n return sent.success\n ? { success: true }\n : {\n success: false,\n error: `Invite saved, but the email failed: ${sent.error}`,\n };\n }\n\n // No provider-side delivery, so hand off to the app.\n try {\n await getInviteHandler()?.({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy: viewer.email ?? viewer.userId,\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, {\n email: email.toLowerCase().trim(),\n });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n const normalized = email.toLowerCase().trim();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nasync function requestOrigin(): Promise<string> {\n const h = await headers();\n const origin = h.get(\"origin\");\n if (origin) return origin;\n const host = h.get(\"x-forwarded-host\") ?? h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return `${proto}://${host}`;\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite, magic-link, or\n * password-recovery redirect. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n\n const res = await auth.completeAuthRedirect(params);\n if (res.success) {\n if (res.needsPassword) {\n const cookieStore = await cookies();\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/** Dismisses the set-password prompt without setting one — see Admin.tsx. */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n OverrideRow,\n OverrideType,\n OverrideValue,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n// Every mutation below re-resolves the viewer server-side through\n// requireRole() (see access.ts) — the client's copy of the role decides what\n// to *show*, never what is *allowed*.\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n return writeOverride(row.pathKey, row.value);\n}\n\nconst OVERRIDE_TYPES: readonly OverrideType[] = [\"text\", \"link\", \"image\", \"button\"];\n\n/**\n * Writes one field's value by id, with no <GRail> involved — the setter that\n * pairs with getFieldValue(). It exists for fields with nothing on the page to\n * click: an SEO title or meta description, an og:image, a product's stock\n * note. Build a drawer or form of your own, and have it (or a Server Action of\n * yours) call this.\n *\n * A bare string is shorthand for a text field. Pass an OverrideValue for the\n * other types ({ type: \"image\", src, alt }, a link, a button).\n *\n * Does its own role check — an editor or admin — so it is safe to call\n * directly from a Client Component, the same as saveOverride. If your own\n * action does other work first, check isEditor() there too.\n *\n * Writes the same row a <GRail> with this id would, so the two stay\n * interchangeable: a field first written here can later be given a visible\n * <GRail>, and vice versa.\n */\nexport async function setFieldValue(\n id: string,\n value: string | OverrideValue,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const pathKey = id.trim();\n if (!pathKey) return { success: false, error: \"A field id is required.\" };\n\n const normalized: OverrideValue =\n typeof value === \"string\" ? { type: \"text\", text: value } : value;\n if (!OVERRIDE_TYPES.includes(normalized.type)) {\n return { success: false, error: `Unknown field type \"${normalized.type}\".` };\n }\n\n return writeOverride(pathKey, normalized);\n}\n\n/** The write shared by saveOverride and setFieldValue; the caller has already authorized. */\nasync function writeOverride(\n pathKey: string,\n value: OverrideValue,\n): Promise<Result> {\n const rowToSend: GRStorageRow = {\n pathKey,\n siteId: await resolveSiteId(),\n type: value.type,\n value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = email.toLowerCase().trim();\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n const onInvite = getInviteHandler();\n const invitedBy = viewer.email ?? viewer.userId;\n\n // The app owns delivery: the adapter creates the account and mints a link,\n // and the app's own handler sends it in its own email. Opt-in on both sides\n // — an adapter that can mint links AND an onInvite handler — so a site that\n // configured neither keeps getting the provider's email exactly as before.\n if (auth.createInviteLink && onInvite) {\n // As elsewhere, the membership is already written; a failure here must not undo it.\n const made = await auth.createInviteLink(normalized, { origin: await requestOrigin() });\n if (!made.success) {\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the sign-in link couldn't be created: ${made.error}`,\n };\n }\n try {\n await onInvite({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy,\n link: made.link,\n isNewUser: made.isNewUser,\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the email failed: ${e instanceof Error ? e.message : \"unknown error\"}`,\n };\n }\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n }\n\n if (auth.inviteUser) {\n // The membership is already written; a failed send must not undo it.\n const sent = await auth.inviteUser(normalized, {\n redirectTo: `${await requestOrigin()}/?edit=true`,\n });\n revalidatePath(\"/\", \"layout\");\n return sent.success\n ? { success: true }\n : {\n success: false,\n error: `Invite saved, but the email failed: ${sent.error}`,\n };\n }\n\n // No provider-side delivery, so hand off to the app.\n try {\n await onInvite?.({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy,\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, {\n email: email.toLowerCase().trim(),\n });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n const normalized = email.toLowerCase().trim();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nasync function requestOrigin(): Promise<string> {\n const h = await headers();\n const origin = h.get(\"origin\");\n if (origin) return origin;\n const host = h.get(\"x-forwarded-host\") ?? h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return `${proto}://${host}`;\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite, magic-link, or\n * password-recovery redirect. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n\n const res = await auth.completeAuthRedirect(params);\n if (res.success) {\n if (res.needsPassword) {\n const cookieStore = await cookies();\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/** Dismisses the set-password prompt without setting one — see Admin.tsx. */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
|