@xk2800/nextjs-template 0.7.1-beta.1 → 0.7.1-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -30
- package/components/auth/deviceCheck.tsx +47 -6
- package/components/auth/oneTap.tsx +5 -5
- package/components/dashboard/admin/adminUserConsentCard.tsx +79 -0
- package/components/dashboard/admin/adminUserDevicesCard.tsx +80 -0
- 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/admin/systemSettingsForm.tsx +33 -0
- package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
- package/components/dashboard/errorFeedback.tsx +2 -4
- package/components/dashboard/feedbackDialog.tsx +4 -4
- package/components/dashboard/marketingUpdatesCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesSection.tsx +10 -0
- package/components/site/cookie-banner.tsx +109 -7
- package/components/ui/alert-dialog.tsx +1 -1
- package/components/ui/badge.tsx +3 -1
- package/components/ui/card.tsx +1 -1
- package/components/ui/data-table.tsx +4 -1
- package/components/ui/dialog.tsx +1 -1
- package/components/ui/dropdown-menu.tsx +2 -2
- package/components/ui/file-upload.tsx +120 -2
- package/components/ui/select.tsx +189 -0
- package/components/ui/switch.tsx +3 -3
- package/dist/activity/logger.js +36 -0
- package/dist/activity/logger.js.map +1 -1
- package/dist/activity/logger.mjs +4 -4
- package/dist/activity/queries.d.mts +1 -1
- package/dist/activity/queries.d.ts +1 -1
- package/dist/activity/queries.js +36 -0
- package/dist/activity/queries.js.map +1 -1
- package/dist/activity/queries.mjs +3 -3
- package/dist/admin/queries.d.mts +19 -1
- package/dist/admin/queries.d.ts +19 -1
- package/dist/admin/queries.js +64 -0
- package/dist/admin/queries.js.map +1 -1
- package/dist/admin/queries.mjs +26 -5
- package/dist/admin/queries.mjs.map +1 -1
- package/dist/api/feedback.js +53 -2
- package/dist/api/feedback.js.map +1 -1
- package/dist/api/feedback.mjs +9 -8
- 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.js +51 -0
- package/dist/auth/helpers.js.map +1 -1
- package/dist/auth/helpers.mjs +9 -64
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.js +51 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/index.mjs +8 -7
- package/dist/{chunk-KAEA35OC.mjs → chunk-6MDQRT6U.mjs} +4 -4
- package/dist/{chunk-KAEA35OC.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
- package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
- package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
- package/dist/chunk-6Z3WUSDJ.mjs +62 -0
- package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
- package/dist/{chunk-VQ5B4MA7.mjs → chunk-7F2POZM6.mjs} +4 -4
- package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
- package/dist/{chunk-XWVAJXAG.mjs → chunk-7QVIOQ47.mjs} +23 -19
- 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/{chunk-3MGEAGHU.mjs → chunk-P2G23JMY.mjs} +11 -6
- package/dist/chunk-P2G23JMY.mjs.map +1 -0
- package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
- package/dist/chunk-TKWNEKK6.mjs.map +1 -0
- package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
- package/dist/chunk-TR5XFBTD.mjs.map +1 -0
- package/dist/{chunk-WZDAMCUX.mjs → chunk-ZJMFE5B2.mjs} +4 -4
- package/dist/{chunk-WZDAMCUX.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
- package/dist/config/env.d.mts +2 -0
- package/dist/config/env.d.ts +2 -0
- package/dist/config/env.js +6 -0
- package/dist/config/env.js.map +1 -1
- package/dist/config/env.mjs +1 -1
- package/dist/db/index.d.mts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +36 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/index.mjs +3 -3
- package/dist/db/schema.d.mts +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +30 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/db/schema.mjs +1 -1
- package/dist/errors.js +36 -0
- package/dist/errors.js.map +1 -1
- package/dist/errors.mjs +4 -4
- 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/dist/{schema-UuqxoOzS.d.mts → schema-CN_Y6EXv.d.mts} +148 -2
- package/dist/{schema-UuqxoOzS.d.ts → schema-CN_Y6EXv.d.ts} +148 -2
- package/dist/sessions/queries.js +36 -0
- package/dist/sessions/queries.js.map +1 -1
- package/dist/sessions/queries.mjs +3 -3
- package/dist/settings/queries.d.mts +4 -3
- package/dist/settings/queries.d.ts +4 -3
- package/dist/settings/queries.js +42 -0
- package/dist/settings/queries.js.map +1 -1
- package/dist/settings/queries.mjs +4 -4
- package/dist/users/queries.js +36 -0
- package/dist/users/queries.js.map +1 -1
- package/dist/users/queries.mjs +3 -3
- package/lib/cookie-consent.ts +72 -20
- package/lib/cookie-names.ts +47 -0
- package/lib/device-fingerprint.ts +66 -10
- package/lib/recalculate-columns.ts +2 -0
- package/package.json +28 -3
- package/styles/theme.css +23 -3
- package/dist/chunk-3MGEAGHU.mjs.map +0 -1
- package/dist/chunk-N6ECRCHY.mjs.map +0 -1
- package/dist/chunk-TMJYAOC2.mjs.map +0 -1
- package/dist/chunk-XWVAJXAG.mjs.map +0 -1
- package/dist/chunk-ZQCALTKB.mjs.map +0 -1
package/dist/admin/queries.js
CHANGED
|
@@ -48,6 +48,7 @@ __export(schema_exports, {
|
|
|
48
48
|
accounts: () => accounts,
|
|
49
49
|
activityLogs: () => activityLogs,
|
|
50
50
|
authThrottle: () => authThrottle,
|
|
51
|
+
consentLog: () => consentLog,
|
|
51
52
|
deviceFingerprints: () => deviceFingerprints,
|
|
52
53
|
errorRefs: () => errorRefs,
|
|
53
54
|
passkeys: () => passkeys,
|
|
@@ -202,10 +203,32 @@ var deviceFingerprints = pgCore.pgTable("device_fingerprint", {
|
|
|
202
203
|
userAgent: pgCore.text("userAgent"),
|
|
203
204
|
country: pgCore.text("country"),
|
|
204
205
|
city: pgCore.text("city"),
|
|
206
|
+
// Latest FingerprintJS signals for this device (lib/device-fingerprint.ts
|
|
207
|
+
// getDeviceSignals(): accessibility signals dropped, large values hashed),
|
|
208
|
+
// shown on the admin user page. Null for rows from before this column.
|
|
209
|
+
components: pgCore.jsonb("components").$type(),
|
|
205
210
|
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
206
211
|
}, (table) => [
|
|
207
212
|
pgCore.unique("device_fingerprint_user_visitor").on(table.userId, table.visitorId)
|
|
208
213
|
]);
|
|
214
|
+
var consentLog = pgCore.pgTable("consent_log", {
|
|
215
|
+
id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
|
|
216
|
+
userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
217
|
+
// The cookie_consent value: "all", "necessary" or a comma list of purposes.
|
|
218
|
+
choice: pgCore.text("choice").notNull(),
|
|
219
|
+
// CONSENT_VERSION (lib/cookie-names.ts) at the time — which banner/policy
|
|
220
|
+
// wording the user agreed to.
|
|
221
|
+
version: pgCore.integer("version").notNull(),
|
|
222
|
+
// id of the user's record this one follows ("" for their first). Unique per
|
|
223
|
+
// user, so concurrent posts from several tabs that all saw the same latest
|
|
224
|
+
// record can't each insert a duplicate — one wins, the rest conflict. Works
|
|
225
|
+
// without transactions (neon-http has none). Null on rows from before it.
|
|
226
|
+
previousId: pgCore.text("previousId"),
|
|
227
|
+
createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
|
|
228
|
+
}, (table) => [
|
|
229
|
+
pgCore.index("consent_log_user_idx").on(table.userId, table.createdAt),
|
|
230
|
+
pgCore.unique("consent_log_chain").on(table.userId, table.previousId)
|
|
231
|
+
]);
|
|
209
232
|
var authThrottle = pgCore.pgTable("auth_throttle", {
|
|
210
233
|
fingerprint: pgCore.text("fingerprint").notNull().primaryKey(),
|
|
211
234
|
count: pgCore.integer("count").notNull().default(0),
|
|
@@ -235,6 +258,13 @@ var systemSettings = pgCore.pgTable("system_settings", {
|
|
|
235
258
|
// Attach the error a user hit to feedback they send from it (see
|
|
236
259
|
// lib/feedback-route.ts). Off = only what the user typed is sent.
|
|
237
260
|
feedbackAttachErrors: pgCore.boolean("feedbackAttachErrors").notNull().default(true),
|
|
261
|
+
// GDPR/ePrivacy consent mode. On: cookie banner, FingerprintJS and One Tap
|
|
262
|
+
// only after "Accept all", device record erased on withdrawal. Off (no EU/UK
|
|
263
|
+
// users): no banner, treated as consented. Null = follow
|
|
264
|
+
// NEXT_PUBLIC_COOKIE_BANNER (nullable so migrating never flips a deployed
|
|
265
|
+
// banner on or off). proxy.ts mirrors the effective value into the
|
|
266
|
+
// consent_required cookie for the client (lib/cookie-consent.ts).
|
|
267
|
+
consentRequired: pgCore.boolean("consentRequired"),
|
|
238
268
|
updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow(),
|
|
239
269
|
// Who last changed a setting. This row doesn't "belong" to that admin the
|
|
240
270
|
// way sessions/activityLogs belong to their user, so deleting that admin's
|
|
@@ -256,6 +286,12 @@ var envSchema = zod.z.object({
|
|
|
256
286
|
PORT: zod.z.string().default("3000").transform(Number),
|
|
257
287
|
DATABASE_URL: zod.z.string().default(""),
|
|
258
288
|
RESEND_API_KEY: zod.z.string().default(""),
|
|
289
|
+
// Full access key for marketing broadcasts (admin Marketing email page).
|
|
290
|
+
// Blank = use RESEND_API_KEY.
|
|
291
|
+
RESEND_BROADCAST_API_KEY: zod.z.string().default(""),
|
|
292
|
+
// Resend segment users join from the dashboard "Email updates" toggle.
|
|
293
|
+
// Blank = toggle hidden.
|
|
294
|
+
RESEND_MARKETING_SEGMENT_ID: zod.z.string().default(""),
|
|
259
295
|
// Sender for every transactional email. Must be on a domain verified in
|
|
260
296
|
// your Resend account.
|
|
261
297
|
EMAIL_FROM: zod.z.string().default("Acme <onboarding@xavierkhew.xyz>"),
|
|
@@ -338,6 +374,11 @@ function lookupGeoLocation(ipAddress) {
|
|
|
338
374
|
}
|
|
339
375
|
return { country, city: geo.city || null };
|
|
340
376
|
}
|
|
377
|
+
function formatDeviceInfo({ os, browser, deviceType }) {
|
|
378
|
+
const label = [browser, os].filter(Boolean).join(" on ");
|
|
379
|
+
const typeLabel = deviceType === "mobile" ? "Mobile" : deviceType === "tablet" ? "Tablet" : "Desktop";
|
|
380
|
+
return label ? `${label} \xB7 ${typeLabel}` : `Unknown device \xB7 ${typeLabel}`;
|
|
381
|
+
}
|
|
341
382
|
|
|
342
383
|
// lib/auth-throttle-limits.ts
|
|
343
384
|
var WINDOW_MS = 15 * 60 * 1e3;
|
|
@@ -464,6 +505,27 @@ async function getRecentNewDevices(limit = 20) {
|
|
|
464
505
|
});
|
|
465
506
|
});
|
|
466
507
|
}
|
|
508
|
+
async function getUserDevices(userId) {
|
|
509
|
+
const rows = await db.select({
|
|
510
|
+
id: deviceFingerprints.id,
|
|
511
|
+
visitorId: deviceFingerprints.visitorId,
|
|
512
|
+
ipAddress: deviceFingerprints.ipAddress,
|
|
513
|
+
userAgent: deviceFingerprints.userAgent,
|
|
514
|
+
country: deviceFingerprints.country,
|
|
515
|
+
city: deviceFingerprints.city,
|
|
516
|
+
components: deviceFingerprints.components,
|
|
517
|
+
createdAt: deviceFingerprints.createdAt
|
|
518
|
+
}).from(deviceFingerprints).where(drizzleOrm.eq(deviceFingerprints.userId, userId)).orderBy(drizzleOrm.desc(deviceFingerprints.createdAt));
|
|
519
|
+
return rows.map((r) => __spreadProps(__spreadValues({}, r), { device: formatDeviceInfo(parseUserAgent(r.userAgent)) }));
|
|
520
|
+
}
|
|
521
|
+
async function getUserConsentLog(userId) {
|
|
522
|
+
return db.select({
|
|
523
|
+
id: consentLog.id,
|
|
524
|
+
choice: consentLog.choice,
|
|
525
|
+
version: consentLog.version,
|
|
526
|
+
createdAt: consentLog.createdAt
|
|
527
|
+
}).from(consentLog).where(drizzleOrm.eq(consentLog.userId, userId)).orderBy(drizzleOrm.desc(consentLog.createdAt));
|
|
528
|
+
}
|
|
467
529
|
var MIN_SHARED_ACCOUNTS = 3;
|
|
468
530
|
async function getSharedDeviceAccounts(limit = 20) {
|
|
469
531
|
const rows = await db.select({
|
|
@@ -485,5 +547,7 @@ exports.getAdminStats = getAdminStats;
|
|
|
485
547
|
exports.getDeviceThrottleActivity = getDeviceThrottleActivity;
|
|
486
548
|
exports.getRecentNewDevices = getRecentNewDevices;
|
|
487
549
|
exports.getSharedDeviceAccounts = getSharedDeviceAccounts;
|
|
550
|
+
exports.getUserConsentLog = getUserConsentLog;
|
|
551
|
+
exports.getUserDevices = getUserDevices;
|
|
488
552
|
//# sourceMappingURL=queries.js.map
|
|
489
553
|
//# sourceMappingURL=queries.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../server/db/schema.ts","../../lib/maintenance.ts","../../config/env-schema.ts","../../config/env.ts","../../server/db/index.ts","../../lib/request-info.ts","../../lib/auth-throttle-limits.ts","../../lib/shared-devices.ts","../../lib/admin-queries.ts"],"names":["pgEnum","pgTable","text","createId","boolean","timestamp","integer","unique","check","sql","index","z","Pool","drizzlePg","neon","drizzle","UAParser","geoip","gte","eq","desc"],"mappings":"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","file":"queries.js","sourcesContent":["import { boolean, check, index, integer, pgEnum, pgTable, text, timestamp, unique } from \"drizzle-orm/pg-core\";\nimport { sql } from \"drizzle-orm\";\nimport { createId } from '@paralleldrive/cuid2'\nimport { DEFAULT_MAINTENANCE_EXEMPT_PATHS } from \"../../lib/maintenance\";\n\n// 'tester' is a regular user account flagged for testing; apps can use it to\n// keep test accounts apart from real users. It has no admin access.\nexport const RoleEnum = pgEnum('roles', ['user', 'admin', 'tester'])\n\nexport const ActivityActionEnum = pgEnum('activity_actions', [\n 'login',\n 'logout',\n 'login_failed',\n 'password_changed',\n 'email_changed',\n 'profile_updated',\n 'session_revoked',\n 'user_deleted',\n 'user_banned',\n 'user_unbanned',\n 'role_changed',\n 'impersonation_started',\n 'impersonation_stopped',\n 'settings_changed',\n 'passkey_added',\n 'passkey_removed',\n])\n\nexport const users = pgTable(\"user\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n name: text('name').notNull(),\n email: text('email').notNull().unique(),\n password: text('password'),\n emailVerified: boolean(\"emailVerified\").notNull().default(false),\n image: text(\"image\"),\n role: RoleEnum('roles').default('user').notNull(),\n banned: boolean('banned').default(false).notNull(),\n bannedAt: timestamp('bannedAt'),\n bannedReason: text('bannedReason'),\n // Required by better-auth's admin plugin schema (auto-expiring bans). We\n // never set this ourselves, so it stays null and bans behave as permanent,\n // same as before the plugin was added.\n banExpires: timestamp('banExpires'),\n lastLoginAt: timestamp('lastLoginAt'),\n lastActiveAt: timestamp('lastActiveAt'),\n // Set by better-auth's twoFactor plugin — true once the user has\n // completed TOTP setup (see twoFactor pgTable below for the secret /\n // backup codes themselves).\n twoFactorEnabled: boolean('twoFactorEnabled').notNull().default(false),\n createdAt: timestamp('createdAt').notNull().defaultNow(),\n updatedAt: timestamp('updatedAt').notNull().defaultNow(),\n});\n\nexport const sessions = pgTable(\"session\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n expiresAt: timestamp(\"expiresAt\").notNull(),\n token: text(\"token\").notNull().unique(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n // Set by better-auth's admin plugin while this session is an admin\n // impersonating another user; holds the impersonating admin's user id.\n impersonatedBy: text(\"impersonatedBy\"),\n});\n\nexport const accounts = pgTable(\"account\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n accountId: text(\"accountId\").notNull(),\n providerId: text(\"providerId\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n accessToken: text(\"accessToken\"),\n refreshToken: text(\"refreshToken\"),\n idToken: text(\"idToken\"),\n accessTokenExpiresAt: timestamp(\"accessTokenExpiresAt\"),\n refreshTokenExpiresAt: timestamp(\"refreshTokenExpiresAt\"),\n scope: text(\"scope\"),\n password: text(\"password\"),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n});\n\n// Better-auth's twoFactor plugin. One row per user (created on first\n// `enable`); secret and backupCodes are its own encrypted-at-rest strings,\n// field names/types must match its expectations exactly (drizzleAdapter in\n// server/auth.ts maps this table to the plugin's \"twoFactor\" model as-is).\nexport const twoFactors = pgTable(\"twoFactor\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n secret: text(\"secret\").notNull(),\n backupCodes: text(\"backupCodes\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n // Added in better-auth 1.7 (TOTP lockout). Defaults match the plugin's.\n verified: boolean(\"verified\").default(true),\n failedVerificationCount: integer(\"failedVerificationCount\").default(0),\n lockedUntil: timestamp(\"lockedUntil\"),\n});\n\n// Better-auth's passkey plugin — one row per registered WebAuthn credential.\nexport const passkeys = pgTable(\"passkey\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n name: text(\"name\"),\n publicKey: text(\"publicKey\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n credentialID: text(\"credentialID\").notNull().unique(),\n counter: integer(\"counter\").notNull(),\n deviceType: text(\"deviceType\").notNull(),\n backedUp: boolean(\"backedUp\").notNull(),\n transports: text(\"transports\"),\n createdAt: timestamp(\"createdAt\"),\n aaguid: text(\"aaguid\"),\n});\n\nexport const verifications = pgTable(\"verification\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n identifier: text(\"identifier\").notNull(),\n value: text(\"value\").notNull(),\n expiresAt: timestamp(\"expiresAt\").notNull(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n});\n\nexport const activityLogs = pgTable(\"activity_log\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n action: ActivityActionEnum('action').notNull(),\n description: text('description').notNull(),\n ipAddress: text('ipAddress'),\n userAgent: text('userAgent'),\n metadata: text('metadata'),\n // Populated on 'login' events only (registration + sign-in) — parsed from\n // userAgent/ipAddress at write time so the record stays stable even if\n // the parsing library or geoip database changes later.\n referrerUrl: text('referrerUrl'),\n os: text('os'),\n browser: text('browser'),\n deviceType: text('deviceType'),\n country: text('country'),\n city: text('city'),\n createdAt: timestamp('createdAt').notNull().defaultNow(),\n});\n\n// One row per (user, device fingerprint) pair. Its only job is to answer\n// \"has this account signed in from this device before?\" — no matching row ⇒\n// send a new-device alert (see app/api/device-check/route.ts). visitorId is\n// FingerprintJS's identifier, computed client-side in\n// components/auth/deviceCheck.tsx. UA+IP are deliberately NOT the signal here:\n// they churn constantly on mobile and would alert on every other login.\nexport const deviceFingerprints = pgTable(\"device_fingerprint\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n visitorId: text(\"visitorId\").notNull(),\n // Context captured at first-seen time — for the admin panel's \"New devices\"\n // card and the alert email. Not part of the new-vs-known decision (that's\n // visitorId only), just so an admin can eyeball whether a sign-in looks like\n // a hijack. Nullable: an old row predates these columns, or geoip missed.\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n country: text(\"country\"),\n city: text(\"city\"),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n}, (table) => [\n unique(\"device_fingerprint_user_visitor\").on(table.userId, table.visitorId),\n]);\n\n// Per-device abuse throttle for /sign-in/email and /sign-up/email — one\n// fixed-window counter row per FingerprintJS visitorId (sent by the auth\n// forms as the x-device-fingerprint header; see lib/auth-throttle.ts).\n// Keyed by device, not IP: IP is shared behind corporate NAT / CGNAT / VPN,\n// so an IP counter throttles coworkers together while missing an abuser who\n// rotates IPs. Rows whose window has elapsed are dead weight but harmless\n// (reset on the next attempt) — prune on a cron if the table ever grows.\nexport const authThrottle = pgTable(\"auth_throttle\", {\n fingerprint: text(\"fingerprint\").notNull().primaryKey(),\n count: integer(\"count\").notNull().default(0),\n windowStart: timestamp(\"windowStart\").notNull().defaultNow(),\n // Blockable data points, overwritten on every attempt (last-write-wins) so\n // the admin panel can show who to block — by IP or by fingerprint — with no\n // join and no per-attempt history table. windowStart anchors the counter's\n // window; lastAttemptAt is just \"most recently seen\".\n lastAttemptAt: timestamp(\"lastAttemptAt\").notNull().defaultNow(),\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n lastEmail: text(\"lastEmail\"),\n lastKind: text(\"lastKind\"), // 'signin' | 'signup'\n});\n\n// Singleton row (id is always 'default') holding admin-editable settings for\n// config that used to be env-var-only. See lib/settings-queries.ts for the\n// cached read path and lazy-seed-from-env behavior.\nexport const systemSettings = pgTable(\"system_settings\", {\n id: text(\"id\").primaryKey().default(\"default\"),\n maintenanceMode: boolean(\"maintenanceMode\").notNull().default(false),\n maintenanceMessage: text(\"maintenanceMessage\"),\n // Path prefixes that stay reachable while maintenance mode is on (see\n // lib/maintenance.ts — a few auth paths are always open on top of these).\n maintenanceExemptPaths: text(\"maintenanceExemptPaths\").array().notNull().default(DEFAULT_MAINTENANCE_EXEMPT_PATHS),\n authEnableGoogle: boolean(\"authEnableGoogle\").notNull().default(true),\n authEnableEmailPassword: boolean(\"authEnableEmailPassword\").notNull().default(true),\n authEnableOneTap: boolean(\"authEnableOneTap\").notNull().default(false),\n enableSessionRevocation: boolean(\"enableSessionRevocation\").notNull().default(true),\n // Attach the error a user hit to feedback they send from it (see\n // lib/feedback-route.ts). Off = only what the user typed is sent.\n feedbackAttachErrors: boolean(\"feedbackAttachErrors\").notNull().default(true),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n // Who last changed a setting. This row doesn't \"belong\" to that admin the\n // way sessions/activityLogs belong to their user, so deleting that admin's\n // account should null this out rather than cascade-delete the singleton row.\n updatedBy: text(\"updatedBy\").references(() => users.id, { onDelete: \"set null\" }),\n}, (table) => [\n check(\"system_settings_singleton\", sql`${table.id} = 'default'`),\n]);\n// Technical detail of unexpected errors, keyed by the short ref the user sees\n// (\"... (ref: K7Q2PX)\", see lib/errors.ts). Never sent to the browser: the\n// feedback route looks a ref up and attaches the detail to the report it\n// sends to Discord/email. Rows older than 30 days are pruned on insert.\nexport const errorRefs = pgTable(\"error_refs\", {\n ref: text(\"ref\").primaryKey(),\n message: text(\"message\").notNull(),\n detail: text(\"detail\").notNull(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n}, (table) => [\n index(\"error_refs_created_at_idx\").on(table.createdAt),\n]);\n","// Pure path logic for maintenance mode — no DB / server-only imports, so it\n// can run in proxy.ts and in the admin form alike.\n\n// Default for the admin-editable exempt list: the public marketing pages.\nexport const DEFAULT_MAINTENANCE_EXEMPT_PATHS = [\n \"/\",\n \"/features\",\n \"/changelog\",\n \"/changelog-template\",\n \"/privacy\",\n \"/terms\",\n]\n\n// Always reachable, whatever the admin saves — otherwise an admin could\n// lock themselves out of the sign-in flow needed to turn maintenance off.\nexport const ALWAYS_OPEN_PATHS = [\n \"/maintenance\",\n \"/login\", // includes /login/2fa\n \"/forgot-password\",\n \"/reset-password\",\n \"/api/auth\",\n \"/api/settings/public\", // login page reads the One Tap flag from here\n]\n\n// `/foo` matches `/foo` and `/foo/...` but not `/foobar`; `/` matches only `/`.\nfunction matches(pathname: string, prefix: string) {\n const p = prefix.length > 1 ? prefix.replace(/\\/+$/, \"\") : prefix\n return pathname === p || (p !== \"/\" && pathname.startsWith(p + \"/\"))\n}\n\nexport function isMaintenanceExempt(pathname: string, exemptPaths: string[]) {\n return [...ALWAYS_OPEN_PATHS, ...exemptPaths].some((p) => matches(pathname, p))\n}\n","// No \"server-only\" guard here — this schema is also imported by scripts that\n// run outside Next.js's bundler (e.g. scripts/doctor.ts), where that guard\n// throws unconditionally. config/env.ts re-exports the parsed, guarded result\n// for app code; import this file directly only from non-Next scripts.\nimport { z } from 'zod';\n\nexport const envSchema = z.object({\n NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),\n PORT: z.string().default('3000').transform(Number),\n DATABASE_URL: z.string().default(''),\n RESEND_API_KEY: z.string().default(''),\n // Sender for every transactional email. Must be on a domain verified in\n // your Resend account.\n EMAIL_FROM: z.string().default('Acme <onboarding@xavierkhew.xyz>'),\n // Display name for this deployment — shown as the authenticator-app label\n // (2FA issuer) and the passkey relying-party name. Consuming projects set\n // this in their own .env; defaults to the template name.\n APP_NAME: z.string().default('Next.js Template'),\n // Where the dashboard feedback form delivers (app/api/feedback). Set either\n // or both; with neither, the Feedback button is hidden. FEEDBACK_EMAIL takes\n // a comma-separated list and sends through Resend (needs RESEND_API_KEY).\n FEEDBACK_DISCORD_WEBHOOK_URL: z.string().default(''),\n FEEDBACK_EMAIL: z.string().default(''),\n // Seed-only: the live value lives in the system_settings DB row (see\n // lib/settings-queries.ts) once it's been seeded on first read, and is\n // changed from the admin System Settings page, not by editing this var.\n ENABLE_SESSION_REVOCATION: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_GOOGLE: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_EMAIL_PASSWORD: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_ONE_TAP: z.string().default('false').transform(val => val === 'true'),\n DB_DRIVER: z.enum(['pg', 'neon']).default('pg'),\n // Only relevant when DB_DRIVER=pg — whether the target Postgres server has SSL\n // enabled. Self-hosted Postgres (e.g. via Docker/Dokploy) typically does not.\n DATABASE_SSL: z.string().default('false').transform(val => val === 'true'),\n});\n","import \"server-only\";\nimport { envSchema } from './env-schema';\n\nconst results = envSchema.safeParse(process.env);\n\nif (results.success && (results.data.NODE_ENV === 'development')) {\n console.log('Environment variables loaded:', Object.keys(results.data));\n}\n\nif (!results.success) {\n console.error('Invalid environment variables:', results.error.format());\n throw new Error('Invalid environment variables');\n}\n\nexport const config = results.data;\n","import 'server-only';\n\n// neon-http driver for production\nimport { neon } from '@neondatabase/serverless';\nimport { drizzle } from 'drizzle-orm/neon-http';\n\n// postgres driver for local development\nimport { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';\nimport { Pool } from 'pg';\n\nimport * as schema from './schema';\nimport { config } from '../../config/env';\n\n// Import the query builder\nimport { PostgresJsDatabase } from 'drizzle-orm/postgres-js';\nimport { NeonHttpDatabase } from 'drizzle-orm/neon-http';\n\ntype DrizzleNeon = NeonHttpDatabase<typeof schema>;\ntype DrizzlePg = PostgresJsDatabase<typeof schema>;\n\nlet db: DrizzleNeon | DrizzlePg\n\n\nif (config.DB_DRIVER === 'pg') {\n const pool = new Pool({\n connectionString: config.DATABASE_URL!,\n ssl: config.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,\n });\n db = drizzlePg(pool, {\n schema,\n logger: true,\n });\n\n console.log('🟡 Using PostgreSQL (pg) driver');\n\n} else if (config.DB_DRIVER === 'neon') {\n const sql = neon(config.DATABASE_URL!);\n db = drizzle(sql, { schema, logger: true });\n\n console.log('🟢 Using Neon (HTTP) driver');\n\n} else {\n console.log('Database connection not established, unknown DB_DRIVER:', config.DB_DRIVER);\n}\n// Note: In production, you might want to disable logging or use a more sophisticated logger.\n\nexport { db }","import \"server-only\"\nimport { UAParser } from \"ua-parser-js\"\nimport geoip from \"geoip-lite\"\n\nconst countryNames = new Intl.DisplayNames([\"en\"], { type: \"region\" })\n\nexport type DeviceType = \"desktop\" | \"mobile\" | \"tablet\"\n\nexport interface DeviceInfo {\n os: string | null\n browser: string | null\n deviceType: DeviceType\n}\n\nexport interface GeoInfo {\n country: string | null\n city: string | null\n}\n\n/**\n * First address in X-Forwarded-For is the original client — everything after\n * is intermediate proxies. Falls back to null (e.g. local dev with no proxy).\n */\nexport function getClientIp(headers: Headers | undefined | null): string | null {\n const forwardedFor = headers?.get(\"x-forwarded-for\")\n if (forwardedFor) return forwardedFor.split(\",\")[0].trim()\n\n return headers?.get(\"x-real-ip\") ?? null\n}\n\nexport function parseUserAgent(userAgent: string | null | undefined): DeviceInfo {\n if (!userAgent) {\n return { os: null, browser: null, deviceType: \"desktop\" }\n }\n\n const { os, browser, device } = new UAParser(userAgent).getResult()\n\n return {\n os: os.name ? [os.name, os.version].filter(Boolean).join(\" \") : null,\n browser: browser.name ?? null,\n deviceType: device.type === \"mobile\" || device.type === \"tablet\" ? device.type : \"desktop\",\n }\n}\n\n/**\n * Offline lookup (geoip-lite ships its own IP-range database, no network\n * call) — resolves to nulls for private/local addresses like 127.0.0.1.\n */\nexport function lookupGeoLocation(ipAddress: string | null | undefined): GeoInfo {\n if (!ipAddress) return { country: null, city: null }\n\n const geo = geoip.lookup(ipAddress)\n if (!geo) return { country: null, city: null }\n\n let country: string | null = geo.country || null\n if (country) {\n try {\n country = countryNames.of(country) ?? country\n } catch {\n // Unrecognized region code — keep the raw code rather than losing it.\n }\n }\n\n return { country, city: geo.city || null }\n}\n\nexport function formatDeviceInfo({ os, browser, deviceType }: DeviceInfo): string {\n const label = [browser, os].filter(Boolean).join(\" on \")\n const typeLabel = deviceType === \"mobile\" ? \"Mobile\" : deviceType === \"tablet\" ? \"Tablet\" : \"Desktop\"\n\n return label ? `${label} · ${typeLabel}` : `Unknown device · ${typeLabel}`\n}\n\nexport function formatLocation({ country, city }: GeoInfo): string {\n if (!country && !city) return \"Unknown\"\n\n return [city, country].filter(Boolean).join(\", \")\n}\n","// Pure throttle policy — no DB, no `server-only`, so it stays unit-testable.\n// Consumed by lib/auth-throttle.ts (the DB plumbing) and its test.\n\n// Tune per deployment, or lift to env vars if you need it configurable.\nexport const WINDOW_MS = 15 * 60 * 1000 // how long attempts accumulate\nexport const MAX = 10 // failed sign-ins / sign-ups per window before a 429, per fingerprint\n\n// Backstop cap keyed by IP instead of fingerprint — the fingerprint header is\n// client-supplied and unsigned, so it's trivial to omit or randomize per\n// request (see server/auth.ts before-hook). Higher than MAX because an IP can\n// be shared behind NAT/CGNAT/VPN.\n// ponytail: flat multiplier, not a real per-deployment tuning — revisit if\n// legitimate shared-IP traffic ever gets caught in it.\nexport const MAX_IP = MAX * 3\n\n// A row whose window has already elapsed counts as \"no row\" — it resets on\n// the next attempt (see recordDeviceAttempt), so the device isn't blocked in\n// the meantime.\nexport function isOverLimit(\n row: { count: number; windowStart: Date } | undefined,\n now: number,\n max: number = MAX,\n): boolean {\n if (!row) return false\n if (now - row.windowStart.getTime() >= WINDOW_MS) return false\n return row.count >= max\n}\n","// Pure grouping for the admin \"Shared devices\" card — no DB, no `server-only`,\n// so it stays unit-testable. Consumed by lib/admin-queries.ts.\n\nexport type SharedDeviceAccountRow = {\n visitorId: string\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n userAgent: string | null\n country: string | null\n city: string | null\n}\n\nexport type SharedDevice = {\n visitorId: string\n accountCount: number\n lastSeenAt: Date\n country: string | null\n city: string | null\n userAgent: string | null\n accounts: {\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n }[]\n}\n\n// Collapse flat (account, device) rows into one entry per device fingerprint,\n// keep only devices reached by `minAccounts`+ accounts (the multi-account /\n// trial-abuse signal), most-accounts device first. Device context (UA/geo) is\n// taken from whichever row for that visitorId is seen first — pass rows newest\n// first if you want the most recent context.\nexport function groupSharedDevices(\n rows: SharedDeviceAccountRow[],\n minAccounts: number,\n limit: number,\n): SharedDevice[] {\n const byVisitor = new Map<string, SharedDevice>()\n\n for (const r of rows) {\n let d = byVisitor.get(r.visitorId)\n if (!d) {\n d = {\n visitorId: r.visitorId,\n accountCount: 0,\n lastSeenAt: r.firstSeenAt,\n country: r.country,\n city: r.city,\n userAgent: r.userAgent,\n accounts: [],\n }\n byVisitor.set(r.visitorId, d)\n }\n d.accountCount++\n if (r.firstSeenAt > d.lastSeenAt) d.lastSeenAt = r.firstSeenAt\n d.accounts.push({\n userId: r.userId,\n userName: r.userName,\n userEmail: r.userEmail,\n banned: r.banned,\n firstSeenAt: r.firstSeenAt,\n })\n }\n\n return [...byVisitor.values()]\n .filter((d) => d.accountCount >= minAccounts)\n .sort(\n (a, b) =>\n b.accountCount - a.accountCount || b.lastSeenAt.getTime() - a.lastSeenAt.getTime(),\n )\n .slice(0, limit)\n}\n","import \"server-only\"\nimport { db } from \"../server/db\"\nimport { users, sessions, authThrottle, deviceFingerprints } from \"../server/db/schema\"\nimport { sql, gte, eq, desc } from \"drizzle-orm\"\nimport { parseUserAgent, lookupGeoLocation } from \"./request-info\"\nimport { WINDOW_MS, isOverLimit } from \"./auth-throttle-limits\"\nimport { groupSharedDevices } from \"./shared-devices\"\n\n// Powers the admin panel's landing page (/dashboard/admin) — kept separate\n// from getAdminStats (the compact overview embedded in the main dashboard)\n// since the two surfaces show different metrics.\nexport async function getAdminDashboardStats() {\n const now = new Date()\n const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\n const [totalUsersResult, newSignupsResult, activeSessionsResult, bannedResult] = await Promise.all([\n db.select({ count: sql<number>`cast(count(*) as integer)` }).from(users),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(gte(users.createdAt, weekAgo)),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(eq(users.banned, true)),\n ])\n\n return {\n totalUsers: totalUsersResult[0]?.count || 0,\n newSignupsThisWeek: newSignupsResult[0]?.count || 0,\n activeSessions: activeSessionsResult[0]?.count || 0,\n bannedCount: bannedResult[0]?.count || 0,\n }\n}\n\nexport async function getAdminStats() {\n const totalUsersResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n const totalUsers = totalUsersResult[0]?.count || 0\n\n const now = new Date()\n const activeSessionsResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`)\n const activeSessions = activeSessionsResult[0]?.count || 0\n\n const adminCountResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(sql`${users.role} = 'admin'`)\n const adminCount = adminCountResult[0]?.count || 0\n\n return {\n totalUsers,\n activeSessions,\n adminCount,\n regularUsers: totalUsers - adminCount,\n }\n}\n\n// Rows below this are noise — a single new signup, or one login typo that\n// then succeeded (a success deletes the row, so a lingering count of 1 is\n// almost always a lone signup).\nconst SUSPICIOUS_ATTEMPTS = 2\n\nexport type DeviceThrottleRow = Awaited<ReturnType<typeof getDeviceThrottleActivity>>[number]\n\n// Powers the admin \"Device throttle\" card. One row per device fingerprint\n// that's currently accumulating failed sign-ins / sign-ups, newest first,\n// with everything an admin needs to block it at the edge (IP) or in-app\n// (fingerprint) — parsed device + geo included for context.\nexport async function getDeviceThrottleActivity(limit = 50) {\n const rows = await db\n .select()\n .from(authThrottle)\n .where(gte(authThrottle.count, SUSPICIOUS_ATTEMPTS))\n .orderBy(desc(authThrottle.lastAttemptAt))\n .limit(limit)\n\n const now = Date.now()\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n const geo = lookupGeoLocation(r.ipAddress)\n return {\n fingerprint: r.fingerprint,\n count: r.count,\n windowStart: r.windowStart,\n lastAttemptAt: r.lastAttemptAt,\n ipAddress: r.ipAddress,\n userAgent: r.userAgent,\n lastEmail: r.lastEmail,\n lastKind: r.lastKind,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n country: geo.country,\n city: geo.city,\n // Would this device be 429'd on its next attempt right now?\n blocked: isOverLimit(r, now),\n // Is the counter still inside its window (attack ongoing) or has it\n // gone quiet (last burst, awaiting reset on next attempt)?\n windowActive: now - r.windowStart.getTime() < WINDOW_MS,\n }\n })\n}\n\nexport type RecentNewDeviceRow = Awaited<ReturnType<typeof getRecentNewDevices>>[number]\n\n// Powers the admin \"New devices\" card. One row per (account, device) pair the\n// first time that account is seen on that device — written by\n// app/api/device-check/route.ts, newest first. The row links to the user's\n// admin detail page, where sessions can be revoked if the sign-in wasn't them.\nexport async function getRecentNewDevices(limit = 20) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n .limit(limit)\n\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n return {\n ...r,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n }\n })\n}\n\n// A device fingerprint (FingerprintJS visitorId) is normally 1:1 with an\n// account, or 1:few (personal + work login on one laptop). One visitorId behind\n// this many accounts is the multi-account / trial-abuse smell.\nconst MIN_SHARED_ACCOUNTS = 3\n\nexport type SharedDeviceRow = Awaited<ReturnType<typeof getSharedDeviceAccounts>>[number]\n\n// Powers the admin \"Shared devices\" card. One row per device fingerprint that\n// MIN_SHARED_ACCOUNTS+ distinct accounts have signed in from, most-shared first,\n// each with its full account list so an admin can open and ban them. The\n// device_fingerprint unique (userId, visitorId) constraint means a plain row\n// count per visitorId already IS the distinct-account count.\nexport async function getSharedDeviceAccounts(limit = 20) {\n // ponytail: pulls the whole device_fingerprint table each call and groups in\n // JS — fine for a template. If this table gets large, add an index on\n // visitorId and push the \"N+ accounts\" filter into a HAVING subquery.\n const rows = await db\n .select({\n visitorId: deviceFingerprints.visitorId,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n banned: users.banned,\n firstSeenAt: deviceFingerprints.createdAt,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return groupSharedDevices(rows, MIN_SHARED_ACCOUNTS, limit).map((d) => ({\n ...d,\n ...parseUserAgent(d.userAgent),\n }))\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../server/db/schema.ts","../../lib/maintenance.ts","../../config/env-schema.ts","../../config/env.ts","../../server/db/index.ts","../../lib/request-info.ts","../../lib/auth-throttle-limits.ts","../../lib/shared-devices.ts","../../lib/admin-queries.ts"],"names":["pgEnum","pgTable","text","createId","boolean","timestamp","integer","jsonb","unique","index","check","sql","z","Pool","drizzlePg","neon","drizzle","UAParser","geoip","gte","eq","desc"],"mappings":"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","file":"queries.js","sourcesContent":["import { boolean, check, index, integer, jsonb, pgEnum, pgTable, text, timestamp, unique } from \"drizzle-orm/pg-core\";\nimport { sql } from \"drizzle-orm\";\nimport { createId } from '@paralleldrive/cuid2'\nimport { DEFAULT_MAINTENANCE_EXEMPT_PATHS } from \"../../lib/maintenance\";\n\n// 'tester' is a regular user account flagged for testing; apps can use it to\n// keep test accounts apart from real users. It has no admin access.\nexport const RoleEnum = pgEnum('roles', ['user', 'admin', 'tester'])\n\nexport const ActivityActionEnum = pgEnum('activity_actions', [\n 'login',\n 'logout',\n 'login_failed',\n 'password_changed',\n 'email_changed',\n 'profile_updated',\n 'session_revoked',\n 'user_deleted',\n 'user_banned',\n 'user_unbanned',\n 'role_changed',\n 'impersonation_started',\n 'impersonation_stopped',\n 'settings_changed',\n 'passkey_added',\n 'passkey_removed',\n])\n\nexport const users = pgTable(\"user\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n name: text('name').notNull(),\n email: text('email').notNull().unique(),\n password: text('password'),\n emailVerified: boolean(\"emailVerified\").notNull().default(false),\n image: text(\"image\"),\n role: RoleEnum('roles').default('user').notNull(),\n banned: boolean('banned').default(false).notNull(),\n bannedAt: timestamp('bannedAt'),\n bannedReason: text('bannedReason'),\n // Required by better-auth's admin plugin schema (auto-expiring bans). We\n // never set this ourselves, so it stays null and bans behave as permanent,\n // same as before the plugin was added.\n banExpires: timestamp('banExpires'),\n lastLoginAt: timestamp('lastLoginAt'),\n lastActiveAt: timestamp('lastActiveAt'),\n // Set by better-auth's twoFactor plugin — true once the user has\n // completed TOTP setup (see twoFactor pgTable below for the secret /\n // backup codes themselves).\n twoFactorEnabled: boolean('twoFactorEnabled').notNull().default(false),\n createdAt: timestamp('createdAt').notNull().defaultNow(),\n updatedAt: timestamp('updatedAt').notNull().defaultNow(),\n});\n\nexport const sessions = pgTable(\"session\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n expiresAt: timestamp(\"expiresAt\").notNull(),\n token: text(\"token\").notNull().unique(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n // Set by better-auth's admin plugin while this session is an admin\n // impersonating another user; holds the impersonating admin's user id.\n impersonatedBy: text(\"impersonatedBy\"),\n});\n\nexport const accounts = pgTable(\"account\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n accountId: text(\"accountId\").notNull(),\n providerId: text(\"providerId\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n accessToken: text(\"accessToken\"),\n refreshToken: text(\"refreshToken\"),\n idToken: text(\"idToken\"),\n accessTokenExpiresAt: timestamp(\"accessTokenExpiresAt\"),\n refreshTokenExpiresAt: timestamp(\"refreshTokenExpiresAt\"),\n scope: text(\"scope\"),\n password: text(\"password\"),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n});\n\n// Better-auth's twoFactor plugin. One row per user (created on first\n// `enable`); secret and backupCodes are its own encrypted-at-rest strings,\n// field names/types must match its expectations exactly (drizzleAdapter in\n// server/auth.ts maps this table to the plugin's \"twoFactor\" model as-is).\nexport const twoFactors = pgTable(\"twoFactor\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n secret: text(\"secret\").notNull(),\n backupCodes: text(\"backupCodes\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n // Added in better-auth 1.7 (TOTP lockout). Defaults match the plugin's.\n verified: boolean(\"verified\").default(true),\n failedVerificationCount: integer(\"failedVerificationCount\").default(0),\n lockedUntil: timestamp(\"lockedUntil\"),\n});\n\n// Better-auth's passkey plugin — one row per registered WebAuthn credential.\nexport const passkeys = pgTable(\"passkey\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n name: text(\"name\"),\n publicKey: text(\"publicKey\").notNull(),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n credentialID: text(\"credentialID\").notNull().unique(),\n counter: integer(\"counter\").notNull(),\n deviceType: text(\"deviceType\").notNull(),\n backedUp: boolean(\"backedUp\").notNull(),\n transports: text(\"transports\"),\n createdAt: timestamp(\"createdAt\"),\n aaguid: text(\"aaguid\"),\n});\n\nexport const verifications = pgTable(\"verification\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n identifier: text(\"identifier\").notNull(),\n value: text(\"value\").notNull(),\n expiresAt: timestamp(\"expiresAt\").notNull(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n});\n\nexport const activityLogs = pgTable(\"activity_log\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n action: ActivityActionEnum('action').notNull(),\n description: text('description').notNull(),\n ipAddress: text('ipAddress'),\n userAgent: text('userAgent'),\n metadata: text('metadata'),\n // Populated on 'login' events only (registration + sign-in) — parsed from\n // userAgent/ipAddress at write time so the record stays stable even if\n // the parsing library or geoip database changes later.\n referrerUrl: text('referrerUrl'),\n os: text('os'),\n browser: text('browser'),\n deviceType: text('deviceType'),\n country: text('country'),\n city: text('city'),\n createdAt: timestamp('createdAt').notNull().defaultNow(),\n});\n\n// One row per (user, device fingerprint) pair. Its only job is to answer\n// \"has this account signed in from this device before?\" — no matching row ⇒\n// send a new-device alert (see app/api/device-check/route.ts). visitorId is\n// FingerprintJS's identifier, computed client-side in\n// components/auth/deviceCheck.tsx. UA+IP are deliberately NOT the signal here:\n// they churn constantly on mobile and would alert on every other login.\nexport const deviceFingerprints = pgTable(\"device_fingerprint\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n visitorId: text(\"visitorId\").notNull(),\n // Context captured at first-seen time — for the admin panel's \"New devices\"\n // card and the alert email. Not part of the new-vs-known decision (that's\n // visitorId only), just so an admin can eyeball whether a sign-in looks like\n // a hijack. Nullable: an old row predates these columns, or geoip missed.\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n country: text(\"country\"),\n city: text(\"city\"),\n // Latest FingerprintJS signals for this device (lib/device-fingerprint.ts\n // getDeviceSignals(): accessibility signals dropped, large values hashed),\n // shown on the admin user page. Null for rows from before this column.\n components: jsonb(\"components\").$type<Record<string, unknown>>(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n}, (table) => [\n unique(\"device_fingerprint_user_visitor\").on(table.userId, table.visitorId),\n]);\n\n// Proof of cookie consent (GDPR Art. 7(1): the controller must be able to\n// demonstrate consent). One row per choice a signed-in user makes, written by\n// app/api/consent from the banner (components/site/cookie-banner.tsx).\n// Deliberately minimal — no IP/UA. Deleted with the account.\nexport const consentLog = pgTable(\"consent_log\", {\n id: text(\"id\").notNull().primaryKey().$defaultFn(() => createId()),\n userId: text(\"userId\")\n .notNull()\n .references(() => users.id, { onDelete: \"cascade\" }),\n // The cookie_consent value: \"all\", \"necessary\" or a comma list of purposes.\n choice: text(\"choice\").notNull(),\n // CONSENT_VERSION (lib/cookie-names.ts) at the time — which banner/policy\n // wording the user agreed to.\n version: integer(\"version\").notNull(),\n // id of the user's record this one follows (\"\" for their first). Unique per\n // user, so concurrent posts from several tabs that all saw the same latest\n // record can't each insert a duplicate — one wins, the rest conflict. Works\n // without transactions (neon-http has none). Null on rows from before it.\n previousId: text(\"previousId\"),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n}, (table) => [\n index(\"consent_log_user_idx\").on(table.userId, table.createdAt),\n unique(\"consent_log_chain\").on(table.userId, table.previousId),\n]);\n\n// Per-device abuse throttle for /sign-in/email and /sign-up/email — one\n// fixed-window counter row per FingerprintJS visitorId (sent by the auth\n// forms as the x-device-fingerprint header; see lib/auth-throttle.ts).\n// Keyed by device, not IP: IP is shared behind corporate NAT / CGNAT / VPN,\n// so an IP counter throttles coworkers together while missing an abuser who\n// rotates IPs. Rows untouched for 30 days are deleted (RETENTION_MS in\n// lib/auth-throttle-limits.ts, swept by lib/auth-throttle.ts).\nexport const authThrottle = pgTable(\"auth_throttle\", {\n fingerprint: text(\"fingerprint\").notNull().primaryKey(),\n count: integer(\"count\").notNull().default(0),\n windowStart: timestamp(\"windowStart\").notNull().defaultNow(),\n // Blockable data points, overwritten on every attempt (last-write-wins) so\n // the admin panel can show who to block — by IP or by fingerprint — with no\n // join and no per-attempt history table. windowStart anchors the counter's\n // window; lastAttemptAt is just \"most recently seen\".\n lastAttemptAt: timestamp(\"lastAttemptAt\").notNull().defaultNow(),\n ipAddress: text(\"ipAddress\"),\n userAgent: text(\"userAgent\"),\n lastEmail: text(\"lastEmail\"),\n lastKind: text(\"lastKind\"), // 'signin' | 'signup'\n});\n\n// Singleton row (id is always 'default') holding admin-editable settings for\n// config that used to be env-var-only. See lib/settings-queries.ts for the\n// cached read path and lazy-seed-from-env behavior.\nexport const systemSettings = pgTable(\"system_settings\", {\n id: text(\"id\").primaryKey().default(\"default\"),\n maintenanceMode: boolean(\"maintenanceMode\").notNull().default(false),\n maintenanceMessage: text(\"maintenanceMessage\"),\n // Path prefixes that stay reachable while maintenance mode is on (see\n // lib/maintenance.ts — a few auth paths are always open on top of these).\n maintenanceExemptPaths: text(\"maintenanceExemptPaths\").array().notNull().default(DEFAULT_MAINTENANCE_EXEMPT_PATHS),\n authEnableGoogle: boolean(\"authEnableGoogle\").notNull().default(true),\n authEnableEmailPassword: boolean(\"authEnableEmailPassword\").notNull().default(true),\n authEnableOneTap: boolean(\"authEnableOneTap\").notNull().default(false),\n enableSessionRevocation: boolean(\"enableSessionRevocation\").notNull().default(true),\n // Attach the error a user hit to feedback they send from it (see\n // lib/feedback-route.ts). Off = only what the user typed is sent.\n feedbackAttachErrors: boolean(\"feedbackAttachErrors\").notNull().default(true),\n // GDPR/ePrivacy consent mode. On: cookie banner, FingerprintJS and One Tap\n // only after \"Accept all\", device record erased on withdrawal. Off (no EU/UK\n // users): no banner, treated as consented. Null = follow\n // NEXT_PUBLIC_COOKIE_BANNER (nullable so migrating never flips a deployed\n // banner on or off). proxy.ts mirrors the effective value into the\n // consent_required cookie for the client (lib/cookie-consent.ts).\n consentRequired: boolean(\"consentRequired\"),\n updatedAt: timestamp(\"updatedAt\").notNull().defaultNow(),\n // Who last changed a setting. This row doesn't \"belong\" to that admin the\n // way sessions/activityLogs belong to their user, so deleting that admin's\n // account should null this out rather than cascade-delete the singleton row.\n updatedBy: text(\"updatedBy\").references(() => users.id, { onDelete: \"set null\" }),\n}, (table) => [\n check(\"system_settings_singleton\", sql`${table.id} = 'default'`),\n]);\n// Technical detail of unexpected errors, keyed by the short ref the user sees\n// (\"... (ref: K7Q2PX)\", see lib/errors.ts). Never sent to the browser: the\n// feedback route looks a ref up and attaches the detail to the report it\n// sends to Discord/email. Rows older than 30 days are pruned on insert.\nexport const errorRefs = pgTable(\"error_refs\", {\n ref: text(\"ref\").primaryKey(),\n message: text(\"message\").notNull(),\n detail: text(\"detail\").notNull(),\n createdAt: timestamp(\"createdAt\").notNull().defaultNow(),\n}, (table) => [\n index(\"error_refs_created_at_idx\").on(table.createdAt),\n]);\n","// Pure path logic for maintenance mode — no DB / server-only imports, so it\n// can run in proxy.ts and in the admin form alike.\n\n// Default for the admin-editable exempt list: the public marketing pages.\nexport const DEFAULT_MAINTENANCE_EXEMPT_PATHS = [\n \"/\",\n \"/features\",\n \"/changelog\",\n \"/changelog-template\",\n \"/privacy\",\n \"/terms\",\n]\n\n// Always reachable, whatever the admin saves — otherwise an admin could\n// lock themselves out of the sign-in flow needed to turn maintenance off.\nexport const ALWAYS_OPEN_PATHS = [\n \"/maintenance\",\n \"/login\", // includes /login/2fa\n \"/forgot-password\",\n \"/reset-password\",\n \"/api/auth\",\n \"/api/settings/public\", // login page reads the One Tap flag from here\n]\n\n// `/foo` matches `/foo` and `/foo/...` but not `/foobar`; `/` matches only `/`.\nfunction matches(pathname: string, prefix: string) {\n const p = prefix.length > 1 ? prefix.replace(/\\/+$/, \"\") : prefix\n return pathname === p || (p !== \"/\" && pathname.startsWith(p + \"/\"))\n}\n\nexport function isMaintenanceExempt(pathname: string, exemptPaths: string[]) {\n return [...ALWAYS_OPEN_PATHS, ...exemptPaths].some((p) => matches(pathname, p))\n}\n","// No \"server-only\" guard here — this schema is also imported by scripts that\n// run outside Next.js's bundler (e.g. scripts/doctor.ts), where that guard\n// throws unconditionally. config/env.ts re-exports the parsed, guarded result\n// for app code; import this file directly only from non-Next scripts.\nimport { z } from 'zod';\n\nexport const envSchema = z.object({\n NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),\n PORT: z.string().default('3000').transform(Number),\n DATABASE_URL: z.string().default(''),\n RESEND_API_KEY: z.string().default(''),\n // Full access key for marketing broadcasts (admin Marketing email page).\n // Blank = use RESEND_API_KEY.\n RESEND_BROADCAST_API_KEY: z.string().default(''),\n // Resend segment users join from the dashboard \"Email updates\" toggle.\n // Blank = toggle hidden.\n RESEND_MARKETING_SEGMENT_ID: z.string().default(''),\n // Sender for every transactional email. Must be on a domain verified in\n // your Resend account.\n EMAIL_FROM: z.string().default('Acme <onboarding@xavierkhew.xyz>'),\n // Display name for this deployment — shown as the authenticator-app label\n // (2FA issuer) and the passkey relying-party name. Consuming projects set\n // this in their own .env; defaults to the template name.\n APP_NAME: z.string().default('Next.js Template'),\n // Where the dashboard feedback form delivers (app/api/feedback). Set either\n // or both; with neither, the Feedback button is hidden. FEEDBACK_EMAIL takes\n // a comma-separated list and sends through Resend (needs RESEND_API_KEY).\n FEEDBACK_DISCORD_WEBHOOK_URL: z.string().default(''),\n FEEDBACK_EMAIL: z.string().default(''),\n // Seed-only: the live value lives in the system_settings DB row (see\n // lib/settings-queries.ts) once it's been seeded on first read, and is\n // changed from the admin System Settings page, not by editing this var.\n ENABLE_SESSION_REVOCATION: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_GOOGLE: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_EMAIL_PASSWORD: z.string().default('true').transform(val => val === 'true'),\n AUTH_ENABLE_ONE_TAP: z.string().default('false').transform(val => val === 'true'),\n DB_DRIVER: z.enum(['pg', 'neon']).default('pg'),\n // Only relevant when DB_DRIVER=pg — whether the target Postgres server has SSL\n // enabled. Self-hosted Postgres (e.g. via Docker/Dokploy) typically does not.\n DATABASE_SSL: z.string().default('false').transform(val => val === 'true'),\n});\n","import \"server-only\";\nimport { envSchema } from './env-schema';\n\nconst results = envSchema.safeParse(process.env);\n\nif (results.success && (results.data.NODE_ENV === 'development')) {\n console.log('Environment variables loaded:', Object.keys(results.data));\n}\n\nif (!results.success) {\n console.error('Invalid environment variables:', results.error.format());\n throw new Error('Invalid environment variables');\n}\n\nexport const config = results.data;\n","import 'server-only';\n\n// neon-http driver for production\nimport { neon } from '@neondatabase/serverless';\nimport { drizzle } from 'drizzle-orm/neon-http';\n\n// postgres driver for local development\nimport { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';\nimport { Pool } from 'pg';\n\nimport * as schema from './schema';\nimport { config } from '../../config/env';\n\n// Import the query builder\nimport { PostgresJsDatabase } from 'drizzle-orm/postgres-js';\nimport { NeonHttpDatabase } from 'drizzle-orm/neon-http';\n\ntype DrizzleNeon = NeonHttpDatabase<typeof schema>;\ntype DrizzlePg = PostgresJsDatabase<typeof schema>;\n\nlet db: DrizzleNeon | DrizzlePg\n\n\nif (config.DB_DRIVER === 'pg') {\n const pool = new Pool({\n connectionString: config.DATABASE_URL!,\n ssl: config.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,\n });\n db = drizzlePg(pool, {\n schema,\n logger: true,\n });\n\n console.log('🟡 Using PostgreSQL (pg) driver');\n\n} else if (config.DB_DRIVER === 'neon') {\n const sql = neon(config.DATABASE_URL!);\n db = drizzle(sql, { schema, logger: true });\n\n console.log('🟢 Using Neon (HTTP) driver');\n\n} else {\n console.log('Database connection not established, unknown DB_DRIVER:', config.DB_DRIVER);\n}\n// Note: In production, you might want to disable logging or use a more sophisticated logger.\n\nexport { db }","import \"server-only\"\nimport { UAParser } from \"ua-parser-js\"\nimport geoip from \"geoip-lite\"\n\nconst countryNames = new Intl.DisplayNames([\"en\"], { type: \"region\" })\n\nexport type DeviceType = \"desktop\" | \"mobile\" | \"tablet\"\n\nexport interface DeviceInfo {\n os: string | null\n browser: string | null\n deviceType: DeviceType\n}\n\nexport interface GeoInfo {\n country: string | null\n city: string | null\n}\n\n/**\n * First address in X-Forwarded-For is the original client — everything after\n * is intermediate proxies. Falls back to null (e.g. local dev with no proxy).\n */\nexport function getClientIp(headers: Headers | undefined | null): string | null {\n const forwardedFor = headers?.get(\"x-forwarded-for\")\n if (forwardedFor) return forwardedFor.split(\",\")[0].trim()\n\n return headers?.get(\"x-real-ip\") ?? null\n}\n\nexport function parseUserAgent(userAgent: string | null | undefined): DeviceInfo {\n if (!userAgent) {\n return { os: null, browser: null, deviceType: \"desktop\" }\n }\n\n const { os, browser, device } = new UAParser(userAgent).getResult()\n\n return {\n os: os.name ? [os.name, os.version].filter(Boolean).join(\" \") : null,\n browser: browser.name ?? null,\n deviceType: device.type === \"mobile\" || device.type === \"tablet\" ? device.type : \"desktop\",\n }\n}\n\n/**\n * Offline lookup (geoip-lite ships its own IP-range database, no network\n * call) — resolves to nulls for private/local addresses like 127.0.0.1.\n */\nexport function lookupGeoLocation(ipAddress: string | null | undefined): GeoInfo {\n if (!ipAddress) return { country: null, city: null }\n\n const geo = geoip.lookup(ipAddress)\n if (!geo) return { country: null, city: null }\n\n let country: string | null = geo.country || null\n if (country) {\n try {\n country = countryNames.of(country) ?? country\n } catch {\n // Unrecognized region code — keep the raw code rather than losing it.\n }\n }\n\n return { country, city: geo.city || null }\n}\n\nexport function formatDeviceInfo({ os, browser, deviceType }: DeviceInfo): string {\n const label = [browser, os].filter(Boolean).join(\" on \")\n const typeLabel = deviceType === \"mobile\" ? \"Mobile\" : deviceType === \"tablet\" ? \"Tablet\" : \"Desktop\"\n\n return label ? `${label} · ${typeLabel}` : `Unknown device · ${typeLabel}`\n}\n\nexport function formatLocation({ country, city }: GeoInfo): string {\n if (!country && !city) return \"Unknown\"\n\n return [city, country].filter(Boolean).join(\", \")\n}\n","// Pure throttle policy — no DB, no `server-only`, so it stays unit-testable.\n// Consumed by lib/auth-throttle.ts (the DB plumbing) and its test.\n\n// Tune per deployment, or lift to env vars if you need it configurable.\nexport const WINDOW_MS = 15 * 60 * 1000 // how long attempts accumulate\nexport const MAX = 10 // failed sign-ins / sign-ups per window before a 429, per fingerprint\n\n// Backstop cap keyed by IP instead of fingerprint — the fingerprint header is\n// client-supplied and unsigned, so it's trivial to omit or randomize per\n// request (see server/auth.ts before-hook). Higher than MAX because an IP can\n// be shared behind NAT/CGNAT/VPN.\n// ponytail: flat multiplier, not a real per-deployment tuning — revisit if\n// legitimate shared-IP traffic ever gets caught in it.\nexport const MAX_IP = MAX * 3\n\n// Rows untouched this long are deleted (lib/auth-throttle.ts). They hold IP,\n// user agent and the last email typed — often for people with no account — so\n// GDPR storage limitation says don't keep them past their use.\nexport const RETENTION_MS = 30 * 24 * 60 * 60 * 1000\n\n// A row whose window has already elapsed counts as \"no row\" — it resets on\n// the next attempt (see recordDeviceAttempt), so the device isn't blocked in\n// the meantime.\nexport function isOverLimit(\n row: { count: number; windowStart: Date } | undefined,\n now: number,\n max: number = MAX,\n): boolean {\n if (!row) return false\n if (now - row.windowStart.getTime() >= WINDOW_MS) return false\n return row.count >= max\n}\n","// Pure grouping for the admin \"Shared devices\" card — no DB, no `server-only`,\n// so it stays unit-testable. Consumed by lib/admin-queries.ts.\n\nexport type SharedDeviceAccountRow = {\n visitorId: string\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n userAgent: string | null\n country: string | null\n city: string | null\n}\n\nexport type SharedDevice = {\n visitorId: string\n accountCount: number\n lastSeenAt: Date\n country: string | null\n city: string | null\n userAgent: string | null\n accounts: {\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n }[]\n}\n\n// Collapse flat (account, device) rows into one entry per device fingerprint,\n// keep only devices reached by `minAccounts`+ accounts (the multi-account /\n// trial-abuse signal), most-accounts device first. Device context (UA/geo) is\n// taken from whichever row for that visitorId is seen first — pass rows newest\n// first if you want the most recent context.\nexport function groupSharedDevices(\n rows: SharedDeviceAccountRow[],\n minAccounts: number,\n limit: number,\n): SharedDevice[] {\n const byVisitor = new Map<string, SharedDevice>()\n\n for (const r of rows) {\n let d = byVisitor.get(r.visitorId)\n if (!d) {\n d = {\n visitorId: r.visitorId,\n accountCount: 0,\n lastSeenAt: r.firstSeenAt,\n country: r.country,\n city: r.city,\n userAgent: r.userAgent,\n accounts: [],\n }\n byVisitor.set(r.visitorId, d)\n }\n d.accountCount++\n if (r.firstSeenAt > d.lastSeenAt) d.lastSeenAt = r.firstSeenAt\n d.accounts.push({\n userId: r.userId,\n userName: r.userName,\n userEmail: r.userEmail,\n banned: r.banned,\n firstSeenAt: r.firstSeenAt,\n })\n }\n\n return [...byVisitor.values()]\n .filter((d) => d.accountCount >= minAccounts)\n .sort(\n (a, b) =>\n b.accountCount - a.accountCount || b.lastSeenAt.getTime() - a.lastSeenAt.getTime(),\n )\n .slice(0, limit)\n}\n","import \"server-only\"\nimport { db } from \"../server/db\"\nimport { users, sessions, authThrottle, deviceFingerprints, consentLog } from \"../server/db/schema\"\nimport { sql, gte, eq, desc } from \"drizzle-orm\"\nimport { parseUserAgent, lookupGeoLocation, formatDeviceInfo } from \"./request-info\"\nimport { WINDOW_MS, isOverLimit } from \"./auth-throttle-limits\"\nimport { groupSharedDevices } from \"./shared-devices\"\n\n// Powers the admin panel's landing page (/dashboard/admin) — kept separate\n// from getAdminStats (the compact overview embedded in the main dashboard)\n// since the two surfaces show different metrics.\nexport async function getAdminDashboardStats() {\n const now = new Date()\n const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\n const [totalUsersResult, newSignupsResult, activeSessionsResult, bannedResult] = await Promise.all([\n db.select({ count: sql<number>`cast(count(*) as integer)` }).from(users),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(gte(users.createdAt, weekAgo)),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(eq(users.banned, true)),\n ])\n\n return {\n totalUsers: totalUsersResult[0]?.count || 0,\n newSignupsThisWeek: newSignupsResult[0]?.count || 0,\n activeSessions: activeSessionsResult[0]?.count || 0,\n bannedCount: bannedResult[0]?.count || 0,\n }\n}\n\nexport async function getAdminStats() {\n const totalUsersResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n const totalUsers = totalUsersResult[0]?.count || 0\n\n const now = new Date()\n const activeSessionsResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`)\n const activeSessions = activeSessionsResult[0]?.count || 0\n\n const adminCountResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(sql`${users.role} = 'admin'`)\n const adminCount = adminCountResult[0]?.count || 0\n\n return {\n totalUsers,\n activeSessions,\n adminCount,\n regularUsers: totalUsers - adminCount,\n }\n}\n\n// Rows below this are noise — a single new signup, or one login typo that\n// then succeeded (a success deletes the row, so a lingering count of 1 is\n// almost always a lone signup).\nconst SUSPICIOUS_ATTEMPTS = 2\n\nexport type DeviceThrottleRow = Awaited<ReturnType<typeof getDeviceThrottleActivity>>[number]\n\n// Powers the admin \"Device throttle\" card. One row per device fingerprint\n// that's currently accumulating failed sign-ins / sign-ups, newest first,\n// with everything an admin needs to block it at the edge (IP) or in-app\n// (fingerprint) — parsed device + geo included for context.\nexport async function getDeviceThrottleActivity(limit = 50) {\n const rows = await db\n .select()\n .from(authThrottle)\n .where(gte(authThrottle.count, SUSPICIOUS_ATTEMPTS))\n .orderBy(desc(authThrottle.lastAttemptAt))\n .limit(limit)\n\n const now = Date.now()\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n const geo = lookupGeoLocation(r.ipAddress)\n return {\n fingerprint: r.fingerprint,\n count: r.count,\n windowStart: r.windowStart,\n lastAttemptAt: r.lastAttemptAt,\n ipAddress: r.ipAddress,\n userAgent: r.userAgent,\n lastEmail: r.lastEmail,\n lastKind: r.lastKind,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n country: geo.country,\n city: geo.city,\n // Would this device be 429'd on its next attempt right now?\n blocked: isOverLimit(r, now),\n // Is the counter still inside its window (attack ongoing) or has it\n // gone quiet (last burst, awaiting reset on next attempt)?\n windowActive: now - r.windowStart.getTime() < WINDOW_MS,\n }\n })\n}\n\nexport type RecentNewDeviceRow = Awaited<ReturnType<typeof getRecentNewDevices>>[number]\n\n// Powers the admin \"New devices\" card. One row per (account, device) pair the\n// first time that account is seen on that device — written by\n// app/api/device-check/route.ts, newest first. The row links to the user's\n// admin detail page, where sessions can be revoked if the sign-in wasn't them.\nexport async function getRecentNewDevices(limit = 20) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n .limit(limit)\n\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n return {\n ...r,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n }\n })\n}\n\nexport type UserDeviceRow = Awaited<ReturnType<typeof getUserDevices>>[number]\n\n// Powers the admin user page's \"Devices\" card: every device this account has\n// signed in from, with the FingerprintJS signals deviceCheck.tsx sent for it\n// (components is null for rows recorded before signals were collected).\nexport async function getUserDevices(userId: string) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n visitorId: deviceFingerprints.visitorId,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n components: deviceFingerprints.components,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .where(eq(deviceFingerprints.userId, userId))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return rows.map((r) => ({ ...r, device: formatDeviceInfo(parseUserAgent(r.userAgent)) }))\n}\n\n// Powers the admin user page's \"Consent\" card: the user's recorded cookie\n// choices (proof of consent, written by app/api/consent), newest first.\nexport async function getUserConsentLog(userId: string) {\n return db\n .select({\n id: consentLog.id,\n choice: consentLog.choice,\n version: consentLog.version,\n createdAt: consentLog.createdAt,\n })\n .from(consentLog)\n .where(eq(consentLog.userId, userId))\n .orderBy(desc(consentLog.createdAt))\n}\n\n// A device fingerprint (FingerprintJS visitorId) is normally 1:1 with an\n// account, or 1:few (personal + work login on one laptop). One visitorId behind\n// this many accounts is the multi-account / trial-abuse smell.\nconst MIN_SHARED_ACCOUNTS = 3\n\nexport type SharedDeviceRow = Awaited<ReturnType<typeof getSharedDeviceAccounts>>[number]\n\n// Powers the admin \"Shared devices\" card. One row per device fingerprint that\n// MIN_SHARED_ACCOUNTS+ distinct accounts have signed in from, most-shared first,\n// each with its full account list so an admin can open and ban them. The\n// device_fingerprint unique (userId, visitorId) constraint means a plain row\n// count per visitorId already IS the distinct-account count.\nexport async function getSharedDeviceAccounts(limit = 20) {\n // ponytail: pulls the whole device_fingerprint table each call and groups in\n // JS — fine for a template. If this table gets large, add an index on\n // visitorId and push the \"N+ accounts\" filter into a HAVING subquery.\n const rows = await db\n .select({\n visitorId: deviceFingerprints.visitorId,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n banned: users.banned,\n firstSeenAt: deviceFingerprints.createdAt,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return groupSharedDevices(rows, MIN_SHARED_ACCOUNTS, limit).map((d) => ({\n ...d,\n ...parseUserAgent(d.userAgent),\n }))\n}\n"]}
|
package/dist/admin/queries.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { parseUserAgent, lookupGeoLocation, WINDOW_MS, isOverLimit } from '../chunk-
|
|
2
|
-
import { db } from '../chunk-
|
|
3
|
-
import { users, sessions, authThrottle, deviceFingerprints } from '../chunk-
|
|
4
|
-
import '../chunk-
|
|
1
|
+
import { parseUserAgent, lookupGeoLocation, WINDOW_MS, isOverLimit, formatDeviceInfo } from '../chunk-6OAHCEDJ.mjs';
|
|
2
|
+
import { db } from '../chunk-ZJMFE5B2.mjs';
|
|
3
|
+
import { users, sessions, authThrottle, deviceFingerprints, consentLog } from '../chunk-TR5XFBTD.mjs';
|
|
4
|
+
import '../chunk-TKWNEKK6.mjs';
|
|
5
5
|
import { __spreadProps, __spreadValues } from '../chunk-3465SI5N.mjs';
|
|
6
6
|
import 'server-only';
|
|
7
7
|
import { sql, gte, eq, desc } from 'drizzle-orm';
|
|
@@ -122,6 +122,27 @@ async function getRecentNewDevices(limit = 20) {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
+
async function getUserDevices(userId) {
|
|
126
|
+
const rows = await db.select({
|
|
127
|
+
id: deviceFingerprints.id,
|
|
128
|
+
visitorId: deviceFingerprints.visitorId,
|
|
129
|
+
ipAddress: deviceFingerprints.ipAddress,
|
|
130
|
+
userAgent: deviceFingerprints.userAgent,
|
|
131
|
+
country: deviceFingerprints.country,
|
|
132
|
+
city: deviceFingerprints.city,
|
|
133
|
+
components: deviceFingerprints.components,
|
|
134
|
+
createdAt: deviceFingerprints.createdAt
|
|
135
|
+
}).from(deviceFingerprints).where(eq(deviceFingerprints.userId, userId)).orderBy(desc(deviceFingerprints.createdAt));
|
|
136
|
+
return rows.map((r) => __spreadProps(__spreadValues({}, r), { device: formatDeviceInfo(parseUserAgent(r.userAgent)) }));
|
|
137
|
+
}
|
|
138
|
+
async function getUserConsentLog(userId) {
|
|
139
|
+
return db.select({
|
|
140
|
+
id: consentLog.id,
|
|
141
|
+
choice: consentLog.choice,
|
|
142
|
+
version: consentLog.version,
|
|
143
|
+
createdAt: consentLog.createdAt
|
|
144
|
+
}).from(consentLog).where(eq(consentLog.userId, userId)).orderBy(desc(consentLog.createdAt));
|
|
145
|
+
}
|
|
125
146
|
var MIN_SHARED_ACCOUNTS = 3;
|
|
126
147
|
async function getSharedDeviceAccounts(limit = 20) {
|
|
127
148
|
const rows = await db.select({
|
|
@@ -138,6 +159,6 @@ async function getSharedDeviceAccounts(limit = 20) {
|
|
|
138
159
|
return groupSharedDevices(rows, MIN_SHARED_ACCOUNTS, limit).map((d) => __spreadValues(__spreadValues({}, d), parseUserAgent(d.userAgent)));
|
|
139
160
|
}
|
|
140
161
|
|
|
141
|
-
export { getAdminDashboardStats, getAdminStats, getDeviceThrottleActivity, getRecentNewDevices, getSharedDeviceAccounts };
|
|
162
|
+
export { getAdminDashboardStats, getAdminStats, getDeviceThrottleActivity, getRecentNewDevices, getSharedDeviceAccounts, getUserConsentLog, getUserDevices };
|
|
142
163
|
//# sourceMappingURL=queries.mjs.map
|
|
143
164
|
//# sourceMappingURL=queries.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../lib/shared-devices.ts","../../lib/admin-queries.ts"],"names":[],"mappings":"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","file":"queries.mjs","sourcesContent":["// Pure grouping for the admin \"Shared devices\" card — no DB, no `server-only`,\n// so it stays unit-testable. Consumed by lib/admin-queries.ts.\n\nexport type SharedDeviceAccountRow = {\n visitorId: string\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n userAgent: string | null\n country: string | null\n city: string | null\n}\n\nexport type SharedDevice = {\n visitorId: string\n accountCount: number\n lastSeenAt: Date\n country: string | null\n city: string | null\n userAgent: string | null\n accounts: {\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n }[]\n}\n\n// Collapse flat (account, device) rows into one entry per device fingerprint,\n// keep only devices reached by `minAccounts`+ accounts (the multi-account /\n// trial-abuse signal), most-accounts device first. Device context (UA/geo) is\n// taken from whichever row for that visitorId is seen first — pass rows newest\n// first if you want the most recent context.\nexport function groupSharedDevices(\n rows: SharedDeviceAccountRow[],\n minAccounts: number,\n limit: number,\n): SharedDevice[] {\n const byVisitor = new Map<string, SharedDevice>()\n\n for (const r of rows) {\n let d = byVisitor.get(r.visitorId)\n if (!d) {\n d = {\n visitorId: r.visitorId,\n accountCount: 0,\n lastSeenAt: r.firstSeenAt,\n country: r.country,\n city: r.city,\n userAgent: r.userAgent,\n accounts: [],\n }\n byVisitor.set(r.visitorId, d)\n }\n d.accountCount++\n if (r.firstSeenAt > d.lastSeenAt) d.lastSeenAt = r.firstSeenAt\n d.accounts.push({\n userId: r.userId,\n userName: r.userName,\n userEmail: r.userEmail,\n banned: r.banned,\n firstSeenAt: r.firstSeenAt,\n })\n }\n\n return [...byVisitor.values()]\n .filter((d) => d.accountCount >= minAccounts)\n .sort(\n (a, b) =>\n b.accountCount - a.accountCount || b.lastSeenAt.getTime() - a.lastSeenAt.getTime(),\n )\n .slice(0, limit)\n}\n","import \"server-only\"\nimport { db } from \"../server/db\"\nimport { users, sessions, authThrottle, deviceFingerprints } from \"../server/db/schema\"\nimport { sql, gte, eq, desc } from \"drizzle-orm\"\nimport { parseUserAgent, lookupGeoLocation } from \"./request-info\"\nimport { WINDOW_MS, isOverLimit } from \"./auth-throttle-limits\"\nimport { groupSharedDevices } from \"./shared-devices\"\n\n// Powers the admin panel's landing page (/dashboard/admin) — kept separate\n// from getAdminStats (the compact overview embedded in the main dashboard)\n// since the two surfaces show different metrics.\nexport async function getAdminDashboardStats() {\n const now = new Date()\n const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\n const [totalUsersResult, newSignupsResult, activeSessionsResult, bannedResult] = await Promise.all([\n db.select({ count: sql<number>`cast(count(*) as integer)` }).from(users),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(gte(users.createdAt, weekAgo)),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(eq(users.banned, true)),\n ])\n\n return {\n totalUsers: totalUsersResult[0]?.count || 0,\n newSignupsThisWeek: newSignupsResult[0]?.count || 0,\n activeSessions: activeSessionsResult[0]?.count || 0,\n bannedCount: bannedResult[0]?.count || 0,\n }\n}\n\nexport async function getAdminStats() {\n const totalUsersResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n const totalUsers = totalUsersResult[0]?.count || 0\n\n const now = new Date()\n const activeSessionsResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`)\n const activeSessions = activeSessionsResult[0]?.count || 0\n\n const adminCountResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(sql`${users.role} = 'admin'`)\n const adminCount = adminCountResult[0]?.count || 0\n\n return {\n totalUsers,\n activeSessions,\n adminCount,\n regularUsers: totalUsers - adminCount,\n }\n}\n\n// Rows below this are noise — a single new signup, or one login typo that\n// then succeeded (a success deletes the row, so a lingering count of 1 is\n// almost always a lone signup).\nconst SUSPICIOUS_ATTEMPTS = 2\n\nexport type DeviceThrottleRow = Awaited<ReturnType<typeof getDeviceThrottleActivity>>[number]\n\n// Powers the admin \"Device throttle\" card. One row per device fingerprint\n// that's currently accumulating failed sign-ins / sign-ups, newest first,\n// with everything an admin needs to block it at the edge (IP) or in-app\n// (fingerprint) — parsed device + geo included for context.\nexport async function getDeviceThrottleActivity(limit = 50) {\n const rows = await db\n .select()\n .from(authThrottle)\n .where(gte(authThrottle.count, SUSPICIOUS_ATTEMPTS))\n .orderBy(desc(authThrottle.lastAttemptAt))\n .limit(limit)\n\n const now = Date.now()\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n const geo = lookupGeoLocation(r.ipAddress)\n return {\n fingerprint: r.fingerprint,\n count: r.count,\n windowStart: r.windowStart,\n lastAttemptAt: r.lastAttemptAt,\n ipAddress: r.ipAddress,\n userAgent: r.userAgent,\n lastEmail: r.lastEmail,\n lastKind: r.lastKind,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n country: geo.country,\n city: geo.city,\n // Would this device be 429'd on its next attempt right now?\n blocked: isOverLimit(r, now),\n // Is the counter still inside its window (attack ongoing) or has it\n // gone quiet (last burst, awaiting reset on next attempt)?\n windowActive: now - r.windowStart.getTime() < WINDOW_MS,\n }\n })\n}\n\nexport type RecentNewDeviceRow = Awaited<ReturnType<typeof getRecentNewDevices>>[number]\n\n// Powers the admin \"New devices\" card. One row per (account, device) pair the\n// first time that account is seen on that device — written by\n// app/api/device-check/route.ts, newest first. The row links to the user's\n// admin detail page, where sessions can be revoked if the sign-in wasn't them.\nexport async function getRecentNewDevices(limit = 20) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n .limit(limit)\n\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n return {\n ...r,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n }\n })\n}\n\n// A device fingerprint (FingerprintJS visitorId) is normally 1:1 with an\n// account, or 1:few (personal + work login on one laptop). One visitorId behind\n// this many accounts is the multi-account / trial-abuse smell.\nconst MIN_SHARED_ACCOUNTS = 3\n\nexport type SharedDeviceRow = Awaited<ReturnType<typeof getSharedDeviceAccounts>>[number]\n\n// Powers the admin \"Shared devices\" card. One row per device fingerprint that\n// MIN_SHARED_ACCOUNTS+ distinct accounts have signed in from, most-shared first,\n// each with its full account list so an admin can open and ban them. The\n// device_fingerprint unique (userId, visitorId) constraint means a plain row\n// count per visitorId already IS the distinct-account count.\nexport async function getSharedDeviceAccounts(limit = 20) {\n // ponytail: pulls the whole device_fingerprint table each call and groups in\n // JS — fine for a template. If this table gets large, add an index on\n // visitorId and push the \"N+ accounts\" filter into a HAVING subquery.\n const rows = await db\n .select({\n visitorId: deviceFingerprints.visitorId,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n banned: users.banned,\n firstSeenAt: deviceFingerprints.createdAt,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return groupSharedDevices(rows, MIN_SHARED_ACCOUNTS, limit).map((d) => ({\n ...d,\n ...parseUserAgent(d.userAgent),\n }))\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../lib/shared-devices.ts","../../lib/admin-queries.ts"],"names":[],"mappings":"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","file":"queries.mjs","sourcesContent":["// Pure grouping for the admin \"Shared devices\" card — no DB, no `server-only`,\n// so it stays unit-testable. Consumed by lib/admin-queries.ts.\n\nexport type SharedDeviceAccountRow = {\n visitorId: string\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n userAgent: string | null\n country: string | null\n city: string | null\n}\n\nexport type SharedDevice = {\n visitorId: string\n accountCount: number\n lastSeenAt: Date\n country: string | null\n city: string | null\n userAgent: string | null\n accounts: {\n userId: string\n userName: string\n userEmail: string\n banned: boolean\n firstSeenAt: Date\n }[]\n}\n\n// Collapse flat (account, device) rows into one entry per device fingerprint,\n// keep only devices reached by `minAccounts`+ accounts (the multi-account /\n// trial-abuse signal), most-accounts device first. Device context (UA/geo) is\n// taken from whichever row for that visitorId is seen first — pass rows newest\n// first if you want the most recent context.\nexport function groupSharedDevices(\n rows: SharedDeviceAccountRow[],\n minAccounts: number,\n limit: number,\n): SharedDevice[] {\n const byVisitor = new Map<string, SharedDevice>()\n\n for (const r of rows) {\n let d = byVisitor.get(r.visitorId)\n if (!d) {\n d = {\n visitorId: r.visitorId,\n accountCount: 0,\n lastSeenAt: r.firstSeenAt,\n country: r.country,\n city: r.city,\n userAgent: r.userAgent,\n accounts: [],\n }\n byVisitor.set(r.visitorId, d)\n }\n d.accountCount++\n if (r.firstSeenAt > d.lastSeenAt) d.lastSeenAt = r.firstSeenAt\n d.accounts.push({\n userId: r.userId,\n userName: r.userName,\n userEmail: r.userEmail,\n banned: r.banned,\n firstSeenAt: r.firstSeenAt,\n })\n }\n\n return [...byVisitor.values()]\n .filter((d) => d.accountCount >= minAccounts)\n .sort(\n (a, b) =>\n b.accountCount - a.accountCount || b.lastSeenAt.getTime() - a.lastSeenAt.getTime(),\n )\n .slice(0, limit)\n}\n","import \"server-only\"\nimport { db } from \"../server/db\"\nimport { users, sessions, authThrottle, deviceFingerprints, consentLog } from \"../server/db/schema\"\nimport { sql, gte, eq, desc } from \"drizzle-orm\"\nimport { parseUserAgent, lookupGeoLocation, formatDeviceInfo } from \"./request-info\"\nimport { WINDOW_MS, isOverLimit } from \"./auth-throttle-limits\"\nimport { groupSharedDevices } from \"./shared-devices\"\n\n// Powers the admin panel's landing page (/dashboard/admin) — kept separate\n// from getAdminStats (the compact overview embedded in the main dashboard)\n// since the two surfaces show different metrics.\nexport async function getAdminDashboardStats() {\n const now = new Date()\n const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\n const [totalUsersResult, newSignupsResult, activeSessionsResult, bannedResult] = await Promise.all([\n db.select({ count: sql<number>`cast(count(*) as integer)` }).from(users),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(gte(users.createdAt, weekAgo)),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`),\n db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(eq(users.banned, true)),\n ])\n\n return {\n totalUsers: totalUsersResult[0]?.count || 0,\n newSignupsThisWeek: newSignupsResult[0]?.count || 0,\n activeSessions: activeSessionsResult[0]?.count || 0,\n bannedCount: bannedResult[0]?.count || 0,\n }\n}\n\nexport async function getAdminStats() {\n const totalUsersResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n const totalUsers = totalUsersResult[0]?.count || 0\n\n const now = new Date()\n const activeSessionsResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(sessions)\n .where(sql`${sessions.expiresAt} > ${now}`)\n const activeSessions = activeSessionsResult[0]?.count || 0\n\n const adminCountResult = await db\n .select({ count: sql<number>`cast(count(*) as integer)` })\n .from(users)\n .where(sql`${users.role} = 'admin'`)\n const adminCount = adminCountResult[0]?.count || 0\n\n return {\n totalUsers,\n activeSessions,\n adminCount,\n regularUsers: totalUsers - adminCount,\n }\n}\n\n// Rows below this are noise — a single new signup, or one login typo that\n// then succeeded (a success deletes the row, so a lingering count of 1 is\n// almost always a lone signup).\nconst SUSPICIOUS_ATTEMPTS = 2\n\nexport type DeviceThrottleRow = Awaited<ReturnType<typeof getDeviceThrottleActivity>>[number]\n\n// Powers the admin \"Device throttle\" card. One row per device fingerprint\n// that's currently accumulating failed sign-ins / sign-ups, newest first,\n// with everything an admin needs to block it at the edge (IP) or in-app\n// (fingerprint) — parsed device + geo included for context.\nexport async function getDeviceThrottleActivity(limit = 50) {\n const rows = await db\n .select()\n .from(authThrottle)\n .where(gte(authThrottle.count, SUSPICIOUS_ATTEMPTS))\n .orderBy(desc(authThrottle.lastAttemptAt))\n .limit(limit)\n\n const now = Date.now()\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n const geo = lookupGeoLocation(r.ipAddress)\n return {\n fingerprint: r.fingerprint,\n count: r.count,\n windowStart: r.windowStart,\n lastAttemptAt: r.lastAttemptAt,\n ipAddress: r.ipAddress,\n userAgent: r.userAgent,\n lastEmail: r.lastEmail,\n lastKind: r.lastKind,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n country: geo.country,\n city: geo.city,\n // Would this device be 429'd on its next attempt right now?\n blocked: isOverLimit(r, now),\n // Is the counter still inside its window (attack ongoing) or has it\n // gone quiet (last burst, awaiting reset on next attempt)?\n windowActive: now - r.windowStart.getTime() < WINDOW_MS,\n }\n })\n}\n\nexport type RecentNewDeviceRow = Awaited<ReturnType<typeof getRecentNewDevices>>[number]\n\n// Powers the admin \"New devices\" card. One row per (account, device) pair the\n// first time that account is seen on that device — written by\n// app/api/device-check/route.ts, newest first. The row links to the user's\n// admin detail page, where sessions can be revoked if the sign-in wasn't them.\nexport async function getRecentNewDevices(limit = 20) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n .limit(limit)\n\n return rows.map((r) => {\n const device = parseUserAgent(r.userAgent)\n return {\n ...r,\n os: device.os,\n browser: device.browser,\n deviceType: device.deviceType,\n }\n })\n}\n\nexport type UserDeviceRow = Awaited<ReturnType<typeof getUserDevices>>[number]\n\n// Powers the admin user page's \"Devices\" card: every device this account has\n// signed in from, with the FingerprintJS signals deviceCheck.tsx sent for it\n// (components is null for rows recorded before signals were collected).\nexport async function getUserDevices(userId: string) {\n const rows = await db\n .select({\n id: deviceFingerprints.id,\n visitorId: deviceFingerprints.visitorId,\n ipAddress: deviceFingerprints.ipAddress,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n components: deviceFingerprints.components,\n createdAt: deviceFingerprints.createdAt,\n })\n .from(deviceFingerprints)\n .where(eq(deviceFingerprints.userId, userId))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return rows.map((r) => ({ ...r, device: formatDeviceInfo(parseUserAgent(r.userAgent)) }))\n}\n\n// Powers the admin user page's \"Consent\" card: the user's recorded cookie\n// choices (proof of consent, written by app/api/consent), newest first.\nexport async function getUserConsentLog(userId: string) {\n return db\n .select({\n id: consentLog.id,\n choice: consentLog.choice,\n version: consentLog.version,\n createdAt: consentLog.createdAt,\n })\n .from(consentLog)\n .where(eq(consentLog.userId, userId))\n .orderBy(desc(consentLog.createdAt))\n}\n\n// A device fingerprint (FingerprintJS visitorId) is normally 1:1 with an\n// account, or 1:few (personal + work login on one laptop). One visitorId behind\n// this many accounts is the multi-account / trial-abuse smell.\nconst MIN_SHARED_ACCOUNTS = 3\n\nexport type SharedDeviceRow = Awaited<ReturnType<typeof getSharedDeviceAccounts>>[number]\n\n// Powers the admin \"Shared devices\" card. One row per device fingerprint that\n// MIN_SHARED_ACCOUNTS+ distinct accounts have signed in from, most-shared first,\n// each with its full account list so an admin can open and ban them. The\n// device_fingerprint unique (userId, visitorId) constraint means a plain row\n// count per visitorId already IS the distinct-account count.\nexport async function getSharedDeviceAccounts(limit = 20) {\n // ponytail: pulls the whole device_fingerprint table each call and groups in\n // JS — fine for a template. If this table gets large, add an index on\n // visitorId and push the \"N+ accounts\" filter into a HAVING subquery.\n const rows = await db\n .select({\n visitorId: deviceFingerprints.visitorId,\n userId: deviceFingerprints.userId,\n userName: users.name,\n userEmail: users.email,\n banned: users.banned,\n firstSeenAt: deviceFingerprints.createdAt,\n userAgent: deviceFingerprints.userAgent,\n country: deviceFingerprints.country,\n city: deviceFingerprints.city,\n })\n .from(deviceFingerprints)\n .innerJoin(users, eq(deviceFingerprints.userId, users.id))\n .orderBy(desc(deviceFingerprints.createdAt))\n\n return groupSharedDevices(rows, MIN_SHARED_ACCOUNTS, limit).map((d) => ({\n ...d,\n ...parseUserAgent(d.userAgent),\n }))\n}\n"]}
|