@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/README.md
CHANGED
|
@@ -33,25 +33,25 @@ 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
|
|
37
|
-
- **Admin:** user management (roles, bans, bulk actions, CSV export), impersonation, system-wide activity log, device-security cards, live auth toggles, maintenance mode
|
|
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
|
+
- **Admin:** user management (roles, bans, bulk actions, CSV export), impersonation, system-wide activity log, device-security cards, live auth toggles, maintenance mode, marketing email via Resend Broadcasts with user self opt-in (Beta)
|
|
38
38
|
- **Ops:** `bun run doctor`, Docker image, Doppler support, separate env files for dev, prod and test
|
|
39
39
|
|
|
40
40
|
## Docs
|
|
41
41
|
|
|
42
|
-
| Topic
|
|
43
|
-
|
|
44
|
-
| Every env var, env vs. admin settings, Doppler
|
|
45
|
-
| Folder layout, request flow, adding protected pages/routes | [Architecture](docs/content/architecture.mdx)
|
|
46
|
-
| Tables, column gotchas, migrations
|
|
47
|
-
| Providers, sessions, helpers, client usage
|
|
48
|
-
| 2FA, passkeys, device alerts, throttle, cookie consent
|
|
49
|
-
| Admin dashboard, impersonation, maintenance mode
|
|
50
|
-
| Every route, body and guard
|
|
51
|
-
| UI, auth, dashboard and email components
|
|
52
|
-
| Installing the npm package
|
|
53
|
-
| Production checklist, migrations, Docker
|
|
54
|
-
| Common errors and fixes
|
|
42
|
+
| Topic | |
|
|
43
|
+
| ---------------------------------------------------------- | --------------------------------------------------- |
|
|
44
|
+
| Every env var, env vs. admin settings, Doppler | [Configuration](docs/content/configuration.mdx) |
|
|
45
|
+
| Folder layout, request flow, adding protected pages/routes | [Architecture](docs/content/architecture.mdx) |
|
|
46
|
+
| Tables, column gotchas, migrations | [Database](docs/content/database.mdx) |
|
|
47
|
+
| Providers, sessions, helpers, client usage | [Authentication](docs/content/authentication.mdx) |
|
|
48
|
+
| 2FA, passkeys, device alerts, throttle, cookie consent | [Security Features](docs/content/security.mdx) |
|
|
49
|
+
| Admin dashboard, impersonation, maintenance mode | [Admin & Maintenance](docs/content/admin.mdx) |
|
|
50
|
+
| Every route, body and guard | [API Reference](docs/content/api.mdx) |
|
|
51
|
+
| UI, auth, dashboard and email components | [Components](docs/content/components.mdx) |
|
|
52
|
+
| Installing the npm package | [Using as a Package](docs/content/npm-package.mdx) |
|
|
53
|
+
| Production checklist, migrations, Docker | [Deployment](docs/content/deployment.mdx) |
|
|
54
|
+
| Common errors and fixes | [Troubleshooting](docs/content/troubleshooting.mdx) |
|
|
55
55
|
|
|
56
56
|
## Using as a package
|
|
57
57
|
|
|
@@ -63,7 +63,7 @@ bun add @xk2800/nextjs-template
|
|
|
63
63
|
|
|
64
64
|
```ts
|
|
65
65
|
// next.config.ts — components ship as raw .tsx
|
|
66
|
-
const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] }
|
|
66
|
+
const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] };
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
```css
|
|
@@ -75,25 +75,27 @@ const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] }
|
|
|
75
75
|
|
|
76
76
|
```ts
|
|
77
77
|
// app/api/auth/[...all]/route.ts
|
|
78
|
-
import { auth } from "@xk2800/nextjs-template/auth"
|
|
79
|
-
import { toNextJsHandler } from "better-auth/next-js"
|
|
80
|
-
export const { GET, POST } = toNextJsHandler(auth)
|
|
78
|
+
import { auth } from "@xk2800/nextjs-template/auth";
|
|
79
|
+
import { toNextJsHandler } from "better-auth/next-js";
|
|
80
|
+
export const { GET, POST } = toNextJsHandler(auth);
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
Peer dependencies, the full import list, migrations, adding providers and One Tap are covered in **[Using as a Package](docs/content/npm-package.mdx)**.
|
|
84
84
|
|
|
85
85
|
## Common scripts
|
|
86
86
|
|
|
87
|
-
|
|
|
88
|
-
|
|
89
|
-
| `bun dev`
|
|
90
|
-
| `bun run doctor`
|
|
91
|
-
| `bun update @xk2800/nextjs-template`
|
|
92
|
-
| `bun run generate`
|
|
93
|
-
| `bun run migrate:dev` / `:prod` / `:test` | Apply migrations
|
|
94
|
-
| `bun run studio:dev`
|
|
95
|
-
| `bun run test`
|
|
96
|
-
| `bun run storybook`
|
|
97
|
-
| `bun run *:doppler`
|
|
87
|
+
| | |
|
|
88
|
+
| ----------------------------------------- | ------------------------------------------------------------ |
|
|
89
|
+
| `bun dev` | Dev server |
|
|
90
|
+
| `bun run doctor` | Check env, auth secret, DB connection and pending migrations |
|
|
91
|
+
| `bun update @xk2800/nextjs-template` | Pull template updates (then `bun run generate`) |
|
|
92
|
+
| `bun run generate` | Generate a migration from `server/db/schema.ts` |
|
|
93
|
+
| `bun run migrate:dev` / `:prod` / `:test` | Apply migrations |
|
|
94
|
+
| `bun run studio:dev` | Drizzle Studio |
|
|
95
|
+
| `bun run test` | Unit tests |
|
|
96
|
+
| `bun run storybook` | Storybook for every component (mock data, no DB needed) |
|
|
97
|
+
| `bun run *:doppler` | Any of the above with secrets from Doppler |
|
|
98
98
|
|
|
99
99
|
**Working on the template itself:** clone this repo, copy `.env.development.example` to `.env.development`, then run `bun install`. Publishing steps are in [PUBLISHING.md](PUBLISHING.md).
|
|
100
|
+
|
|
101
|
+
test
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
import { Button } from "../../ui/button"
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "../../ui/dialog"
|
|
14
|
+
import ConfirmSendDialog from "./confirmSendDialog"
|
|
15
|
+
|
|
16
|
+
interface Preview {
|
|
17
|
+
subject: string | null
|
|
18
|
+
from: string | null
|
|
19
|
+
previewText: string | null
|
|
20
|
+
html: string | null
|
|
21
|
+
text: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function BroadcastActions({ id, name, status }: { id: string; name: string; status: string }) {
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
const [preview, setPreview] = useState<Preview | null>(null)
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
const [sending, setSending] = useState(false)
|
|
30
|
+
const [confirming, setConfirming] = useState(false)
|
|
31
|
+
|
|
32
|
+
const openPreview = async () => {
|
|
33
|
+
setOpen(true)
|
|
34
|
+
if (preview) return
|
|
35
|
+
setLoading(true)
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`/api/admin/marketing/${id}`)
|
|
38
|
+
const data = await response.json()
|
|
39
|
+
if (!response.ok) throw new Error(data.error || "Failed to load preview")
|
|
40
|
+
setPreview(data)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
toast.error(error instanceof Error ? error.message : "Failed to load preview")
|
|
43
|
+
setOpen(false)
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const send = async () => {
|
|
50
|
+
setSending(true)
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(`/api/admin/marketing/${id}`, { method: "POST" })
|
|
53
|
+
const data = await response.json()
|
|
54
|
+
if (!response.ok) throw new Error(data.error || "Failed to send broadcast")
|
|
55
|
+
toast.success("Broadcast sent")
|
|
56
|
+
setOpen(false)
|
|
57
|
+
router.refresh()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
toast.error(error instanceof Error ? error.message : "Failed to send broadcast")
|
|
60
|
+
} finally {
|
|
61
|
+
setSending(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resend has no push for broadcast status, so poll while it's queued and
|
|
66
|
+
// refresh the list once it moves on (e.g. to "sent").
|
|
67
|
+
// ponytail: stops after ~5 min; a broadcast scheduled for later stays
|
|
68
|
+
// "queued" until it goes out, so it's not worth polling that long.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (status !== "queued") return
|
|
71
|
+
let tries = 0
|
|
72
|
+
const timer = setInterval(async () => {
|
|
73
|
+
if (++tries > 100) return clearInterval(timer)
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`/api/admin/marketing/${id}`)
|
|
76
|
+
if (!response.ok) return
|
|
77
|
+
const data = await response.json()
|
|
78
|
+
if (data.status !== "queued") {
|
|
79
|
+
clearInterval(timer)
|
|
80
|
+
router.refresh()
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// network blip, try again next tick
|
|
84
|
+
}
|
|
85
|
+
}, 3000)
|
|
86
|
+
return () => clearInterval(timer)
|
|
87
|
+
}, [id, status, router])
|
|
88
|
+
|
|
89
|
+
const isDraft = status === "draft"
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{status === "queued" && (
|
|
94
|
+
<span className="flex items-center gap-1.5 text-xs">
|
|
95
|
+
<span className="size-2 animate-pulse rounded-full bg-amber-500" />
|
|
96
|
+
Sending…
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
<Button size="sm" variant="outline" onClick={openPreview}>Preview</Button>
|
|
100
|
+
{isDraft && (
|
|
101
|
+
<Button size="sm" disabled={sending} onClick={() => setConfirming(true)}>
|
|
102
|
+
{sending ? "Sending…" : "Send"}
|
|
103
|
+
</Button>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
107
|
+
<DialogContent className="sm:max-w-2xl">
|
|
108
|
+
<DialogHeader>
|
|
109
|
+
<DialogTitle>{preview?.subject || name}</DialogTitle>
|
|
110
|
+
<DialogDescription>
|
|
111
|
+
{preview ? `From ${preview.from ?? "—"}${preview.previewText ? ` · ${preview.previewText}` : ""}` : "Loading…"}
|
|
112
|
+
</DialogDescription>
|
|
113
|
+
</DialogHeader>
|
|
114
|
+
{loading ? (
|
|
115
|
+
<div className="h-[60vh] animate-pulse rounded-md bg-muted" />
|
|
116
|
+
) : preview?.html ? (
|
|
117
|
+
// sandbox="" — no scripts, no navigation out of the frame
|
|
118
|
+
<iframe
|
|
119
|
+
title="Email preview"
|
|
120
|
+
sandbox=""
|
|
121
|
+
srcDoc={preview.html}
|
|
122
|
+
className="h-[60vh] w-full rounded-md border bg-white"
|
|
123
|
+
/>
|
|
124
|
+
) : (
|
|
125
|
+
<pre className="h-[60vh] overflow-auto whitespace-pre-wrap rounded-md border p-4 text-sm">
|
|
126
|
+
{preview?.text ?? ""}
|
|
127
|
+
</pre>
|
|
128
|
+
)}
|
|
129
|
+
{isDraft && (
|
|
130
|
+
<div className="flex justify-end">
|
|
131
|
+
<Button disabled={sending || loading} onClick={() => setConfirming(true)}>
|
|
132
|
+
{sending ? "Sending…" : "Send now"}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
|
|
139
|
+
<ConfirmSendDialog
|
|
140
|
+
open={confirming}
|
|
141
|
+
onOpenChange={setConfirming}
|
|
142
|
+
subject={preview?.subject || name}
|
|
143
|
+
audience="its segment"
|
|
144
|
+
onConfirm={send}
|
|
145
|
+
/>
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
} from "../../ui/alert-dialog"
|
|
13
|
+
|
|
14
|
+
export default function ConfirmSendDialog({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
subject,
|
|
18
|
+
audience,
|
|
19
|
+
onConfirm,
|
|
20
|
+
}: {
|
|
21
|
+
open: boolean
|
|
22
|
+
onOpenChange: (open: boolean) => void
|
|
23
|
+
subject: string
|
|
24
|
+
audience: string
|
|
25
|
+
onConfirm: () => void
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
+
<AlertDialogContent>
|
|
30
|
+
<AlertDialogHeader>
|
|
31
|
+
<AlertDialogTitle>Send this broadcast?</AlertDialogTitle>
|
|
32
|
+
<AlertDialogDescription>
|
|
33
|
+
“{subject}” goes to every subscribed contact in {audience} right away.
|
|
34
|
+
This can't be undone.
|
|
35
|
+
</AlertDialogDescription>
|
|
36
|
+
</AlertDialogHeader>
|
|
37
|
+
<AlertDialogFooter>
|
|
38
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
39
|
+
<AlertDialogAction onClick={onConfirm}>Send now</AlertDialogAction>
|
|
40
|
+
</AlertDialogFooter>
|
|
41
|
+
</AlertDialogContent>
|
|
42
|
+
</AlertDialog>
|
|
43
|
+
)
|
|
44
|
+
}
|