|
5 | 5 | * |
6 | 6 | * Must be called in `<script setup>` context (not in a plugin). |
7 | 7 | * |
8 | | - * Identity and group calls are gated on analytics consent so that events are |
9 | | - * never sent before the user has opted in. When a user grants consent during |
10 | | - * their session (ConsentBanner), the watchers re-fire and identify them |
11 | | - * immediately without requiring a page reload. |
| 8 | + * Identity model |
| 9 | + * -------------- |
| 10 | + * Logged-in users are ALWAYS identified by their opaque `user.id` so that |
| 11 | + * funnel + retention analytics work even for visitors who never accept |
| 12 | + * cookies. Email and name are forwarded ONLY when the user has explicitly |
| 13 | + * consented to analytics — keeping the dashboard PII-free for non-consenters |
| 14 | + * while still letting consented users be looked up by email in PostHog. |
| 15 | + * |
| 16 | + * The watcher depends on `hasConsented`, so when a user clicks "Accept" in |
| 17 | + * the banner the identify() call re-fires immediately and PostHog aliases |
| 18 | + * the anonymous distinct id to the user id (preserving the in-session |
| 19 | + * funnel from anonymous visit → signup → identified user). |
12 | 20 | */ |
13 | 21 | export async function usePostHogIdentity() { |
14 | | - const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups } = useNuxtApp() |
| 22 | + const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups, $posthogSetDemoFlag } = useNuxtApp() |
15 | 23 |
|
16 | 24 | if (!$posthogIdentifyUser) return |
17 | 25 |
|
18 | 26 | const { data: session } = await authClient.useSession(useFetch) |
19 | 27 | const activeOrgState = authClient.useActiveOrganization() |
20 | 28 |
|
21 | | - // Share the consent reactive state. useAnalyticsConsent also applies the |
22 | | - // stored consent flag to PostHog on the client, so calling it here means |
23 | | - // consent is active before the immediate watchers fire below. |
24 | 29 | const { hasConsented } = useAnalyticsConsent() |
25 | 30 |
|
26 | | - // Watch session AND consent so identify re-fires when a new user accepts |
27 | | - // consent during their visit, and is skipped when PostHog is opted-out. |
| 31 | + const config = useRuntimeConfig() |
| 32 | + const liveDemoEmail = (config.public.liveDemoEmail as string | undefined) || '' |
| 33 | + const demoOrgSlug = (config.public.demoOrgSlug as string | undefined) || '' |
| 34 | + |
28 | 35 | watch( |
29 | 36 | [() => session.value, hasConsented] as const, |
30 | 37 | ([currentSession, consented], prev) => { |
31 | 38 | const user = currentSession?.user |
32 | 39 | const previousUser = (prev?.[0] as typeof session.value)?.user |
33 | 40 |
|
34 | | - if (user?.id && consented) { |
35 | | - // Forward person properties for opted-in users so they're identifiable |
36 | | - // in PostHog. GDPR-safe: gated on explicit user consent. |
37 | | - ;($posthogIdentifyUser as (userId: string, properties?: Record<string, string | undefined>) => void)( |
38 | | - user.id, |
39 | | - { |
| 41 | + if (user?.id) { |
| 42 | + // Forward person properties (email, name) ONLY if consent was given. |
| 43 | + // Without consent we still identify with the opaque user.id so |
| 44 | + // returning users are stable in PostHog — but no PII is attached. |
| 45 | + const identify = $posthogIdentifyUser as ( |
| 46 | + userId: string, |
| 47 | + properties?: Record<string, string | undefined>, |
| 48 | + ) => void |
| 49 | + if (consented) { |
| 50 | + identify(user.id, { |
40 | 51 | email: user.email, |
41 | 52 | name: user.name || undefined, |
42 | | - }, |
43 | | - ) |
| 53 | + }) |
| 54 | + } |
| 55 | + else { |
| 56 | + identify(user.id) |
| 57 | + } |
| 58 | + |
| 59 | + // Demo tagging by user email — fires the moment the demo account |
| 60 | + // signs in, even before an organisation context is loaded. |
| 61 | + const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined |
| 62 | + if (setDemoFlag && liveDemoEmail) { |
| 63 | + setDemoFlag(user.email === liveDemoEmail) |
| 64 | + } |
44 | 65 | } |
45 | 66 | else if (previousUser?.id && !user?.id) { |
46 | 67 | // Always reset on log-out regardless of consent state so that |
47 | 68 | // no user identity leaks into the next anonymous session. |
| 69 | + // Reset also clears the registered super properties (incl. is_demo). |
48 | 70 | ($posthogReset as () => void)() |
49 | 71 | } |
50 | 72 | }, |
51 | 73 | { immediate: true }, |
52 | 74 | ) |
53 | 75 |
|
54 | | - // Watch org AND consent for group analytics — same gating logic as above. |
55 | 76 | watch( |
56 | 77 | [() => activeOrgState.value?.data, hasConsented] as const, |
57 | 78 | async ([org, consented]) => { |
58 | | - if (consented) { |
59 | | - if (org?.id) { |
60 | | - // Fetch the current member's role to enrich group properties — useful |
61 | | - // for debugging permission issues without exposing personal data. |
62 | | - let memberRole: string | undefined |
63 | | - try { |
64 | | - const { data } = await authClient.organization.getActiveMemberRole() |
65 | | - memberRole = data?.role ?? undefined |
66 | | - } |
67 | | - catch { /* non-critical; role is just an enrichment property */ } |
68 | | - |
69 | | - // Only org id, name, and member role are forwarded; slug is omitted to minimise data. |
70 | | - ;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({ |
71 | | - id: org.id, |
72 | | - name: org.name || undefined, |
73 | | - member_role: memberRole, |
74 | | - }) |
| 79 | + if (org?.id) { |
| 80 | + // Forward org name only for consenters; anonymous users get an |
| 81 | + // opaque org-id-only group so per-org metrics still aggregate. |
| 82 | + const setOrg = $posthogSetOrganization as (org: { id: string, name?: string }) => void |
| 83 | + if (consented) { |
| 84 | + setOrg({ id: org.id, name: org.name || undefined }) |
75 | 85 | } |
76 | 86 | else { |
77 | | - // Clear org group when user has no active organization to avoid |
78 | | - // attributing events to the previously selected org. |
79 | | - ($posthogResetGroups as (() => void) | undefined)?.() |
| 87 | + setOrg({ id: org.id }) |
80 | 88 | } |
| 89 | + |
| 90 | + // Demo tagging by org slug — covers self-hosted deployments |
| 91 | + // where the demo org may be owned by a non-demo email account. |
| 92 | + // We only set true here; the email-based watcher above handles |
| 93 | + // clearing the flag when the user is not the demo user. |
| 94 | + const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined |
| 95 | + if (setDemoFlag && demoOrgSlug && org.slug === demoOrgSlug) { |
| 96 | + setDemoFlag(true) |
| 97 | + } |
| 98 | + } |
| 99 | + else { |
| 100 | + ($posthogResetGroups as (() => void) | undefined)?.() |
81 | 101 | } |
82 | 102 | }, |
83 | 103 | { immediate: true }, |
|
0 commit comments