@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.
Files changed (127) hide show
  1. package/README.md +32 -30
  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/broadcastActions.tsx +148 -0
  7. package/components/dashboard/admin/confirmSendDialog.tsx +44 -0
  8. package/components/dashboard/admin/marketingEmailForm.tsx +115 -0
  9. package/components/dashboard/admin/marketingSection.tsx +67 -0
  10. package/components/dashboard/admin/subscribersCard.tsx +62 -0
  11. package/components/dashboard/admin/systemSettingsForm.tsx +33 -0
  12. package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
  13. package/components/dashboard/errorFeedback.tsx +2 -4
  14. package/components/dashboard/feedbackDialog.tsx +4 -4
  15. package/components/dashboard/marketingUpdatesCard.tsx +62 -0
  16. package/components/dashboard/marketingUpdatesSection.tsx +10 -0
  17. package/components/site/cookie-banner.tsx +109 -7
  18. package/components/ui/alert-dialog.tsx +1 -1
  19. package/components/ui/badge.tsx +3 -1
  20. package/components/ui/card.tsx +1 -1
  21. package/components/ui/data-table.tsx +4 -1
  22. package/components/ui/dialog.tsx +1 -1
  23. package/components/ui/dropdown-menu.tsx +2 -2
  24. package/components/ui/file-upload.tsx +120 -2
  25. package/components/ui/select.tsx +189 -0
  26. package/components/ui/switch.tsx +3 -3
  27. package/dist/activity/logger.js +36 -0
  28. package/dist/activity/logger.js.map +1 -1
  29. package/dist/activity/logger.mjs +4 -4
  30. package/dist/activity/queries.d.mts +1 -1
  31. package/dist/activity/queries.d.ts +1 -1
  32. package/dist/activity/queries.js +36 -0
  33. package/dist/activity/queries.js.map +1 -1
  34. package/dist/activity/queries.mjs +3 -3
  35. package/dist/admin/queries.d.mts +19 -1
  36. package/dist/admin/queries.d.ts +19 -1
  37. package/dist/admin/queries.js +64 -0
  38. package/dist/admin/queries.js.map +1 -1
  39. package/dist/admin/queries.mjs +26 -5
  40. package/dist/admin/queries.mjs.map +1 -1
  41. package/dist/api/feedback.js +53 -2
  42. package/dist/api/feedback.js.map +1 -1
  43. package/dist/api/feedback.mjs +9 -8
  44. package/dist/api/feedback.mjs.map +1 -1
  45. package/dist/api/marketing.d.mts +36 -0
  46. package/dist/api/marketing.d.ts +36 -0
  47. package/dist/api/marketing.js +1357 -0
  48. package/dist/api/marketing.js.map +1 -0
  49. package/dist/api/marketing.mjs +170 -0
  50. package/dist/api/marketing.mjs.map +1 -0
  51. package/dist/auth/helpers.js +51 -0
  52. package/dist/auth/helpers.js.map +1 -1
  53. package/dist/auth/helpers.mjs +9 -64
  54. package/dist/auth/helpers.mjs.map +1 -1
  55. package/dist/auth/index.js +51 -0
  56. package/dist/auth/index.js.map +1 -1
  57. package/dist/auth/index.mjs +8 -7
  58. package/dist/{chunk-KAEA35OC.mjs → chunk-6MDQRT6U.mjs} +4 -4
  59. package/dist/{chunk-KAEA35OC.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
  60. package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
  61. package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
  62. package/dist/chunk-6Z3WUSDJ.mjs +62 -0
  63. package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
  64. package/dist/{chunk-VQ5B4MA7.mjs → chunk-7F2POZM6.mjs} +4 -4
  65. package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
  66. package/dist/{chunk-XWVAJXAG.mjs → chunk-7QVIOQ47.mjs} +23 -19
  67. package/dist/chunk-7QVIOQ47.mjs.map +1 -0
  68. package/dist/chunk-C6RGN6QA.mjs +60 -0
  69. package/dist/chunk-C6RGN6QA.mjs.map +1 -0
  70. package/dist/chunk-OVKVD2HH.mjs +25 -0
  71. package/dist/chunk-OVKVD2HH.mjs.map +1 -0
  72. package/dist/{chunk-3MGEAGHU.mjs → chunk-P2G23JMY.mjs} +11 -6
  73. package/dist/chunk-P2G23JMY.mjs.map +1 -0
  74. package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
  75. package/dist/chunk-TKWNEKK6.mjs.map +1 -0
  76. package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
  77. package/dist/chunk-TR5XFBTD.mjs.map +1 -0
  78. package/dist/{chunk-WZDAMCUX.mjs → chunk-ZJMFE5B2.mjs} +4 -4
  79. package/dist/{chunk-WZDAMCUX.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
  80. package/dist/config/env.d.mts +2 -0
  81. package/dist/config/env.d.ts +2 -0
  82. package/dist/config/env.js +6 -0
  83. package/dist/config/env.js.map +1 -1
  84. package/dist/config/env.mjs +1 -1
  85. package/dist/db/index.d.mts +1 -1
  86. package/dist/db/index.d.ts +1 -1
  87. package/dist/db/index.js +36 -0
  88. package/dist/db/index.js.map +1 -1
  89. package/dist/db/index.mjs +3 -3
  90. package/dist/db/schema.d.mts +1 -1
  91. package/dist/db/schema.d.ts +1 -1
  92. package/dist/db/schema.js +30 -0
  93. package/dist/db/schema.js.map +1 -1
  94. package/dist/db/schema.mjs +1 -1
  95. package/dist/errors.js +36 -0
  96. package/dist/errors.js.map +1 -1
  97. package/dist/errors.mjs +4 -4
  98. package/dist/marketing/index.d.mts +35 -0
  99. package/dist/marketing/index.d.ts +35 -0
  100. package/dist/marketing/index.js +125 -0
  101. package/dist/marketing/index.js.map +1 -0
  102. package/dist/marketing/index.mjs +6 -0
  103. package/dist/marketing/index.mjs.map +1 -0
  104. package/dist/{schema-UuqxoOzS.d.mts → schema-CN_Y6EXv.d.mts} +148 -2
  105. package/dist/{schema-UuqxoOzS.d.ts → schema-CN_Y6EXv.d.ts} +148 -2
  106. package/dist/sessions/queries.js +36 -0
  107. package/dist/sessions/queries.js.map +1 -1
  108. package/dist/sessions/queries.mjs +3 -3
  109. package/dist/settings/queries.d.mts +4 -3
  110. package/dist/settings/queries.d.ts +4 -3
  111. package/dist/settings/queries.js +42 -0
  112. package/dist/settings/queries.js.map +1 -1
  113. package/dist/settings/queries.mjs +4 -4
  114. package/dist/users/queries.js +36 -0
  115. package/dist/users/queries.js.map +1 -1
  116. package/dist/users/queries.mjs +3 -3
  117. package/lib/cookie-consent.ts +72 -20
  118. package/lib/cookie-names.ts +47 -0
  119. package/lib/device-fingerprint.ts +66 -10
  120. package/lib/recalculate-columns.ts +2 -0
  121. package/package.json +28 -3
  122. package/styles/theme.css +23 -3
  123. package/dist/chunk-3MGEAGHU.mjs.map +0 -1
  124. package/dist/chunk-N6ECRCHY.mjs.map +0 -1
  125. package/dist/chunk-TMJYAOC2.mjs.map +0 -1
  126. package/dist/chunk-XWVAJXAG.mjs.map +0 -1
  127. 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 | [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) |
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` | 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 |
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 || 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
+ }
@@ -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
+ &ldquo;{subject}&rdquo; goes to every subscribed contact in {audience} right away.
34
+ This can&apos;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
+ }