@xk2800/nextjs-template 0.7.1-beta.2 → 0.7.1-beta.4
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 +1 -1
- 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/systemSettingsForm.tsx +33 -0
- package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
- package/components/site/cookie-banner.tsx +109 -7
- package/components/ui/file-upload.tsx +103 -2
- package/dist/activity/logger.js +30 -0
- package/dist/activity/logger.js.map +1 -1
- package/dist/activity/logger.mjs +3 -3
- package/dist/activity/queries.d.mts +1 -1
- package/dist/activity/queries.d.ts +1 -1
- package/dist/activity/queries.js +30 -0
- package/dist/activity/queries.js.map +1 -1
- package/dist/activity/queries.mjs +2 -2
- package/dist/admin/queries.d.mts +19 -1
- package/dist/admin/queries.d.ts +19 -1
- package/dist/admin/queries.js +58 -0
- package/dist/admin/queries.js.map +1 -1
- package/dist/admin/queries.mjs +25 -4
- package/dist/admin/queries.mjs.map +1 -1
- package/dist/api/feedback.js +46 -2
- package/dist/api/feedback.js.map +1 -1
- package/dist/api/feedback.mjs +7 -7
- package/dist/auth/helpers.d.mts +3 -3
- package/dist/auth/helpers.d.ts +3 -3
- package/dist/auth/helpers.js +44 -0
- package/dist/auth/helpers.js.map +1 -1
- package/dist/auth/helpers.mjs +6 -6
- package/dist/auth/index.js +44 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/index.mjs +6 -6
- package/dist/auth-client/index.d.mts +16 -16
- package/dist/auth-client/index.d.ts +16 -16
- package/dist/{chunk-XWVAJXAG.mjs → chunk-4GGLYS5R.mjs} +20 -8
- package/dist/chunk-4GGLYS5R.mjs.map +1 -0
- package/dist/{chunk-KAEA35OC.mjs → chunk-6KTSULA5.mjs} +4 -4
- package/dist/{chunk-KAEA35OC.mjs.map → chunk-6KTSULA5.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-3MGEAGHU.mjs → chunk-F776DQAM.mjs} +10 -5
- package/dist/chunk-F776DQAM.mjs.map +1 -0
- package/dist/{chunk-WZDAMCUX.mjs → chunk-F7CG5CA3.mjs} +3 -3
- package/dist/{chunk-WZDAMCUX.mjs.map → chunk-F7CG5CA3.mjs.map} +1 -1
- package/dist/{chunk-VQ5B4MA7.mjs → chunk-FVLMSLTX.mjs} +4 -4
- package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-FVLMSLTX.mjs.map} +1 -1
- package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
- package/dist/chunk-TR5XFBTD.mjs.map +1 -0
- package/dist/db/index.d.mts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +30 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/index.mjs +2 -2
- 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 +30 -0
- package/dist/errors.js.map +1 -1
- package/dist/errors.mjs +3 -3
- 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 +30 -0
- package/dist/sessions/queries.js.map +1 -1
- package/dist/sessions/queries.mjs +2 -2
- package/dist/settings/queries.d.mts +4 -3
- package/dist/settings/queries.d.ts +4 -3
- package/dist/settings/queries.js +36 -0
- package/dist/settings/queries.js.map +1 -1
- package/dist/settings/queries.mjs +3 -3
- package/dist/users/queries.js +30 -0
- package/dist/users/queries.js.map +1 -1
- package/dist/users/queries.mjs +2 -2
- package/lib/cookie-consent.ts +72 -20
- package/lib/cookie-names.ts +47 -0
- package/lib/device-fingerprint.ts +66 -10
- package/package.json +7 -2
- package/dist/chunk-3MGEAGHU.mjs.map +0 -1
- package/dist/chunk-N6ECRCHY.mjs.map +0 -1
- package/dist/chunk-XWVAJXAG.mjs.map +0 -1
- 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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
26
|
-
const
|
|
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 (
|
|
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,
|
|
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'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 & 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
32
|
-
fingerprint
|
|
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
|
-
|
|
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
|
-
|
|
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
|