@xk2800/nextjs-template 0.7.1-beta.3 → 0.7.1-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/components/auth/deviceCheck.tsx +47 -6
  3. package/components/auth/oneTap.tsx +5 -5
  4. package/components/dashboard/admin/adminUserConsentCard.tsx +79 -0
  5. package/components/dashboard/admin/adminUserDevicesCard.tsx +80 -0
  6. package/components/dashboard/admin/systemSettingsForm.tsx +33 -0
  7. package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
  8. package/components/site/cookie-banner.tsx +109 -7
  9. package/dist/activity/logger.js +30 -0
  10. package/dist/activity/logger.js.map +1 -1
  11. package/dist/activity/logger.mjs +3 -3
  12. package/dist/activity/queries.d.mts +1 -1
  13. package/dist/activity/queries.d.ts +1 -1
  14. package/dist/activity/queries.js +30 -0
  15. package/dist/activity/queries.js.map +1 -1
  16. package/dist/activity/queries.mjs +2 -2
  17. package/dist/admin/queries.d.mts +19 -1
  18. package/dist/admin/queries.d.ts +19 -1
  19. package/dist/admin/queries.js +58 -0
  20. package/dist/admin/queries.js.map +1 -1
  21. package/dist/admin/queries.mjs +25 -4
  22. package/dist/admin/queries.mjs.map +1 -1
  23. package/dist/api/feedback.js +46 -2
  24. package/dist/api/feedback.js.map +1 -1
  25. package/dist/api/feedback.mjs +7 -7
  26. package/dist/auth/helpers.js +44 -0
  27. package/dist/auth/helpers.js.map +1 -1
  28. package/dist/auth/helpers.mjs +6 -6
  29. package/dist/auth/index.js +44 -0
  30. package/dist/auth/index.js.map +1 -1
  31. package/dist/auth/index.mjs +6 -6
  32. package/dist/{chunk-XWVAJXAG.mjs → chunk-4GGLYS5R.mjs} +20 -8
  33. package/dist/chunk-4GGLYS5R.mjs.map +1 -0
  34. package/dist/{chunk-KAEA35OC.mjs → chunk-6KTSULA5.mjs} +4 -4
  35. package/dist/{chunk-KAEA35OC.mjs.map → chunk-6KTSULA5.mjs.map} +1 -1
  36. package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
  37. package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
  38. package/dist/{chunk-3MGEAGHU.mjs → chunk-F776DQAM.mjs} +10 -5
  39. package/dist/chunk-F776DQAM.mjs.map +1 -0
  40. package/dist/{chunk-WZDAMCUX.mjs → chunk-F7CG5CA3.mjs} +3 -3
  41. package/dist/{chunk-WZDAMCUX.mjs.map → chunk-F7CG5CA3.mjs.map} +1 -1
  42. package/dist/{chunk-VQ5B4MA7.mjs → chunk-FVLMSLTX.mjs} +4 -4
  43. package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-FVLMSLTX.mjs.map} +1 -1
  44. package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
  45. package/dist/chunk-TR5XFBTD.mjs.map +1 -0
  46. package/dist/db/index.d.mts +1 -1
  47. package/dist/db/index.d.ts +1 -1
  48. package/dist/db/index.js +30 -0
  49. package/dist/db/index.js.map +1 -1
  50. package/dist/db/index.mjs +2 -2
  51. package/dist/db/schema.d.mts +1 -1
  52. package/dist/db/schema.d.ts +1 -1
  53. package/dist/db/schema.js +30 -0
  54. package/dist/db/schema.js.map +1 -1
  55. package/dist/db/schema.mjs +1 -1
  56. package/dist/errors.js +30 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/errors.mjs +3 -3
  59. package/dist/{schema-UuqxoOzS.d.mts → schema-CN_Y6EXv.d.mts} +148 -2
  60. package/dist/{schema-UuqxoOzS.d.ts → schema-CN_Y6EXv.d.ts} +148 -2
  61. package/dist/sessions/queries.js +30 -0
  62. package/dist/sessions/queries.js.map +1 -1
  63. package/dist/sessions/queries.mjs +2 -2
  64. package/dist/settings/queries.d.mts +4 -3
  65. package/dist/settings/queries.d.ts +4 -3
  66. package/dist/settings/queries.js +36 -0
  67. package/dist/settings/queries.js.map +1 -1
  68. package/dist/settings/queries.mjs +3 -3
  69. package/dist/users/queries.js +30 -0
  70. package/dist/users/queries.js.map +1 -1
  71. package/dist/users/queries.mjs +2 -2
  72. package/lib/cookie-consent.ts +72 -20
  73. package/lib/cookie-names.ts +47 -0
  74. package/lib/device-fingerprint.ts +66 -10
  75. package/package.json +7 -2
  76. package/dist/chunk-3MGEAGHU.mjs.map +0 -1
  77. package/dist/chunk-N6ECRCHY.mjs.map +0 -1
  78. package/dist/chunk-XWVAJXAG.mjs.map +0 -1
  79. package/dist/chunk-ZQCALTKB.mjs.map +0 -1
package/README.md CHANGED
@@ -33,7 +33,7 @@ UPDATE "user" SET roles = 'admin' WHERE email = 'you@example.com';
33
33
  ## Features
34
34
 
35
35
  - **Auth:** email/password, Google OAuth, Google One Tap, passkeys, TOTP 2FA with backup codes, password reset, email verification
36
- - **Security:** new-device email alerts, per-device sign-in/sign-up throttle, active-session revocation, self-serve account deletion, cookie-consent banner
36
+ - **Security:** new-device email alerts, per-device sign-in/sign-up throttle, active-session revocation, self-serve account deletion, cookie-consent banner with an admin consent-mode switch
37
37
  - **Admin:** user management (roles, bans, bulk actions, CSV export), impersonation, system-wide activity log, device-security cards, live auth toggles, maintenance mode
38
38
  - **Ops:** `bun run doctor`, Docker image, Doppler support, separate env files for dev, prod and test
39
39
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  import { useEffect } from 'react'
4
4
  import { useSession } from '../../lib/auth-client'
5
- import { getVisitorId } from '../../lib/device-fingerprint'
6
- import { useCookieConsent } from '../../lib/cookie-consent'
5
+ import { clearStoredVisitorId, getDeviceSignals, getStoredVisitorId, getVisitorId } from '../../lib/device-fingerprint'
6
+ import { useConsentFor, useCookieConsent } from '../../lib/cookie-consent'
7
7
 
8
8
  // Mount once (e.g. in the root layout). When a session is present it computes
9
9
  // a FingerprintJS device id and hands it to /api/device-check, which emails
@@ -13,15 +13,51 @@ import { useCookieConsent } from '../../lib/cookie-consent'
13
13
  // email/password, Google OAuth and One Tap without touching any of them. The
14
14
  // endpoint is idempotent — the alert only fires on the first ever POST for a
15
15
  // given (user, device).
16
+ //
17
+ // Also handles withdrawn consent: once the visitor's choice no longer
18
+ // includes the "fingerprint" purpose ("Necessary only", or unticked under
19
+ // "Customize"), the stored device id is sent to DELETE /api/device-check so this device's
20
+ // record is erased, then forgotten. Withdrawn while signed out ⇒ it happens on
21
+ // the next sign-in.
22
+ // ponytail: erases only the signed-in account's row for this device. The id
23
+ // is forgotten after that, so other accounts that used this browser keep
24
+ // their rows until those accounts are deleted (deliberate: one person's
25
+ // choice shouldn't erase other accounts' security records).
16
26
  export default function DeviceCheck() {
17
27
  const { data: session } = useSession()
18
28
  const userId = session?.user?.id
19
29
  // The fingerprint needs optional-cookie consent (lib/device-fingerprint.ts);
20
30
  // re-run when it's granted so the check doesn't wait for the next page load.
21
31
  const consent = useCookieConsent()
32
+ const fingerprintAllowed = useConsentFor('fingerprint')
22
33
 
23
34
  useEffect(() => {
24
- if (!userId || consent !== 'all') return
35
+ if (!userId) return
36
+
37
+ // A choice was made (consent !== null) and it excludes the fingerprint.
38
+ if (consent !== null && !fingerprintAllowed) {
39
+ // Re-consenting later in this tab must POST again — the row is gone.
40
+ try {
41
+ sessionStorage.removeItem('device-checked')
42
+ } catch {
43
+ // ignore
44
+ }
45
+ const visitorId = getStoredVisitorId()
46
+ if (!visitorId) return
47
+ fetch('/api/device-check', {
48
+ method: 'DELETE',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({ visitorId }),
51
+ })
52
+ .then((res) => {
53
+ // Keep the id on failure so the next page load retries the erasure.
54
+ if (res.ok) clearStoredVisitorId()
55
+ })
56
+ .catch(() => {})
57
+ return
58
+ }
59
+
60
+ if (!fingerprintAllowed) return
25
61
 
26
62
  const run = async () => {
27
63
  try {
@@ -34,11 +70,16 @@ export default function DeviceCheck() {
34
70
  const visitorId = await getVisitorId()
35
71
  if (!visitorId) return
36
72
 
37
- await fetch('/api/device-check', {
73
+ const res = await fetch('/api/device-check', {
38
74
  method: 'POST',
39
75
  headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify({ visitorId }),
76
+ // components: the device's signals for the admin user page.
77
+ body: JSON.stringify({ visitorId, components: await getDeviceSignals() }),
41
78
  })
79
+ // Only a success counts as checked — a failed request (server error,
80
+ // deploy in progress) must retry on the next page load, not wait for
81
+ // a new browser session.
82
+ if (!res.ok) return
42
83
 
43
84
  try {
44
85
  sessionStorage.setItem('device-checked', userId)
@@ -50,7 +91,7 @@ export default function DeviceCheck() {
50
91
  // Best-effort: fingerprinting or the network failing must never surface
51
92
  // to the user mid-app.
52
93
  run().catch(() => {})
53
- }, [userId, consent])
94
+ }, [userId, consent, fingerprintAllowed])
54
95
 
55
96
  return null
56
97
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { authClient, useSession } from '../../lib/auth-client'
4
- import { useCookieConsent } from '../../lib/cookie-consent'
4
+ import { useConsentFor } from '../../lib/cookie-consent'
5
5
  import { useRouter } from 'next/navigation'
6
6
  import React, { useEffect, useState } from 'react'
7
7
 
@@ -22,8 +22,8 @@ const OneTap = ({ callbackURL = '/dashboard' }: OneTapProps) => {
22
22
  // never fire the prompt before we've actually heard back.
23
23
  const [oneTapOffered, setOneTapOffered] = useState<boolean | null>(null)
24
24
  // Google's One Tap script is third-party and can use Google's cookies, so
25
- // it only loads once the visitor accepts optional cookies.
26
- const consent = useCookieConsent()
25
+ // it only loads once the visitor consents to the "onetap" purpose.
26
+ const oneTapAllowed = useConsentFor('onetap')
27
27
 
28
28
  useEffect(() => {
29
29
  if (process.env.NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP !== 'true') return
@@ -39,7 +39,7 @@ const OneTap = ({ callbackURL = '/dashboard' }: OneTapProps) => {
39
39
  if (process.env.NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP !== 'true') return
40
40
  if (isPending || session) return
41
41
  if (oneTapOffered !== true) return
42
- if (consent !== 'all') return
42
+ if (!oneTapAllowed) return
43
43
 
44
44
  authClient.oneTap({
45
45
  callbackURL,
@@ -47,7 +47,7 @@ const OneTap = ({ callbackURL = '/dashboard' }: OneTapProps) => {
47
47
  console.log('One Tap prompt notification:', notification)
48
48
  }
49
49
  })
50
- }, [session, isPending, callbackURL, oneTapOffered, consent])
50
+ }, [session, isPending, callbackURL, oneTapOffered, oneTapAllowed])
51
51
 
52
52
  return null
53
53
  }
@@ -0,0 +1,79 @@
1
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
2
+ import { Badge } from "../../ui/badge"
3
+ import { CONSENT_PURPOSES, consentAllows, type ConsentPurpose } from "../../../lib/cookie-names"
4
+ import { formatDateTime } from "../../../lib/formatters"
5
+
6
+ interface ConsentRecord {
7
+ id: string
8
+ choice: string
9
+ version: number
10
+ createdAt: Date
11
+ }
12
+
13
+ const PURPOSE_LABELS: Record<ConsentPurpose, string> = {
14
+ fingerprint: "Security fingerprint",
15
+ onetap: "Google One Tap",
16
+ }
17
+
18
+ function describeChoice(choice: string): string {
19
+ if (choice === "all") return "Accepted all"
20
+ if (choice === "necessary") return "Necessary only"
21
+ return "Custom"
22
+ }
23
+
24
+ // The user's cookie-consent records (consent_log, proof of consent under GDPR
25
+ // Art. 7(1)): the current choice per purpose, then every change with the
26
+ // consent-text version it was given under.
27
+ export default function AdminUserConsentCard({
28
+ records,
29
+ consentRequired,
30
+ }: {
31
+ records: ConsentRecord[]
32
+ consentRequired: boolean
33
+ }) {
34
+ const current = records[0]
35
+
36
+ return (
37
+ <Card>
38
+ <CardHeader>
39
+ <CardTitle>Consent</CardTitle>
40
+ <CardDescription>
41
+ Cookie choices this user made while signed in, kept as proof of consent
42
+ {!consentRequired && ". Consent isn't currently required, so new choices aren't being recorded"}
43
+ </CardDescription>
44
+ </CardHeader>
45
+ <CardContent>
46
+ {!current ? (
47
+ <p className="text-center text-gray-500 py-8">No consent recorded</p>
48
+ ) : (
49
+ <div className="space-y-4">
50
+ <div className="flex flex-wrap gap-2">
51
+ {CONSENT_PURPOSES.map((purpose) => {
52
+ const allowed = consentAllows(current.choice, purpose)
53
+ return (
54
+ <Badge key={purpose} variant={allowed ? "default" : "outline"}>
55
+ {PURPOSE_LABELS[purpose]}: {allowed ? "allowed" : "declined"}
56
+ </Badge>
57
+ )
58
+ })}
59
+ </div>
60
+ <ul className="divide-y divide-border rounded-md border border-border text-sm">
61
+ {records.map((r, i) => (
62
+ <li key={r.id} className="flex flex-wrap items-center justify-between gap-2 px-4 py-2">
63
+ <span>
64
+ <span className="font-medium">{describeChoice(r.choice)}</span>
65
+ <span className="ml-2 font-mono text-xs text-gray-500">{r.choice}</span>
66
+ {i === 0 && <span className="ml-2 text-xs text-gray-500">(current)</span>}
67
+ </span>
68
+ <span className="text-gray-500">
69
+ v{r.version} · {formatDateTime(r.createdAt)}
70
+ </span>
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ </div>
75
+ )}
76
+ </CardContent>
77
+ </Card>
78
+ )
79
+ }
@@ -0,0 +1,80 @@
1
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
2
+ import { formatLocation } from "../../../lib/request-info"
3
+
4
+ interface UserDevice {
5
+ id: string
6
+ visitorId: string
7
+ device: string
8
+ ipAddress: string | null
9
+ country: string | null
10
+ city: string | null
11
+ components: Record<string, unknown> | null
12
+ createdAt: Date
13
+ }
14
+
15
+ // Signals arrive as whatever FingerprintJS produced: primitives, arrays,
16
+ // nested objects, or { hash } for values too large to store (canvas etc.).
17
+ function formatSignal(value: unknown): string {
18
+ if (value === null || value === undefined) return "—"
19
+ if (Array.isArray(value) && value.every((v) => typeof v !== "object")) return value.join(", ") || "—"
20
+ if (typeof value === "object") return JSON.stringify(value)
21
+ return String(value)
22
+ }
23
+
24
+ // Every device this account has signed in from (device_fingerprint rows),
25
+ // each with the FingerprintJS signals behind its visitorId in a collapsible
26
+ // section. Accessibility signals are never collected (lib/device-fingerprint.ts).
27
+ export default function AdminUserDevicesCard({ devices }: { devices: UserDevice[] }) {
28
+ return (
29
+ <Card>
30
+ <CardHeader>
31
+ <CardTitle>Devices</CardTitle>
32
+ <CardDescription>
33
+ Devices this account has signed in from, with the browser and device signals behind each
34
+ fingerprint. Only recorded for visitors who allowed the security fingerprint (when consent is required).
35
+ </CardDescription>
36
+ </CardHeader>
37
+ <CardContent>
38
+ {devices.length === 0 ? (
39
+ <p className="text-center text-gray-500 py-8">No devices recorded</p>
40
+ ) : (
41
+ <div className="divide-y divide-border rounded-md border border-border">
42
+ {devices.map((d) => {
43
+ const signals = d.components ? Object.entries(d.components) : []
44
+ return (
45
+ <details key={d.id} className="group p-4">
46
+ <summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2">
47
+ <div className="min-w-0">
48
+ <p className="font-medium">{d.device}</p>
49
+ <p className="text-sm text-gray-500">
50
+ {formatLocation({ country: d.country, city: d.city })} · {d.ipAddress ?? "Unknown IP"} · first seen{" "}
51
+ {d.createdAt.toLocaleString()}
52
+ </p>
53
+ </div>
54
+ <span className="text-sm text-gray-500 group-open:hidden">Show {signals.length} signals</span>
55
+ <span className="hidden text-sm text-gray-500 group-open:inline">Hide signals</span>
56
+ </summary>
57
+ <p className="mt-3 break-all font-mono text-xs text-gray-500">visitorId: {d.visitorId}</p>
58
+ {signals.length === 0 ? (
59
+ <p className="mt-2 text-sm text-gray-500">
60
+ No signals yet. They&apos;re collected the next time this device signs in.
61
+ </p>
62
+ ) : (
63
+ <dl className="mt-3 grid grid-cols-1 gap-x-4 gap-y-1 text-sm sm:grid-cols-[max-content_1fr]">
64
+ {signals.map(([name, value]) => (
65
+ <div key={name} className="contents">
66
+ <dt className="font-mono text-gray-500">{name}</dt>
67
+ <dd className="mb-2 break-all font-mono sm:mb-0">{formatSignal(value)}</dd>
68
+ </div>
69
+ ))}
70
+ </dl>
71
+ )}
72
+ </details>
73
+ )
74
+ })}
75
+ </div>
76
+ )}
77
+ </CardContent>
78
+ </Card>
79
+ )
80
+ }
@@ -52,6 +52,7 @@ export default function SystemSettingsForm({ initialSettings, configuredFlags }:
52
52
  authEnableOneTap: settings.authEnableOneTap,
53
53
  enableSessionRevocation: settings.enableSessionRevocation,
54
54
  feedbackAttachErrors: settings.feedbackAttachErrors,
55
+ consentRequired: settings.consentRequired ?? true,
55
56
  }),
56
57
  })
57
58
 
@@ -161,6 +162,38 @@ export default function SystemSettingsForm({ initialSettings, configuredFlags }:
161
162
  </CardContent>
162
163
  </Card>
163
164
 
165
+ <Card>
166
+ <CardHeader>
167
+ <CardTitle>Privacy &amp; Consent</CardTitle>
168
+ <CardDescription>
169
+ Whether visitors must opt in before device fingerprinting and Google One Tap run
170
+ </CardDescription>
171
+ </CardHeader>
172
+ <CardContent className="space-y-4">
173
+ <div className="flex items-center justify-between gap-4">
174
+ <div>
175
+ <Label htmlFor="consent-toggle">Require consent (GDPR / ePrivacy)</Label>
176
+ <p className="text-sm text-gray-500">
177
+ On: cookie banner where visitors allow fingerprinting and One Tap separately, and
178
+ the device record is erased if fingerprint consent is withdrawn. Off: no banner, everything runs for
179
+ every visitor.
180
+ </p>
181
+ {!settings.consentRequired && (
182
+ <p className="text-sm text-amber-600 dark:text-amber-500">
183
+ Only turn this off if none of your users are in the EU, the UK or another region with
184
+ consent rules. Fingerprinting without consent breaks those laws.
185
+ </p>
186
+ )}
187
+ </div>
188
+ <Switch
189
+ id="consent-toggle"
190
+ checked={settings.consentRequired ?? true}
191
+ onCheckedChange={(checked) => setSettings({ ...settings, consentRequired: checked })}
192
+ />
193
+ </div>
194
+ </CardContent>
195
+ </Card>
196
+
164
197
  <Card>
165
198
  <CardHeader>
166
199
  <CardTitle>Feedback</CardTitle>
@@ -1,9 +1,15 @@
1
- import { getSystemSettings, getConfiguredAuthFlags } from "@xk2800/nextjs-template/settings/queries"
1
+ import { getSystemSettings, getConfiguredAuthFlags, isConsentRequired } from "@xk2800/nextjs-template/settings/queries"
2
2
  import SystemSettingsForm from "./systemSettingsForm"
3
3
 
4
4
  export default async function SystemSettingsSection() {
5
5
  const settings = await getSystemSettings()
6
6
  const configuredFlags = getConfiguredAuthFlags()
7
7
 
8
- return <SystemSettingsForm initialSettings={settings} configuredFlags={configuredFlags} />
8
+ // Show the effective consent mode (null = NEXT_PUBLIC_COOKIE_BANNER default).
9
+ return (
10
+ <SystemSettingsForm
11
+ initialSettings={{ ...settings, consentRequired: isConsentRequired(settings) }}
12
+ configuredFlags={configuredFlags}
13
+ />
14
+ )
9
15
  }
@@ -6,19 +6,90 @@ import { useEffect, useState } from "react"
6
6
  // Relative imports: this file ships raw in the npm package, where `@/` would
7
7
  // resolve against the consuming project instead.
8
8
  import { Button } from "../ui/button"
9
- import { COOKIE_BANNER_ENABLED, setCookieConsent, useCookieConsent } from "../../lib/cookie-consent"
9
+ import { Checkbox } from "../ui/checkbox"
10
+ import { Label } from "../ui/label"
11
+ import {
12
+ readConsent,
13
+ setCookieConsent,
14
+ useConsentRequired,
15
+ useCookieConsent,
16
+ type ConsentPurpose,
17
+ } from "../../lib/cookie-consent"
18
+ import { CONSENT_VERSION } from "../../lib/cookie-names"
19
+ import { useSession } from "../../lib/auth-client"
20
+
21
+ // Last choice sent to app/api/consent from this browser, as
22
+ // "userId|choice|version", so each choice is recorded once, not on every load.
23
+ const RECORDED_KEY = "consent-recorded"
24
+
25
+ // Proof of consent: once a signed-in user has a choice (made now, or before
26
+ // they signed in), record it server-side. The route is idempotent, so a lost
27
+ // localStorage marker only costs one redundant request.
28
+ function useRecordConsent(consentRequired: boolean) {
29
+ const consent = useCookieConsent()
30
+ const { data: session } = useSession()
31
+ const userId = session?.user?.id
32
+
33
+ useEffect(() => {
34
+ if (!consentRequired || !userId || consent === null) return
35
+ const choice = readConsent()
36
+ if (!choice) return
37
+ const marker = `${userId}|${choice}|${CONSENT_VERSION}`
38
+ try {
39
+ if (localStorage.getItem(RECORDED_KEY) === marker) return
40
+ } catch {
41
+ // No localStorage — fall through; the route dedupes.
42
+ }
43
+ fetch("/api/consent", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ choice }),
47
+ })
48
+ .then((res) => {
49
+ if (!res.ok) return
50
+ try {
51
+ localStorage.setItem(RECORDED_KEY, marker)
52
+ } catch {
53
+ // ignore
54
+ }
55
+ })
56
+ .catch(() => {})
57
+ }, [consentRequired, userId, consent])
58
+ }
59
+
60
+ const PURPOSES: { id: ConsentPurpose; label: string; description: string }[] = [
61
+ {
62
+ id: "fingerprint",
63
+ label: "Security fingerprint",
64
+ description:
65
+ "A device identifier built from your browser and device characteristics, used to alert you about sign-ins from new devices and to block repeated failed sign-ins.",
66
+ },
67
+ {
68
+ id: "onetap",
69
+ label: "Google One Tap",
70
+ description: "Quick sign-in with Google. Loads a Google script that may set Google's own cookies.",
71
+ },
72
+ ]
10
73
 
11
74
  // Mount once in the root layout. Shows until the visitor picks an option;
12
75
  // "Necessary only" is as prominent as "Accept all" on purpose — GDPR requires
13
- // rejecting to be as easy as accepting.
76
+ // rejecting to be as easy as accepting. "Customize" gives one choice per
77
+ // purpose, unticked by default (pre-ticked boxes aren't valid consent).
14
78
  export function CookieBanner() {
15
79
  const consent = useCookieConsent()
80
+ const consentRequired = useConsentRequired()
81
+ const [customizing, setCustomizing] = useState(false)
82
+ const [picked, setPicked] = useState<ConsentPurpose[]>([])
16
83
  // useCookieConsent() is null on the server *and* before a choice, so wait
17
84
  // for mount to tell them apart — otherwise returning visitors see a flash.
18
85
  const [mounted, setMounted] = useState(false)
19
86
  useEffect(() => setMounted(true), [])
87
+ useRecordConsent(consentRequired)
88
+
89
+ if (!consentRequired || !mounted || consent) return null
20
90
 
21
- if (!COOKIE_BANNER_ENABLED || !mounted || consent) return null
91
+ const toggle = (id: ConsentPurpose, on: boolean) =>
92
+ setPicked((prev) => (on ? [...prev, id] : prev.filter((p) => p !== id)))
22
93
 
23
94
  return (
24
95
  <div
@@ -28,16 +99,46 @@ export function CookieBanner() {
28
99
  className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-lg rounded-lg border border-border bg-background p-4 shadow-lg"
29
100
  >
30
101
  <p className="text-sm text-muted-foreground">
31
- We use necessary cookies to keep you signed in. With your permission we also use a device
32
- fingerprint to detect suspicious sign-ins, and Google One Tap sign-in.{" "}
102
+ We use necessary cookies to keep you signed in. With your permission we also use a security
103
+ fingerprint, built from your browser and device characteristics, to detect suspicious
104
+ sign-ins, and Google One Tap sign-in. You can allow each separately.{" "}
33
105
  <Link href="/privacy" className="underline underline-offset-4 hover:text-foreground">
34
106
  Privacy policy
35
107
  </Link>
36
108
  </p>
37
- <div className="mt-3 flex justify-end gap-2">
109
+
110
+ {customizing && (
111
+ <div className="mt-3 space-y-3">
112
+ {PURPOSES.map((p) => (
113
+ <div key={p.id} className="flex items-start gap-3">
114
+ <Checkbox
115
+ id={`consent-${p.id}`}
116
+ checked={picked.includes(p.id)}
117
+ onCheckedChange={(checked) => toggle(p.id, checked === true)}
118
+ className="mt-0.5"
119
+ />
120
+ <div>
121
+ <Label htmlFor={`consent-${p.id}`}>{p.label}</Label>
122
+ <p className="text-xs text-muted-foreground">{p.description}</p>
123
+ </div>
124
+ </div>
125
+ ))}
126
+ </div>
127
+ )}
128
+
129
+ <div className="mt-3 flex flex-wrap justify-end gap-2">
38
130
  <Button variant="outline" size="sm" onClick={() => setCookieConsent("necessary")}>
39
131
  Necessary only
40
132
  </Button>
133
+ {customizing ? (
134
+ <Button variant="outline" size="sm" onClick={() => setCookieConsent(picked)}>
135
+ Save choices
136
+ </Button>
137
+ ) : (
138
+ <Button variant="outline" size="sm" onClick={() => setCustomizing(true)}>
139
+ Customize
140
+ </Button>
141
+ )}
41
142
  <Button size="sm" onClick={() => setCookieConsent("all")}>
42
143
  Accept all
43
144
  </Button>
@@ -48,7 +149,8 @@ export function CookieBanner() {
48
149
 
49
150
  // Withdrawing consent must be as easy as giving it — this reopens the banner.
50
151
  export function CookieSettingsButton({ className }: { className?: string }) {
51
- if (!COOKIE_BANNER_ENABLED) return null
152
+ const consentRequired = useConsentRequired()
153
+ if (!consentRequired) return null
52
154
  return (
53
155
  <button type="button" className={className} onClick={() => setCookieConsent(null)}>
54
156
  Cookie settings
@@ -24,6 +24,7 @@ __export(schema_exports, {
24
24
  accounts: () => accounts,
25
25
  activityLogs: () => activityLogs,
26
26
  authThrottle: () => authThrottle,
27
+ consentLog: () => consentLog,
27
28
  deviceFingerprints: () => deviceFingerprints,
28
29
  errorRefs: () => errorRefs,
29
30
  passkeys: () => passkeys,
@@ -178,10 +179,32 @@ var deviceFingerprints = pgCore.pgTable("device_fingerprint", {
178
179
  userAgent: pgCore.text("userAgent"),
179
180
  country: pgCore.text("country"),
180
181
  city: pgCore.text("city"),
182
+ // Latest FingerprintJS signals for this device (lib/device-fingerprint.ts
183
+ // getDeviceSignals(): accessibility signals dropped, large values hashed),
184
+ // shown on the admin user page. Null for rows from before this column.
185
+ components: pgCore.jsonb("components").$type(),
181
186
  createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
182
187
  }, (table) => [
183
188
  pgCore.unique("device_fingerprint_user_visitor").on(table.userId, table.visitorId)
184
189
  ]);
190
+ var consentLog = pgCore.pgTable("consent_log", {
191
+ id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
192
+ userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
193
+ // The cookie_consent value: "all", "necessary" or a comma list of purposes.
194
+ choice: pgCore.text("choice").notNull(),
195
+ // CONSENT_VERSION (lib/cookie-names.ts) at the time — which banner/policy
196
+ // wording the user agreed to.
197
+ version: pgCore.integer("version").notNull(),
198
+ // id of the user's record this one follows ("" for their first). Unique per
199
+ // user, so concurrent posts from several tabs that all saw the same latest
200
+ // record can't each insert a duplicate — one wins, the rest conflict. Works
201
+ // without transactions (neon-http has none). Null on rows from before it.
202
+ previousId: pgCore.text("previousId"),
203
+ createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
204
+ }, (table) => [
205
+ pgCore.index("consent_log_user_idx").on(table.userId, table.createdAt),
206
+ pgCore.unique("consent_log_chain").on(table.userId, table.previousId)
207
+ ]);
185
208
  var authThrottle = pgCore.pgTable("auth_throttle", {
186
209
  fingerprint: pgCore.text("fingerprint").notNull().primaryKey(),
187
210
  count: pgCore.integer("count").notNull().default(0),
@@ -211,6 +234,13 @@ var systemSettings = pgCore.pgTable("system_settings", {
211
234
  // Attach the error a user hit to feedback they send from it (see
212
235
  // lib/feedback-route.ts). Off = only what the user typed is sent.
213
236
  feedbackAttachErrors: pgCore.boolean("feedbackAttachErrors").notNull().default(true),
237
+ // GDPR/ePrivacy consent mode. On: cookie banner, FingerprintJS and One Tap
238
+ // only after "Accept all", device record erased on withdrawal. Off (no EU/UK
239
+ // users): no banner, treated as consented. Null = follow
240
+ // NEXT_PUBLIC_COOKIE_BANNER (nullable so migrating never flips a deployed
241
+ // banner on or off). proxy.ts mirrors the effective value into the
242
+ // consent_required cookie for the client (lib/cookie-consent.ts).
243
+ consentRequired: pgCore.boolean("consentRequired"),
214
244
  updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow(),
215
245
  // Who last changed a setting. This row doesn't "belong" to that admin the
216
246
  // way sessions/activityLogs belong to their user, so deleting that admin's