@dyrected/core 2.19.0 → 2.20.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/dist/{app-CXF4MdJc.d.cts → app-v23fNf58.d.cts} +171 -28
- package/dist/{app-CXF4MdJc.d.ts → app-v23fNf58.d.ts} +171 -28
- package/dist/index.cjs +511 -21
- package/dist/index.d.cts +73 -5
- package/dist/index.d.ts +73 -5
- package/dist/index.js +491 -21
- package/dist/server.cjs +1167 -136
- package/dist/server.d.cts +79 -29
- package/dist/server.d.ts +79 -29
- package/dist/server.js +1158 -136
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -3061,7 +3061,7 @@ function assertValidTrashInConfig(config, source = "config") {
|
|
|
3061
3061
|
if (col.slug === "trash") {
|
|
3062
3062
|
throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
|
|
3063
3063
|
}
|
|
3064
|
-
const isSystem = col.slug
|
|
3064
|
+
const isSystem = INTERNAL_SYSTEM_COLLECTIONS.has(col.slug);
|
|
3065
3065
|
if (isSystem && col.trash) {
|
|
3066
3066
|
throw new Error(`System collection "${col.slug}" cannot enable trash.`);
|
|
3067
3067
|
}
|
|
@@ -3168,12 +3168,20 @@ async function restoreDocument(options) {
|
|
|
3168
3168
|
}
|
|
3169
3169
|
return { restoredDoc: docToRestore };
|
|
3170
3170
|
}
|
|
3171
|
-
var TRASH_COLLECTION, TRASH_COLLECTION_CONFIG;
|
|
3171
|
+
var TRASH_COLLECTION, INTERNAL_SYSTEM_COLLECTIONS, TRASH_COLLECTION_CONFIG;
|
|
3172
3172
|
var init_trash = __esm({
|
|
3173
3173
|
"src/trash.ts"() {
|
|
3174
3174
|
"use strict";
|
|
3175
3175
|
init_hooks();
|
|
3176
3176
|
TRASH_COLLECTION = "__trash";
|
|
3177
|
+
INTERNAL_SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
3178
|
+
"__audit",
|
|
3179
|
+
"__sessions",
|
|
3180
|
+
"__task_locks",
|
|
3181
|
+
"__trash",
|
|
3182
|
+
"__workflow_history",
|
|
3183
|
+
"__lifecycle_events"
|
|
3184
|
+
]);
|
|
3177
3185
|
TRASH_COLLECTION_CONFIG = {
|
|
3178
3186
|
slug: TRASH_COLLECTION,
|
|
3179
3187
|
labels: { singular: "Trash entry", plural: "Trash entries" },
|
|
@@ -6236,13 +6244,27 @@ async function hashPassword(plain) {
|
|
|
6236
6244
|
const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
|
|
6237
6245
|
return `${salt}:${derivedKey.toString("hex")}`;
|
|
6238
6246
|
}
|
|
6247
|
+
function hasUsablePassword(stored) {
|
|
6248
|
+
if (typeof stored !== "string" || !stored) return false;
|
|
6249
|
+
const parts = stored.split(":");
|
|
6250
|
+
return parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]);
|
|
6251
|
+
}
|
|
6239
6252
|
async function verifyPassword(plain, stored) {
|
|
6240
|
-
|
|
6253
|
+
if (typeof plain !== "string" || !plain || typeof stored !== "string" || !stored) {
|
|
6254
|
+
return false;
|
|
6255
|
+
}
|
|
6256
|
+
const parts = stored.split(":");
|
|
6257
|
+
if (parts.length !== 2) return false;
|
|
6258
|
+
const [salt, storedHash] = parts;
|
|
6241
6259
|
if (!salt || !storedHash) return false;
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6260
|
+
try {
|
|
6261
|
+
const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
|
|
6262
|
+
const storedBuffer = Buffer.from(storedHash, "hex");
|
|
6263
|
+
if (derivedKey.length !== storedBuffer.length) return false;
|
|
6264
|
+
return timingSafeEqual(derivedKey, storedBuffer);
|
|
6265
|
+
} catch {
|
|
6266
|
+
return false;
|
|
6267
|
+
}
|
|
6246
6268
|
}
|
|
6247
6269
|
|
|
6248
6270
|
// src/auth/token.ts
|
|
@@ -6258,6 +6280,8 @@ function getSecret() {
|
|
|
6258
6280
|
}
|
|
6259
6281
|
var DEFAULT_EXPIRY = "7d";
|
|
6260
6282
|
var DEFAULT_SESSION_EXPIRY = DEFAULT_EXPIRY;
|
|
6283
|
+
var DEFAULT_INVITE_EXPIRY = "48h";
|
|
6284
|
+
var DEFAULT_RESET_PASSWORD_EXPIRY = "2h";
|
|
6261
6285
|
function resolveSessionTokenExpiry(collection) {
|
|
6262
6286
|
const auth = collection.auth;
|
|
6263
6287
|
if (auth && typeof auth === "object" && typeof auth.tokenExpiration === "string" && auth.tokenExpiration.length > 0) {
|
|
@@ -6265,6 +6289,20 @@ function resolveSessionTokenExpiry(collection) {
|
|
|
6265
6289
|
}
|
|
6266
6290
|
return DEFAULT_SESSION_EXPIRY;
|
|
6267
6291
|
}
|
|
6292
|
+
function resolveInviteTokenExpiry(collection) {
|
|
6293
|
+
const auth = collection.auth;
|
|
6294
|
+
if (auth && typeof auth === "object" && typeof auth.inviteExpiration === "string" && auth.inviteExpiration.length > 0) {
|
|
6295
|
+
return auth.inviteExpiration;
|
|
6296
|
+
}
|
|
6297
|
+
return DEFAULT_INVITE_EXPIRY;
|
|
6298
|
+
}
|
|
6299
|
+
function resolveResetPasswordTokenExpiry(collection) {
|
|
6300
|
+
const auth = collection.auth;
|
|
6301
|
+
if (auth && typeof auth === "object" && typeof auth.resetPasswordExpiration === "string" && auth.resetPasswordExpiration.length > 0) {
|
|
6302
|
+
return auth.resetPasswordExpiration;
|
|
6303
|
+
}
|
|
6304
|
+
return DEFAULT_RESET_PASSWORD_EXPIRY;
|
|
6305
|
+
}
|
|
6268
6306
|
async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
|
|
6269
6307
|
return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
|
|
6270
6308
|
}
|
|
@@ -6569,13 +6607,34 @@ function getAdminRoleForCollection(collection) {
|
|
|
6569
6607
|
}
|
|
6570
6608
|
return "admin";
|
|
6571
6609
|
}
|
|
6610
|
+
var STANDARD_ADMIN_ROLES = /* @__PURE__ */ new Set([
|
|
6611
|
+
"admin",
|
|
6612
|
+
"super_admin",
|
|
6613
|
+
"superadmin",
|
|
6614
|
+
"super admin",
|
|
6615
|
+
"super-admin",
|
|
6616
|
+
"owner"
|
|
6617
|
+
]);
|
|
6572
6618
|
function isUserAdmin(user, collection) {
|
|
6573
6619
|
if (!user) return false;
|
|
6574
|
-
const
|
|
6575
|
-
if (
|
|
6620
|
+
const rawRoles = Array.isArray(user.roles) ? user.roles : typeof user.roles === "string" ? [user.roles] : Array.isArray(user.role) ? user.role : typeof user.role === "string" ? [user.role] : [];
|
|
6621
|
+
if (rawRoles.length === 0) return false;
|
|
6622
|
+
const roles = rawRoles.filter((r) => typeof r === "string");
|
|
6576
6623
|
const configuredRole = getAdminRoleForCollection(collection);
|
|
6577
|
-
|
|
6578
|
-
|
|
6624
|
+
for (const r of roles) {
|
|
6625
|
+
if (r === configuredRole || r.toLowerCase() === configuredRole.toLowerCase()) {
|
|
6626
|
+
return true;
|
|
6627
|
+
}
|
|
6628
|
+
const normalized = r.trim().toLowerCase();
|
|
6629
|
+
if (STANDARD_ADMIN_ROLES.has(normalized)) {
|
|
6630
|
+
return true;
|
|
6631
|
+
}
|
|
6632
|
+
const stripped = normalized.replace(/[\s_-]+/g, "");
|
|
6633
|
+
if (stripped === "admin" || stripped === "superadmin" || stripped === "owner") {
|
|
6634
|
+
return true;
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6637
|
+
return false;
|
|
6579
6638
|
}
|
|
6580
6639
|
|
|
6581
6640
|
// src/utils/collection-search.ts
|
|
@@ -7224,7 +7283,7 @@ function compileNavigation(config) {
|
|
|
7224
7283
|
const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
|
|
7225
7284
|
if (!mentionedTrash) {
|
|
7226
7285
|
const hasTrash = collections.some(
|
|
7227
|
-
(col) => !col.slug
|
|
7286
|
+
(col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
|
|
7228
7287
|
);
|
|
7229
7288
|
if (hasTrash) {
|
|
7230
7289
|
registerItem(void 0, {
|
|
@@ -11383,9 +11442,16 @@ function resolveAdminUrl(c, config, clientUrl) {
|
|
|
11383
11442
|
function appendQueryParam(url, key, value) {
|
|
11384
11443
|
return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
|
|
11385
11444
|
}
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
|
|
11445
|
+
function resolveActionUrl(c, config, customUrl) {
|
|
11446
|
+
if (customUrl && typeof customUrl === "string" && customUrl.trim().length > 0) {
|
|
11447
|
+
try {
|
|
11448
|
+
return new URL(customUrl.trim(), c.req.url).toString();
|
|
11449
|
+
} catch {
|
|
11450
|
+
return customUrl.trim();
|
|
11451
|
+
}
|
|
11452
|
+
}
|
|
11453
|
+
return resolveAdminUrl(c, config);
|
|
11454
|
+
}
|
|
11389
11455
|
|
|
11390
11456
|
// src/auth/lockout.ts
|
|
11391
11457
|
var DEFAULT_MAX_LOGIN_ATTEMPTS = 5;
|
|
@@ -11442,15 +11508,21 @@ var emailTokens = {
|
|
|
11442
11508
|
width: "600px"
|
|
11443
11509
|
};
|
|
11444
11510
|
function escapeHtml(value) {
|
|
11445
|
-
return value.replace(
|
|
11446
|
-
"
|
|
11447
|
-
|
|
11448
|
-
|
|
11449
|
-
|
|
11450
|
-
|
|
11451
|
-
|
|
11511
|
+
return value.replace(
|
|
11512
|
+
/[&<>'"]/g,
|
|
11513
|
+
(character) => ({
|
|
11514
|
+
"&": "&",
|
|
11515
|
+
"<": "<",
|
|
11516
|
+
">": ">",
|
|
11517
|
+
"'": "'",
|
|
11518
|
+
'"': """
|
|
11519
|
+
})[character]
|
|
11520
|
+
);
|
|
11452
11521
|
}
|
|
11453
11522
|
function safeHttpUrl(value) {
|
|
11523
|
+
if (value.startsWith("{{") && value.endsWith("}}")) {
|
|
11524
|
+
return value;
|
|
11525
|
+
}
|
|
11454
11526
|
try {
|
|
11455
11527
|
const url = new URL(value);
|
|
11456
11528
|
return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
|
|
@@ -11461,8 +11533,8 @@ function safeHttpUrl(value) {
|
|
|
11461
11533
|
function heading(content) {
|
|
11462
11534
|
return `<h1 style="margin:0;font-family:${emailTokens.font};font-size:24px;line-height:1.25;font-weight:700;color:${emailTokens.colors.text}">${escapeHtml(content)}</h1>`;
|
|
11463
11535
|
}
|
|
11464
|
-
function paragraph(content, margin = "0 0 16px") {
|
|
11465
|
-
return `<p style="margin:${margin};font-family:${emailTokens.font};font-size
|
|
11536
|
+
function paragraph(content, margin = "0 0 16px", size = "15px") {
|
|
11537
|
+
return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:${size};line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
|
|
11466
11538
|
}
|
|
11467
11539
|
function sectionLabel(content) {
|
|
11468
11540
|
return `<p style="margin:0 0 8px;font-family:${emailTokens.font};font-size:11px;line-height:1.4;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:${emailTokens.colors.subtle}">${escapeHtml(content)}</p>`;
|
|
@@ -11492,10 +11564,13 @@ function detailBox(content, monospace = false) {
|
|
|
11492
11564
|
function ctaButton(label, href) {
|
|
11493
11565
|
const safeHref = safeHttpUrl(href);
|
|
11494
11566
|
if (!safeHref) return "";
|
|
11495
|
-
return table(
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
11567
|
+
return table(
|
|
11568
|
+
row(
|
|
11569
|
+
`<a href="${safeHref}" style="display:inline-block;padding:13px 22px;border-radius:${emailTokens.radius.control};background:${emailTokens.colors.accent};font-family:${emailTokens.font};font-size:14px;line-height:1.2;font-weight:700;color:${emailTokens.colors.text};text-decoration:none">${escapeHtml(label)}</a>`,
|
|
11570
|
+
"padding:8px 0 24px"
|
|
11571
|
+
),
|
|
11572
|
+
"width:auto"
|
|
11573
|
+
);
|
|
11499
11574
|
}
|
|
11500
11575
|
function safeLinkDetailBox(href) {
|
|
11501
11576
|
const safeHref = safeHttpUrl(href);
|
|
@@ -11503,9 +11578,33 @@ function safeLinkDetailBox(href) {
|
|
|
11503
11578
|
return detailBox(safeHref, true);
|
|
11504
11579
|
}
|
|
11505
11580
|
function alertBox(content) {
|
|
11506
|
-
return table(
|
|
11581
|
+
return table(
|
|
11582
|
+
row(
|
|
11583
|
+
escapeHtml(content),
|
|
11584
|
+
`padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`
|
|
11585
|
+
),
|
|
11586
|
+
`width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`
|
|
11587
|
+
);
|
|
11507
11588
|
}
|
|
11508
|
-
function
|
|
11589
|
+
function resolveBlocks(input) {
|
|
11590
|
+
if (Array.isArray(input)) {
|
|
11591
|
+
const parts = [];
|
|
11592
|
+
for (const item of input) {
|
|
11593
|
+
if (Array.isArray(item)) {
|
|
11594
|
+
for (const sub of item) {
|
|
11595
|
+
if (typeof sub === "string" && sub.length > 0) parts.push(sub);
|
|
11596
|
+
}
|
|
11597
|
+
} else if (typeof item === "string" && item.length > 0) {
|
|
11598
|
+
parts.push(item);
|
|
11599
|
+
}
|
|
11600
|
+
}
|
|
11601
|
+
return parts.join("\n");
|
|
11602
|
+
}
|
|
11603
|
+
return typeof input === "string" ? input : "";
|
|
11604
|
+
}
|
|
11605
|
+
function layout({ preheader, title, content, footer, siteName }) {
|
|
11606
|
+
const resolvedContent = resolveBlocks(content);
|
|
11607
|
+
const resolvedFooter = resolveBlocks(footer);
|
|
11509
11608
|
return `<!doctype html>
|
|
11510
11609
|
<html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
|
|
11511
11610
|
<head>
|
|
@@ -11526,8 +11625,8 @@ function layout({ preheader, title, content, footer }) {
|
|
|
11526
11625
|
${table(
|
|
11527
11626
|
row(
|
|
11528
11627
|
table(
|
|
11529
|
-
row(" ", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel("Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(
|
|
11530
|
-
`${divider()}${paragraph(
|
|
11628
|
+
row(" ", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel(siteName ?? "Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(resolvedContent, "padding:0 32px 32px") + row(
|
|
11629
|
+
`${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
|
|
11531
11630
|
"padding:0 32px 28px"
|
|
11532
11631
|
),
|
|
11533
11632
|
`width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
|
|
@@ -11540,6 +11639,81 @@ function layout({ preheader, title, content, footer }) {
|
|
|
11540
11639
|
</body>
|
|
11541
11640
|
</html>`;
|
|
11542
11641
|
}
|
|
11642
|
+
function getDefaultEmailTemplate(purpose, options) {
|
|
11643
|
+
const siteName = options?.siteName ?? "Dyrected";
|
|
11644
|
+
switch (purpose) {
|
|
11645
|
+
case "invite":
|
|
11646
|
+
return {
|
|
11647
|
+
subject: `You've been invited to ${siteName}`,
|
|
11648
|
+
rawHtml: layout({
|
|
11649
|
+
siteName,
|
|
11650
|
+
preheader: `You've been invited to join ${siteName}.`,
|
|
11651
|
+
title: "You've been invited",
|
|
11652
|
+
content: [
|
|
11653
|
+
paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
|
|
11654
|
+
ctaButton("Accept invitation", "{{url}}"),
|
|
11655
|
+
sectionLabel("Invitation link"),
|
|
11656
|
+
safeLinkDetailBox("{{url}}"),
|
|
11657
|
+
paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
|
|
11658
|
+
],
|
|
11659
|
+
footer: "If you weren't expecting this invitation, you can safely ignore this email."
|
|
11660
|
+
})
|
|
11661
|
+
};
|
|
11662
|
+
case "resetPassword":
|
|
11663
|
+
return {
|
|
11664
|
+
subject: "Reset your password",
|
|
11665
|
+
rawHtml: layout({
|
|
11666
|
+
siteName,
|
|
11667
|
+
preheader: `Reset your ${siteName} password.`,
|
|
11668
|
+
title: "Reset your password",
|
|
11669
|
+
content: [
|
|
11670
|
+
paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
|
|
11671
|
+
ctaButton("Reset password", "{{url}}"),
|
|
11672
|
+
sectionLabel("Reset link"),
|
|
11673
|
+
safeLinkDetailBox("{{url}}"),
|
|
11674
|
+
paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
|
|
11675
|
+
],
|
|
11676
|
+
footer: "If you didn't request a password reset, you can safely ignore this email."
|
|
11677
|
+
})
|
|
11678
|
+
};
|
|
11679
|
+
case "welcome":
|
|
11680
|
+
return {
|
|
11681
|
+
subject: `Welcome to ${siteName} \u2014 your account is ready`,
|
|
11682
|
+
rawHtml: layout({
|
|
11683
|
+
siteName,
|
|
11684
|
+
preheader: `Your ${siteName} account is ready.`,
|
|
11685
|
+
title: "Welcome \u2014 your account is ready",
|
|
11686
|
+
content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
|
|
11687
|
+
footer: "If you didn't create this account, you can safely ignore this email."
|
|
11688
|
+
})
|
|
11689
|
+
};
|
|
11690
|
+
case "passwordChanged":
|
|
11691
|
+
return {
|
|
11692
|
+
subject: "Your password has been changed",
|
|
11693
|
+
rawHtml: layout({
|
|
11694
|
+
siteName,
|
|
11695
|
+
preheader: `Your ${siteName} password was changed.`,
|
|
11696
|
+
title: "Password changed",
|
|
11697
|
+
content: [
|
|
11698
|
+
paragraph("The password for your account has been changed."),
|
|
11699
|
+
alertBox("If you did not make this change, please contact support or reset your password immediately.")
|
|
11700
|
+
],
|
|
11701
|
+
footer: "Security notice: this confirms a recent credential change."
|
|
11702
|
+
})
|
|
11703
|
+
};
|
|
11704
|
+
default:
|
|
11705
|
+
return {
|
|
11706
|
+
subject: `Notification from ${siteName}`,
|
|
11707
|
+
rawHtml: layout({
|
|
11708
|
+
siteName,
|
|
11709
|
+
preheader: `Account notification from ${siteName}.`,
|
|
11710
|
+
title: "Account Notification",
|
|
11711
|
+
content: [paragraph(`This is an account notification from ${siteName}.`)],
|
|
11712
|
+
footer: "If you have any questions, please reach out to our team."
|
|
11713
|
+
})
|
|
11714
|
+
};
|
|
11715
|
+
}
|
|
11716
|
+
}
|
|
11543
11717
|
|
|
11544
11718
|
// src/services/email.service.ts
|
|
11545
11719
|
var _devSend = null;
|
|
@@ -11567,11 +11741,18 @@ async function getDevSend() {
|
|
|
11567
11741
|
password: account.pass
|
|
11568
11742
|
});
|
|
11569
11743
|
_devSend = async ({ to, subject, html }) => {
|
|
11570
|
-
const info = await transport.sendMail({
|
|
11744
|
+
const info = await transport.sendMail({
|
|
11745
|
+
from: '"Dyrected Dev" <dev@dyrected.local>',
|
|
11746
|
+
to,
|
|
11747
|
+
subject,
|
|
11748
|
+
html
|
|
11749
|
+
});
|
|
11750
|
+
const previewUrl = nodemailer.default.getTestMessageUrl(info) || void 0;
|
|
11571
11751
|
logger.info({
|
|
11572
11752
|
msg: "Email preview URL",
|
|
11573
|
-
previewUrl
|
|
11753
|
+
previewUrl
|
|
11574
11754
|
});
|
|
11755
|
+
return { previewUrl };
|
|
11575
11756
|
};
|
|
11576
11757
|
return _devSend;
|
|
11577
11758
|
} catch {
|
|
@@ -11597,69 +11778,286 @@ async function sendEmail(config, payload) {
|
|
|
11597
11778
|
err,
|
|
11598
11779
|
msg: "Email provider send failed",
|
|
11599
11780
|
to: payload.to,
|
|
11600
|
-
subject: payload.subject
|
|
11781
|
+
subject: "subject" in payload ? payload.subject : void 0,
|
|
11782
|
+
template: "template" in payload ? payload.template : void 0
|
|
11601
11783
|
});
|
|
11602
11784
|
throw err;
|
|
11603
11785
|
}
|
|
11604
|
-
return;
|
|
11786
|
+
return { success: true };
|
|
11605
11787
|
}
|
|
11606
11788
|
if (process.env.NODE_ENV !== "production") {
|
|
11607
11789
|
const devSend = await getDevSend();
|
|
11608
|
-
|
|
11790
|
+
if (devSend) {
|
|
11791
|
+
if ("template" in payload && payload.template) {
|
|
11792
|
+
const devHtml = `
|
|
11793
|
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
|
11794
|
+
<h3 style="margin-top:0; color: #1e293b;">External Template Email (Dev Preview)</h3>
|
|
11795
|
+
<p><strong>To:</strong> ${payload.to}</p>
|
|
11796
|
+
<p><strong>Template ID:</strong> <code>${payload.template}</code></p>
|
|
11797
|
+
${payload.subject ? `<p><strong>Subject:</strong> ${payload.subject}</p>` : ""}
|
|
11798
|
+
<p><strong>Variables:</strong></p>
|
|
11799
|
+
<pre style="background: #f8fafc; padding: 12px; border-radius: 6px; overflow-x: auto;">${JSON.stringify(payload.variables, null, 2)}</pre>
|
|
11800
|
+
</div>
|
|
11801
|
+
`;
|
|
11802
|
+
const res = await devSend({
|
|
11803
|
+
to: payload.to,
|
|
11804
|
+
subject: payload.subject ?? `[Template: ${payload.template}] External Email`,
|
|
11805
|
+
html: devHtml
|
|
11806
|
+
});
|
|
11807
|
+
return { success: true, previewUrl: res?.previewUrl };
|
|
11808
|
+
} else if ("html" in payload) {
|
|
11809
|
+
const res = await devSend({
|
|
11810
|
+
to: payload.to,
|
|
11811
|
+
subject: payload.subject,
|
|
11812
|
+
html: payload.html
|
|
11813
|
+
});
|
|
11814
|
+
return { success: true, previewUrl: res?.previewUrl };
|
|
11815
|
+
}
|
|
11816
|
+
}
|
|
11817
|
+
}
|
|
11818
|
+
return { success: true };
|
|
11819
|
+
}
|
|
11820
|
+
function interpolateVariables(str, args, escapeMode = "html") {
|
|
11821
|
+
if (!str || typeof str !== "string") return str;
|
|
11822
|
+
return str.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => {
|
|
11823
|
+
const parts = path.split(".");
|
|
11824
|
+
let val = args;
|
|
11825
|
+
for (const part of parts) {
|
|
11826
|
+
if (val == null) break;
|
|
11827
|
+
val = val[part];
|
|
11828
|
+
}
|
|
11829
|
+
if (val === void 0 || val === null) return "";
|
|
11830
|
+
const raw = String(val);
|
|
11831
|
+
return escapeMode === "html" ? escapeHtml(raw) : raw;
|
|
11832
|
+
});
|
|
11833
|
+
}
|
|
11834
|
+
function formatDbTemplateDoc(doc, args) {
|
|
11835
|
+
if (doc.format === "external_template" || doc.externalTemplateId) {
|
|
11836
|
+
const rawVars = doc.variables && typeof doc.variables === "object" ? doc.variables : {};
|
|
11837
|
+
const interpolatedVars = { ...args };
|
|
11838
|
+
for (const [key, val] of Object.entries(rawVars)) {
|
|
11839
|
+
if (typeof val === "string") {
|
|
11840
|
+
interpolatedVars[key] = interpolateVariables(val, args);
|
|
11841
|
+
} else {
|
|
11842
|
+
interpolatedVars[key] = val;
|
|
11843
|
+
}
|
|
11844
|
+
}
|
|
11845
|
+
return {
|
|
11846
|
+
template: doc.externalTemplateId ?? "",
|
|
11847
|
+
variables: interpolatedVars,
|
|
11848
|
+
...doc.subject ? { subject: interpolateVariables(doc.subject, args) } : {}
|
|
11849
|
+
};
|
|
11850
|
+
}
|
|
11851
|
+
return {
|
|
11852
|
+
subject: interpolateVariables(doc.subject || "", args),
|
|
11853
|
+
html: interpolateVariables(doc.rawHtml || "", args)
|
|
11854
|
+
};
|
|
11855
|
+
}
|
|
11856
|
+
async function getDbEmailTemplateOverride(db, collectionSlug, purpose, args) {
|
|
11857
|
+
if (!db || typeof db.find !== "function") return null;
|
|
11858
|
+
try {
|
|
11859
|
+
if (collectionSlug) {
|
|
11860
|
+
const specific = await db.find({
|
|
11861
|
+
collection: "__email_templates",
|
|
11862
|
+
where: {
|
|
11863
|
+
collectionSlug,
|
|
11864
|
+
purpose,
|
|
11865
|
+
active: true
|
|
11866
|
+
},
|
|
11867
|
+
limit: 1
|
|
11868
|
+
});
|
|
11869
|
+
const doc2 = specific?.docs?.[0];
|
|
11870
|
+
if (doc2) {
|
|
11871
|
+
return formatDbTemplateDoc(doc2, args);
|
|
11872
|
+
}
|
|
11873
|
+
}
|
|
11874
|
+
const global = await db.find({
|
|
11875
|
+
collection: "__email_templates",
|
|
11876
|
+
where: {
|
|
11877
|
+
collectionSlug: "*",
|
|
11878
|
+
purpose,
|
|
11879
|
+
active: true
|
|
11880
|
+
},
|
|
11881
|
+
limit: 1
|
|
11882
|
+
});
|
|
11883
|
+
const doc = global?.docs?.[0];
|
|
11884
|
+
if (doc) {
|
|
11885
|
+
return formatDbTemplateDoc(doc, args);
|
|
11886
|
+
}
|
|
11887
|
+
} catch {
|
|
11888
|
+
return null;
|
|
11609
11889
|
}
|
|
11890
|
+
return null;
|
|
11610
11891
|
}
|
|
11611
|
-
function
|
|
11612
|
-
const
|
|
11892
|
+
function enrichArgs(config, collection, args) {
|
|
11893
|
+
const slug = collection?.slug;
|
|
11894
|
+
const readableSlug = slug?.replace(/^__/, "");
|
|
11895
|
+
const fallbackLabel = readableSlug ? readableSlug.charAt(0).toUpperCase() + readableSlug.slice(1) : "Account";
|
|
11613
11896
|
return {
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
11897
|
+
...args,
|
|
11898
|
+
collection: slug,
|
|
11899
|
+
collectionLabel: collection?.labels?.singular || collection?.labels?.plural || fallbackLabel,
|
|
11900
|
+
collectionLabels: collection?.labels,
|
|
11901
|
+
siteName: config.admin?.branding?.logoText ?? "Dyrected"
|
|
11902
|
+
};
|
|
11903
|
+
}
|
|
11904
|
+
function toOutboundEmail(to, result, metadata) {
|
|
11905
|
+
if ("template" in result) {
|
|
11906
|
+
return {
|
|
11907
|
+
to,
|
|
11908
|
+
type: "template",
|
|
11909
|
+
template: result.template,
|
|
11910
|
+
variables: result.variables,
|
|
11911
|
+
...result.subject ? { subject: result.subject } : {},
|
|
11912
|
+
...metadata
|
|
11913
|
+
};
|
|
11914
|
+
}
|
|
11915
|
+
return {
|
|
11916
|
+
to,
|
|
11917
|
+
type: "html",
|
|
11918
|
+
subject: result.subject ?? "Notification",
|
|
11919
|
+
html: result.html,
|
|
11920
|
+
...result.text ? { text: result.text } : {},
|
|
11921
|
+
...metadata
|
|
11922
|
+
};
|
|
11923
|
+
}
|
|
11924
|
+
function buildWelcomeEmail(config, args, collection, dbOverride) {
|
|
11925
|
+
if (dbOverride) return dbOverride;
|
|
11926
|
+
const enriched = enrichArgs(config, collection, args);
|
|
11927
|
+
const collectionAuth = collection?.auth;
|
|
11928
|
+
if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.welcome) {
|
|
11929
|
+
return collectionAuth.email.templates.welcome(enriched);
|
|
11930
|
+
}
|
|
11931
|
+
const custom = config.email?.templates?.welcome?.(enriched);
|
|
11932
|
+
if (custom) return custom;
|
|
11933
|
+
return {
|
|
11934
|
+
subject: "Welcome \u2014 your account is ready",
|
|
11935
|
+
html: layout({
|
|
11936
|
+
siteName: enriched.siteName,
|
|
11937
|
+
preheader: `Your ${enriched.siteName} account is ready.`,
|
|
11617
11938
|
title: "Welcome \u2014 your account is ready",
|
|
11618
|
-
content:
|
|
11939
|
+
content: [
|
|
11940
|
+
paragraph("Your account has been created. You can now log in with:"),
|
|
11941
|
+
detailBox(args.email)
|
|
11942
|
+
],
|
|
11619
11943
|
footer: "If you didn't create this account, you can safely ignore this email."
|
|
11620
11944
|
})
|
|
11621
11945
|
};
|
|
11622
11946
|
}
|
|
11623
|
-
function buildInviteEmail(config, args) {
|
|
11624
|
-
|
|
11947
|
+
function buildInviteEmail(config, args, collection, dbOverride) {
|
|
11948
|
+
if (dbOverride) return dbOverride;
|
|
11949
|
+
const enriched = enrichArgs(config, collection, args);
|
|
11950
|
+
const collectionAuth = collection?.auth;
|
|
11951
|
+
if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.invite) {
|
|
11952
|
+
return collectionAuth.email.templates.invite(enriched);
|
|
11953
|
+
}
|
|
11954
|
+
const custom = config.email?.templates?.invite?.(enriched);
|
|
11955
|
+
if (custom) return custom;
|
|
11625
11956
|
const inviteLink = args.url;
|
|
11626
11957
|
const inviteLinkBlock = inviteLink ? safeLinkDetailBox(inviteLink) : "";
|
|
11627
11958
|
return {
|
|
11628
|
-
subject:
|
|
11629
|
-
html:
|
|
11630
|
-
|
|
11959
|
+
subject: "You've been invited",
|
|
11960
|
+
html: layout({
|
|
11961
|
+
siteName: enriched.siteName,
|
|
11962
|
+
preheader: `You've been invited to join ${enriched.siteName}.`,
|
|
11631
11963
|
title: "You've been invited",
|
|
11632
|
-
content:
|
|
11964
|
+
content: [
|
|
11965
|
+
args.invitedByEmail && paragraph(`You were invited by ${args.invitedByEmail}.`),
|
|
11966
|
+
inviteLink ? [
|
|
11967
|
+
paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
|
|
11968
|
+
ctaButton("Accept invitation", inviteLink),
|
|
11969
|
+
sectionLabel("Invitation link"),
|
|
11970
|
+
inviteLinkBlock,
|
|
11971
|
+
paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
|
|
11972
|
+
] : [
|
|
11973
|
+
paragraph("Use the invitation token below to accept your invitation. It expires in 7 days."),
|
|
11974
|
+
sectionLabel("Invitation token"),
|
|
11975
|
+
detailBox(args.token, true)
|
|
11976
|
+
]
|
|
11977
|
+
],
|
|
11633
11978
|
footer: "If you weren't expecting this invitation, you can safely ignore this email."
|
|
11634
11979
|
})
|
|
11635
11980
|
};
|
|
11636
11981
|
}
|
|
11637
|
-
function buildResetPasswordEmail(config, args) {
|
|
11638
|
-
|
|
11982
|
+
function buildResetPasswordEmail(config, args, collection, dbOverride) {
|
|
11983
|
+
if (dbOverride) return dbOverride;
|
|
11984
|
+
const enriched = enrichArgs(config, collection, args);
|
|
11985
|
+
const collectionAuth = collection?.auth;
|
|
11986
|
+
if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.resetPassword) {
|
|
11987
|
+
return collectionAuth.email.templates.resetPassword(enriched);
|
|
11988
|
+
}
|
|
11989
|
+
const custom = config.email?.templates?.resetPassword?.(enriched);
|
|
11990
|
+
if (custom) return custom;
|
|
11639
11991
|
const resetLink = args.url;
|
|
11640
11992
|
const resetLinkBlock = resetLink ? safeLinkDetailBox(resetLink) : "";
|
|
11641
11993
|
return {
|
|
11642
|
-
subject:
|
|
11643
|
-
html:
|
|
11644
|
-
|
|
11994
|
+
subject: "Reset your password",
|
|
11995
|
+
html: layout({
|
|
11996
|
+
siteName: enriched.siteName,
|
|
11997
|
+
preheader: `Reset your ${enriched.siteName} password.`,
|
|
11645
11998
|
title: "Reset your password",
|
|
11646
|
-
content:
|
|
11999
|
+
content: [
|
|
12000
|
+
resetLink ? [
|
|
12001
|
+
paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
|
|
12002
|
+
ctaButton("Reset password", resetLink),
|
|
12003
|
+
sectionLabel("Reset link"),
|
|
12004
|
+
resetLinkBlock,
|
|
12005
|
+
paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
|
|
12006
|
+
] : [
|
|
12007
|
+
paragraph("We received a request to reset your password. The reset token expires in 1 hour."),
|
|
12008
|
+
sectionLabel("Reset token"),
|
|
12009
|
+
detailBox(args.token, true)
|
|
12010
|
+
]
|
|
12011
|
+
],
|
|
11647
12012
|
footer: "If you didn't request a password reset, you can safely ignore this email."
|
|
11648
12013
|
})
|
|
11649
12014
|
};
|
|
11650
12015
|
}
|
|
11651
|
-
function buildPasswordChangedEmail(config, args) {
|
|
11652
|
-
|
|
12016
|
+
function buildPasswordChangedEmail(config, args, collection, dbOverride) {
|
|
12017
|
+
if (dbOverride) return dbOverride;
|
|
12018
|
+
const enriched = enrichArgs(config, collection, args);
|
|
12019
|
+
const collectionAuth = collection?.auth;
|
|
12020
|
+
if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.passwordChanged) {
|
|
12021
|
+
return collectionAuth.email.templates.passwordChanged(enriched);
|
|
12022
|
+
}
|
|
12023
|
+
const custom = config.email?.templates?.passwordChanged?.(enriched);
|
|
12024
|
+
if (custom) return custom;
|
|
11653
12025
|
return {
|
|
11654
|
-
subject:
|
|
11655
|
-
html:
|
|
11656
|
-
|
|
12026
|
+
subject: "Your password has been changed",
|
|
12027
|
+
html: layout({
|
|
12028
|
+
siteName: enriched.siteName,
|
|
12029
|
+
preheader: `Your ${enriched.siteName} password was changed.`,
|
|
11657
12030
|
title: "Password changed",
|
|
11658
|
-
content:
|
|
12031
|
+
content: [
|
|
12032
|
+
paragraph("The password for this account was just changed:"),
|
|
12033
|
+
detailBox(args.email),
|
|
12034
|
+
spacer(),
|
|
12035
|
+
alertBox("If you did not make this change, please contact support immediately.")
|
|
12036
|
+
],
|
|
11659
12037
|
footer: "This is an automated security notification."
|
|
11660
12038
|
})
|
|
11661
12039
|
};
|
|
11662
12040
|
}
|
|
12041
|
+
async function resolveEmailTemplate(params) {
|
|
12042
|
+
const { config, collection, purpose, args, db } = params;
|
|
12043
|
+
const enriched = enrichArgs(config, collection, args);
|
|
12044
|
+
let dbOverride = null;
|
|
12045
|
+
if (config.email?.adminEditable !== false && db) {
|
|
12046
|
+
dbOverride = await getDbEmailTemplateOverride(db, collection?.slug, purpose, enriched);
|
|
12047
|
+
}
|
|
12048
|
+
switch (purpose) {
|
|
12049
|
+
case "welcome":
|
|
12050
|
+
return buildWelcomeEmail(config, enriched, collection, dbOverride);
|
|
12051
|
+
case "invite":
|
|
12052
|
+
return buildInviteEmail(config, enriched, collection, dbOverride);
|
|
12053
|
+
case "resetPassword":
|
|
12054
|
+
return buildResetPasswordEmail(config, enriched, collection, dbOverride);
|
|
12055
|
+
case "passwordChanged":
|
|
12056
|
+
return buildPasswordChangedEmail(config, enriched, collection, dbOverride);
|
|
12057
|
+
default:
|
|
12058
|
+
throw new Error(`Unknown email template purpose: ${purpose}`);
|
|
12059
|
+
}
|
|
12060
|
+
}
|
|
11663
12061
|
|
|
11664
12062
|
// src/controllers/auth.controller.ts
|
|
11665
12063
|
var AuthController = class {
|
|
@@ -11696,9 +12094,10 @@ var AuthController = class {
|
|
|
11696
12094
|
const data = {
|
|
11697
12095
|
...safeExtraFields,
|
|
11698
12096
|
email,
|
|
11699
|
-
password:
|
|
12097
|
+
password: null,
|
|
12098
|
+
invitedAt: Date.now()
|
|
11700
12099
|
};
|
|
11701
|
-
if (this.hasField("status")) {
|
|
12100
|
+
if (this.hasField("status") && !data.status) {
|
|
11702
12101
|
data.status = "pending";
|
|
11703
12102
|
}
|
|
11704
12103
|
return data;
|
|
@@ -11821,6 +12220,7 @@ var AuthController = class {
|
|
|
11821
12220
|
...rolePayload
|
|
11822
12221
|
}
|
|
11823
12222
|
});
|
|
12223
|
+
const safeUser = this.sanitizeUser(user);
|
|
11824
12224
|
const token = await issueAuthSessionToken({
|
|
11825
12225
|
config,
|
|
11826
12226
|
userId: user.id,
|
|
@@ -11830,15 +12230,28 @@ var AuthController = class {
|
|
|
11830
12230
|
ip: c.get("clientIp"),
|
|
11831
12231
|
authSource: "local"
|
|
11832
12232
|
});
|
|
11833
|
-
|
|
11834
|
-
|
|
12233
|
+
resolveEmailTemplate({
|
|
12234
|
+
config,
|
|
12235
|
+
collection: this.collection,
|
|
12236
|
+
purpose: "welcome",
|
|
12237
|
+
args: { email: body.email, user: safeUser },
|
|
12238
|
+
db
|
|
12239
|
+
}).then(
|
|
12240
|
+
(emailResult) => sendEmail(
|
|
12241
|
+
config,
|
|
12242
|
+
toOutboundEmail(body.email, emailResult, {
|
|
12243
|
+
collection: this.collection.slug,
|
|
12244
|
+
purpose: "welcome",
|
|
12245
|
+
user: safeUser
|
|
12246
|
+
})
|
|
12247
|
+
)
|
|
12248
|
+
).catch(
|
|
11835
12249
|
(err) => getRequestLogger(c, "auth").error({
|
|
11836
12250
|
err,
|
|
11837
12251
|
msg: "Failed to send welcome email",
|
|
11838
12252
|
email: body.email
|
|
11839
12253
|
})
|
|
11840
12254
|
);
|
|
11841
|
-
const safeUser = this.sanitizeUser(user);
|
|
11842
12255
|
return c.json({ token, user: safeUser });
|
|
11843
12256
|
}
|
|
11844
12257
|
// ---------------------------------------------------------------------------
|
|
@@ -11872,6 +12285,16 @@ var AuthController = class {
|
|
|
11872
12285
|
403
|
|
11873
12286
|
);
|
|
11874
12287
|
}
|
|
12288
|
+
if (!hasUsablePassword(user.password)) {
|
|
12289
|
+
return c.json(
|
|
12290
|
+
{
|
|
12291
|
+
error: true,
|
|
12292
|
+
code: "PASSWORD_NOT_SET",
|
|
12293
|
+
message: "This account does not have a password set. Please use the invite link or reset password."
|
|
12294
|
+
},
|
|
12295
|
+
401
|
|
12296
|
+
);
|
|
12297
|
+
}
|
|
11875
12298
|
const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
|
|
11876
12299
|
if (lockedUntilMs !== null && lockedUntilMs > Date.now()) {
|
|
11877
12300
|
const retryAfterSeconds = Math.max(
|
|
@@ -12049,34 +12472,67 @@ var AuthController = class {
|
|
|
12049
12472
|
limit: 1
|
|
12050
12473
|
});
|
|
12051
12474
|
const user = result.docs[0];
|
|
12475
|
+
let resetToken;
|
|
12476
|
+
let url;
|
|
12477
|
+
let emailSent = false;
|
|
12478
|
+
let emailError;
|
|
12052
12479
|
if (user) {
|
|
12053
|
-
const
|
|
12480
|
+
const siteId = c.get("siteId");
|
|
12481
|
+
resetToken = await signCollectionToken(
|
|
12054
12482
|
{
|
|
12055
|
-
sub: user.id,
|
|
12483
|
+
sub: String(user.id),
|
|
12056
12484
|
email: user.email,
|
|
12057
12485
|
collection: this.collection.slug,
|
|
12058
|
-
purpose: "reset"
|
|
12486
|
+
purpose: "reset",
|
|
12487
|
+
...siteId ? { siteId } : {}
|
|
12059
12488
|
},
|
|
12060
|
-
|
|
12489
|
+
resolveResetPasswordTokenExpiry(this.collection)
|
|
12061
12490
|
);
|
|
12062
|
-
const
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12491
|
+
const customResetUrl = body?.resetUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.resetPassword : void 0);
|
|
12492
|
+
url = appendQueryParam(
|
|
12493
|
+
resolveActionUrl(c, config, customResetUrl),
|
|
12494
|
+
"token",
|
|
12495
|
+
resetToken
|
|
12496
|
+
);
|
|
12497
|
+
if (body?.sendEmail !== false) {
|
|
12498
|
+
try {
|
|
12499
|
+
const emailResult = await resolveEmailTemplate({
|
|
12500
|
+
config,
|
|
12501
|
+
collection: this.collection,
|
|
12502
|
+
purpose: "resetPassword",
|
|
12503
|
+
args: {
|
|
12504
|
+
token: resetToken,
|
|
12505
|
+
url,
|
|
12506
|
+
user
|
|
12507
|
+
},
|
|
12508
|
+
db
|
|
12509
|
+
});
|
|
12510
|
+
await sendEmail(
|
|
12511
|
+
config,
|
|
12512
|
+
toOutboundEmail(user.email, emailResult, {
|
|
12513
|
+
collection: this.collection.slug,
|
|
12514
|
+
purpose: "resetPassword",
|
|
12515
|
+
user
|
|
12516
|
+
})
|
|
12517
|
+
);
|
|
12518
|
+
emailSent = true;
|
|
12519
|
+
} catch (err) {
|
|
12520
|
+
emailSent = false;
|
|
12521
|
+
emailError = err?.message ?? "Email delivery failed";
|
|
12522
|
+
getRequestLogger(c, "auth").error({
|
|
12523
|
+
err,
|
|
12524
|
+
msg: "Failed to send password reset email",
|
|
12525
|
+
email: user.email
|
|
12526
|
+
});
|
|
12527
|
+
}
|
|
12075
12528
|
}
|
|
12076
12529
|
}
|
|
12077
12530
|
return c.json({
|
|
12078
12531
|
success: true,
|
|
12079
|
-
message: "If an account with that email exists, a reset link has been sent."
|
|
12532
|
+
message: "If an account with that email exists, a reset link has been sent.",
|
|
12533
|
+
...body?.sendEmail === false && resetToken ? { token: resetToken, resetUrl: url } : {},
|
|
12534
|
+
emailSent,
|
|
12535
|
+
...emailError ? { emailError } : {}
|
|
12080
12536
|
});
|
|
12081
12537
|
}
|
|
12082
12538
|
// ---------------------------------------------------------------------------
|
|
@@ -12110,6 +12566,13 @@ var AuthController = class {
|
|
|
12110
12566
|
400
|
|
12111
12567
|
);
|
|
12112
12568
|
}
|
|
12569
|
+
const currentSiteId = c.get("siteId");
|
|
12570
|
+
if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
|
|
12571
|
+
return c.json(
|
|
12572
|
+
{ error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
|
|
12573
|
+
403
|
|
12574
|
+
);
|
|
12575
|
+
}
|
|
12113
12576
|
const hashedPassword = await hashPassword(body.password);
|
|
12114
12577
|
await db.update({
|
|
12115
12578
|
collection: this.collection.slug,
|
|
@@ -12124,10 +12587,24 @@ var AuthController = class {
|
|
|
12124
12587
|
userId: payload.sub,
|
|
12125
12588
|
collection: this.collection.slug
|
|
12126
12589
|
});
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12130
|
-
|
|
12590
|
+
resolveEmailTemplate({
|
|
12591
|
+
config,
|
|
12592
|
+
collection: this.collection,
|
|
12593
|
+
purpose: "passwordChanged",
|
|
12594
|
+
args: {
|
|
12595
|
+
email: payload.email,
|
|
12596
|
+
user: { id: payload.sub, email: payload.email }
|
|
12597
|
+
},
|
|
12598
|
+
db
|
|
12599
|
+
}).then(
|
|
12600
|
+
(emailResult) => sendEmail(
|
|
12601
|
+
config,
|
|
12602
|
+
toOutboundEmail(payload.email, emailResult, {
|
|
12603
|
+
collection: this.collection.slug,
|
|
12604
|
+
purpose: "passwordChanged"
|
|
12605
|
+
})
|
|
12606
|
+
)
|
|
12607
|
+
).catch(
|
|
12131
12608
|
(err) => getRequestLogger(c, "auth").error({
|
|
12132
12609
|
err,
|
|
12133
12610
|
msg: "Failed to send password-changed email",
|
|
@@ -12156,61 +12633,117 @@ var AuthController = class {
|
|
|
12156
12633
|
if (!body?.email) {
|
|
12157
12634
|
return c.json({ error: true, message: "email is required." }, 400);
|
|
12158
12635
|
}
|
|
12159
|
-
const
|
|
12636
|
+
const {
|
|
12637
|
+
email,
|
|
12638
|
+
inviteUrl,
|
|
12639
|
+
sendEmail: shouldSendEmail,
|
|
12640
|
+
data: nestedData,
|
|
12641
|
+
...topLevelFields
|
|
12642
|
+
} = body;
|
|
12643
|
+
const inviteData = {
|
|
12644
|
+
...topLevelFields,
|
|
12645
|
+
...nestedData && typeof nestedData === "object" && !Array.isArray(nestedData) ? nestedData : {}
|
|
12646
|
+
};
|
|
12160
12647
|
const existing = await db.find({
|
|
12161
12648
|
collection: this.collection.slug,
|
|
12162
|
-
where: { email
|
|
12649
|
+
where: { email },
|
|
12163
12650
|
limit: 1
|
|
12164
12651
|
});
|
|
12165
12652
|
const existingUser = existing.docs[0];
|
|
12166
12653
|
const existingIsPending = existingUser?.status === "pending";
|
|
12167
|
-
if (existingUser && !existingIsPending) {
|
|
12654
|
+
if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
|
|
12168
12655
|
return c.json(
|
|
12169
|
-
{
|
|
12656
|
+
{
|
|
12657
|
+
error: true,
|
|
12658
|
+
code: "USER_ALREADY_EXISTS",
|
|
12659
|
+
message: "An account with that email already exists and has a password set. Use forgot password if needed."
|
|
12660
|
+
},
|
|
12170
12661
|
409
|
|
12171
12662
|
);
|
|
12172
12663
|
}
|
|
12664
|
+
const now2 = Date.now();
|
|
12665
|
+
let targetUser;
|
|
12173
12666
|
if (!existingUser) {
|
|
12174
|
-
await
|
|
12667
|
+
const pendingData = await this.buildPendingInviteData(email, inviteData);
|
|
12668
|
+
pendingData.invitedAt = now2;
|
|
12669
|
+
targetUser = await db.create({
|
|
12175
12670
|
collection: this.collection.slug,
|
|
12176
|
-
data:
|
|
12671
|
+
data: pendingData
|
|
12177
12672
|
});
|
|
12178
|
-
} else
|
|
12179
|
-
|
|
12673
|
+
} else {
|
|
12674
|
+
const updateData = {
|
|
12675
|
+
...inviteData,
|
|
12676
|
+
invitedAt: now2
|
|
12677
|
+
};
|
|
12678
|
+
if (this.hasField("status") && !existingUser.status) {
|
|
12679
|
+
updateData.status = "pending";
|
|
12680
|
+
}
|
|
12681
|
+
targetUser = await db.update({
|
|
12180
12682
|
collection: this.collection.slug,
|
|
12181
12683
|
id: String(existingUser.id),
|
|
12182
|
-
data:
|
|
12684
|
+
data: updateData
|
|
12183
12685
|
});
|
|
12184
12686
|
}
|
|
12687
|
+
const siteId = c.get("siteId");
|
|
12185
12688
|
const inviteToken = await signCollectionToken(
|
|
12186
12689
|
{
|
|
12187
|
-
sub:
|
|
12188
|
-
email
|
|
12690
|
+
sub: email,
|
|
12691
|
+
email,
|
|
12189
12692
|
collection: this.collection.slug,
|
|
12190
|
-
purpose: "invite"
|
|
12693
|
+
purpose: "invite",
|
|
12694
|
+
...siteId ? { siteId } : {}
|
|
12191
12695
|
},
|
|
12192
|
-
|
|
12696
|
+
resolveInviteTokenExpiry(this.collection)
|
|
12193
12697
|
);
|
|
12194
|
-
const
|
|
12195
|
-
|
|
12196
|
-
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
|
|
12200
|
-
|
|
12201
|
-
|
|
12202
|
-
|
|
12203
|
-
|
|
12204
|
-
|
|
12205
|
-
|
|
12206
|
-
|
|
12207
|
-
|
|
12698
|
+
const customInviteUrl = inviteUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.invite : void 0);
|
|
12699
|
+
const url = appendQueryParam(
|
|
12700
|
+
resolveActionUrl(c, config, customInviteUrl),
|
|
12701
|
+
"inviteToken",
|
|
12702
|
+
inviteToken
|
|
12703
|
+
);
|
|
12704
|
+
let emailSent = false;
|
|
12705
|
+
let emailError;
|
|
12706
|
+
if (shouldSendEmail !== false) {
|
|
12707
|
+
try {
|
|
12708
|
+
const emailResult = await resolveEmailTemplate({
|
|
12709
|
+
config,
|
|
12710
|
+
collection: this.collection,
|
|
12711
|
+
purpose: "invite",
|
|
12712
|
+
args: {
|
|
12713
|
+
token: inviteToken,
|
|
12714
|
+
invitedByEmail: requestUser.email,
|
|
12715
|
+
url,
|
|
12716
|
+
data: inviteData,
|
|
12717
|
+
user: targetUser
|
|
12718
|
+
},
|
|
12719
|
+
db
|
|
12720
|
+
});
|
|
12721
|
+
await sendEmail(
|
|
12722
|
+
config,
|
|
12723
|
+
toOutboundEmail(email, emailResult, {
|
|
12724
|
+
collection: this.collection.slug,
|
|
12725
|
+
purpose: "invite",
|
|
12726
|
+
user: targetUser
|
|
12727
|
+
})
|
|
12728
|
+
);
|
|
12729
|
+
emailSent = true;
|
|
12730
|
+
} catch (err) {
|
|
12731
|
+
emailSent = false;
|
|
12732
|
+
emailError = err?.message ?? "Email delivery failed";
|
|
12733
|
+
getRequestLogger(c, "auth").error({
|
|
12734
|
+
err,
|
|
12735
|
+
msg: "Failed to send invite email",
|
|
12736
|
+
email
|
|
12737
|
+
});
|
|
12738
|
+
}
|
|
12208
12739
|
}
|
|
12209
12740
|
return c.json({
|
|
12210
12741
|
success: true,
|
|
12211
|
-
message: `Invite sent to ${
|
|
12742
|
+
message: emailSent ? `Invite sent to ${email}.` : shouldSendEmail === false ? `Invite created for ${email}.` : `Invite created for ${email}, but email delivery failed.`,
|
|
12212
12743
|
token: inviteToken,
|
|
12213
|
-
inviteUrl: url
|
|
12744
|
+
inviteUrl: url,
|
|
12745
|
+
emailSent,
|
|
12746
|
+
...emailError ? { emailError } : {}
|
|
12214
12747
|
});
|
|
12215
12748
|
}
|
|
12216
12749
|
// ---------------------------------------------------------------------------
|
|
@@ -12244,6 +12777,13 @@ var AuthController = class {
|
|
|
12244
12777
|
400
|
|
12245
12778
|
);
|
|
12246
12779
|
}
|
|
12780
|
+
const currentSiteId = c.get("siteId");
|
|
12781
|
+
if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
|
|
12782
|
+
return c.json(
|
|
12783
|
+
{ error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
|
|
12784
|
+
403
|
|
12785
|
+
);
|
|
12786
|
+
}
|
|
12247
12787
|
const inviteeEmail = payload.sub;
|
|
12248
12788
|
const existing = await db.find({
|
|
12249
12789
|
collection: this.collection.slug,
|
|
@@ -12252,12 +12792,26 @@ var AuthController = class {
|
|
|
12252
12792
|
});
|
|
12253
12793
|
const existingUser = existing.docs[0];
|
|
12254
12794
|
const existingIsPending = existingUser?.status === "pending";
|
|
12255
|
-
if (existingUser && !existingIsPending) {
|
|
12795
|
+
if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
|
|
12256
12796
|
return c.json(
|
|
12257
|
-
{
|
|
12797
|
+
{
|
|
12798
|
+
error: true,
|
|
12799
|
+
code: "INVITE_ALREADY_ACCEPTED",
|
|
12800
|
+
message: "This invitation has already been accepted. Please log in or use forgot password."
|
|
12801
|
+
},
|
|
12258
12802
|
409
|
|
12259
12803
|
);
|
|
12260
12804
|
}
|
|
12805
|
+
if (existingUser && payload.iat && typeof existingUser.invitedAt === "number" && payload.iat * 1e3 < existingUser.invitedAt - 2e3) {
|
|
12806
|
+
return c.json(
|
|
12807
|
+
{
|
|
12808
|
+
error: true,
|
|
12809
|
+
code: "INVITE_SUPERSEDED",
|
|
12810
|
+
message: "This invitation link has been superseded by a newer invitation. Please use the latest link."
|
|
12811
|
+
},
|
|
12812
|
+
400
|
|
12813
|
+
);
|
|
12814
|
+
}
|
|
12261
12815
|
const { token: _t, password: _p, ...extraFields } = body;
|
|
12262
12816
|
const hashedPassword = await hashPassword(body.password);
|
|
12263
12817
|
const user = existingUser ? await db.update({
|
|
@@ -12281,23 +12835,179 @@ var AuthController = class {
|
|
|
12281
12835
|
ip: c.get("clientIp"),
|
|
12282
12836
|
authSource: "local"
|
|
12283
12837
|
});
|
|
12284
|
-
const
|
|
12285
|
-
|
|
12286
|
-
|
|
12287
|
-
|
|
12838
|
+
const safeUser = this.sanitizeUser(user);
|
|
12839
|
+
resolveEmailTemplate({
|
|
12840
|
+
config,
|
|
12841
|
+
collection: this.collection,
|
|
12842
|
+
purpose: "welcome",
|
|
12843
|
+
args: {
|
|
12844
|
+
email: inviteeEmail,
|
|
12845
|
+
user: safeUser
|
|
12846
|
+
},
|
|
12847
|
+
db
|
|
12848
|
+
}).then(
|
|
12849
|
+
(emailResult) => sendEmail(
|
|
12850
|
+
config,
|
|
12851
|
+
toOutboundEmail(inviteeEmail, emailResult, {
|
|
12852
|
+
collection: this.collection.slug,
|
|
12853
|
+
purpose: "welcome",
|
|
12854
|
+
user: safeUser
|
|
12855
|
+
})
|
|
12856
|
+
)
|
|
12857
|
+
).catch(
|
|
12288
12858
|
(err) => getRequestLogger(c, "auth").error({
|
|
12289
12859
|
err,
|
|
12290
12860
|
msg: "Failed to send welcome email",
|
|
12291
12861
|
email: inviteeEmail
|
|
12292
12862
|
})
|
|
12293
12863
|
);
|
|
12294
|
-
const safeUser = this.sanitizeUser(user);
|
|
12295
12864
|
return c.json({ token: sessionToken, user: safeUser }, 201);
|
|
12296
12865
|
}
|
|
12866
|
+
// ---------------------------------------------------------------------------
|
|
12867
|
+
// GET /tokens/verify?token=...&purpose=invite|reset
|
|
12868
|
+
// Public. Validates token signature, expiration, database existence, and state.
|
|
12869
|
+
// ---------------------------------------------------------------------------
|
|
12870
|
+
async verifyToken(c) {
|
|
12871
|
+
const db = c.get("config").db;
|
|
12872
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
12873
|
+
const token = c.req.query("token");
|
|
12874
|
+
const purpose = c.req.query("purpose");
|
|
12875
|
+
if (!token) {
|
|
12876
|
+
return c.json(
|
|
12877
|
+
{ valid: false, code: "TOKEN_REQUIRED", message: "Token is required." },
|
|
12878
|
+
400
|
|
12879
|
+
);
|
|
12880
|
+
}
|
|
12881
|
+
let payload;
|
|
12882
|
+
try {
|
|
12883
|
+
payload = await verifyCollectionToken(token);
|
|
12884
|
+
} catch {
|
|
12885
|
+
return c.json(
|
|
12886
|
+
{
|
|
12887
|
+
valid: false,
|
|
12888
|
+
code: "TOKEN_EXPIRED_OR_INVALID",
|
|
12889
|
+
message: "Token is invalid or has expired."
|
|
12890
|
+
},
|
|
12891
|
+
200
|
|
12892
|
+
);
|
|
12893
|
+
}
|
|
12894
|
+
if (payload.collection !== this.collection.slug) {
|
|
12895
|
+
return c.json(
|
|
12896
|
+
{
|
|
12897
|
+
valid: false,
|
|
12898
|
+
code: "COLLECTION_MISMATCH",
|
|
12899
|
+
message: "Token does not belong to this collection."
|
|
12900
|
+
},
|
|
12901
|
+
200
|
|
12902
|
+
);
|
|
12903
|
+
}
|
|
12904
|
+
if (purpose && payload.purpose !== purpose) {
|
|
12905
|
+
return c.json(
|
|
12906
|
+
{
|
|
12907
|
+
valid: false,
|
|
12908
|
+
code: "PURPOSE_MISMATCH",
|
|
12909
|
+
message: `Token was issued for '${payload.purpose}', not '${purpose}'.`
|
|
12910
|
+
},
|
|
12911
|
+
200
|
|
12912
|
+
);
|
|
12913
|
+
}
|
|
12914
|
+
const currentSiteId = c.get("siteId");
|
|
12915
|
+
if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
|
|
12916
|
+
return c.json(
|
|
12917
|
+
{
|
|
12918
|
+
valid: false,
|
|
12919
|
+
code: "SITE_MISMATCH",
|
|
12920
|
+
message: "Token is not valid for this site."
|
|
12921
|
+
},
|
|
12922
|
+
200
|
|
12923
|
+
);
|
|
12924
|
+
}
|
|
12925
|
+
if (payload.purpose === "invite") {
|
|
12926
|
+
const result = await db.find({
|
|
12927
|
+
collection: this.collection.slug,
|
|
12928
|
+
where: { email: payload.email ?? payload.sub },
|
|
12929
|
+
limit: 1
|
|
12930
|
+
});
|
|
12931
|
+
const user = result.docs[0];
|
|
12932
|
+
if (!user) {
|
|
12933
|
+
return c.json(
|
|
12934
|
+
{
|
|
12935
|
+
valid: false,
|
|
12936
|
+
code: "USER_NOT_FOUND",
|
|
12937
|
+
message: "Invited account was not found."
|
|
12938
|
+
},
|
|
12939
|
+
200
|
|
12940
|
+
);
|
|
12941
|
+
}
|
|
12942
|
+
if (user._deleted || user.deletedAt) {
|
|
12943
|
+
return c.json(
|
|
12944
|
+
{
|
|
12945
|
+
valid: false,
|
|
12946
|
+
code: "USER_DELETED",
|
|
12947
|
+
message: "Account has been deleted."
|
|
12948
|
+
},
|
|
12949
|
+
200
|
|
12950
|
+
);
|
|
12951
|
+
}
|
|
12952
|
+
if (user.status !== "pending" && hasUsablePassword(user.password)) {
|
|
12953
|
+
return c.json(
|
|
12954
|
+
{
|
|
12955
|
+
valid: false,
|
|
12956
|
+
code: "INVITE_ALREADY_ACCEPTED",
|
|
12957
|
+
message: "This invitation has already been accepted."
|
|
12958
|
+
},
|
|
12959
|
+
200
|
|
12960
|
+
);
|
|
12961
|
+
}
|
|
12962
|
+
if (payload.iat && typeof user.invitedAt === "number" && payload.iat * 1e3 < user.invitedAt - 2e3) {
|
|
12963
|
+
return c.json(
|
|
12964
|
+
{
|
|
12965
|
+
valid: false,
|
|
12966
|
+
code: "INVITE_SUPERSEDED",
|
|
12967
|
+
message: "This invitation link has been superseded by a newer one."
|
|
12968
|
+
},
|
|
12969
|
+
200
|
|
12970
|
+
);
|
|
12971
|
+
}
|
|
12972
|
+
return c.json({
|
|
12973
|
+
valid: true,
|
|
12974
|
+
email: user.email,
|
|
12975
|
+
collection: this.collection.slug
|
|
12976
|
+
});
|
|
12977
|
+
}
|
|
12978
|
+
if (payload.purpose === "reset") {
|
|
12979
|
+
const result = await db.find({
|
|
12980
|
+
collection: this.collection.slug,
|
|
12981
|
+
where: { id: payload.sub },
|
|
12982
|
+
limit: 1
|
|
12983
|
+
});
|
|
12984
|
+
const user = result.docs[0];
|
|
12985
|
+
if (!user || user._deleted || user.deletedAt) {
|
|
12986
|
+
return c.json(
|
|
12987
|
+
{
|
|
12988
|
+
valid: false,
|
|
12989
|
+
code: "USER_NOT_FOUND",
|
|
12990
|
+
message: "Account was not found."
|
|
12991
|
+
},
|
|
12992
|
+
200
|
|
12993
|
+
);
|
|
12994
|
+
}
|
|
12995
|
+
return c.json({
|
|
12996
|
+
valid: true,
|
|
12997
|
+
email: user.email,
|
|
12998
|
+
collection: this.collection.slug
|
|
12999
|
+
});
|
|
13000
|
+
}
|
|
13001
|
+
return c.json({
|
|
13002
|
+
valid: true,
|
|
13003
|
+
email: payload.email,
|
|
13004
|
+
collection: this.collection.slug
|
|
13005
|
+
});
|
|
13006
|
+
}
|
|
12297
13007
|
};
|
|
12298
13008
|
|
|
12299
13009
|
// src/controllers/admin-auth.controller.ts
|
|
12300
|
-
import { randomBytes as
|
|
13010
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
12301
13011
|
import { TextEncoder as TextEncoder2 } from "util";
|
|
12302
13012
|
import {
|
|
12303
13013
|
SignJWT as SignJWT2,
|
|
@@ -12512,7 +13222,7 @@ var AdminAuthController = class {
|
|
|
12512
13222
|
access.roles,
|
|
12513
13223
|
access.data
|
|
12514
13224
|
),
|
|
12515
|
-
password: await hashPassword(
|
|
13225
|
+
password: await hashPassword(randomBytes3(32).toString("hex"))
|
|
12516
13226
|
}
|
|
12517
13227
|
});
|
|
12518
13228
|
} else if (user) {
|
|
@@ -12599,7 +13309,7 @@ var AdminAuthController = class {
|
|
|
12599
13309
|
}
|
|
12600
13310
|
async buildOidcStartUrl(provider, returnTo, siteId, origin) {
|
|
12601
13311
|
const discovery = await this.getOidcDiscovery(provider);
|
|
12602
|
-
const nonce =
|
|
13312
|
+
const nonce = randomBytes3(16).toString("hex");
|
|
12603
13313
|
const state = await this.signState({
|
|
12604
13314
|
providerId: provider.id,
|
|
12605
13315
|
returnTo,
|
|
@@ -15422,6 +16132,31 @@ function generateOpenApi(config) {
|
|
|
15422
16132
|
spec.paths[`${path}/accept-invite`] = {
|
|
15423
16133
|
post: publicAuthPost("Accept an invitation")
|
|
15424
16134
|
};
|
|
16135
|
+
spec.paths[`${path}/tokens/verify`] = {
|
|
16136
|
+
get: {
|
|
16137
|
+
tags: [collectionTag],
|
|
16138
|
+
summary: `Verify an invite or password reset token`,
|
|
16139
|
+
parameters: [
|
|
16140
|
+
{
|
|
16141
|
+
name: "token",
|
|
16142
|
+
in: "query",
|
|
16143
|
+
required: true,
|
|
16144
|
+
schema: { type: "string" }
|
|
16145
|
+
},
|
|
16146
|
+
{
|
|
16147
|
+
name: "purpose",
|
|
16148
|
+
in: "query",
|
|
16149
|
+
required: false,
|
|
16150
|
+
schema: { type: "string", enum: ["invite", "reset"] }
|
|
16151
|
+
}
|
|
16152
|
+
],
|
|
16153
|
+
responses: {
|
|
16154
|
+
200: {
|
|
16155
|
+
description: "Token verification result"
|
|
16156
|
+
}
|
|
16157
|
+
}
|
|
16158
|
+
}
|
|
16159
|
+
};
|
|
15425
16160
|
spec.paths[`${path}/{id}/change-password`] = {
|
|
15426
16161
|
post: {
|
|
15427
16162
|
tags: [collectionTag],
|
|
@@ -16032,7 +16767,7 @@ function registerRoutes(app, config) {
|
|
|
16032
16767
|
return resolveBooleanAccess(requestConfig, access, accessArgs);
|
|
16033
16768
|
};
|
|
16034
16769
|
const filteredCollections = await Promise.all(
|
|
16035
|
-
collections.filter((col) => !col.slug
|
|
16770
|
+
collections.filter((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && (!siteId || col.shared || !col.siteId || col.siteId === siteId)).map(async (col) => ({
|
|
16036
16771
|
slug: col.slug,
|
|
16037
16772
|
labels: col.labels,
|
|
16038
16773
|
shared: !!col.shared,
|
|
@@ -16174,7 +16909,7 @@ function registerRoutes(app, config) {
|
|
|
16174
16909
|
model: effectiveAi?.model
|
|
16175
16910
|
},
|
|
16176
16911
|
trash: {
|
|
16177
|
-
enabled: collections.some((col) => !col.slug
|
|
16912
|
+
enabled: collections.some((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).enabled),
|
|
16178
16913
|
retentionDays: requestConfig.trash?.retentionDays !== void 0 ? requestConfig.trash.retentionDays : 30,
|
|
16179
16914
|
allowPermanentDelete: requestConfig.trash?.allowPermanentDelete !== false
|
|
16180
16915
|
},
|
|
@@ -16185,7 +16920,7 @@ function registerRoutes(app, config) {
|
|
|
16185
16920
|
uploadCollectionConfigured: requestConfig.collections.some((collection) => !!collection.upload),
|
|
16186
16921
|
trashPurgeOverdue: await (async () => {
|
|
16187
16922
|
const hasRetention = collections.some(
|
|
16188
|
-
(col) => !col.slug
|
|
16923
|
+
(col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).retentionDays !== null
|
|
16189
16924
|
);
|
|
16190
16925
|
if (!hasRetention || !requestConfig.db) return false;
|
|
16191
16926
|
try {
|
|
@@ -16823,6 +17558,7 @@ function registerRoutes(app, config) {
|
|
|
16823
17558
|
app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
|
|
16824
17559
|
app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
|
|
16825
17560
|
app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
|
|
17561
|
+
app.get(`${path}/tokens/verify`, (c) => authController.verifyToken(c));
|
|
16826
17562
|
}
|
|
16827
17563
|
const auditController = new AuditController();
|
|
16828
17564
|
for (const collection of config.collections) {
|
|
@@ -16867,6 +17603,126 @@ function registerRoutes(app, config) {
|
|
|
16867
17603
|
app.get(path, (c) => controller.get(c));
|
|
16868
17604
|
app.patch(path, (c) => controller.update(c));
|
|
16869
17605
|
}
|
|
17606
|
+
const testEmailRateLimitMap = /* @__PURE__ */ new Map();
|
|
17607
|
+
const TEST_EMAIL_MAX = 5;
|
|
17608
|
+
const TEST_EMAIL_WINDOW_MS = 6e4;
|
|
17609
|
+
const handleTestEmailDispatch = async (c) => {
|
|
17610
|
+
const user = c.get("user");
|
|
17611
|
+
const adminCol = config.collections.find((col) => col.slug === "__admins") || config.collections.find((col) => col.auth);
|
|
17612
|
+
if (!isUserAdmin(user, adminCol)) {
|
|
17613
|
+
return c.json({ error: true, message: "Forbidden: Admin access required to dispatch test emails" }, 403);
|
|
17614
|
+
}
|
|
17615
|
+
const userId = String(user?.id ?? user?.sub ?? "unknown");
|
|
17616
|
+
const now2 = Date.now();
|
|
17617
|
+
const window2 = (testEmailRateLimitMap.get(userId) ?? []).filter(
|
|
17618
|
+
(ts) => now2 - ts < TEST_EMAIL_WINDOW_MS
|
|
17619
|
+
);
|
|
17620
|
+
if (window2.length >= TEST_EMAIL_MAX) {
|
|
17621
|
+
return c.json(
|
|
17622
|
+
{ error: true, message: `Rate limit exceeded: maximum ${TEST_EMAIL_MAX} test emails per minute.` },
|
|
17623
|
+
429
|
|
17624
|
+
);
|
|
17625
|
+
}
|
|
17626
|
+
window2.push(now2);
|
|
17627
|
+
testEmailRateLimitMap.set(userId, window2);
|
|
17628
|
+
const body = await c.req.json().catch(() => ({}));
|
|
17629
|
+
const {
|
|
17630
|
+
to,
|
|
17631
|
+
subject,
|
|
17632
|
+
html,
|
|
17633
|
+
format = "html",
|
|
17634
|
+
externalTemplateId,
|
|
17635
|
+
purpose = "invite",
|
|
17636
|
+
collectionSlug = "users"
|
|
17637
|
+
} = body;
|
|
17638
|
+
if (!to || typeof to !== "string" || !to.includes("@")) {
|
|
17639
|
+
return c.json({ error: true, message: "A valid recipient email address is required" }, 400);
|
|
17640
|
+
}
|
|
17641
|
+
const siteName = config.admin?.branding?.logoText || "Dyrected";
|
|
17642
|
+
let appUrl = config.admin?.siteUrl;
|
|
17643
|
+
if (!appUrl) {
|
|
17644
|
+
try {
|
|
17645
|
+
appUrl = new URL(c.req.url).origin;
|
|
17646
|
+
} catch {
|
|
17647
|
+
appUrl = "http://localhost:3000";
|
|
17648
|
+
}
|
|
17649
|
+
}
|
|
17650
|
+
const targetCol = config.collections.find((x) => x.slug === collectionSlug);
|
|
17651
|
+
const resolvedCollectionLabel = collectionSlug === "*" ? "Account" : targetCol?.labels?.singular || targetCol?.labels?.plural || (collectionSlug.startsWith("__") ? collectionSlug.slice(2).charAt(0).toUpperCase() + collectionSlug.slice(3) : collectionSlug.charAt(0).toUpperCase() + collectionSlug.slice(1));
|
|
17652
|
+
const mockData = {
|
|
17653
|
+
"{{url}}": `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
|
|
17654
|
+
"{{token}}": `mock_test_token_${Date.now()}`,
|
|
17655
|
+
"{{email}}": to,
|
|
17656
|
+
"{{collectionLabel}}": resolvedCollectionLabel,
|
|
17657
|
+
"{{siteName}}": siteName,
|
|
17658
|
+
"{{user.name}}": typeof user?.name === "string" ? user.name : "Test Recipient",
|
|
17659
|
+
"{{user.first_name}}": typeof user?.first_name === "string" ? user.first_name : "Test",
|
|
17660
|
+
"{{user.email}}": to
|
|
17661
|
+
};
|
|
17662
|
+
try {
|
|
17663
|
+
let result;
|
|
17664
|
+
if (format === "external_template") {
|
|
17665
|
+
if (!externalTemplateId) {
|
|
17666
|
+
return c.json({ error: true, message: "External template ID is required when format is external_template" }, 400);
|
|
17667
|
+
}
|
|
17668
|
+
const variables = {
|
|
17669
|
+
email: to,
|
|
17670
|
+
url: `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
|
|
17671
|
+
token: `mock_test_token_${Date.now()}`,
|
|
17672
|
+
collection: collectionSlug,
|
|
17673
|
+
collectionLabel: resolvedCollectionLabel,
|
|
17674
|
+
siteName,
|
|
17675
|
+
user: {
|
|
17676
|
+
name: typeof user?.name === "string" ? user.name : "Test Recipient",
|
|
17677
|
+
first_name: typeof user?.first_name === "string" ? user.first_name : "Test",
|
|
17678
|
+
email: to
|
|
17679
|
+
}
|
|
17680
|
+
};
|
|
17681
|
+
let testSubject = subject || `[TEST] ${purpose} email`;
|
|
17682
|
+
for (const [tag, sample] of Object.entries(mockData)) {
|
|
17683
|
+
testSubject = testSubject.split(tag).join(sample);
|
|
17684
|
+
}
|
|
17685
|
+
result = await sendEmail(config, {
|
|
17686
|
+
to,
|
|
17687
|
+
type: "template",
|
|
17688
|
+
template: externalTemplateId,
|
|
17689
|
+
variables,
|
|
17690
|
+
subject: testSubject,
|
|
17691
|
+
purpose,
|
|
17692
|
+
collection: collectionSlug
|
|
17693
|
+
});
|
|
17694
|
+
} else {
|
|
17695
|
+
let finalSubject = subject || `[TEST] ${purpose} email`;
|
|
17696
|
+
let finalHtml = html || "";
|
|
17697
|
+
for (const [tag, sample] of Object.entries(mockData)) {
|
|
17698
|
+
finalSubject = finalSubject.split(tag).join(sample);
|
|
17699
|
+
finalHtml = finalHtml.split(tag).join(sample);
|
|
17700
|
+
}
|
|
17701
|
+
result = await sendEmail(config, {
|
|
17702
|
+
to,
|
|
17703
|
+
type: "html",
|
|
17704
|
+
subject: finalSubject,
|
|
17705
|
+
html: finalHtml,
|
|
17706
|
+
purpose,
|
|
17707
|
+
collection: collectionSlug
|
|
17708
|
+
});
|
|
17709
|
+
}
|
|
17710
|
+
return c.json({
|
|
17711
|
+
success: true,
|
|
17712
|
+
message: `Test email sent to ${to}`,
|
|
17713
|
+
previewUrl: result?.previewUrl
|
|
17714
|
+
});
|
|
17715
|
+
} catch (err) {
|
|
17716
|
+
getConfigLogger(config, "email").error({
|
|
17717
|
+
err,
|
|
17718
|
+
msg: "Failed to dispatch test email",
|
|
17719
|
+
to
|
|
17720
|
+
});
|
|
17721
|
+
return c.json({ error: true, message: err.message || "Failed to send test email" }, 500);
|
|
17722
|
+
}
|
|
17723
|
+
};
|
|
17724
|
+
app.post("/api/__email_templates/test", requireAuth(config), handleTestEmailDispatch);
|
|
17725
|
+
app.post("/api/email-templates/test", requireAuth(config), handleTestEmailDispatch);
|
|
16870
17726
|
if (!process.env.DYRECTED_JWT_SECRET) {
|
|
16871
17727
|
getConfigLogger(config, "router").warn({
|
|
16872
17728
|
msg: "DYRECTED_JWT_SECRET is not set; token-mode live preview is signing with an insecure default"
|
|
@@ -17321,6 +18177,114 @@ var AUTH_SESSIONS_COLLECTION_CONFIG = {
|
|
|
17321
18177
|
},
|
|
17322
18178
|
admin: { hidden: true }
|
|
17323
18179
|
};
|
|
18180
|
+
var EMAIL_TEMPLATES_COLLECTION_SLUG = "__email_templates";
|
|
18181
|
+
var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
|
|
18182
|
+
slug: EMAIL_TEMPLATES_COLLECTION_SLUG,
|
|
18183
|
+
labels: { singular: "Email Template", plural: "Email Templates" },
|
|
18184
|
+
detail: false,
|
|
18185
|
+
fields: [
|
|
18186
|
+
{
|
|
18187
|
+
name: "collectionSlug",
|
|
18188
|
+
type: "select",
|
|
18189
|
+
label: "Applies To",
|
|
18190
|
+
required: true,
|
|
18191
|
+
defaultValue: "*",
|
|
18192
|
+
options: [
|
|
18193
|
+
{ label: "Global (All Users & Collections)", value: "*" }
|
|
18194
|
+
],
|
|
18195
|
+
admin: {
|
|
18196
|
+
description: "Choose which user collection this template applies to, or 'Global' for all users."
|
|
18197
|
+
}
|
|
18198
|
+
},
|
|
18199
|
+
{
|
|
18200
|
+
name: "purpose",
|
|
18201
|
+
type: "select",
|
|
18202
|
+
label: "Purpose",
|
|
18203
|
+
required: true,
|
|
18204
|
+
options: [
|
|
18205
|
+
{ label: "Invite", value: "invite" },
|
|
18206
|
+
{ label: "Password Reset", value: "resetPassword" },
|
|
18207
|
+
{ label: "Welcome", value: "welcome" },
|
|
18208
|
+
{ label: "Password Changed", value: "passwordChanged" }
|
|
18209
|
+
],
|
|
18210
|
+
admin: { width: "50%" }
|
|
18211
|
+
},
|
|
18212
|
+
{
|
|
18213
|
+
name: "format",
|
|
18214
|
+
type: "select",
|
|
18215
|
+
label: "Format",
|
|
18216
|
+
required: true,
|
|
18217
|
+
defaultValue: "html",
|
|
18218
|
+
options: [
|
|
18219
|
+
{ label: "HTML", value: "html" },
|
|
18220
|
+
{ label: "External Template", value: "external_template" }
|
|
18221
|
+
],
|
|
18222
|
+
admin: { width: "50%" }
|
|
18223
|
+
},
|
|
18224
|
+
{
|
|
18225
|
+
name: "subject",
|
|
18226
|
+
type: "text",
|
|
18227
|
+
label: "Subject",
|
|
18228
|
+
admin: {
|
|
18229
|
+
description: "Subject line (optional when using external provider template)"
|
|
18230
|
+
}
|
|
18231
|
+
},
|
|
18232
|
+
{
|
|
18233
|
+
name: "rawHtml",
|
|
18234
|
+
type: "textarea",
|
|
18235
|
+
label: "HTML Content",
|
|
18236
|
+
admin: {
|
|
18237
|
+
description: "Raw HTML email content with {{variable}} tags"
|
|
18238
|
+
}
|
|
18239
|
+
},
|
|
18240
|
+
{
|
|
18241
|
+
name: "externalTemplateId",
|
|
18242
|
+
type: "text",
|
|
18243
|
+
label: "External Template ID",
|
|
18244
|
+
admin: {
|
|
18245
|
+
description: "Template ID or alias from Seamailer, Postmark, SendGrid, etc."
|
|
18246
|
+
}
|
|
18247
|
+
},
|
|
18248
|
+
{
|
|
18249
|
+
name: "active",
|
|
18250
|
+
type: "boolean",
|
|
18251
|
+
label: "Active",
|
|
18252
|
+
defaultValue: true,
|
|
18253
|
+
admin: {
|
|
18254
|
+
description: "When active, this template overrides code config. When disabled, falls back to code."
|
|
18255
|
+
}
|
|
18256
|
+
}
|
|
18257
|
+
],
|
|
18258
|
+
admin: {
|
|
18259
|
+
group: "Settings",
|
|
18260
|
+
icon: "Mail",
|
|
18261
|
+
groupBy: "collectionSlug",
|
|
18262
|
+
defaultColumns: ["purpose", "collectionSlug", "format", "active"]
|
|
18263
|
+
},
|
|
18264
|
+
hooks: {
|
|
18265
|
+
beforeChange: [
|
|
18266
|
+
async ({ data, req }) => {
|
|
18267
|
+
if (data && data.format === "html") {
|
|
18268
|
+
const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
|
|
18269
|
+
const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
|
|
18270
|
+
if (!data.subject) {
|
|
18271
|
+
data.subject = defaults.subject;
|
|
18272
|
+
}
|
|
18273
|
+
if (!data.rawHtml) {
|
|
18274
|
+
data.rawHtml = defaults.rawHtml;
|
|
18275
|
+
}
|
|
18276
|
+
}
|
|
18277
|
+
return data;
|
|
18278
|
+
}
|
|
18279
|
+
]
|
|
18280
|
+
},
|
|
18281
|
+
access: {
|
|
18282
|
+
read: ({ user }) => isUserAdmin(user),
|
|
18283
|
+
create: ({ user }) => isUserAdmin(user),
|
|
18284
|
+
update: ({ user }) => isUserAdmin(user),
|
|
18285
|
+
delete: ({ user }) => isUserAdmin(user)
|
|
18286
|
+
}
|
|
18287
|
+
};
|
|
17324
18288
|
function normalizeConfig(config) {
|
|
17325
18289
|
const schemaAwareConfig = normalizeSchemaFragment(config);
|
|
17326
18290
|
assertValidDeclarativeHooksInConfig(schemaAwareConfig, "config");
|
|
@@ -17335,10 +18299,13 @@ function normalizeConfig(config) {
|
|
|
17335
18299
|
const needsTasks = (config.tasks?.length ?? 0) > 0;
|
|
17336
18300
|
const needsAuthSessions = collections.some((col) => !!col.auth);
|
|
17337
18301
|
const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
|
|
17338
|
-
const
|
|
18302
|
+
const adminAuthCollection = getAdminAuthCollection({
|
|
17339
18303
|
collections,
|
|
17340
18304
|
adminAuth: schemaAwareConfig.adminAuth
|
|
17341
|
-
})
|
|
18305
|
+
});
|
|
18306
|
+
const adminAuthCollectionSlug = adminAuthCollection?.slug;
|
|
18307
|
+
const adminOnly = ({ user }) => isUserAdmin(user, adminAuthCollection);
|
|
18308
|
+
const adminOtherThanSelf = ({ user, id }) => isUserAdmin(user, adminAuthCollection) && user?.id !== id;
|
|
17342
18309
|
const normalizedCollections = collections.map((col) => {
|
|
17343
18310
|
let fields = col.fields || [];
|
|
17344
18311
|
const existingFieldNames = new Set(fields.map((f) => f.name));
|
|
@@ -17387,7 +18354,7 @@ function normalizeConfig(config) {
|
|
|
17387
18354
|
{ value: "viewer", label: "Viewer" }
|
|
17388
18355
|
],
|
|
17389
18356
|
access: {
|
|
17390
|
-
update:
|
|
18357
|
+
update: adminOnly
|
|
17391
18358
|
}
|
|
17392
18359
|
}
|
|
17393
18360
|
];
|
|
@@ -17405,7 +18372,7 @@ function normalizeConfig(config) {
|
|
|
17405
18372
|
{ value: "pending", label: "Pending" }
|
|
17406
18373
|
],
|
|
17407
18374
|
access: {
|
|
17408
|
-
update:
|
|
18375
|
+
update: adminOtherThanSelf
|
|
17409
18376
|
},
|
|
17410
18377
|
admin: {
|
|
17411
18378
|
condition: `!(data.roles && "admin" in data.roles) && data.role != "admin"`
|
|
@@ -17474,7 +18441,7 @@ function normalizeConfig(config) {
|
|
|
17474
18441
|
access: {
|
|
17475
18442
|
...field.access || {},
|
|
17476
18443
|
// Must be an admin; cannot edit own roles (no self-elevation).
|
|
17477
|
-
update:
|
|
18444
|
+
update: adminOtherThanSelf
|
|
17478
18445
|
}
|
|
17479
18446
|
};
|
|
17480
18447
|
}
|
|
@@ -17483,7 +18450,7 @@ function normalizeConfig(config) {
|
|
|
17483
18450
|
...field,
|
|
17484
18451
|
access: {
|
|
17485
18452
|
...field.access || {},
|
|
17486
|
-
update:
|
|
18453
|
+
update: adminOtherThanSelf
|
|
17487
18454
|
}
|
|
17488
18455
|
};
|
|
17489
18456
|
}
|
|
@@ -17575,6 +18542,52 @@ function normalizeConfig(config) {
|
|
|
17575
18542
|
if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
|
|
17576
18543
|
systemCollections.push(TRASH_COLLECTION_CONFIG);
|
|
17577
18544
|
}
|
|
18545
|
+
const needsEmailTemplates = schemaAwareConfig.email?.adminEditable === true;
|
|
18546
|
+
if (needsEmailTemplates && !normalizedCollections.some((col) => col.slug === EMAIL_TEMPLATES_COLLECTION_SLUG)) {
|
|
18547
|
+
const adminCol = getAdminAuthCollection(schemaAwareConfig);
|
|
18548
|
+
const customAccess = schemaAwareConfig.email?.access;
|
|
18549
|
+
const resolveOpAccess = (op) => {
|
|
18550
|
+
if (typeof customAccess === "function") return customAccess;
|
|
18551
|
+
if (customAccess && typeof customAccess === "object" && customAccess[op]) return customAccess[op];
|
|
18552
|
+
return ({ user }) => isUserAdmin(user, adminCol);
|
|
18553
|
+
};
|
|
18554
|
+
const customPurposes = schemaAwareConfig.email?.purposes ?? [];
|
|
18555
|
+
const purposeOptions = [
|
|
18556
|
+
{ label: "Invite", value: "invite" },
|
|
18557
|
+
{ label: "Password Reset", value: "resetPassword" },
|
|
18558
|
+
{ label: "Welcome", value: "welcome" },
|
|
18559
|
+
{ label: "Password Changed", value: "passwordChanged" },
|
|
18560
|
+
...customPurposes
|
|
18561
|
+
];
|
|
18562
|
+
const targetableCollections = normalizedCollections.filter(
|
|
18563
|
+
(c) => !c.slug.startsWith("__")
|
|
18564
|
+
);
|
|
18565
|
+
const audienceOptions = [
|
|
18566
|
+
{ label: "Global (All Collections)", value: "*" },
|
|
18567
|
+
...targetableCollections.map((c) => ({
|
|
18568
|
+
label: `${c.labels?.singular || c.labels?.plural || c.slug} (${c.slug})`,
|
|
18569
|
+
value: c.slug
|
|
18570
|
+
}))
|
|
18571
|
+
];
|
|
18572
|
+
systemCollections.push({
|
|
18573
|
+
...EMAIL_TEMPLATES_COLLECTION_CONFIG,
|
|
18574
|
+
fields: EMAIL_TEMPLATES_COLLECTION_CONFIG.fields.map((f) => {
|
|
18575
|
+
if (f.name === "collectionSlug") {
|
|
18576
|
+
return { ...f, options: audienceOptions };
|
|
18577
|
+
}
|
|
18578
|
+
if (f.name === "purpose") {
|
|
18579
|
+
return { ...f, options: purposeOptions };
|
|
18580
|
+
}
|
|
18581
|
+
return f;
|
|
18582
|
+
}),
|
|
18583
|
+
access: {
|
|
18584
|
+
read: resolveOpAccess("read"),
|
|
18585
|
+
create: resolveOpAccess("create"),
|
|
18586
|
+
update: resolveOpAccess("update"),
|
|
18587
|
+
delete: resolveOpAccess("delete")
|
|
18588
|
+
}
|
|
18589
|
+
});
|
|
18590
|
+
}
|
|
17578
18591
|
return {
|
|
17579
18592
|
...schemaAwareConfig,
|
|
17580
18593
|
collections: [...normalizedCollections, ...systemCollections],
|
|
@@ -17888,8 +18901,10 @@ export {
|
|
|
17888
18901
|
AUTH_SESSIONS_COLLECTION,
|
|
17889
18902
|
AuthController,
|
|
17890
18903
|
CollectionController,
|
|
18904
|
+
DEFAULT_INVITE_EXPIRY,
|
|
17891
18905
|
DEFAULT_LOCK_TIME_MS,
|
|
17892
18906
|
DEFAULT_MAX_LOGIN_ATTEMPTS,
|
|
18907
|
+
DEFAULT_RESET_PASSWORD_EXPIRY,
|
|
17893
18908
|
DEFAULT_SESSION_EXPIRY,
|
|
17894
18909
|
EmbeddingService,
|
|
17895
18910
|
GlobalController,
|
|
@@ -17921,13 +18936,16 @@ export {
|
|
|
17921
18936
|
getAIModel,
|
|
17922
18937
|
getAuthSession,
|
|
17923
18938
|
getConfigLogger,
|
|
18939
|
+
getDbEmailTemplateOverride,
|
|
17924
18940
|
getEmbeddingModel,
|
|
17925
18941
|
getLockedUntilMs,
|
|
17926
18942
|
getObservabilityRuntime,
|
|
17927
18943
|
getRequestLogger,
|
|
17928
18944
|
hasEmbeddingApiKey,
|
|
18945
|
+
hasUsablePassword,
|
|
17929
18946
|
hashContent,
|
|
17930
18947
|
hashPassword,
|
|
18948
|
+
interpolateVariables,
|
|
17931
18949
|
isAuthSessionActive,
|
|
17932
18950
|
issueAuthSessionToken,
|
|
17933
18951
|
normalizeFieldValue,
|
|
@@ -17936,7 +18954,10 @@ export {
|
|
|
17936
18954
|
registerRoutes,
|
|
17937
18955
|
renderPrometheusMetrics,
|
|
17938
18956
|
resolveAuthLockoutConfig,
|
|
18957
|
+
resolveEmailTemplate,
|
|
18958
|
+
resolveInviteTokenExpiry,
|
|
17939
18959
|
resolveObservabilityConfig,
|
|
18960
|
+
resolveResetPasswordTokenExpiry,
|
|
17940
18961
|
resolveSessionTokenExpiry,
|
|
17941
18962
|
revokeAllAuthSessions,
|
|
17942
18963
|
revokeAuthSession,
|
|
@@ -17946,6 +18967,7 @@ export {
|
|
|
17946
18967
|
serializeViewForApi,
|
|
17947
18968
|
shouldSampleRequest,
|
|
17948
18969
|
signCollectionToken,
|
|
18970
|
+
toOutboundEmail,
|
|
17949
18971
|
touchAuthSession,
|
|
17950
18972
|
validatePayloadAgainstFields,
|
|
17951
18973
|
verifyCollectionToken,
|