hazo_auth 7.0.2 → 8.0.1

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 (86) hide show
  1. package/README.md +34 -0
  2. package/SETUP_CHECKLIST.md +31 -0
  3. package/cli-src/lib/auth/auth_types.ts +3 -0
  4. package/cli-src/lib/auth/hazo_get_auth.server.ts +19 -0
  5. package/cli-src/lib/legal/legal_docs_config.server.ts +61 -0
  6. package/cli-src/lib/legal/legal_docs_reader.server.ts +36 -0
  7. package/cli-src/lib/legal/legal_docs_service.ts +196 -0
  8. package/cli-src/lib/legal/legal_docs_types.ts +31 -0
  9. package/cli-src/lib/services/registration_service.ts +16 -1
  10. package/dist/client.d.ts +1 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +3 -0
  13. package/dist/components/layouts/index.d.ts +1 -0
  14. package/dist/components/layouts/index.d.ts.map +1 -1
  15. package/dist/components/layouts/index.js +2 -0
  16. package/dist/components/layouts/legal/index.d.ts +5 -0
  17. package/dist/components/layouts/legal/index.d.ts.map +1 -0
  18. package/dist/components/layouts/legal/index.js +4 -0
  19. package/dist/components/layouts/legal/legal_acceptance_gate.d.ts +7 -0
  20. package/dist/components/layouts/legal/legal_acceptance_gate.d.ts.map +1 -0
  21. package/dist/components/layouts/legal/legal_acceptance_gate.js +84 -0
  22. package/dist/components/layouts/legal/legal_doc_checkbox_list.d.ts +9 -0
  23. package/dist/components/layouts/legal/legal_doc_checkbox_list.d.ts.map +1 -0
  24. package/dist/components/layouts/legal/legal_doc_checkbox_list.js +11 -0
  25. package/dist/components/layouts/legal/legal_doc_combined_view.d.ts +9 -0
  26. package/dist/components/layouts/legal/legal_doc_combined_view.d.ts.map +1 -0
  27. package/dist/components/layouts/legal/legal_doc_combined_view.js +11 -0
  28. package/dist/components/layouts/legal/legal_doc_drawer.d.ts +8 -0
  29. package/dist/components/layouts/legal/legal_doc_drawer.d.ts.map +1 -0
  30. package/dist/components/layouts/legal/legal_doc_drawer.js +55 -0
  31. package/dist/components/layouts/register/hooks/use_register_form.d.ts +5 -1
  32. package/dist/components/layouts/register/hooks/use_register_form.d.ts.map +1 -1
  33. package/dist/components/layouts/register/hooks/use_register_form.js +25 -10
  34. package/dist/components/layouts/register/index.d.ts.map +1 -1
  35. package/dist/components/layouts/register/index.js +21 -1
  36. package/dist/components/layouts/user_management/index.d.ts.map +1 -1
  37. package/dist/components/layouts/user_management/index.js +45 -7
  38. package/dist/components/ui/button.d.ts +1 -1
  39. package/dist/components/ui/input-otp.d.ts +2 -2
  40. package/dist/components/ui/sheet.d.ts +1 -1
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/lib/auth/auth_types.d.ts +2 -0
  44. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  45. package/dist/lib/auth/auth_types.js +0 -2
  46. package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
  47. package/dist/lib/auth/hazo_get_auth.server.js +19 -0
  48. package/dist/lib/legal/legal_docs_config.server.d.ts +22 -0
  49. package/dist/lib/legal/legal_docs_config.server.d.ts.map +1 -0
  50. package/dist/lib/legal/legal_docs_config.server.js +52 -0
  51. package/dist/lib/legal/legal_docs_reader.server.d.ts +15 -0
  52. package/dist/lib/legal/legal_docs_reader.server.d.ts.map +1 -0
  53. package/dist/lib/legal/legal_docs_reader.server.js +24 -0
  54. package/dist/lib/legal/legal_docs_service.d.ts +49 -0
  55. package/dist/lib/legal/legal_docs_service.d.ts.map +1 -0
  56. package/dist/lib/legal/legal_docs_service.js +140 -0
  57. package/dist/lib/legal/legal_docs_types.d.ts +25 -0
  58. package/dist/lib/legal/legal_docs_types.d.ts.map +1 -0
  59. package/dist/lib/legal/legal_docs_types.js +2 -0
  60. package/dist/lib/services/registration_service.d.ts +5 -0
  61. package/dist/lib/services/registration_service.d.ts.map +1 -1
  62. package/dist/lib/services/registration_service.js +6 -0
  63. package/dist/page_components/index.d.ts +0 -5
  64. package/dist/page_components/index.d.ts.map +1 -1
  65. package/dist/page_components/index.js +0 -5
  66. package/dist/server/routes/index.d.ts +3 -0
  67. package/dist/server/routes/index.d.ts.map +1 -1
  68. package/dist/server/routes/index.js +4 -0
  69. package/dist/server/routes/legal_docs_accept.d.ts +3 -0
  70. package/dist/server/routes/legal_docs_accept.d.ts.map +1 -0
  71. package/dist/server/routes/legal_docs_accept.js +43 -0
  72. package/dist/server/routes/legal_docs_get.d.ts +3 -0
  73. package/dist/server/routes/legal_docs_get.d.ts.map +1 -0
  74. package/dist/server/routes/legal_docs_get.js +49 -0
  75. package/dist/server/routes/legal_docs_publish.d.ts +3 -0
  76. package/dist/server/routes/legal_docs_publish.d.ts.map +1 -0
  77. package/dist/server/routes/legal_docs_publish.js +35 -0
  78. package/dist/server/routes/register.d.ts.map +1 -1
  79. package/dist/server/routes/register.js +26 -0
  80. package/dist/server/routes/user_management_users.d.ts +2 -2
  81. package/dist/server/routes/user_management_users.d.ts.map +1 -1
  82. package/dist/server/routes/user_management_users.js +46 -2
  83. package/dist/strings.d.ts +2 -0
  84. package/dist/strings.d.ts.map +1 -0
  85. package/dist/strings.js +3 -0
  86. package/package.json +5 -22
package/README.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  A reusable authentication UI component package powered by Next.js, TailwindCSS, and shadcn. It integrates `hazo_config` for configuration management and `hazo_connect` for data access, enabling future components to stay aligned with platform conventions.
4
4
 
5
+ ### What's New in v8.0.1 🔧
6
+
7
+ **Auto-test and middleware bug fixes**
8
+
9
+ - Fixed 307 redirects blocking OTP, strings, consent, and legal_docs API routes when hit without auth cookies — all four are now in `middleware.ts` `public_routes`.
10
+ - Auto-test runner register calls now include `legal_accepted` hashes when legal docs are configured, fixing 24 test failures that cascaded from the initial registration step.
11
+
12
+ ### What's New in v8.0.0 ⚠️ BREAKING CHANGE
13
+
14
+ **Legal Document Acceptance** — opt-in, INI-configured. Add `[hazo_auth__legal_docs]` to `hazo_auth_config.ini` to require users to accept terms before registering. Each doc is a markdown file; hazo_auth hashes it, tracks acceptance history, and blocks register until all current hashes are accepted.
15
+
16
+ **Breaking:** Deprecated page-component wrappers removed (`LoginPage`, `RegisterPage`, `ForgotPasswordPage`, `ResetPasswordPage`, `VerifyEmailPage` from `hazo_auth/page_components/*`). Use `*Layout` components directly in a server component instead.
17
+
18
+ Run these migrations (in order) to upgrade an existing database:
19
+ - `017_legal_acceptance_column.sql`
20
+ - `018_hazo_legal_acceptances.sql`
21
+ - `019_hazo_legal_doc_versions.sql`
22
+
23
+ **New exports** — `LegalAcceptanceGate`, `LegalDocDrawer`, `LegalDocCheckboxList`, `LegalDocCombinedView` from `hazo_auth/client`; `legalDocsGET`, `legalDocsAcceptPOST`, `legalDocsPublishPOST` from `hazo_auth/server/routes`; `LegalDoc`, `LegalAcceptanceRecord`, `LegalAcceptanceMap` types from `hazo_auth`.
24
+
25
+ **New dependency** — `react-markdown` (markdown rendering for legal doc drawers).
26
+
27
+ ```ini
28
+ # config/hazo_auth_config.ini
29
+ [hazo_auth__legal_docs]
30
+ display_mode = separate # separate | combined
31
+ doc_1_key = tos
32
+ doc_1_title = Terms of Service
33
+ doc_1_path = legal/tos.md
34
+ doc_2_key = privacy
35
+ doc_2_title = Privacy Policy
36
+ doc_2_path = legal/privacy.md
37
+ ```
38
+
5
39
  ### What's New in v5.3.1 🔧
6
40
 
7
41
  **`get_client_ip(request)` exported from `hazo_auth/server-lib`** — extracts the client IP from `x-forwarded-for` (first element), falling back to `x-real-ip`, then `"unknown"`. Previously private to `hazo_get_auth.server.ts`. Useful for consumers that need consistent IP extraction across handlers (e.g., `hazo_feedback` audit logging).
@@ -2,6 +2,37 @@
2
2
 
3
3
  This checklist provides step-by-step instructions for setting up the `hazo_auth` package in your Next.js project. AI assistants can follow this guide to ensure complete and correct setup.
4
4
 
5
+ ## v8.0.0 Migration (from v7.x)
6
+
7
+ ### Breaking changes
8
+
9
+ Remove the deprecated page-component imports if you used them:
10
+
11
+ ```diff
12
+ - import LoginPage from 'hazo_auth/page_components/login';
13
+ - import RegisterPage from 'hazo_auth/page_components/register';
14
+ + // Use the Layout components directly in a server component instead
15
+ + import LoginLayout from 'hazo_auth/components/layouts/login';
16
+ + import RegisterLayout from 'hazo_auth/components/layouts/register';
17
+ ```
18
+
19
+ ### New migrations (run in order)
20
+
21
+ ```bash
22
+ npm run migrate migrations/017_legal_acceptance_column.sql
23
+ npm run migrate migrations/018_hazo_legal_acceptances.sql
24
+ npm run migrate migrations/019_hazo_legal_doc_versions.sql
25
+ ```
26
+
27
+ ### Legal docs setup (optional)
28
+
29
+ 1. Add `[hazo_auth__legal_docs]` to `config/hazo_auth_config.ini` (see commented sample at the bottom of that file)
30
+ 2. Create markdown files at the configured paths (e.g. `legal/tos.md`, `legal/privacy.md`)
31
+ 3. Wrap your app layout with `<LegalAcceptanceGate>` from `hazo_auth/client`
32
+ 4. Go to User Management → Legal Docs tab, click "Publish current version" per doc to activate gating for existing users
33
+
34
+ ---
35
+
5
36
  ## v5.3.0 Migration (from v5.2.x)
6
37
 
7
38
  If you are already on hazo_auth `5.2.x`, the only mandatory work is wiring up hazo_notify's template manager at boot. Apps that don't use the templated email path (only the plain `send_email`) keep working unchanged after bumping the peer dep.
@@ -1,5 +1,6 @@
1
1
  // file_description: Type definitions and error classes for hazo_get_auth utility
2
2
  // section: types
3
+ import type { LegalAcceptanceMap } from '../legal/legal_docs_types';
3
4
 
4
5
  /**
5
6
  * User data structure returned by hazo_get_auth
@@ -13,6 +14,8 @@ export type HazoAuthUser = {
13
14
  managed_by_user_id?: string | null;
14
15
  // App-specific user data (JSON object stored as TEXT in database)
15
16
  app_user_data: Record<string, unknown> | null;
17
+ // Legal document acceptance map (JSON object stored in database)
18
+ legal_acceptance: LegalAcceptanceMap | null;
16
19
  };
17
20
 
18
21
  /**
@@ -59,6 +59,24 @@ function parse_app_user_data(
59
59
  }
60
60
  }
61
61
 
62
+ /**
63
+ * Parse raw legal_acceptance field from DB to LegalAcceptanceMap
64
+ * @param raw - Raw value from database (string or object)
65
+ * @returns Parsed LegalAcceptanceMap or null
66
+ */
67
+ function parse_legal_acceptance(
68
+ raw: unknown,
69
+ ): import('../legal/legal_docs_types').LegalAcceptanceMap | null {
70
+ if (!raw) return null;
71
+ try {
72
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
73
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) return null;
74
+ return parsed as import('../legal/legal_docs_types').LegalAcceptanceMap;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
62
80
  /**
63
81
  * Gets client IP address from request
64
82
  * @param request - NextRequest object
@@ -185,6 +203,7 @@ async function fetch_user_data_from_db(user_id: string): Promise<{
185
203
  (user_db.profile_picture_url as string | null) || null,
186
204
  managed_by_user_id: (user_db.managed_by_user_id as string | undefined) || null,
187
205
  app_user_data: parse_app_user_data(user_db.app_user_data),
206
+ legal_acceptance: parse_legal_acceptance(user_db.legal_acceptance),
188
207
  };
189
208
 
190
209
  // v5.x: Fetch user's roles from hazo_user_scopes (scope-based role assignments)
@@ -0,0 +1,61 @@
1
+ // file_description: server-only helper to read legal docs configuration from hazo_auth_config.ini
2
+ // section: server-only-guard
3
+ import 'server-only';
4
+
5
+ // section: imports
6
+ import { read_config_section } from '../config/config_loader.server.js';
7
+ import type { LegalDocConfig, LegalDocsConfig } from './legal_docs_types';
8
+
9
+ // section: constants
10
+ const SECTION_NAME = 'hazo_auth__legal_docs';
11
+
12
+ // section: cache
13
+ // Cached after first load — INI changes require server restart anyway
14
+ let _cached: LegalDocsConfig | null = null;
15
+
16
+ // section: exports
17
+
18
+ /**
19
+ * Reads legal docs configuration from hazo_auth_config.ini.
20
+ * Returns an empty docs array if the section is absent (legal docs disabled).
21
+ *
22
+ * Expected INI shape:
23
+ * [hazo_auth__legal_docs]
24
+ * display_mode = separate ; or: combined
25
+ * doc_1_key = terms
26
+ * doc_1_title = Terms of Service
27
+ * doc_1_path = legal/terms.md
28
+ * doc_2_key = privacy
29
+ * doc_2_title = Privacy Policy
30
+ * doc_2_path = legal/privacy.md
31
+ */
32
+ export function get_legal_docs_config(): LegalDocsConfig {
33
+ if (_cached) return _cached;
34
+
35
+ const section = read_config_section(SECTION_NAME) ?? {};
36
+
37
+ const docs: LegalDocConfig[] = [];
38
+ let i = 1;
39
+ while (section[`doc_${i}_key`]) {
40
+ docs.push({
41
+ key: section[`doc_${i}_key`],
42
+ title: section[`doc_${i}_title`] ?? section[`doc_${i}_key`],
43
+ path: section[`doc_${i}_path`],
44
+ });
45
+ i++;
46
+ }
47
+
48
+ _cached = {
49
+ docs,
50
+ display_mode: section['display_mode'] === 'combined' ? 'combined' : 'separate',
51
+ };
52
+
53
+ return _cached;
54
+ }
55
+
56
+ /**
57
+ * Call this in tests to clear the cache between runs.
58
+ */
59
+ export function _reset_legal_docs_config_cache(): void {
60
+ _cached = null;
61
+ }
@@ -0,0 +1,36 @@
1
+ // file_description: server-only utility that reads a legal document from disk and returns its content + SHA-256 hash
2
+ // section: server-only-guard
3
+ import 'server-only';
4
+
5
+ // section: imports
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { createHash } from 'crypto';
9
+
10
+ // section: types
11
+
12
+ export interface ReadDocResult {
13
+ content: string;
14
+ hash: string; // "sha256:<hex>"
15
+ }
16
+
17
+ // section: exports
18
+
19
+ /**
20
+ * Reads a legal document from the filesystem and returns its text content
21
+ * together with a deterministic SHA-256 hash of that content.
22
+ *
23
+ * @param doc_path - Absolute path, or a path relative to process.cwd().
24
+ * @returns { content, hash } where hash is formatted as "sha256:<hex>".
25
+ * @throws If the file cannot be read.
26
+ */
27
+ export function read_legal_doc(doc_path: string): ReadDocResult {
28
+ const abs_path = path.isAbsolute(doc_path)
29
+ ? doc_path
30
+ : path.join(process.cwd(), doc_path);
31
+
32
+ const content = fs.readFileSync(abs_path, 'utf-8');
33
+ const hex = createHash('sha256').update(content).digest('hex');
34
+
35
+ return { content, hash: `sha256:${hex}` };
36
+ }
@@ -0,0 +1,196 @@
1
+ // file_description: service functions for the legal document acceptance system
2
+ // section: imports
3
+ import { createCrudService } from 'hazo_connect/server';
4
+ import type { LegalAcceptanceMap } from './legal_docs_types';
5
+
6
+ // section: helpers
7
+
8
+ function generate_id(): string {
9
+ return crypto.randomUUID();
10
+ }
11
+
12
+ // section: exports
13
+
14
+ /**
15
+ * Write legal acceptance records. Call from:
16
+ * - registration_service (bundled with register POST, pre-session)
17
+ * - legal_docs_accept route (authenticated, post-login)
18
+ *
19
+ * Inserts one row per doc into hazo_legal_acceptances (audit history) and
20
+ * merges the result into the denormalised hazo_users.legal_acceptance JSONB
21
+ * column for fast "has this user accepted the current version?" queries.
22
+ */
23
+ export async function write_legal_acceptance(
24
+ adapter: any,
25
+ user_id: string,
26
+ accepted: Record<string, { hash: string }>,
27
+ ip: string | null,
28
+ user_agent: string | null,
29
+ ): Promise<void> {
30
+ const now = new Date().toISOString();
31
+
32
+ // 1. Insert one history row per doc
33
+ const history_service = createCrudService(adapter, 'hazo_legal_acceptances');
34
+ for (const [doc_key, { hash }] of Object.entries(accepted)) {
35
+ await history_service.insert({
36
+ id: generate_id(),
37
+ user_id,
38
+ doc_key,
39
+ doc_hash: hash,
40
+ accepted_at: now,
41
+ ip,
42
+ user_agent,
43
+ });
44
+ }
45
+
46
+ // 2. Merge into denormalized JSONB on hazo_users
47
+ const users_service = createCrudService(adapter, 'hazo_users');
48
+ const rows = await users_service.findBy({ id: user_id });
49
+
50
+ const existing_raw = rows[0]?.legal_acceptance;
51
+ let existing: LegalAcceptanceMap = {};
52
+ if (existing_raw) {
53
+ try {
54
+ existing = typeof existing_raw === 'string'
55
+ ? JSON.parse(existing_raw)
56
+ : (existing_raw as LegalAcceptanceMap);
57
+ } catch { /* corrupt — start fresh */ }
58
+ }
59
+
60
+ const updated: LegalAcceptanceMap = { ...existing };
61
+ for (const [doc_key, { hash }] of Object.entries(accepted)) {
62
+ updated[doc_key] = { hash, accepted_at: now, ip, user_agent };
63
+ }
64
+
65
+ await users_service.updateById(user_id, {
66
+ legal_acceptance: JSON.stringify(updated),
67
+ changed_at: now,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Publish a doc version as the new required version (admin action).
73
+ * Upserts hazo_legal_doc_versions keyed on doc_key.
74
+ */
75
+ export async function publish_doc_version(
76
+ adapter: any,
77
+ doc_key: string,
78
+ required_hash: string,
79
+ published_by_user_id: string,
80
+ ): Promise<{ published_at: string }> {
81
+ const now = new Date().toISOString();
82
+
83
+ // createCrudService with doc_key as primary key so updateById works correctly
84
+ const versions_service = createCrudService(adapter, 'hazo_legal_doc_versions', {
85
+ primaryKeys: ['doc_key'],
86
+ autoId: false,
87
+ });
88
+
89
+ const existing = await versions_service.findBy({ doc_key });
90
+
91
+ if (existing.length > 0) {
92
+ await versions_service.updateById(doc_key, {
93
+ required_hash,
94
+ published_at: now,
95
+ published_by_user_id,
96
+ });
97
+ } else {
98
+ await versions_service.insert({
99
+ doc_key,
100
+ required_hash,
101
+ published_at: now,
102
+ published_by_user_id,
103
+ });
104
+ }
105
+
106
+ return { published_at: now };
107
+ }
108
+
109
+ /**
110
+ * Return required version info keyed by doc_key.
111
+ */
112
+ export async function get_required_versions(
113
+ adapter: any,
114
+ doc_keys: string[],
115
+ ): Promise<Record<string, { required_hash: string; published_at: string }>> {
116
+ if (doc_keys.length === 0) return {};
117
+
118
+ const versions_service = createCrudService(adapter, 'hazo_legal_doc_versions', {
119
+ primaryKeys: ['doc_key'],
120
+ autoId: false,
121
+ });
122
+
123
+ const rows = await versions_service.list((qb) =>
124
+ qb.whereIn('doc_key', doc_keys).select(['doc_key', 'required_hash', 'published_at'])
125
+ );
126
+
127
+ const result: Record<string, { required_hash: string; published_at: string }> = {};
128
+ for (const row of rows) {
129
+ result[String(row.doc_key)] = {
130
+ required_hash: String(row.required_hash),
131
+ published_at: String(row.published_at),
132
+ };
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Return acceptance history for a user across all doc keys, newest first.
139
+ */
140
+ export async function get_user_acceptance_history(
141
+ adapter: any,
142
+ user_id: string,
143
+ ): Promise<Array<{
144
+ doc_key: string;
145
+ doc_hash: string;
146
+ accepted_at: string;
147
+ ip: string | null;
148
+ user_agent: string | null;
149
+ }>> {
150
+ const history_service = createCrudService(adapter, 'hazo_legal_acceptances');
151
+
152
+ return history_service.list((qb) =>
153
+ qb
154
+ .where('user_id', 'eq', user_id)
155
+ .select(['doc_key', 'doc_hash', 'accepted_at', 'ip', 'user_agent'])
156
+ .order('accepted_at', 'desc')
157
+ ) as Promise<Array<{
158
+ doc_key: string;
159
+ doc_hash: string;
160
+ accepted_at: string;
161
+ ip: string | null;
162
+ user_agent: string | null;
163
+ }>>;
164
+ }
165
+
166
+ /**
167
+ * Count how many users have accepted the current required hash for a doc key.
168
+ * Returns { current, total } where current is users on the required_hash and
169
+ * total is all users in the system (including those with no legal_acceptance data).
170
+ *
171
+ * Note: This performs an in-process scan over all users. For large user bases
172
+ * consider a dedicated SQL query in a future optimisation.
173
+ */
174
+ export async function get_compliance_count(
175
+ adapter: any,
176
+ doc_key: string,
177
+ required_hash: string,
178
+ ): Promise<{ current: number; total: number }> {
179
+ const users_service = createCrudService(adapter, 'hazo_users');
180
+ const all_users = await users_service.list((qb) =>
181
+ qb.select(['id', 'legal_acceptance'])
182
+ );
183
+
184
+ let current = 0;
185
+ for (const user of all_users) {
186
+ let map: LegalAcceptanceMap = {};
187
+ try {
188
+ map = typeof user.legal_acceptance === 'string'
189
+ ? JSON.parse(user.legal_acceptance as string)
190
+ : ((user.legal_acceptance ?? {}) as LegalAcceptanceMap);
191
+ } catch { /* ignore corrupt rows */ }
192
+ if (map[doc_key]?.hash === required_hash) current++;
193
+ }
194
+
195
+ return { current, total: all_users.length };
196
+ }
@@ -0,0 +1,31 @@
1
+ // file_description: shared types for the legal document acceptance system
2
+
3
+ export interface LegalDocConfig {
4
+ key: string;
5
+ title: string;
6
+ path: string;
7
+ }
8
+
9
+ export interface LegalDocsConfig {
10
+ docs: LegalDocConfig[];
11
+ display_mode: 'separate' | 'combined';
12
+ }
13
+
14
+ export interface LegalDoc {
15
+ key: string;
16
+ title: string;
17
+ content: string;
18
+ hash: string;
19
+ required_hash: string | null;
20
+ required_published_at: string | null;
21
+ }
22
+
23
+ export interface LegalAcceptanceRecord {
24
+ hash: string;
25
+ accepted_at: string;
26
+ ip: string | null;
27
+ user_agent: string | null;
28
+ }
29
+
30
+ // Shape of hazo_users.legal_acceptance JSONB
31
+ export type LegalAcceptanceMap = Record<string, LegalAcceptanceRecord>;
@@ -16,6 +16,7 @@ import {
16
16
  is_user_types_enabled,
17
17
  get_default_user_type,
18
18
  } from "../user_types_config.server.js";
19
+ import { write_legal_acceptance } from "../legal/legal_docs_service.js";
19
20
 
20
21
  // section: types
21
22
  export type RegistrationData = {
@@ -23,6 +24,9 @@ export type RegistrationData = {
23
24
  password: string;
24
25
  name?: string;
25
26
  url_on_logon?: string;
27
+ legal_accepted?: Record<string, { hash: string }>;
28
+ ip?: string | null;
29
+ user_agent?: string | null;
26
30
  };
27
31
 
28
32
  export type RegistrationResult = {
@@ -156,7 +160,7 @@ export async function register_user(
156
160
  user_email: email,
157
161
  user_name: name,
158
162
  });
159
-
163
+
160
164
  if (!email_result.success) {
161
165
  const logger = create_app_logger();
162
166
  logger.error("registration_service_email_send_failed", {
@@ -170,6 +174,17 @@ export async function register_user(
170
174
  }
171
175
  }
172
176
 
177
+ // Write legal acceptance records if provided
178
+ if (data.legal_accepted && Object.keys(data.legal_accepted).length > 0) {
179
+ await write_legal_acceptance(
180
+ adapter,
181
+ user_id,
182
+ data.legal_accepted,
183
+ data.ip ?? null,
184
+ data.user_agent ?? null,
185
+ );
186
+ }
187
+
173
188
  return {
174
189
  success: true,
175
190
  user_id,
package/dist/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components/index.js";
2
+ export { LegalAcceptanceGate, LegalDocDrawer, LegalDocCheckboxList, LegalDocCombinedView } from "./components/layouts/legal/index.js";
2
3
  export { cn, merge_class_names } from "./lib/utils.js";
3
4
  export * from "./lib/auth/auth_types.js";
4
5
  export { use_auth_status, trigger_auth_status_refresh } from "./components/layouts/shared/hooks/use_auth_status.js";
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAYA,cAAc,oBAAoB,CAAC;AAInC,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAIpD,cAAc,uBAAuB,CAAC;AAItC,OAAO,EAAE,eAAe,EAAE,2BAA2B,EAAE,MAAM,mDAAmD,CAAC;AACjH,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC3G,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iDAAiD,CAAC;AAC7G,OAAO,EAAE,iBAAiB,EAAE,yBAAyB,EAAE,MAAM,qDAAqD,CAAC;AACnH,YAAY,EAAE,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,qDAAqD,CAAC;AAGvI,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAI/E,cAAc,8CAA8C,CAAC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAYA,cAAc,oBAAoB,CAAC;AAInC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AAInI,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAIpD,cAAc,uBAAuB,CAAC;AAItC,OAAO,EAAE,eAAe,EAAE,2BAA2B,EAAE,MAAM,mDAAmD,CAAC;AACjH,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC3G,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iDAAiD,CAAC;AAC7G,OAAO,EAAE,iBAAiB,EAAE,yBAAyB,EAAE,MAAM,qDAAqD,CAAC;AACnH,YAAY,EAAE,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,qDAAqD,CAAC;AAGvI,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAI/E,cAAc,8CAA8C,CAAC"}
package/dist/client.js CHANGED
@@ -10,6 +10,9 @@
10
10
  // section: component_exports
11
11
  // All UI and layout components are client-safe
12
12
  export * from "./components/index.js";
13
+ // section: legal_exports
14
+ // Legal acceptance gate and primitives
15
+ export { LegalAcceptanceGate, LegalDocDrawer, LegalDocCheckboxList, LegalDocCombinedView } from "./components/layouts/legal/index.js";
13
16
  // section: utility_exports
14
17
  // CSS utility functions
15
18
  export { cn, merge_class_names } from "./lib/utils.js";
@@ -14,5 +14,6 @@ export { UserManagementLayout } from "./user_management/index.js";
14
14
  export type { UserManagementLayoutProps } from "./user_management/index";
15
15
  export { default as DevLockLayout } from "./dev_lock/index.js";
16
16
  export type { DevLockLayoutProps } from "./dev_lock/index";
17
+ export { LegalDocDrawer, LegalDocCheckboxList, LegalDocCombinedView, LegalAcceptanceGate } from "./legal/index.js";
17
18
  export * from "./shared/index.js";
18
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC1E,YAAY,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEzE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AACxE,YAAY,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,EAAE,OAAO,IAAI,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAChF,YAAY,EAAE,4BAA4B,EAAE,MAAM,4BAA4B,CAAC;AAE/E,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClE,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC/D,YAAY,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEzE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC5D,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAG3D,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAC7D,YAAY,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC1E,YAAY,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEzE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AACxE,YAAY,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,EAAE,OAAO,IAAI,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAChF,YAAY,EAAE,4BAA4B,EAAE,MAAM,4BAA4B,CAAC;AAE/E,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClE,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAC/D,YAAY,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAEzE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC5D,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAG3D,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGhH,cAAc,gBAAgB,CAAC"}
@@ -8,5 +8,7 @@ export { default as EmailVerificationLayout } from "./email_verification/index.j
8
8
  export { default as MySettingsLayout } from "./my_settings/index.js";
9
9
  export { UserManagementLayout } from "./user_management/index.js";
10
10
  export { default as DevLockLayout } from "./dev_lock/index.js";
11
+ // section: legal_exports
12
+ export { LegalDocDrawer, LegalDocCheckboxList, LegalDocCombinedView, LegalAcceptanceGate } from "./legal/index.js";
11
13
  // section: shared_exports
12
14
  export * from "./shared/index.js";
@@ -0,0 +1,5 @@
1
+ export { LegalDocDrawer } from './legal_doc_drawer.js';
2
+ export { LegalDocCheckboxList } from './legal_doc_checkbox_list.js';
3
+ export { LegalDocCombinedView } from './legal_doc_combined_view.js';
4
+ export { LegalAcceptanceGate } from './legal_acceptance_gate.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/layouts/legal/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { LegalDocDrawer } from './legal_doc_drawer.js';
2
+ export { LegalDocCheckboxList } from './legal_doc_checkbox_list.js';
3
+ export { LegalDocCombinedView } from './legal_doc_combined_view.js';
4
+ export { LegalAcceptanceGate } from './legal_acceptance_gate.js';
@@ -0,0 +1,7 @@
1
+ interface LegalAcceptanceGateProps {
2
+ children: React.ReactNode;
3
+ apiBasePath?: string;
4
+ }
5
+ export declare function LegalAcceptanceGate({ children, apiBasePath }: LegalAcceptanceGateProps): import("react/jsx-runtime").JSX.Element | null;
6
+ export {};
7
+ //# sourceMappingURL=legal_acceptance_gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"legal_acceptance_gate.d.ts","sourceRoot":"","sources":["../../../../src/components/layouts/legal/legal_acceptance_gate.tsx"],"names":[],"mappings":"AAQA,UAAU,wBAAwB;IAChC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAOD,wBAAgB,mBAAmB,CAAC,EAAE,QAAQ,EAAE,WAA8B,EAAE,EAAE,wBAAwB,kDAwHzG"}
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState, useEffect } from 'react';
4
+ import { LegalDocCheckboxList } from './legal_doc_checkbox_list.js';
5
+ import { LegalDocCombinedView } from './legal_doc_combined_view.js';
6
+ import { Button } from '../../ui/button.js';
7
+ export function LegalAcceptanceGate({ children, apiBasePath = '/api/hazo_auth' }) {
8
+ const [gate, set_gate] = useState({ status: 'loading' });
9
+ const [accepted, set_accepted] = useState({});
10
+ const [submitting, set_submitting] = useState(false);
11
+ const [error, set_error] = useState(null);
12
+ useEffect(() => {
13
+ check_gate();
14
+ // eslint-disable-next-line react-hooks/exhaustive-deps
15
+ }, []);
16
+ async function check_gate() {
17
+ var _a, _b;
18
+ try {
19
+ const [docs_res, me_res] = await Promise.all([
20
+ fetch(`${apiBasePath}/legal_docs`).then((r) => r.json()),
21
+ fetch(`${apiBasePath}/me`, { credentials: 'include' }).then((r) => r.json()),
22
+ ]);
23
+ if (!docs_res.ok || docs_res.data.docs.length === 0 || !me_res.ok || !((_a = me_res.data) === null || _a === void 0 ? void 0 : _a.user)) {
24
+ set_gate({ status: 'clear' });
25
+ return;
26
+ }
27
+ const docs = docs_res.data.docs;
28
+ const display_mode = docs_res.data.display_mode;
29
+ const user_acceptance = (_b = me_res.data.user.legal_acceptance) !== null && _b !== void 0 ? _b : {};
30
+ const gated = docs.filter((doc) => {
31
+ var _a;
32
+ if (!doc.required_hash)
33
+ return false;
34
+ return ((_a = user_acceptance[doc.key]) === null || _a === void 0 ? void 0 : _a.hash) !== doc.required_hash;
35
+ });
36
+ if (gated.length === 0) {
37
+ set_gate({ status: 'clear' });
38
+ }
39
+ else {
40
+ set_gate({ status: 'required', docs: gated, display_mode });
41
+ }
42
+ }
43
+ catch (_c) {
44
+ set_gate({ status: 'clear' });
45
+ }
46
+ }
47
+ async function handle_submit() {
48
+ var _a;
49
+ if (gate.status !== 'required')
50
+ return;
51
+ set_submitting(true);
52
+ set_error(null);
53
+ try {
54
+ const accepted_payload = Object.fromEntries(gate.docs
55
+ .filter((doc) => accepted[doc.key])
56
+ .map((doc) => [doc.key, { hash: doc.hash }]));
57
+ const res = await fetch(`${apiBasePath}/legal_docs/accept`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ credentials: 'include',
61
+ body: JSON.stringify({ accepted: accepted_payload }),
62
+ });
63
+ const result = await res.json();
64
+ if (result.ok) {
65
+ set_gate({ status: 'clear' });
66
+ }
67
+ else {
68
+ set_error((_a = result.error) !== null && _a !== void 0 ? _a : 'Failed to record acceptance');
69
+ }
70
+ }
71
+ catch (_b) {
72
+ set_error('Network error — please try again');
73
+ }
74
+ finally {
75
+ set_submitting(false);
76
+ }
77
+ }
78
+ if (gate.status === 'loading')
79
+ return null;
80
+ if (gate.status === 'clear')
81
+ return _jsx(_Fragment, { children: children });
82
+ const all_accepted = gate.docs.every((doc) => accepted[doc.key]);
83
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-4", children: _jsxs("div", { className: "w-full max-w-lg space-y-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold", children: "Legal Documents" }), _jsx("p", { className: "text-muted-foreground mt-1", children: "Please review and accept the following documents to continue." })] }), gate.display_mode === 'combined' ? (_jsx(LegalDocCombinedView, { docs: gate.docs, accepted: gate.docs.length > 0 && gate.docs.every((d) => accepted[d.key]), onAcceptedChange: (value) => gate.docs.forEach((doc) => set_accepted((prev) => (Object.assign(Object.assign({}, prev), { [doc.key]: value })))) })) : (_jsx(LegalDocCheckboxList, { docs: gate.docs, accepted: accepted, onAcceptedChange: (key, value) => set_accepted((prev) => (Object.assign(Object.assign({}, prev), { [key]: value }))) })), error && _jsx("p", { className: "text-destructive text-sm", children: error }), _jsx(Button, { onClick: handle_submit, disabled: !all_accepted || submitting, className: "w-full", children: submitting ? 'Saving...' : 'Continue' })] }) }));
84
+ }