@xk2800/nextjs-template 0.7.1-beta.7 → 0.7.1-beta.8
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/components/dashboard/admin/broadcastActions.tsx +148 -0
- package/components/dashboard/admin/confirmSendDialog.tsx +44 -0
- package/components/dashboard/admin/marketingEmailForm.tsx +115 -0
- package/components/dashboard/admin/marketingSection.tsx +67 -0
- package/components/dashboard/admin/subscribersCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesSection.tsx +10 -0
- package/dist/api/feedback.mjs +2 -1
- package/dist/api/feedback.mjs.map +1 -1
- package/dist/api/marketing.d.mts +36 -0
- package/dist/api/marketing.d.ts +36 -0
- package/dist/api/marketing.js +1357 -0
- package/dist/api/marketing.js.map +1 -0
- package/dist/api/marketing.mjs +170 -0
- package/dist/api/marketing.mjs.map +1 -0
- package/dist/auth/helpers.mjs +5 -60
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.mjs +2 -1
- package/dist/chunk-6Z3WUSDJ.mjs +62 -0
- package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
- package/dist/{chunk-6B45N7ZB.mjs → chunk-7QVIOQ47.mjs} +4 -13
- package/dist/chunk-7QVIOQ47.mjs.map +1 -0
- package/dist/chunk-C6RGN6QA.mjs +60 -0
- package/dist/chunk-C6RGN6QA.mjs.map +1 -0
- package/dist/chunk-OVKVD2HH.mjs +25 -0
- package/dist/chunk-OVKVD2HH.mjs.map +1 -0
- package/dist/marketing/index.d.mts +35 -0
- package/dist/marketing/index.d.ts +35 -0
- package/dist/marketing/index.js +125 -0
- package/dist/marketing/index.js.map +1 -0
- package/dist/marketing/index.mjs +6 -0
- package/dist/marketing/index.mjs.map +1 -0
- package/package.json +22 -2
- package/dist/chunk-6B45N7ZB.mjs.map +0 -1
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var components = require('@react-email/components');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
require('server-only');
|
|
6
|
+
var server = require('next/server');
|
|
7
|
+
var headers = require('next/headers');
|
|
8
|
+
var zod = require('zod');
|
|
9
|
+
var betterAuth = require('better-auth');
|
|
10
|
+
var drizzle = require('better-auth/adapters/drizzle');
|
|
11
|
+
var cuid2 = require('@paralleldrive/cuid2');
|
|
12
|
+
var drizzleOrm = require('drizzle-orm');
|
|
13
|
+
var serverless = require('@neondatabase/serverless');
|
|
14
|
+
var neonHttp = require('drizzle-orm/neon-http');
|
|
15
|
+
var nodePostgres = require('drizzle-orm/node-postgres');
|
|
16
|
+
var pg = require('pg');
|
|
17
|
+
var pgCore = require('drizzle-orm/pg-core');
|
|
18
|
+
var plugins = require('better-auth/plugins');
|
|
19
|
+
var passkey = require('@better-auth/passkey');
|
|
20
|
+
var api = require('better-auth/api');
|
|
21
|
+
var uaParserJs = require('ua-parser-js');
|
|
22
|
+
var geoip = require('geoip-lite');
|
|
23
|
+
var react = require('react');
|
|
24
|
+
var navigation = require('next/navigation');
|
|
25
|
+
var crypto = require('crypto');
|
|
26
|
+
|
|
27
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
28
|
+
|
|
29
|
+
var geoip__default = /*#__PURE__*/_interopDefault(geoip);
|
|
30
|
+
|
|
31
|
+
var __defProp = Object.defineProperty;
|
|
32
|
+
var __defProps = Object.defineProperties;
|
|
33
|
+
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
|
|
34
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
35
|
+
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
36
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
37
|
+
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
38
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
39
|
+
var __spreadValues = (a, b) => {
|
|
40
|
+
for (var prop in b || (b = {}))
|
|
41
|
+
if (__hasOwnProp.call(b, prop))
|
|
42
|
+
__defNormalProp(a, prop, b[prop]);
|
|
43
|
+
if (__getOwnPropSymbols)
|
|
44
|
+
for (var prop of __getOwnPropSymbols(b)) {
|
|
45
|
+
if (__propIsEnum.call(b, prop))
|
|
46
|
+
__defNormalProp(a, prop, b[prop]);
|
|
47
|
+
}
|
|
48
|
+
return a;
|
|
49
|
+
};
|
|
50
|
+
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
|
51
|
+
var __esm = (fn, res) => function __init() {
|
|
52
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
53
|
+
};
|
|
54
|
+
var __export = (target, all) => {
|
|
55
|
+
for (var name in all)
|
|
56
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// components/email/email-template-passkey-change.tsx
|
|
60
|
+
var email_template_passkey_change_exports = {};
|
|
61
|
+
__export(email_template_passkey_change_exports, {
|
|
62
|
+
EmailTemplatePasskeyChange: () => EmailTemplatePasskeyChange
|
|
63
|
+
});
|
|
64
|
+
function EmailTemplatePasskeyChange({
|
|
65
|
+
firstName,
|
|
66
|
+
action,
|
|
67
|
+
device,
|
|
68
|
+
location,
|
|
69
|
+
ipAddress,
|
|
70
|
+
when,
|
|
71
|
+
manageUrl
|
|
72
|
+
}) {
|
|
73
|
+
const verb = action === "added" ? "added to" : "removed from";
|
|
74
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(components.Html, { children: [
|
|
75
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Head, {}),
|
|
76
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Tailwind, { children: /* @__PURE__ */ jsxRuntime.jsx(components.Body, { className: "bg-white font-sans", children: /* @__PURE__ */ jsxRuntime.jsx(components.Container, { className: "mx-auto max-w-[560px] px-0 pt-5 pb-12", children: /* @__PURE__ */ jsxRuntime.jsxs(components.Section, { className: "rounded-xl bg-[#f8f9fa] p-10", children: [
|
|
77
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Heading, { className: "mb-3 text-center text-2xl font-bold text-[#1a1a1a]", children: [
|
|
78
|
+
"A passkey was ",
|
|
79
|
+
action
|
|
80
|
+
] }),
|
|
81
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "mb-6 text-base leading-6 text-[#666666]", children: [
|
|
82
|
+
"Hi ",
|
|
83
|
+
firstName,
|
|
84
|
+
", a passkey was just ",
|
|
85
|
+
verb,
|
|
86
|
+
" your account."
|
|
87
|
+
] }),
|
|
88
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Section, { className: "mb-6 rounded-lg bg-white p-5", children: [
|
|
89
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "m-0 mb-2 text-sm leading-5 text-[#1a1a1a]", children: [
|
|
90
|
+
/* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Device:" }),
|
|
91
|
+
" ",
|
|
92
|
+
device
|
|
93
|
+
] }),
|
|
94
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "m-0 mb-2 text-sm leading-5 text-[#1a1a1a]", children: [
|
|
95
|
+
/* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Approx. location:" }),
|
|
96
|
+
" ",
|
|
97
|
+
location
|
|
98
|
+
] }),
|
|
99
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "m-0 mb-2 text-sm leading-5 text-[#1a1a1a]", children: [
|
|
100
|
+
/* @__PURE__ */ jsxRuntime.jsx("strong", { children: "IP address:" }),
|
|
101
|
+
" ",
|
|
102
|
+
ipAddress
|
|
103
|
+
] }),
|
|
104
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "m-0 text-sm leading-5 text-[#1a1a1a]", children: [
|
|
105
|
+
/* @__PURE__ */ jsxRuntime.jsx("strong", { children: "When:" }),
|
|
106
|
+
" ",
|
|
107
|
+
when
|
|
108
|
+
] })
|
|
109
|
+
] }),
|
|
110
|
+
manageUrl && /* @__PURE__ */ jsxRuntime.jsx(components.Section, { className: "mb-6 text-center", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
111
|
+
components.Button,
|
|
112
|
+
{
|
|
113
|
+
href: manageUrl,
|
|
114
|
+
className: "rounded-lg bg-[#1a1a1a] px-6 py-3 text-sm font-bold text-white no-underline",
|
|
115
|
+
children: "Review your passkeys"
|
|
116
|
+
}
|
|
117
|
+
) }),
|
|
118
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Text, { className: "text-sm leading-[22px] text-[#666666]", children: "If this was you, no action is needed. If you don't recognize it, change your password now and review your passkeys and active sessions." })
|
|
119
|
+
] }) }) }) })
|
|
120
|
+
] });
|
|
121
|
+
}
|
|
122
|
+
var init_email_template_passkey_change = __esm({
|
|
123
|
+
"components/email/email-template-passkey-change.tsx"() {
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// components/email/email-template-reset-password.tsx
|
|
128
|
+
var email_template_reset_password_exports = {};
|
|
129
|
+
__export(email_template_reset_password_exports, {
|
|
130
|
+
EmailTemplateResetPassword: () => EmailTemplateResetPassword
|
|
131
|
+
});
|
|
132
|
+
function EmailTemplateResetPassword({ firstName, url }) {
|
|
133
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(components.Html, { children: [
|
|
134
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Head, {}),
|
|
135
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Body, { style: { backgroundColor: "#ffffff", fontFamily: "Arial, sans-serif" }, children: /* @__PURE__ */ jsxRuntime.jsx(components.Container, { style: { margin: "0 auto", padding: "20px 0 48px", maxWidth: "560px" }, children: /* @__PURE__ */ jsxRuntime.jsxs(components.Section, { style: { backgroundColor: "#f8f9fa", borderRadius: "12px", padding: "40px", textAlign: "center" }, children: [
|
|
136
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Heading, { style: { fontSize: "28px", fontWeight: "bold", marginBottom: "12px", color: "#1a1a1a" }, children: "Reset your password" }),
|
|
137
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { style: { fontSize: "16px", color: "#666666", marginBottom: "32px", lineHeight: "24px" }, children: [
|
|
138
|
+
"Hi ",
|
|
139
|
+
firstName,
|
|
140
|
+
", we got a request to reset your password. This link expires in an hour \u2014 if you didn't ask for this, you can safely ignore this email."
|
|
141
|
+
] }),
|
|
142
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Section, { style: { marginBottom: "32px" }, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
143
|
+
components.Button,
|
|
144
|
+
{
|
|
145
|
+
href: url,
|
|
146
|
+
style: {
|
|
147
|
+
backgroundColor: "#000000",
|
|
148
|
+
color: "#ffffff",
|
|
149
|
+
padding: "12px 32px",
|
|
150
|
+
borderRadius: "6px",
|
|
151
|
+
textDecoration: "none",
|
|
152
|
+
fontSize: "16px",
|
|
153
|
+
fontWeight: "bold"
|
|
154
|
+
},
|
|
155
|
+
children: "Reset password"
|
|
156
|
+
}
|
|
157
|
+
) }),
|
|
158
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { style: { fontSize: "13px", color: "#999999", lineHeight: "20px", wordBreak: "break-all" }, children: [
|
|
159
|
+
"Or paste this link into your browser:",
|
|
160
|
+
/* @__PURE__ */ jsxRuntime.jsx("br", {}),
|
|
161
|
+
/* @__PURE__ */ jsxRuntime.jsx("a", { href: url, style: { color: "#000000", textDecoration: "underline" }, children: url })
|
|
162
|
+
] })
|
|
163
|
+
] }) }) })
|
|
164
|
+
] });
|
|
165
|
+
}
|
|
166
|
+
var init_email_template_reset_password = __esm({
|
|
167
|
+
"components/email/email-template-reset-password.tsx"() {
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// components/email/email-template-verify-email.tsx
|
|
172
|
+
var email_template_verify_email_exports = {};
|
|
173
|
+
__export(email_template_verify_email_exports, {
|
|
174
|
+
EmailTemplateVerifyEmail: () => EmailTemplateVerifyEmail
|
|
175
|
+
});
|
|
176
|
+
function EmailTemplateVerifyEmail({ firstName, url }) {
|
|
177
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(components.Html, { children: [
|
|
178
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Head, {}),
|
|
179
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Tailwind, { children: /* @__PURE__ */ jsxRuntime.jsx(components.Body, { className: "bg-white font-sans", children: /* @__PURE__ */ jsxRuntime.jsx(components.Container, { className: "mx-auto max-w-[560px] px-0 pt-5 pb-12", children: /* @__PURE__ */ jsxRuntime.jsxs(components.Section, { className: "rounded-xl bg-[#f8f9fa] p-10 text-center", children: [
|
|
180
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Heading, { className: "mb-3 text-[28px] font-bold text-[#1a1a1a]", children: "Confirm your email" }),
|
|
181
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "mb-8 text-base leading-6 text-[#666666]", children: [
|
|
182
|
+
"Hi ",
|
|
183
|
+
firstName,
|
|
184
|
+
", click the button below to verify this email address. If you didn't create an account, you can safely ignore this email."
|
|
185
|
+
] }),
|
|
186
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Section, { className: "mb-8", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
187
|
+
components.Button,
|
|
188
|
+
{
|
|
189
|
+
href: url,
|
|
190
|
+
className: "rounded-md bg-black px-8 py-3 text-base font-bold text-white no-underline",
|
|
191
|
+
children: "Verify email"
|
|
192
|
+
}
|
|
193
|
+
) }),
|
|
194
|
+
/* @__PURE__ */ jsxRuntime.jsxs(components.Text, { className: "text-[13px] leading-5 break-all text-[#999999]", children: [
|
|
195
|
+
"Or paste this link into your browser:",
|
|
196
|
+
/* @__PURE__ */ jsxRuntime.jsx("br", {}),
|
|
197
|
+
/* @__PURE__ */ jsxRuntime.jsx(components.Link, { href: url, className: "text-black underline", children: url })
|
|
198
|
+
] })
|
|
199
|
+
] }) }) }) })
|
|
200
|
+
] });
|
|
201
|
+
}
|
|
202
|
+
var init_email_template_verify_email = __esm({
|
|
203
|
+
"components/email/email-template-verify-email.tsx"() {
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// server/db/schema.ts
|
|
208
|
+
var schema_exports = {};
|
|
209
|
+
__export(schema_exports, {
|
|
210
|
+
ActivityActionEnum: () => ActivityActionEnum,
|
|
211
|
+
RoleEnum: () => RoleEnum,
|
|
212
|
+
accounts: () => accounts,
|
|
213
|
+
activityLogs: () => activityLogs,
|
|
214
|
+
authThrottle: () => authThrottle,
|
|
215
|
+
consentLog: () => consentLog,
|
|
216
|
+
deviceFingerprints: () => deviceFingerprints,
|
|
217
|
+
errorRefs: () => errorRefs,
|
|
218
|
+
passkeys: () => passkeys,
|
|
219
|
+
sessions: () => sessions,
|
|
220
|
+
systemSettings: () => systemSettings,
|
|
221
|
+
twoFactors: () => twoFactors,
|
|
222
|
+
users: () => users,
|
|
223
|
+
verifications: () => verifications
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// lib/maintenance.ts
|
|
227
|
+
var DEFAULT_MAINTENANCE_EXEMPT_PATHS = [
|
|
228
|
+
"/",
|
|
229
|
+
"/features",
|
|
230
|
+
"/changelog",
|
|
231
|
+
"/changelog-template",
|
|
232
|
+
"/privacy",
|
|
233
|
+
"/terms"
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// server/db/schema.ts
|
|
237
|
+
var RoleEnum = pgCore.pgEnum("roles", ["user", "admin", "tester"]);
|
|
238
|
+
var ActivityActionEnum = pgCore.pgEnum("activity_actions", [
|
|
239
|
+
"login",
|
|
240
|
+
"logout",
|
|
241
|
+
"login_failed",
|
|
242
|
+
"password_changed",
|
|
243
|
+
"email_changed",
|
|
244
|
+
"profile_updated",
|
|
245
|
+
"session_revoked",
|
|
246
|
+
"user_deleted",
|
|
247
|
+
"user_banned",
|
|
248
|
+
"user_unbanned",
|
|
249
|
+
"role_changed",
|
|
250
|
+
"impersonation_started",
|
|
251
|
+
"impersonation_stopped",
|
|
252
|
+
"settings_changed",
|
|
253
|
+
"passkey_added",
|
|
254
|
+
"passkey_removed"
|
|
255
|
+
]);
|
|
256
|
+
var users = pgCore.pgTable("user", {
|
|
257
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
258
|
+
name: pgCore.text("name").notNull(),
|
|
259
|
+
email: pgCore.text("email").notNull().unique(),
|
|
260
|
+
password: pgCore.text("password"),
|
|
261
|
+
emailVerified: pgCore.boolean("emailVerified").notNull().default(false),
|
|
262
|
+
image: pgCore.text("image"),
|
|
263
|
+
role: RoleEnum("roles").default("user").notNull(),
|
|
264
|
+
banned: pgCore.boolean("banned").default(false).notNull(),
|
|
265
|
+
bannedAt: pgCore.timestamp("bannedAt"),
|
|
266
|
+
bannedReason: pgCore.text("bannedReason"),
|
|
267
|
+
// Required by better-auth's admin plugin schema (auto-expiring bans). We
|
|
268
|
+
// never set this ourselves, so it stays null and bans behave as permanent,
|
|
269
|
+
// same as before the plugin was added.
|
|
270
|
+
banExpires: pgCore.timestamp("banExpires"),
|
|
271
|
+
lastLoginAt: pgCore.timestamp("lastLoginAt"),
|
|
272
|
+
lastActiveAt: pgCore.timestamp("lastActiveAt"),
|
|
273
|
+
// Set by better-auth's twoFactor plugin — true once the user has
|
|
274
|
+
// completed TOTP setup (see twoFactor pgTable below for the secret /
|
|
275
|
+
// backup codes themselves).
|
|
276
|
+
twoFactorEnabled: pgCore.boolean("twoFactorEnabled").notNull().default(false),
|
|
277
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow(),
|
|
278
|
+
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow()
|
|
279
|
+
});
|
|
280
|
+
var sessions = pgCore.pgTable("session", {
|
|
281
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
282
|
+
expiresAt: pgCore.timestamp("expiresAt").notNull(),
|
|
283
|
+
token: pgCore.text("token").notNull().unique(),
|
|
284
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow(),
|
|
285
|
+
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow(),
|
|
286
|
+
ipAddress: pgCore.text("ipAddress"),
|
|
287
|
+
userAgent: pgCore.text("userAgent"),
|
|
288
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
289
|
+
// Set by better-auth's admin plugin while this session is an admin
|
|
290
|
+
// impersonating another user; holds the impersonating admin's user id.
|
|
291
|
+
impersonatedBy: pgCore.text("impersonatedBy")
|
|
292
|
+
});
|
|
293
|
+
var accounts = pgCore.pgTable("account", {
|
|
294
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
295
|
+
accountId: pgCore.text("accountId").notNull(),
|
|
296
|
+
providerId: pgCore.text("providerId").notNull(),
|
|
297
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
298
|
+
accessToken: pgCore.text("accessToken"),
|
|
299
|
+
refreshToken: pgCore.text("refreshToken"),
|
|
300
|
+
idToken: pgCore.text("idToken"),
|
|
301
|
+
accessTokenExpiresAt: pgCore.timestamp("accessTokenExpiresAt"),
|
|
302
|
+
refreshTokenExpiresAt: pgCore.timestamp("refreshTokenExpiresAt"),
|
|
303
|
+
scope: pgCore.text("scope"),
|
|
304
|
+
password: pgCore.text("password"),
|
|
305
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow(),
|
|
306
|
+
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow()
|
|
307
|
+
});
|
|
308
|
+
var twoFactors = pgCore.pgTable("twoFactor", {
|
|
309
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
310
|
+
secret: pgCore.text("secret").notNull(),
|
|
311
|
+
backupCodes: pgCore.text("backupCodes").notNull(),
|
|
312
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
313
|
+
// Added in better-auth 1.7 (TOTP lockout). Defaults match the plugin's.
|
|
314
|
+
verified: pgCore.boolean("verified").default(true),
|
|
315
|
+
failedVerificationCount: pgCore.integer("failedVerificationCount").default(0),
|
|
316
|
+
lockedUntil: pgCore.timestamp("lockedUntil")
|
|
317
|
+
});
|
|
318
|
+
var passkeys = pgCore.pgTable("passkey", {
|
|
319
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
320
|
+
name: pgCore.text("name"),
|
|
321
|
+
publicKey: pgCore.text("publicKey").notNull(),
|
|
322
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
323
|
+
credentialID: pgCore.text("credentialID").notNull().unique(),
|
|
324
|
+
counter: pgCore.integer("counter").notNull(),
|
|
325
|
+
deviceType: pgCore.text("deviceType").notNull(),
|
|
326
|
+
backedUp: pgCore.boolean("backedUp").notNull(),
|
|
327
|
+
transports: pgCore.text("transports"),
|
|
328
|
+
createdAt: pgCore.timestamp("createdAt"),
|
|
329
|
+
aaguid: pgCore.text("aaguid")
|
|
330
|
+
});
|
|
331
|
+
var verifications = pgCore.pgTable("verification", {
|
|
332
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
333
|
+
identifier: pgCore.text("identifier").notNull(),
|
|
334
|
+
value: pgCore.text("value").notNull(),
|
|
335
|
+
expiresAt: pgCore.timestamp("expiresAt").notNull(),
|
|
336
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow(),
|
|
337
|
+
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow()
|
|
338
|
+
});
|
|
339
|
+
var activityLogs = pgCore.pgTable("activity_log", {
|
|
340
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
341
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
342
|
+
action: ActivityActionEnum("action").notNull(),
|
|
343
|
+
description: pgCore.text("description").notNull(),
|
|
344
|
+
ipAddress: pgCore.text("ipAddress"),
|
|
345
|
+
userAgent: pgCore.text("userAgent"),
|
|
346
|
+
metadata: pgCore.text("metadata"),
|
|
347
|
+
// Populated on 'login' events only (registration + sign-in) — parsed from
|
|
348
|
+
// userAgent/ipAddress at write time so the record stays stable even if
|
|
349
|
+
// the parsing library or geoip database changes later.
|
|
350
|
+
referrerUrl: pgCore.text("referrerUrl"),
|
|
351
|
+
os: pgCore.text("os"),
|
|
352
|
+
browser: pgCore.text("browser"),
|
|
353
|
+
deviceType: pgCore.text("deviceType"),
|
|
354
|
+
country: pgCore.text("country"),
|
|
355
|
+
city: pgCore.text("city"),
|
|
356
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
357
|
+
});
|
|
358
|
+
var deviceFingerprints = pgCore.pgTable("device_fingerprint", {
|
|
359
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
360
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
361
|
+
visitorId: pgCore.text("visitorId").notNull(),
|
|
362
|
+
// Context captured at first-seen time — for the admin panel's "New devices"
|
|
363
|
+
// card and the alert email. Not part of the new-vs-known decision (that's
|
|
364
|
+
// visitorId only), just so an admin can eyeball whether a sign-in looks like
|
|
365
|
+
// a hijack. Nullable: an old row predates these columns, or geoip missed.
|
|
366
|
+
ipAddress: pgCore.text("ipAddress"),
|
|
367
|
+
userAgent: pgCore.text("userAgent"),
|
|
368
|
+
country: pgCore.text("country"),
|
|
369
|
+
city: pgCore.text("city"),
|
|
370
|
+
// Latest FingerprintJS signals for this device (lib/device-fingerprint.ts
|
|
371
|
+
// getDeviceSignals(): accessibility signals dropped, large values hashed),
|
|
372
|
+
// shown on the admin user page. Null for rows from before this column.
|
|
373
|
+
components: pgCore.jsonb("components").$type(),
|
|
374
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
375
|
+
}, (table) => [
|
|
376
|
+
pgCore.unique("device_fingerprint_user_visitor").on(table.userId, table.visitorId)
|
|
377
|
+
]);
|
|
378
|
+
var consentLog = pgCore.pgTable("consent_log", {
|
|
379
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
380
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
381
|
+
// The cookie_consent value: "all", "necessary" or a comma list of purposes.
|
|
382
|
+
choice: pgCore.text("choice").notNull(),
|
|
383
|
+
// CONSENT_VERSION (lib/cookie-names.ts) at the time — which banner/policy
|
|
384
|
+
// wording the user agreed to.
|
|
385
|
+
version: pgCore.integer("version").notNull(),
|
|
386
|
+
// id of the user's record this one follows ("" for their first). Unique per
|
|
387
|
+
// user, so concurrent posts from several tabs that all saw the same latest
|
|
388
|
+
// record can't each insert a duplicate — one wins, the rest conflict. Works
|
|
389
|
+
// without transactions (neon-http has none). Null on rows from before it.
|
|
390
|
+
previousId: pgCore.text("previousId"),
|
|
391
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
392
|
+
}, (table) => [
|
|
393
|
+
pgCore.index("consent_log_user_idx").on(table.userId, table.createdAt),
|
|
394
|
+
pgCore.unique("consent_log_chain").on(table.userId, table.previousId)
|
|
395
|
+
]);
|
|
396
|
+
var authThrottle = pgCore.pgTable("auth_throttle", {
|
|
397
|
+
fingerprint: pgCore.text("fingerprint").notNull().primaryKey(),
|
|
398
|
+
count: pgCore.integer("count").notNull().default(0),
|
|
399
|
+
windowStart: pgCore.timestamp("windowStart").notNull().defaultNow(),
|
|
400
|
+
// Blockable data points, overwritten on every attempt (last-write-wins) so
|
|
401
|
+
// the admin panel can show who to block — by IP or by fingerprint — with no
|
|
402
|
+
// join and no per-attempt history table. windowStart anchors the counter's
|
|
403
|
+
// window; lastAttemptAt is just "most recently seen".
|
|
404
|
+
lastAttemptAt: pgCore.timestamp("lastAttemptAt").notNull().defaultNow(),
|
|
405
|
+
ipAddress: pgCore.text("ipAddress"),
|
|
406
|
+
userAgent: pgCore.text("userAgent"),
|
|
407
|
+
lastEmail: pgCore.text("lastEmail"),
|
|
408
|
+
lastKind: pgCore.text("lastKind")
|
|
409
|
+
// 'signin' | 'signup'
|
|
410
|
+
});
|
|
411
|
+
var systemSettings = pgCore.pgTable("system_settings", {
|
|
412
|
+
id: pgCore.text("id").primaryKey().default("default"),
|
|
413
|
+
maintenanceMode: pgCore.boolean("maintenanceMode").notNull().default(false),
|
|
414
|
+
maintenanceMessage: pgCore.text("maintenanceMessage"),
|
|
415
|
+
// Path prefixes that stay reachable while maintenance mode is on (see
|
|
416
|
+
// lib/maintenance.ts — a few auth paths are always open on top of these).
|
|
417
|
+
maintenanceExemptPaths: pgCore.text("maintenanceExemptPaths").array().notNull().default(DEFAULT_MAINTENANCE_EXEMPT_PATHS),
|
|
418
|
+
authEnableGoogle: pgCore.boolean("authEnableGoogle").notNull().default(true),
|
|
419
|
+
authEnableEmailPassword: pgCore.boolean("authEnableEmailPassword").notNull().default(true),
|
|
420
|
+
authEnableOneTap: pgCore.boolean("authEnableOneTap").notNull().default(false),
|
|
421
|
+
enableSessionRevocation: pgCore.boolean("enableSessionRevocation").notNull().default(true),
|
|
422
|
+
// Attach the error a user hit to feedback they send from it (see
|
|
423
|
+
// lib/feedback-route.ts). Off = only what the user typed is sent.
|
|
424
|
+
feedbackAttachErrors: pgCore.boolean("feedbackAttachErrors").notNull().default(true),
|
|
425
|
+
// GDPR/ePrivacy consent mode. On: cookie banner, FingerprintJS and One Tap
|
|
426
|
+
// only after "Accept all", device record erased on withdrawal. Off (no EU/UK
|
|
427
|
+
// users): no banner, treated as consented. Null = follow
|
|
428
|
+
// NEXT_PUBLIC_COOKIE_BANNER (nullable so migrating never flips a deployed
|
|
429
|
+
// banner on or off). proxy.ts mirrors the effective value into the
|
|
430
|
+
// consent_required cookie for the client (lib/cookie-consent.ts).
|
|
431
|
+
consentRequired: pgCore.boolean("consentRequired"),
|
|
432
|
+
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow(),
|
|
433
|
+
// Who last changed a setting. This row doesn't "belong" to that admin the
|
|
434
|
+
// way sessions/activityLogs belong to their user, so deleting that admin's
|
|
435
|
+
// account should null this out rather than cascade-delete the singleton row.
|
|
436
|
+
updatedBy: pgCore.text("updatedBy").references(() => users.id, { onDelete: "set null" })
|
|
437
|
+
}, (table) => [
|
|
438
|
+
pgCore.check("system_settings_singleton", drizzleOrm.sql`${table.id} = 'default'`)
|
|
439
|
+
]);
|
|
440
|
+
var errorRefs = pgCore.pgTable("error_refs", {
|
|
441
|
+
ref: pgCore.text("ref").primaryKey(),
|
|
442
|
+
message: pgCore.text("message").notNull(),
|
|
443
|
+
detail: pgCore.text("detail").notNull(),
|
|
444
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
445
|
+
}, (table) => [
|
|
446
|
+
pgCore.index("error_refs_created_at_idx").on(table.createdAt)
|
|
447
|
+
]);
|
|
448
|
+
var envSchema = zod.z.object({
|
|
449
|
+
NODE_ENV: zod.z.enum(["development", "production", "test"]).default("development"),
|
|
450
|
+
PORT: zod.z.string().default("3000").transform(Number),
|
|
451
|
+
DATABASE_URL: zod.z.string().default(""),
|
|
452
|
+
RESEND_API_KEY: zod.z.string().default(""),
|
|
453
|
+
// Full access key for marketing broadcasts (admin Marketing email page).
|
|
454
|
+
// Blank = use RESEND_API_KEY.
|
|
455
|
+
RESEND_BROADCAST_API_KEY: zod.z.string().default(""),
|
|
456
|
+
// Resend segment users join from the dashboard "Email updates" toggle.
|
|
457
|
+
// Blank = toggle hidden.
|
|
458
|
+
RESEND_MARKETING_SEGMENT_ID: zod.z.string().default(""),
|
|
459
|
+
// Sender for every transactional email. Must be on a domain verified in
|
|
460
|
+
// your Resend account.
|
|
461
|
+
EMAIL_FROM: zod.z.string().default("Acme <onboarding@xavierkhew.xyz>"),
|
|
462
|
+
// Display name for this deployment — shown as the authenticator-app label
|
|
463
|
+
// (2FA issuer) and the passkey relying-party name. Consuming projects set
|
|
464
|
+
// this in their own .env; defaults to the template name.
|
|
465
|
+
APP_NAME: zod.z.string().default("Next.js Template"),
|
|
466
|
+
// Where the dashboard feedback form delivers (app/api/feedback). Set either
|
|
467
|
+
// or both; with neither, the Feedback button is hidden. FEEDBACK_EMAIL takes
|
|
468
|
+
// a comma-separated list and sends through Resend (needs RESEND_API_KEY).
|
|
469
|
+
FEEDBACK_DISCORD_WEBHOOK_URL: zod.z.string().default(""),
|
|
470
|
+
FEEDBACK_EMAIL: zod.z.string().default(""),
|
|
471
|
+
// Seed-only: the live value lives in the system_settings DB row (see
|
|
472
|
+
// lib/settings-queries.ts) once it's been seeded on first read, and is
|
|
473
|
+
// changed from the admin System Settings page, not by editing this var.
|
|
474
|
+
ENABLE_SESSION_REVOCATION: zod.z.string().default("true").transform((val) => val === "true"),
|
|
475
|
+
AUTH_ENABLE_GOOGLE: zod.z.string().default("true").transform((val) => val === "true"),
|
|
476
|
+
AUTH_ENABLE_EMAIL_PASSWORD: zod.z.string().default("true").transform((val) => val === "true"),
|
|
477
|
+
AUTH_ENABLE_ONE_TAP: zod.z.string().default("false").transform((val) => val === "true"),
|
|
478
|
+
DB_DRIVER: zod.z.enum(["pg", "neon"]).default("pg"),
|
|
479
|
+
// Only relevant when DB_DRIVER=pg — whether the target Postgres server has SSL
|
|
480
|
+
// enabled. Self-hosted Postgres (e.g. via Docker/Dokploy) typically does not.
|
|
481
|
+
DATABASE_SSL: zod.z.string().default("false").transform((val) => val === "true")
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// config/env.ts
|
|
485
|
+
var results = envSchema.safeParse(process.env);
|
|
486
|
+
if (results.success && results.data.NODE_ENV === "development") {
|
|
487
|
+
console.log("Environment variables loaded:", Object.keys(results.data));
|
|
488
|
+
}
|
|
489
|
+
if (!results.success) {
|
|
490
|
+
console.error("Invalid environment variables:", results.error.format());
|
|
491
|
+
throw new Error("Invalid environment variables");
|
|
492
|
+
}
|
|
493
|
+
var config = results.data;
|
|
494
|
+
|
|
495
|
+
// server/db/index.ts
|
|
496
|
+
var db;
|
|
497
|
+
if (config.DB_DRIVER === "pg") {
|
|
498
|
+
const pool = new pg.Pool({
|
|
499
|
+
connectionString: config.DATABASE_URL,
|
|
500
|
+
ssl: config.DATABASE_SSL ? { rejectUnauthorized: false } : void 0
|
|
501
|
+
});
|
|
502
|
+
db = nodePostgres.drizzle(pool, {
|
|
503
|
+
schema: schema_exports,
|
|
504
|
+
logger: true
|
|
505
|
+
});
|
|
506
|
+
console.log("\u{1F7E1} Using PostgreSQL (pg) driver");
|
|
507
|
+
} else if (config.DB_DRIVER === "neon") {
|
|
508
|
+
const sql4 = serverless.neon(config.DATABASE_URL);
|
|
509
|
+
db = neonHttp.drizzle(sql4, { schema: schema_exports, logger: true });
|
|
510
|
+
console.log("\u{1F7E2} Using Neon (HTTP) driver");
|
|
511
|
+
} else {
|
|
512
|
+
console.log("Database connection not established, unknown DB_DRIVER:", config.DB_DRIVER);
|
|
513
|
+
}
|
|
514
|
+
async function logActivity({
|
|
515
|
+
userId,
|
|
516
|
+
action,
|
|
517
|
+
description,
|
|
518
|
+
ipAddress,
|
|
519
|
+
userAgent,
|
|
520
|
+
metadata,
|
|
521
|
+
referrerUrl,
|
|
522
|
+
os,
|
|
523
|
+
browser,
|
|
524
|
+
deviceType,
|
|
525
|
+
country,
|
|
526
|
+
city
|
|
527
|
+
}) {
|
|
528
|
+
try {
|
|
529
|
+
await db.insert(activityLogs).values({
|
|
530
|
+
userId,
|
|
531
|
+
action,
|
|
532
|
+
description,
|
|
533
|
+
ipAddress: ipAddress || null,
|
|
534
|
+
userAgent: userAgent || null,
|
|
535
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
536
|
+
referrerUrl: referrerUrl || null,
|
|
537
|
+
os: os || null,
|
|
538
|
+
browser: browser || null,
|
|
539
|
+
deviceType: deviceType || null,
|
|
540
|
+
country: country || null,
|
|
541
|
+
city: city || null
|
|
542
|
+
});
|
|
543
|
+
} catch (error) {
|
|
544
|
+
console.error("Failed to log activity:", error);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
var countryNames = new Intl.DisplayNames(["en"], { type: "region" });
|
|
548
|
+
function getClientIp(headers3) {
|
|
549
|
+
var _a;
|
|
550
|
+
const forwardedFor = headers3 == null ? void 0 : headers3.get("x-forwarded-for");
|
|
551
|
+
if (forwardedFor) return forwardedFor.split(",")[0].trim();
|
|
552
|
+
return (_a = headers3 == null ? void 0 : headers3.get("x-real-ip")) != null ? _a : null;
|
|
553
|
+
}
|
|
554
|
+
function parseUserAgent(userAgent) {
|
|
555
|
+
var _a;
|
|
556
|
+
if (!userAgent) {
|
|
557
|
+
return { os: null, browser: null, deviceType: "desktop" };
|
|
558
|
+
}
|
|
559
|
+
const { os, browser, device } = new uaParserJs.UAParser(userAgent).getResult();
|
|
560
|
+
return {
|
|
561
|
+
os: os.name ? [os.name, os.version].filter(Boolean).join(" ") : null,
|
|
562
|
+
browser: (_a = browser.name) != null ? _a : null,
|
|
563
|
+
deviceType: device.type === "mobile" || device.type === "tablet" ? device.type : "desktop"
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function lookupGeoLocation(ipAddress) {
|
|
567
|
+
var _a;
|
|
568
|
+
if (!ipAddress) return { country: null, city: null };
|
|
569
|
+
const geo = geoip__default.default.lookup(ipAddress);
|
|
570
|
+
if (!geo) return { country: null, city: null };
|
|
571
|
+
let country = geo.country || null;
|
|
572
|
+
if (country) {
|
|
573
|
+
try {
|
|
574
|
+
country = (_a = countryNames.of(country)) != null ? _a : country;
|
|
575
|
+
} catch (e) {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { country, city: geo.city || null };
|
|
579
|
+
}
|
|
580
|
+
function formatDeviceInfo({ os, browser, deviceType }) {
|
|
581
|
+
const label = [browser, os].filter(Boolean).join(" on ");
|
|
582
|
+
const typeLabel = deviceType === "mobile" ? "Mobile" : deviceType === "tablet" ? "Tablet" : "Desktop";
|
|
583
|
+
return label ? `${label} \xB7 ${typeLabel}` : `Unknown device \xB7 ${typeLabel}`;
|
|
584
|
+
}
|
|
585
|
+
function formatLocation({ country, city }) {
|
|
586
|
+
if (!country && !city) return "Unknown";
|
|
587
|
+
return [city, country].filter(Boolean).join(", ");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// lib/cookie-names.ts
|
|
591
|
+
var LOGIN_REFERRER_COOKIE = "login_referrer";
|
|
592
|
+
var DEVICE_FINGERPRINT_HEADER = "x-device-fingerprint";
|
|
593
|
+
|
|
594
|
+
// lib/auth-throttle-limits.ts
|
|
595
|
+
var WINDOW_MS = 15 * 60 * 1e3;
|
|
596
|
+
var MAX = 10;
|
|
597
|
+
var MAX_IP = MAX * 3;
|
|
598
|
+
var RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
599
|
+
function isOverLimit(row, now, max = MAX) {
|
|
600
|
+
if (!row) return false;
|
|
601
|
+
if (now - row.windowStart.getTime() >= WINDOW_MS) return false;
|
|
602
|
+
return row.count >= max;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// lib/auth-throttle.ts
|
|
606
|
+
async function isDeviceThrottled(fingerprint, max) {
|
|
607
|
+
try {
|
|
608
|
+
const [row] = await db.select({ count: authThrottle.count, windowStart: authThrottle.windowStart }).from(authThrottle).where(drizzleOrm.eq(authThrottle.fingerprint, fingerprint)).limit(1);
|
|
609
|
+
return isOverLimit(row, Date.now(), max);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
console.error("auth-throttle read failed, allowing request", error);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async function recordDeviceAttempt(fingerprint, meta) {
|
|
616
|
+
const now = /* @__PURE__ */ new Date();
|
|
617
|
+
const cutoff = new Date(now.getTime() - WINDOW_MS);
|
|
618
|
+
const seen = {
|
|
619
|
+
lastAttemptAt: now,
|
|
620
|
+
ipAddress: meta.ipAddress,
|
|
621
|
+
userAgent: meta.userAgent,
|
|
622
|
+
lastEmail: meta.email,
|
|
623
|
+
lastKind: meta.kind
|
|
624
|
+
};
|
|
625
|
+
try {
|
|
626
|
+
await db.insert(authThrottle).values(__spreadValues({ fingerprint, count: 1, windowStart: now }, seen)).onConflictDoUpdate({
|
|
627
|
+
target: authThrottle.fingerprint,
|
|
628
|
+
set: __spreadValues({
|
|
629
|
+
count: drizzleOrm.sql`case when ${authThrottle.windowStart} < ${cutoff} then 1 else ${authThrottle.count} + 1 end`,
|
|
630
|
+
windowStart: drizzleOrm.sql`case when ${authThrottle.windowStart} < ${cutoff} then ${now} else ${authThrottle.windowStart} end`
|
|
631
|
+
}, seen)
|
|
632
|
+
});
|
|
633
|
+
} catch (error) {
|
|
634
|
+
console.error("auth-throttle write failed", error);
|
|
635
|
+
}
|
|
636
|
+
await pruneStaleRows(now);
|
|
637
|
+
}
|
|
638
|
+
var PRUNE_EVERY_MS = 60 * 60 * 1e3;
|
|
639
|
+
var lastPrune = 0;
|
|
640
|
+
async function pruneStaleRows(now = /* @__PURE__ */ new Date()) {
|
|
641
|
+
if (now.getTime() - lastPrune < PRUNE_EVERY_MS) return;
|
|
642
|
+
lastPrune = now.getTime();
|
|
643
|
+
try {
|
|
644
|
+
await db.delete(authThrottle).where(drizzleOrm.lt(authThrottle.lastAttemptAt, new Date(now.getTime() - RETENTION_MS)));
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.error("auth-throttle prune failed", error);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function clearDeviceThrottle(fingerprint) {
|
|
650
|
+
try {
|
|
651
|
+
await db.delete(authThrottle).where(drizzleOrm.eq(authThrottle.fingerprint, fingerprint));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
console.error("auth-throttle clear failed", error);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
var SETTINGS_ROW_ID = "default";
|
|
657
|
+
var CACHE_TTL_MS = 1e4;
|
|
658
|
+
var cached = null;
|
|
659
|
+
function fallbackSettings() {
|
|
660
|
+
return {
|
|
661
|
+
id: SETTINGS_ROW_ID,
|
|
662
|
+
maintenanceMode: false,
|
|
663
|
+
maintenanceMessage: null,
|
|
664
|
+
maintenanceExemptPaths: DEFAULT_MAINTENANCE_EXEMPT_PATHS,
|
|
665
|
+
authEnableGoogle: config.AUTH_ENABLE_GOOGLE,
|
|
666
|
+
authEnableEmailPassword: config.AUTH_ENABLE_EMAIL_PASSWORD,
|
|
667
|
+
authEnableOneTap: config.AUTH_ENABLE_ONE_TAP,
|
|
668
|
+
enableSessionRevocation: config.ENABLE_SESSION_REVOCATION,
|
|
669
|
+
feedbackAttachErrors: true,
|
|
670
|
+
consentRequired: null,
|
|
671
|
+
updatedAt: /* @__PURE__ */ new Date(0),
|
|
672
|
+
updatedBy: null
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
async function loadOrSeed() {
|
|
676
|
+
var _a;
|
|
677
|
+
const existing = await db.select().from(systemSettings).where(drizzleOrm.eq(systemSettings.id, SETTINGS_ROW_ID)).limit(1);
|
|
678
|
+
if (existing[0]) return existing[0];
|
|
679
|
+
const seeded = await db.insert(systemSettings).values({
|
|
680
|
+
id: SETTINGS_ROW_ID,
|
|
681
|
+
maintenanceMode: false,
|
|
682
|
+
authEnableGoogle: config.AUTH_ENABLE_GOOGLE,
|
|
683
|
+
authEnableEmailPassword: config.AUTH_ENABLE_EMAIL_PASSWORD,
|
|
684
|
+
authEnableOneTap: config.AUTH_ENABLE_ONE_TAP,
|
|
685
|
+
enableSessionRevocation: config.ENABLE_SESSION_REVOCATION
|
|
686
|
+
}).onConflictDoNothing().returning();
|
|
687
|
+
if (seeded[0]) return seeded[0];
|
|
688
|
+
const retry = await db.select().from(systemSettings).where(drizzleOrm.eq(systemSettings.id, SETTINGS_ROW_ID)).limit(1);
|
|
689
|
+
return (_a = retry[0]) != null ? _a : fallbackSettings();
|
|
690
|
+
}
|
|
691
|
+
async function getSystemSettings() {
|
|
692
|
+
const now = Date.now();
|
|
693
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
694
|
+
try {
|
|
695
|
+
const value = await loadOrSeed();
|
|
696
|
+
cached = { value, expiresAt: now + CACHE_TTL_MS };
|
|
697
|
+
return value;
|
|
698
|
+
} catch (error) {
|
|
699
|
+
console.error("Failed to load system settings, failing open:", error);
|
|
700
|
+
return fallbackSettings();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function getEffectiveAuthFlags() {
|
|
704
|
+
const settings = await getSystemSettings();
|
|
705
|
+
return {
|
|
706
|
+
google: config.AUTH_ENABLE_GOOGLE && settings.authEnableGoogle,
|
|
707
|
+
emailPassword: config.AUTH_ENABLE_EMAIL_PASSWORD && settings.authEnableEmailPassword,
|
|
708
|
+
oneTap: config.AUTH_ENABLE_ONE_TAP && settings.authEnableOneTap
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
var EMAIL_FROM = config.EMAIL_FROM;
|
|
712
|
+
var resend;
|
|
713
|
+
async function getResend() {
|
|
714
|
+
if (!resend) {
|
|
715
|
+
const { Resend } = await import('resend');
|
|
716
|
+
resend = new Resend(config.RESEND_API_KEY);
|
|
717
|
+
}
|
|
718
|
+
return resend;
|
|
719
|
+
}
|
|
720
|
+
var BROADCAST_API_KEY = config.RESEND_BROADCAST_API_KEY || config.RESEND_API_KEY;
|
|
721
|
+
var broadcastResend;
|
|
722
|
+
async function getBroadcastResend() {
|
|
723
|
+
if (!broadcastResend) {
|
|
724
|
+
const { Resend } = await import('resend');
|
|
725
|
+
broadcastResend = new Resend(BROADCAST_API_KEY);
|
|
726
|
+
}
|
|
727
|
+
return broadcastResend;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// server/auth.ts
|
|
731
|
+
async function logFailedLogin(ctx) {
|
|
732
|
+
var _a, _b, _c;
|
|
733
|
+
try {
|
|
734
|
+
const raw = (_a = ctx.body) == null ? void 0 : _a.email;
|
|
735
|
+
const email = typeof raw === "string" ? raw.toLowerCase() : null;
|
|
736
|
+
if (!email) return;
|
|
737
|
+
const [user] = await db.select({ id: users.id }).from(users).where(drizzleOrm.eq(users.email, email)).limit(1);
|
|
738
|
+
if (!user) return;
|
|
739
|
+
await logActivity({
|
|
740
|
+
userId: user.id,
|
|
741
|
+
action: "login_failed",
|
|
742
|
+
description: "Failed login attempt",
|
|
743
|
+
ipAddress: getClientIp(ctx.headers),
|
|
744
|
+
userAgent: (_c = (_b = ctx.headers) == null ? void 0 : _b.get("user-agent")) != null ? _c : null
|
|
745
|
+
});
|
|
746
|
+
} catch (error) {
|
|
747
|
+
console.error("Failed to log login_failed activity", error);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async function notifyPasskeyChange(ctx, action) {
|
|
751
|
+
var _a, _b, _c, _d;
|
|
752
|
+
try {
|
|
753
|
+
const session = await api.getSessionFromCtx(ctx);
|
|
754
|
+
const user = session == null ? void 0 : session.user;
|
|
755
|
+
if (!user) return;
|
|
756
|
+
const ip = getClientIp(ctx.headers);
|
|
757
|
+
await logActivity({
|
|
758
|
+
userId: user.id,
|
|
759
|
+
action: action === "added" ? "passkey_added" : "passkey_removed",
|
|
760
|
+
description: `Passkey ${action}`,
|
|
761
|
+
ipAddress: ip,
|
|
762
|
+
userAgent: (_b = (_a = ctx.headers) == null ? void 0 : _a.get("user-agent")) != null ? _b : null
|
|
763
|
+
});
|
|
764
|
+
if (!config.RESEND_API_KEY) return;
|
|
765
|
+
const { EmailTemplatePasskeyChange: EmailTemplatePasskeyChange2 } = await Promise.resolve().then(() => (init_email_template_passkey_change(), email_template_passkey_change_exports));
|
|
766
|
+
await (await getResend()).emails.send({
|
|
767
|
+
from: EMAIL_FROM,
|
|
768
|
+
to: [user.email],
|
|
769
|
+
subject: action === "added" ? "A passkey was added to your account" : "A passkey was removed from your account",
|
|
770
|
+
react: EmailTemplatePasskeyChange2({
|
|
771
|
+
firstName: user.name || "there",
|
|
772
|
+
action,
|
|
773
|
+
device: formatDeviceInfo(parseUserAgent((_d = (_c = ctx.headers) == null ? void 0 : _c.get("user-agent")) != null ? _d : null)),
|
|
774
|
+
location: formatLocation(lookupGeoLocation(ip)),
|
|
775
|
+
ipAddress: ip || "Unknown",
|
|
776
|
+
when: (/* @__PURE__ */ new Date()).toUTCString(),
|
|
777
|
+
manageUrl: `${process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || ""}/dashboard/settings`
|
|
778
|
+
})
|
|
779
|
+
});
|
|
780
|
+
} catch (error) {
|
|
781
|
+
console.error("Failed to send passkey-change notification", error);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function createAuth(overrides = {}) {
|
|
785
|
+
const socialProviders = __spreadValues(__spreadValues({}, config.AUTH_ENABLE_GOOGLE ? {
|
|
786
|
+
google: {
|
|
787
|
+
clientId: process.env.AUTH_GOOGLE_ID || process.env.GOOGLE_CLIENT_ID || "",
|
|
788
|
+
clientSecret: process.env.AUTH_GOOGLE_SECRET || process.env.GOOGLE_CLIENT_SECRET || ""
|
|
789
|
+
}
|
|
790
|
+
} : {}), overrides.socialProviders);
|
|
791
|
+
const emailAndPasswordEnabled = config.AUTH_ENABLE_EMAIL_PASSWORD;
|
|
792
|
+
const hasEnabledSocialProvider = Object.values(socialProviders).some(Boolean);
|
|
793
|
+
if (!hasEnabledSocialProvider && !emailAndPasswordEnabled) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
"No auth provider is enabled \u2014 every social provider is off and emailAndPassword.enabled is false. Enable AUTH_ENABLE_GOOGLE / AUTH_ENABLE_EMAIL_PASSWORD, or pass your own provider via createAuth({ socialProviders: {...} })."
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
const baseURL = process.env.NEXTAUTH_URL || process.env.BETTER_AUTH_URL || "http://localhost:3000";
|
|
799
|
+
return betterAuth.betterAuth({
|
|
800
|
+
database: drizzle.drizzleAdapter(db, {
|
|
801
|
+
provider: "pg",
|
|
802
|
+
schema: {
|
|
803
|
+
user: users,
|
|
804
|
+
account: accounts,
|
|
805
|
+
session: sessions,
|
|
806
|
+
verification: verifications,
|
|
807
|
+
twoFactor: twoFactors,
|
|
808
|
+
passkey: passkeys
|
|
809
|
+
}
|
|
810
|
+
}),
|
|
811
|
+
// Session configuration (30 days expiration matching previous NextAuth setup)
|
|
812
|
+
session: {
|
|
813
|
+
expiresIn: 60 * 60 * 24 * 30,
|
|
814
|
+
// 30 days
|
|
815
|
+
updateAge: 60 * 60 * 24,
|
|
816
|
+
// Update session every 24 hours
|
|
817
|
+
cookieCache: {
|
|
818
|
+
enabled: true,
|
|
819
|
+
// Enable hybrid session caching for performance
|
|
820
|
+
maxAge: 5 * 60
|
|
821
|
+
// 5 minutes
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
// Email & Password Authentication (replaces Credentials provider)
|
|
825
|
+
// Toggle per-project via AUTH_ENABLE_EMAIL_PASSWORD in that project's own .env.*
|
|
826
|
+
emailAndPassword: {
|
|
827
|
+
enabled: emailAndPasswordEnabled,
|
|
828
|
+
requireEmailVerification: false,
|
|
829
|
+
// `url` is fully composed by better-auth (baseURL + verification token +
|
|
830
|
+
// callbackURL) — see app/(auth)/forgot-password and reset-password for
|
|
831
|
+
// the pages this points at.
|
|
832
|
+
async sendResetPassword({ user, url }) {
|
|
833
|
+
const { EmailTemplateResetPassword: EmailTemplateResetPassword2 } = await Promise.resolve().then(() => (init_email_template_reset_password(), email_template_reset_password_exports));
|
|
834
|
+
await (await getResend()).emails.send({
|
|
835
|
+
from: EMAIL_FROM,
|
|
836
|
+
to: [user.email],
|
|
837
|
+
subject: "Reset your password",
|
|
838
|
+
react: EmailTemplateResetPassword2({ firstName: user.name || "there", url })
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
},
|
|
842
|
+
// Email verification. `requireEmailVerification` above stays false, so this
|
|
843
|
+
// is opt-in: users trigger it from the dashboard via
|
|
844
|
+
// authClient.sendVerificationEmail(). better-auth composes `url`
|
|
845
|
+
// (baseURL + token + callbackURL) and serves the /api/auth/verify-email
|
|
846
|
+
// callback that flips users.emailVerified on click.
|
|
847
|
+
emailVerification: {
|
|
848
|
+
autoSignInAfterVerification: true,
|
|
849
|
+
async sendVerificationEmail({ user, url }) {
|
|
850
|
+
const { EmailTemplateVerifyEmail: EmailTemplateVerifyEmail2 } = await Promise.resolve().then(() => (init_email_template_verify_email(), email_template_verify_email_exports));
|
|
851
|
+
await (await getResend()).emails.send({
|
|
852
|
+
from: EMAIL_FROM,
|
|
853
|
+
to: [user.email],
|
|
854
|
+
subject: "Confirm your email",
|
|
855
|
+
react: EmailTemplateVerifyEmail2({ firstName: user.name || "there", url })
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
// Social Providers — google is included by default; toggle per-project via
|
|
860
|
+
// AUTH_ENABLE_GOOGLE. overrides.socialProviders can still add more (or replace it).
|
|
861
|
+
socialProviders,
|
|
862
|
+
// User schema configuration
|
|
863
|
+
user: {
|
|
864
|
+
additionalFields: {
|
|
865
|
+
role: {
|
|
866
|
+
type: "string",
|
|
867
|
+
required: false,
|
|
868
|
+
defaultValue: "user",
|
|
869
|
+
input: false
|
|
870
|
+
// Prevent users from setting their own role
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
// Self-service "Delete account" (dashboard Settings → Danger zone).
|
|
874
|
+
// Credential users confirm with their password; OAuth-only users have
|
|
875
|
+
// no password, so better-auth instead requires a session younger than
|
|
876
|
+
// `freshAge` (default 1 day). Every user-owned table cascades on delete.
|
|
877
|
+
// Impersonation and last-admin guards live in hooks.before below.
|
|
878
|
+
deleteUser: { enabled: true }
|
|
879
|
+
},
|
|
880
|
+
// Advanced options
|
|
881
|
+
advanced: {
|
|
882
|
+
database: {
|
|
883
|
+
generateId: () => cuid2.createId()
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
// Every new session row corresponds to a sign-in (credential or OAuth) —
|
|
887
|
+
// use that as the "last logged in" signal rather than session updates,
|
|
888
|
+
// which also fire on cookie-cache refresh / expiry extension.
|
|
889
|
+
databaseHooks: {
|
|
890
|
+
session: {
|
|
891
|
+
create: {
|
|
892
|
+
async after(session, context) {
|
|
893
|
+
var _a, _b, _c, _d;
|
|
894
|
+
const impersonatedBy = session.impersonatedBy;
|
|
895
|
+
if (impersonatedBy) {
|
|
896
|
+
const target = await db.select({ email: users.email }).from(users).where(drizzleOrm.eq(users.id, session.userId)).limit(1);
|
|
897
|
+
await logActivity({
|
|
898
|
+
userId: impersonatedBy,
|
|
899
|
+
action: "impersonation_started",
|
|
900
|
+
description: `Admin started impersonating ${(_b = (_a = target[0]) == null ? void 0 : _a.email) != null ? _b : session.userId}`,
|
|
901
|
+
metadata: { impersonatedUserId: session.userId }
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
await db.update(users).set({ lastLoginAt: /* @__PURE__ */ new Date() }).where(drizzleOrm.eq(users.id, session.userId));
|
|
907
|
+
} catch (error) {
|
|
908
|
+
console.error("Failed to update lastLoginAt", error);
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const headers3 = context == null ? void 0 : context.headers;
|
|
912
|
+
const ipAddress = getClientIp(headers3);
|
|
913
|
+
const userAgent = (_c = headers3 == null ? void 0 : headers3.get("user-agent")) != null ? _c : null;
|
|
914
|
+
const referrerUrl = ((_d = context == null ? void 0 : context.getCookie) == null ? void 0 : _d.call(context, LOGIN_REFERRER_COOKIE)) || (headers3 == null ? void 0 : headers3.get("referer")) || null;
|
|
915
|
+
const device = parseUserAgent(userAgent);
|
|
916
|
+
const geo = lookupGeoLocation(ipAddress);
|
|
917
|
+
const [{ count }] = await db.select({ count: drizzleOrm.sql`count(*)` }).from(sessions).where(drizzleOrm.eq(sessions.userId, session.userId));
|
|
918
|
+
const isFirstSession = Number(count) <= 1;
|
|
919
|
+
await logActivity({
|
|
920
|
+
userId: session.userId,
|
|
921
|
+
action: "login",
|
|
922
|
+
description: isFirstSession ? "New account registered and signed in" : "User logged in",
|
|
923
|
+
ipAddress,
|
|
924
|
+
userAgent,
|
|
925
|
+
referrerUrl,
|
|
926
|
+
os: device.os,
|
|
927
|
+
browser: device.browser,
|
|
928
|
+
deviceType: device.deviceType,
|
|
929
|
+
country: geo.country,
|
|
930
|
+
city: geo.city
|
|
931
|
+
});
|
|
932
|
+
} catch (error) {
|
|
933
|
+
console.error("Failed to log login activity", error);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
// better-auth's admin plugin ends impersonation by deleting the
|
|
940
|
+
// impersonation session and restoring the admin's original session
|
|
941
|
+
// straight from a signed cookie — it never runs session.create, so
|
|
942
|
+
// there's no databaseHooks entry point to log the stop event from.
|
|
943
|
+
// Reading the session here (before the endpoint deletes it) is the only
|
|
944
|
+
// point where both the admin id and the impersonated user id are still
|
|
945
|
+
// available together.
|
|
946
|
+
hooks: {
|
|
947
|
+
before: api.createAuthMiddleware(async (ctx) => {
|
|
948
|
+
var _a, _b, _c, _d;
|
|
949
|
+
if (ctx.path === "/sign-in/email" || ctx.path === "/sign-up/email") {
|
|
950
|
+
const fingerprint = (_a = ctx.headers) == null ? void 0 : _a.get(DEVICE_FINGERPRINT_HEADER);
|
|
951
|
+
const ip = getClientIp(ctx.headers);
|
|
952
|
+
const throttled = fingerprint && await isDeviceThrottled(fingerprint) || ip && await isDeviceThrottled(`ip:${ip}`, MAX_IP);
|
|
953
|
+
if (throttled) {
|
|
954
|
+
throw new api.APIError("TOO_MANY_REQUESTS", {
|
|
955
|
+
message: "Too many attempts from this device. Try again in a few minutes."
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (ctx.path === "/sign-in/social") {
|
|
960
|
+
if (((_b = ctx.body) == null ? void 0 : _b.provider) === "google") {
|
|
961
|
+
const flags = await getEffectiveAuthFlags();
|
|
962
|
+
if (!flags.google) {
|
|
963
|
+
throw new api.APIError("FORBIDDEN", { message: "Google sign-in is currently disabled." });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (ctx.path === "/sign-in/email") {
|
|
969
|
+
const flags = await getEffectiveAuthFlags();
|
|
970
|
+
if (!flags.emailPassword) {
|
|
971
|
+
throw new api.APIError("FORBIDDEN", { message: "Email/password sign-in is currently disabled." });
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (ctx.path === "/one-tap/callback") {
|
|
976
|
+
const flags = await getEffectiveAuthFlags();
|
|
977
|
+
if (!flags.google || !flags.oneTap) {
|
|
978
|
+
throw new api.APIError("FORBIDDEN", { message: "Google One Tap is currently disabled." });
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (ctx.path === "/delete-user") {
|
|
983
|
+
const current2 = await api.getSessionFromCtx(ctx, { disableCookieCache: true });
|
|
984
|
+
if (!current2) return;
|
|
985
|
+
if (current2.session.impersonatedBy) {
|
|
986
|
+
throw new api.APIError("FORBIDDEN", { message: "Stop impersonating before deleting an account." });
|
|
987
|
+
}
|
|
988
|
+
if (current2.user.role === "admin") {
|
|
989
|
+
const [{ count }] = await db.select({ count: drizzleOrm.sql`count(*)` }).from(users).where(drizzleOrm.eq(users.role, "admin"));
|
|
990
|
+
if (Number(count) <= 1) {
|
|
991
|
+
throw new api.APIError("FORBIDDEN", { message: "You're the only admin. Promote another user to admin first." });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (ctx.path !== "/admin/stop-impersonating") return;
|
|
997
|
+
const current = await api.getSessionFromCtx(ctx, { disableCookieCache: true });
|
|
998
|
+
if (current == null ? void 0 : current.session.impersonatedBy) {
|
|
999
|
+
const target = await db.select({ email: users.email }).from(users).where(drizzleOrm.eq(users.id, current.session.userId)).limit(1);
|
|
1000
|
+
await logActivity({
|
|
1001
|
+
userId: current.session.impersonatedBy,
|
|
1002
|
+
action: "impersonation_stopped",
|
|
1003
|
+
description: `Admin stopped impersonating ${(_d = (_c = target[0]) == null ? void 0 : _c.email) != null ? _d : current.session.userId}`,
|
|
1004
|
+
metadata: { impersonatedUserId: current.session.userId }
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}),
|
|
1008
|
+
after: api.createAuthMiddleware(async (ctx) => {
|
|
1009
|
+
var _a, _b, _c, _d;
|
|
1010
|
+
if (ctx.path === "/passkey/verify-registration" || ctx.path === "/passkey/delete-passkey") {
|
|
1011
|
+
if (ctx.context.returned instanceof api.APIError) return;
|
|
1012
|
+
await notifyPasskeyChange(
|
|
1013
|
+
ctx,
|
|
1014
|
+
ctx.path === "/passkey/verify-registration" ? "added" : "removed"
|
|
1015
|
+
);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (ctx.path !== "/sign-in/email" && ctx.path !== "/sign-up/email") return;
|
|
1019
|
+
const fingerprint = (_a = ctx.headers) == null ? void 0 : _a.get(DEVICE_FINGERPRINT_HEADER);
|
|
1020
|
+
const ip = getClientIp(ctx.headers);
|
|
1021
|
+
const ipKey = ip ? `ip:${ip}` : null;
|
|
1022
|
+
if (!fingerprint && !ipKey) return;
|
|
1023
|
+
const failed = ctx.context.returned instanceof api.APIError;
|
|
1024
|
+
const rawEmail = (_b = ctx.body) == null ? void 0 : _b.email;
|
|
1025
|
+
const meta = {
|
|
1026
|
+
ipAddress: ip,
|
|
1027
|
+
userAgent: (_d = (_c = ctx.headers) == null ? void 0 : _c.get("user-agent")) != null ? _d : null,
|
|
1028
|
+
email: typeof rawEmail === "string" ? rawEmail.toLowerCase() : null
|
|
1029
|
+
};
|
|
1030
|
+
const keys = [fingerprint, ipKey].filter((k) => !!k);
|
|
1031
|
+
if (ctx.path === "/sign-up/email") {
|
|
1032
|
+
await Promise.all(keys.map((key) => recordDeviceAttempt(key, __spreadProps(__spreadValues({}, meta), { kind: "signup" }))));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (failed) {
|
|
1036
|
+
await Promise.all(keys.map((key) => recordDeviceAttempt(key, __spreadProps(__spreadValues({}, meta), { kind: "signin" }))));
|
|
1037
|
+
await logFailedLogin(ctx);
|
|
1038
|
+
} else {
|
|
1039
|
+
await Promise.all(keys.map((key) => clearDeviceThrottle(key)));
|
|
1040
|
+
}
|
|
1041
|
+
})
|
|
1042
|
+
},
|
|
1043
|
+
// Base URL for callbacks
|
|
1044
|
+
baseURL,
|
|
1045
|
+
// Secret for signing cookies and tokens
|
|
1046
|
+
secret: process.env.AUTH_SECRET,
|
|
1047
|
+
// The OAuth `state` is a one-time verification row: the callback consumes
|
|
1048
|
+
// and deletes it. When the same callback URL gets hit twice (browser
|
|
1049
|
+
// prefetch / bfcache / double nav — common on a repeat Google sign-in
|
|
1050
|
+
// where Google auto-redirects with no consent screen), the first hit
|
|
1051
|
+
// logs the user in and the second finds no `state` row and throws
|
|
1052
|
+
// `please_restart_the_process`. Default sends that to better-auth's bare
|
|
1053
|
+
// error page; point it at /login instead, which already bounces an
|
|
1054
|
+
// authenticated user straight to their callbackUrl — so the losing race
|
|
1055
|
+
// just lands them logged in. See better-auth#5658 / #6544.
|
|
1056
|
+
onAPIError: {
|
|
1057
|
+
errorURL: "/login"
|
|
1058
|
+
},
|
|
1059
|
+
plugins: [
|
|
1060
|
+
// Registered for impersonation (auth.api.impersonateUser / stopImpersonating)
|
|
1061
|
+
// only — role/ban/delete stay on our own custom routes above. adminRoles
|
|
1062
|
+
// matches our existing role column so the plugin's own permission checks
|
|
1063
|
+
// line up with hasRole(role, 'admin'). banReason is remapped onto our
|
|
1064
|
+
// existing bannedReason column instead of adding a duplicate field.
|
|
1065
|
+
plugins.admin({
|
|
1066
|
+
adminRoles: ["admin"],
|
|
1067
|
+
schema: {
|
|
1068
|
+
user: {
|
|
1069
|
+
fields: {
|
|
1070
|
+
banReason: "bannedReason"
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}),
|
|
1075
|
+
// TOTP second factor + encrypted backup codes. `issuer` is the label
|
|
1076
|
+
// shown in the user's authenticator app.
|
|
1077
|
+
plugins.twoFactor({ issuer: config.APP_NAME }),
|
|
1078
|
+
// WebAuthn passkeys (Touch ID / Windows Hello / security keys) as a
|
|
1079
|
+
// passwordless sign-in method. rpID must be the bare host and origin
|
|
1080
|
+
// the full URL — both derived from baseURL above.
|
|
1081
|
+
passkey.passkey({
|
|
1082
|
+
rpID: new URL(baseURL).hostname,
|
|
1083
|
+
rpName: config.APP_NAME,
|
|
1084
|
+
origin: baseURL
|
|
1085
|
+
}),
|
|
1086
|
+
// checks to see if oneTap is enabled and if google provider is available, then add the oneTap plugin.
|
|
1087
|
+
// Keep this spread last: a leading spread turns `plugins` from a tuple into a plain
|
|
1088
|
+
// array and better-auth 1.7 then drops plugin fields (e.g. session.impersonatedBy).
|
|
1089
|
+
...config.AUTH_ENABLE_ONE_TAP && Boolean(socialProviders.google) ? [plugins.oneTap()] : []
|
|
1090
|
+
]
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
var _auth;
|
|
1094
|
+
function getDefaultAuth() {
|
|
1095
|
+
if (!_auth) _auth = createAuth();
|
|
1096
|
+
return _auth;
|
|
1097
|
+
}
|
|
1098
|
+
var auth = new Proxy({}, {
|
|
1099
|
+
get(_target, prop) {
|
|
1100
|
+
const instance = getDefaultAuth();
|
|
1101
|
+
const value = Reflect.get(instance, prop);
|
|
1102
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
1103
|
+
},
|
|
1104
|
+
// "prop" in auth (e.g. better-auth's toNextJsHandler does `"handler" in auth`)
|
|
1105
|
+
// must also see the real instance — without this it checks the empty
|
|
1106
|
+
// placeholder target and always reports false.
|
|
1107
|
+
has(_target, prop) {
|
|
1108
|
+
return Reflect.has(getDefaultAuth(), prop);
|
|
1109
|
+
},
|
|
1110
|
+
ownKeys(_target) {
|
|
1111
|
+
return Reflect.ownKeys(getDefaultAuth());
|
|
1112
|
+
},
|
|
1113
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
1114
|
+
return Reflect.getOwnPropertyDescriptor(getDefaultAuth(), prop);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
var getCurrentSession = react.cache(async () => {
|
|
1118
|
+
const session = await auth.api.getSession({
|
|
1119
|
+
headers: await headers.headers()
|
|
1120
|
+
});
|
|
1121
|
+
return session;
|
|
1122
|
+
});
|
|
1123
|
+
react.cache(async () => {
|
|
1124
|
+
var _a;
|
|
1125
|
+
const session = await getCurrentSession();
|
|
1126
|
+
if (!(session == null ? void 0 : session.user)) {
|
|
1127
|
+
navigation.redirect("/login");
|
|
1128
|
+
}
|
|
1129
|
+
const user = await db.select().from(users).where(drizzleOrm.eq(users.id, session.user.id)).limit(1);
|
|
1130
|
+
if ((_a = user[0]) == null ? void 0 : _a.banned) {
|
|
1131
|
+
await auth.api.signOut({
|
|
1132
|
+
headers: await headers.headers()
|
|
1133
|
+
});
|
|
1134
|
+
navigation.redirect("/login?error=account_banned");
|
|
1135
|
+
}
|
|
1136
|
+
return session;
|
|
1137
|
+
});
|
|
1138
|
+
function hasRole(userRole, requiredRole) {
|
|
1139
|
+
if (!userRole) return false;
|
|
1140
|
+
{
|
|
1141
|
+
return userRole === "admin";
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
var MARKETING_SEGMENT_ID = config.RESEND_MARKETING_SEGMENT_ID;
|
|
1145
|
+
async function setSubscribed(email, name, subscribed) {
|
|
1146
|
+
const resend2 = await getBroadcastResend();
|
|
1147
|
+
const contact = await resend2.contacts.get({ email });
|
|
1148
|
+
const exists = !contact.error;
|
|
1149
|
+
if (contact.error && contact.error.name !== "not_found") throw new Error(contact.error.message);
|
|
1150
|
+
if (!subscribed) {
|
|
1151
|
+
if (!exists) return;
|
|
1152
|
+
const { error: error2 } = await resend2.contacts.update({ email, unsubscribed: true });
|
|
1153
|
+
if (error2) throw new Error(error2.message);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const { error } = exists ? await resend2.contacts.update({ email, unsubscribed: false }) : await resend2.contacts.create({ email, firstName: name || void 0, unsubscribed: false });
|
|
1157
|
+
if (error) throw new Error(error.message);
|
|
1158
|
+
const segments = await resend2.contacts.segments.list({ email });
|
|
1159
|
+
if (segments.error) throw new Error(segments.error.message);
|
|
1160
|
+
if (!segments.data.data.some((s) => s.id === MARKETING_SEGMENT_ID)) {
|
|
1161
|
+
const added = await resend2.contacts.segments.add({ email, segmentId: MARKETING_SEGMENT_ID });
|
|
1162
|
+
if (added.error) throw new Error(added.error.message);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// lib/marketing-email.ts
|
|
1167
|
+
var UNSUBSCRIBE_PLACEHOLDER = "{{{RESEND_UNSUBSCRIBE_URL}}}";
|
|
1168
|
+
var escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1169
|
+
function textToBroadcastHtml(text2) {
|
|
1170
|
+
const paragraphs = text2.trim().split(/\n\s*\n/).map((p) => `<p style="margin:0 0 16px;line-height:24px">${escapeHtml(p.trim()).replace(/\n/g, "<br>")}</p>`).join("");
|
|
1171
|
+
return `<div style="font-family:Arial,sans-serif;font-size:16px;color:#1a1a1a;max-width:560px;margin:0 auto;padding:20px 0">${paragraphs}<p style="margin-top:32px;font-size:12px;color:#999999"><a href="${UNSUBSCRIBE_PLACEHOLDER}" style="color:#999999">Unsubscribe</a></p></div>`;
|
|
1172
|
+
}
|
|
1173
|
+
function textToBroadcastText(text2) {
|
|
1174
|
+
return `${text2.trim()}
|
|
1175
|
+
|
|
1176
|
+
--
|
|
1177
|
+
Unsubscribe: ${UNSUBSCRIBE_PLACEHOLDER}`;
|
|
1178
|
+
}
|
|
1179
|
+
var REF_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
1180
|
+
var DETAIL_MAX_LENGTH = 8e3;
|
|
1181
|
+
var RETENTION_MS2 = 30 * 24 * 60 * 60 * 1e3;
|
|
1182
|
+
function describeError(error) {
|
|
1183
|
+
if (error instanceof Error) return error.stack || `${error.name}: ${error.message}`;
|
|
1184
|
+
try {
|
|
1185
|
+
return JSON.stringify(error, null, 2);
|
|
1186
|
+
} catch (e) {
|
|
1187
|
+
return String(error);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function storeDetail(ref, message, error) {
|
|
1191
|
+
const save = async () => {
|
|
1192
|
+
try {
|
|
1193
|
+
await db.insert(errorRefs).values({
|
|
1194
|
+
ref,
|
|
1195
|
+
message,
|
|
1196
|
+
detail: describeError(error).slice(0, DETAIL_MAX_LENGTH)
|
|
1197
|
+
});
|
|
1198
|
+
await db.delete(errorRefs).where(drizzleOrm.lt(errorRefs.createdAt, new Date(Date.now() - RETENTION_MS2)));
|
|
1199
|
+
} catch (storeError) {
|
|
1200
|
+
console.error(`[error ref ${ref}] could not store error detail`, storeError);
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
try {
|
|
1204
|
+
server.after(save);
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
void save();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function unexpectedErrorMessage(error, message = "Something went wrong. Please try again.") {
|
|
1210
|
+
const ref = Array.from(crypto.randomBytes(6), (b) => REF_ALPHABET[b % REF_ALPHABET.length]).join("");
|
|
1211
|
+
console.error(`[error ref ${ref}] ${message}`, error);
|
|
1212
|
+
storeDetail(ref, message, error);
|
|
1213
|
+
return `${message} (ref: ${ref})`;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// lib/marketing-route.ts
|
|
1217
|
+
async function getSession() {
|
|
1218
|
+
return auth.api.getSession({ headers: await headers.headers() });
|
|
1219
|
+
}
|
|
1220
|
+
async function requireAdmin() {
|
|
1221
|
+
const session = await getSession();
|
|
1222
|
+
if (!(session == null ? void 0 : session.user)) {
|
|
1223
|
+
return server.NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
1224
|
+
}
|
|
1225
|
+
if (!hasRole(session.user.role)) {
|
|
1226
|
+
return server.NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
1227
|
+
}
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
var createSchema = zod.z.object({
|
|
1231
|
+
segmentId: zod.z.string().min(1),
|
|
1232
|
+
subject: zod.z.string().trim().min(1).max(200),
|
|
1233
|
+
previewText: zod.z.string().trim().max(200).optional(),
|
|
1234
|
+
body: zod.z.string().trim().min(1).max(5e4),
|
|
1235
|
+
send: zod.z.boolean()
|
|
1236
|
+
});
|
|
1237
|
+
async function createBroadcastPOST(request) {
|
|
1238
|
+
const denied = await requireAdmin();
|
|
1239
|
+
if (denied) return denied;
|
|
1240
|
+
const parsed = createSchema.safeParse(await request.json().catch(() => null));
|
|
1241
|
+
if (!parsed.success) {
|
|
1242
|
+
return server.NextResponse.json({ error: "Segment, subject and message are required" }, { status: 400 });
|
|
1243
|
+
}
|
|
1244
|
+
const { segmentId, subject, previewText, body, send } = parsed.data;
|
|
1245
|
+
try {
|
|
1246
|
+
const resend2 = await getBroadcastResend();
|
|
1247
|
+
const created = await resend2.broadcasts.create({
|
|
1248
|
+
segmentId,
|
|
1249
|
+
from: EMAIL_FROM,
|
|
1250
|
+
subject,
|
|
1251
|
+
previewText: previewText || void 0,
|
|
1252
|
+
name: subject,
|
|
1253
|
+
html: textToBroadcastHtml(body),
|
|
1254
|
+
text: textToBroadcastText(body)
|
|
1255
|
+
});
|
|
1256
|
+
if (created.error) {
|
|
1257
|
+
return server.NextResponse.json({ error: created.error.message }, { status: 502 });
|
|
1258
|
+
}
|
|
1259
|
+
if (send) {
|
|
1260
|
+
const sent = await resend2.broadcasts.send(created.data.id);
|
|
1261
|
+
if (sent.error) {
|
|
1262
|
+
return server.NextResponse.json(
|
|
1263
|
+
{ error: `Draft saved in Resend, but sending failed: ${sent.error.message}` },
|
|
1264
|
+
{ status: 502 }
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return server.NextResponse.json({ success: true, id: created.data.id, sent: send });
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
return server.NextResponse.json(
|
|
1271
|
+
{ error: unexpectedErrorMessage(error, "Failed to create broadcast") },
|
|
1272
|
+
{ status: 500 }
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async function getBroadcastGET(_request, { params }) {
|
|
1277
|
+
const denied = await requireAdmin();
|
|
1278
|
+
if (denied) return denied;
|
|
1279
|
+
try {
|
|
1280
|
+
const { id } = await params;
|
|
1281
|
+
const { data, error } = await (await getBroadcastResend()).broadcasts.get(id);
|
|
1282
|
+
if (error) {
|
|
1283
|
+
return server.NextResponse.json({ error: error.message }, { status: 502 });
|
|
1284
|
+
}
|
|
1285
|
+
return server.NextResponse.json({
|
|
1286
|
+
subject: data.subject,
|
|
1287
|
+
from: data.from,
|
|
1288
|
+
previewText: data.preview_text,
|
|
1289
|
+
html: data.html,
|
|
1290
|
+
text: data.text,
|
|
1291
|
+
status: data.status
|
|
1292
|
+
});
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
return server.NextResponse.json(
|
|
1295
|
+
{ error: unexpectedErrorMessage(error, "Failed to load broadcast") },
|
|
1296
|
+
{ status: 500 }
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async function sendBroadcastPOST(_request, { params }) {
|
|
1301
|
+
const denied = await requireAdmin();
|
|
1302
|
+
if (denied) return denied;
|
|
1303
|
+
try {
|
|
1304
|
+
const { id } = await params;
|
|
1305
|
+
const resend2 = await getBroadcastResend();
|
|
1306
|
+
const current = await resend2.broadcasts.get(id);
|
|
1307
|
+
if (current.error) {
|
|
1308
|
+
return server.NextResponse.json({ error: current.error.message }, { status: 502 });
|
|
1309
|
+
}
|
|
1310
|
+
if (current.data.status !== "draft") {
|
|
1311
|
+
return server.NextResponse.json({ error: "This broadcast has already been sent" }, { status: 409 });
|
|
1312
|
+
}
|
|
1313
|
+
const sent = await resend2.broadcasts.send(id);
|
|
1314
|
+
if (sent.error) {
|
|
1315
|
+
return server.NextResponse.json({ error: sent.error.message }, { status: 502 });
|
|
1316
|
+
}
|
|
1317
|
+
return server.NextResponse.json({ success: true });
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
return server.NextResponse.json(
|
|
1320
|
+
{ error: unexpectedErrorMessage(error, "Failed to send broadcast") },
|
|
1321
|
+
{ status: 500 }
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
var subscriptionSchema = zod.z.object({ subscribed: zod.z.boolean() });
|
|
1326
|
+
async function updateSubscriptionPUT(request) {
|
|
1327
|
+
const session = await getSession();
|
|
1328
|
+
if (!(session == null ? void 0 : session.user)) {
|
|
1329
|
+
return server.NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
1330
|
+
}
|
|
1331
|
+
if (!MARKETING_SEGMENT_ID) {
|
|
1332
|
+
return server.NextResponse.json({ error: "Product updates aren't set up" }, { status: 404 });
|
|
1333
|
+
}
|
|
1334
|
+
const parsed = subscriptionSchema.safeParse(await request.json().catch(() => null));
|
|
1335
|
+
if (!parsed.success) {
|
|
1336
|
+
return server.NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
|
1337
|
+
}
|
|
1338
|
+
if (parsed.data.subscribed && !session.user.emailVerified) {
|
|
1339
|
+
return server.NextResponse.json({ error: "Verify your email address first" }, { status: 403 });
|
|
1340
|
+
}
|
|
1341
|
+
try {
|
|
1342
|
+
await setSubscribed(session.user.email, session.user.name, parsed.data.subscribed);
|
|
1343
|
+
return server.NextResponse.json({ subscribed: parsed.data.subscribed });
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
return server.NextResponse.json(
|
|
1346
|
+
{ error: unexpectedErrorMessage(error, "Failed to update subscription") },
|
|
1347
|
+
{ status: 500 }
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
exports.createBroadcastPOST = createBroadcastPOST;
|
|
1353
|
+
exports.getBroadcastGET = getBroadcastGET;
|
|
1354
|
+
exports.sendBroadcastPOST = sendBroadcastPOST;
|
|
1355
|
+
exports.updateSubscriptionPUT = updateSubscriptionPUT;
|
|
1356
|
+
//# sourceMappingURL=marketing.js.map
|
|
1357
|
+
//# sourceMappingURL=marketing.js.map
|