hazo_auth 10.7.1 → 10.8.0
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 +7 -0
- package/cli-src/cli/generate.ts +2 -0
- package/cli-src/lib/services/branding_service.ts +64 -0
- package/cli-src/lib/services/invitation_service.ts +23 -0
- package/cli-src/lib/services/scope_service.ts +28 -1
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +2 -0
- package/dist/components/layouts/create_firm/index.d.ts +7 -1
- package/dist/components/layouts/create_firm/index.d.ts.map +1 -1
- package/dist/components/layouts/create_firm/index.js +2 -2
- package/dist/components/layouts/scope_management/components/branding_editor.d.ts +6 -0
- package/dist/components/layouts/scope_management/components/branding_editor.d.ts.map +1 -1
- package/dist/components/layouts/scope_management/components/branding_editor.js +41 -2
- package/dist/components/layouts/shared/firm_branding_to_brand_identity.d.ts +17 -0
- package/dist/components/layouts/shared/firm_branding_to_brand_identity.d.ts.map +1 -0
- package/dist/components/layouts/shared/firm_branding_to_brand_identity.js +52 -0
- package/dist/components/layouts/shared/hooks/use_firm_branding.d.ts +6 -0
- package/dist/components/layouts/shared/hooks/use_firm_branding.d.ts.map +1 -1
- package/dist/components/ui/input.js +1 -1
- package/dist/lib/services/branding_service.d.ts +6 -0
- package/dist/lib/services/branding_service.d.ts.map +1 -1
- package/dist/lib/services/branding_service.js +60 -0
- package/dist/lib/services/invitation_service.d.ts +3 -0
- package/dist/lib/services/invitation_service.d.ts.map +1 -1
- package/dist/lib/services/invitation_service.js +18 -0
- package/dist/lib/services/scope_service.d.ts +21 -0
- package/dist/lib/services/scope_service.d.ts.map +1 -1
- package/dist/lib/services/scope_service.js +27 -1
- package/dist/page_components/create_firm.d.ts.map +1 -1
- package/dist/page_components/create_firm.js +4 -3
- package/dist/server_pages/index.d.ts +2 -0
- package/dist/server_pages/index.d.ts.map +1 -1
- package/dist/server_pages/index.js +1 -0
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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 v10.8.0
|
|
6
|
+
|
|
7
|
+
- **Firm branding gained `app_title` + `footer`.** `FirmBranding` (from `use_firm_branding`) now carries `app_title?: string` and `footer?: { public?: FooterConfig; app?: FooterConfig }` alongside the existing logo/color fields — migration `022_add_app_title_and_footer_to_hazo_scopes.sql` adds the backing `app_title`/`footer_config` columns to `hazo_scopes`. The scope-management branding editor exposes both as new form inputs.
|
|
8
|
+
- **`firm_branding_to_brand_identity()`** (`hazo_auth/src/components/layouts/shared/firm_branding_to_brand_identity.ts`) — maps a `FirmBranding` record (hex colors) into a `hazo_theme` `BrandIdentity` (HSL channel strings), for consumers rendering hazo_ui's `Logo`/`AppHeader`/`AppFooter` chrome components from stored firm branding.
|
|
9
|
+
- Server-side `validate_branding` now rejects footer link/social hrefs that resolve to an unsafe scheme (`javascript:`, `data:`, etc.) before persisting, as defense-in-depth alongside hazo_ui's client-side `safeHref()`.
|
|
10
|
+
- `hazo_theme` optional peer dependency range bumped to `^0.2.0`.
|
|
11
|
+
|
|
5
12
|
### What's New in v10.4.13
|
|
6
13
|
|
|
7
14
|
- **`validate_session_secret_config()`** exported from `hazo_auth/server-lib` — hard startup validator that throws `HazoConfigError` (code `HAZO_AUTH_CONFIG`) when `JWT_SECRET` is unset or shorter than 32 chars. Call it from a Next.js `instrumentation.ts` `register()` hook to fail boot loudly instead of hitting the silent split-brain a bad/missing secret otherwise causes (edge proxy bounces to `/login` while server-side auth falls back to unsigned cookies and still considers the user authenticated).
|
package/cli-src/cli/generate.ts
CHANGED
|
@@ -64,6 +64,7 @@ const ROUTES: RouteDefinition[] = [
|
|
|
64
64
|
{ name: "nextauth_post", path: "api/auth/[...nextauth]", method: "POST", export_name: "nextauthPOST" },
|
|
65
65
|
{ name: "oauth_google_callback", path: "api/hazo_auth/oauth/google/callback", method: "GET", export_name: "oauthGoogleCallbackGET" },
|
|
66
66
|
{ name: "set_password", path: "api/hazo_auth/set_password", method: "POST", export_name: "setPasswordPOST" },
|
|
67
|
+
{ name: "create_firm", path: "api/hazo_auth/create_firm", method: "POST", export_name: "createFirmPOST" },
|
|
67
68
|
// Static assets (default auth-page images served from the package's dist/assets/images).
|
|
68
69
|
// Without this route the default image_src (/api/hazo_auth/assets/login_default.jpg) 404s
|
|
69
70
|
// and login/register/etc. render with an empty image panel.
|
|
@@ -77,6 +78,7 @@ const PAGES: PageDefinition[] = [
|
|
|
77
78
|
{ name: "reset_password", path: "hazo_auth/reset_password", component_name: "ResetPasswordPage", import_path: "hazo_auth/pages/reset_password" },
|
|
78
79
|
{ name: "verify_email", path: "hazo_auth/verify_email", component_name: "VerifyEmailPage", import_path: "hazo_auth/pages/verify_email" },
|
|
79
80
|
{ name: "my_settings", path: "hazo_auth/my_settings", component_name: "MySettingsPage", import_path: "hazo_auth/pages/my_settings" },
|
|
81
|
+
{ name: "create_firm", path: "hazo_auth/create_firm", component_name: "CreateFirmPage", import_path: "hazo_auth/pages/create_firm" },
|
|
80
82
|
];
|
|
81
83
|
// Note: Using barrel export "hazo_auth/pages" which maps to dist/server_pages/index.js in package.json exports
|
|
82
84
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// file_description: service for firm branding operations with inheritance support
|
|
2
2
|
// section: imports
|
|
3
3
|
import type { HazoConnectAdapter } from "hazo_connect";
|
|
4
|
+
import type { FooterConfig } from "hazo_theme";
|
|
4
5
|
import {
|
|
5
6
|
type FirmBranding,
|
|
6
7
|
type ScopeRecord,
|
|
@@ -28,6 +29,8 @@ export type UpdateBrandingData = {
|
|
|
28
29
|
primary_color?: string | null;
|
|
29
30
|
secondary_color?: string | null;
|
|
30
31
|
tagline?: string | null;
|
|
32
|
+
app_title?: string | null;
|
|
33
|
+
footer?: { public?: FooterConfig; app?: FooterConfig } | null;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
// section: validation
|
|
@@ -39,6 +42,47 @@ function is_valid_hex_color(color: string): boolean {
|
|
|
39
42
|
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Validates an admin-supplied href against a scheme allowlist. Blocks
|
|
47
|
+
* `javascript:`/`data:`/`vbscript:` URIs that would become stored XSS when the
|
|
48
|
+
* value is later rendered into an `<a href>` (defense-in-depth alongside the
|
|
49
|
+
* render-time `safeHref` filter in hazo_ui's chrome components). Allows
|
|
50
|
+
* http(s), mailto, and same-origin absolute paths (`/…` but not `//…`).
|
|
51
|
+
*/
|
|
52
|
+
function is_safe_href(url: string): boolean {
|
|
53
|
+
const t = url.trim().toLowerCase();
|
|
54
|
+
if (t.startsWith("http://") || t.startsWith("https://") || t.startsWith("mailto:")) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return t.startsWith("/") && !t.startsWith("//");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Walks every admin-configurable href in a footer config (column links + social
|
|
62
|
+
* links across both public/app variants) and returns the first unsafe one, or
|
|
63
|
+
* null when all are safe.
|
|
64
|
+
*/
|
|
65
|
+
function first_unsafe_footer_href(
|
|
66
|
+
footer: UpdateBrandingData["footer"],
|
|
67
|
+
): string | null {
|
|
68
|
+
if (!footer) return null;
|
|
69
|
+
for (const variant of ["public", "app"] as const) {
|
|
70
|
+
const cfg: FooterConfig | undefined = footer[variant];
|
|
71
|
+
if (!cfg) continue;
|
|
72
|
+
for (const column of cfg.columns ?? []) {
|
|
73
|
+
for (const link of column.links ?? []) {
|
|
74
|
+
if (link?.href && !is_safe_href(link.href)) return link.href;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cfg.social) {
|
|
78
|
+
for (const handle of Object.values(cfg.social)) {
|
|
79
|
+
if (typeof handle === "string" && handle && !is_safe_href(handle)) return handle;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
/**
|
|
43
87
|
* Validates branding data
|
|
44
88
|
*/
|
|
@@ -66,6 +110,20 @@ export function validate_branding(data: UpdateBrandingData): {
|
|
|
66
110
|
return { valid: false, error: "Logo URL must be 500 characters or less" };
|
|
67
111
|
}
|
|
68
112
|
|
|
113
|
+
if (data.app_title && typeof data.app_title === "string" && data.app_title.length > 200) {
|
|
114
|
+
return { valid: false, error: "App title must be 200 characters or less" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reject unsafe footer link/social hrefs (javascript:/data: etc.) before they
|
|
118
|
+
// are persisted to footer_config and later rendered into <a href>.
|
|
119
|
+
const bad_href = first_unsafe_footer_href(data.footer);
|
|
120
|
+
if (bad_href) {
|
|
121
|
+
return {
|
|
122
|
+
valid: false,
|
|
123
|
+
error: "Footer links must use http(s), mailto, or same-origin (/…) URLs",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
69
127
|
return { valid: true };
|
|
70
128
|
}
|
|
71
129
|
|
|
@@ -240,6 +298,8 @@ export async function update_branding(
|
|
|
240
298
|
const updated_primary = data.primary_color !== undefined ? data.primary_color : current.primary_color;
|
|
241
299
|
const updated_secondary = data.secondary_color !== undefined ? data.secondary_color : current.secondary_color;
|
|
242
300
|
const updated_tagline = data.tagline !== undefined ? data.tagline : current.tagline;
|
|
301
|
+
const updated_app_title = data.app_title !== undefined ? data.app_title : current.app_title;
|
|
302
|
+
const updated_footer = data.footer !== undefined ? data.footer : current.footer;
|
|
243
303
|
|
|
244
304
|
// Update the scope with individual fields
|
|
245
305
|
const update_result = await update_scope(adapter, scope_id, {
|
|
@@ -247,6 +307,8 @@ export async function update_branding(
|
|
|
247
307
|
primary_color: updated_primary || null,
|
|
248
308
|
secondary_color: updated_secondary || null,
|
|
249
309
|
tagline: updated_tagline || null,
|
|
310
|
+
app_title: updated_app_title || null,
|
|
311
|
+
footer: updated_footer || null,
|
|
250
312
|
});
|
|
251
313
|
|
|
252
314
|
if (!update_result.success) {
|
|
@@ -317,6 +379,8 @@ export async function replace_branding(
|
|
|
317
379
|
primary_color: branding?.primary_color || null,
|
|
318
380
|
secondary_color: branding?.secondary_color || null,
|
|
319
381
|
tagline: branding?.tagline || null,
|
|
382
|
+
app_title: branding?.app_title || null,
|
|
383
|
+
footer: branding?.footer || null,
|
|
320
384
|
});
|
|
321
385
|
|
|
322
386
|
if (!update_result.success) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// section: imports
|
|
3
3
|
import type { HazoConnectAdapter } from "hazo_connect";
|
|
4
4
|
import { createCrudService } from "hazo_connect/server";
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
5
6
|
import { create_app_logger } from "../app_logger.js";
|
|
6
7
|
import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
|
|
7
8
|
import { assign_user_scope } from "./user_scope_service.js";
|
|
@@ -14,7 +15,10 @@ export type InvitationStatus = "PENDING" | "ACCEPTED" | "EXPIRED" | "REVOKED";
|
|
|
14
15
|
export type InvitationRecord = {
|
|
15
16
|
id: string;
|
|
16
17
|
email_address: string;
|
|
18
|
+
/** Opaque unique token identifying this invitation (e.g. for an accept link). */
|
|
19
|
+
token: string;
|
|
17
20
|
scope_id: string;
|
|
21
|
+
root_scope_id: string;
|
|
18
22
|
role_id: string;
|
|
19
23
|
status: InvitationStatus;
|
|
20
24
|
invited_by: string | null;
|
|
@@ -48,6 +52,15 @@ const MAX_PENDING_PER_EMAIL = 5;
|
|
|
48
52
|
|
|
49
53
|
// section: helpers
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Generates an opaque, unique invitation token.
|
|
57
|
+
* `hazo_invitations.token` is `NOT NULL UNIQUE` in the schema (migration 011 /
|
|
58
|
+
* db_setup_sqlite.sql / db_setup_postgres.sql) — every insert must supply one.
|
|
59
|
+
*/
|
|
60
|
+
function generate_invitation_token(): string {
|
|
61
|
+
return randomBytes(32).toString("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/**
|
|
52
65
|
* Creates a new invitation
|
|
53
66
|
*/
|
|
@@ -85,9 +98,19 @@ export async function create_invitation(
|
|
|
85
98
|
const expires_hours = data.expires_in_hours || DEFAULT_EXPIRY_HOURS;
|
|
86
99
|
const expires_at = new Date(now.getTime() + expires_hours * 60 * 60 * 1000);
|
|
87
100
|
|
|
101
|
+
// `hazo_invitations.root_scope_id` is NOT NULL — same computation
|
|
102
|
+
// `assign_user_scope` (user_scope_service.ts) uses for the analogous
|
|
103
|
+
// `hazo_user_scopes.root_scope_id` column: resolve the scope's root
|
|
104
|
+
// ancestor, falling back to the scope itself if it IS the root (or the
|
|
105
|
+
// computation fails).
|
|
106
|
+
const computed_root_scope_id = await get_root_scope_id(adapter, data.scope_id);
|
|
107
|
+
const root_scope_id = computed_root_scope_id || data.scope_id;
|
|
108
|
+
|
|
88
109
|
const inserted = await invitation_service.insert({
|
|
89
110
|
email_address: data.email_address.toLowerCase(),
|
|
111
|
+
token: generate_invitation_token(),
|
|
90
112
|
scope_id: data.scope_id,
|
|
113
|
+
root_scope_id,
|
|
91
114
|
role_id: data.role_id,
|
|
92
115
|
status: "PENDING",
|
|
93
116
|
invited_by: data.invited_by || null,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// section: imports
|
|
3
3
|
import type { HazoConnectAdapter } from "hazo_connect";
|
|
4
4
|
import { createCrudService } from "hazo_connect/server";
|
|
5
|
+
import type { FooterConfig } from "hazo_theme";
|
|
5
6
|
import { create_app_logger } from "../app_logger.js";
|
|
6
7
|
import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
|
|
7
8
|
|
|
@@ -24,6 +25,8 @@ export type FirmBranding = {
|
|
|
24
25
|
primary_color?: string | null;
|
|
25
26
|
secondary_color?: string | null;
|
|
26
27
|
tagline?: string | null;
|
|
28
|
+
app_title?: string | null;
|
|
29
|
+
footer?: { public?: FooterConfig; app?: FooterConfig } | null;
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
export type ScopeRecord = {
|
|
@@ -36,6 +39,8 @@ export type ScopeRecord = {
|
|
|
36
39
|
primary_color: string | null;
|
|
37
40
|
secondary_color: string | null;
|
|
38
41
|
tagline: string | null;
|
|
42
|
+
app_title: string | null;
|
|
43
|
+
footer: { public?: FooterConfig; app?: FooterConfig } | null;
|
|
39
44
|
created_at: string;
|
|
40
45
|
changed_at: string;
|
|
41
46
|
};
|
|
@@ -56,6 +61,8 @@ export type CreateScopeData = {
|
|
|
56
61
|
primary_color?: string | null;
|
|
57
62
|
secondary_color?: string | null;
|
|
58
63
|
tagline?: string | null;
|
|
64
|
+
app_title?: string | null;
|
|
65
|
+
footer?: { public?: FooterConfig; app?: FooterConfig } | null;
|
|
59
66
|
};
|
|
60
67
|
|
|
61
68
|
export type UpdateScopeData = {
|
|
@@ -67,6 +74,8 @@ export type UpdateScopeData = {
|
|
|
67
74
|
primary_color?: string | null;
|
|
68
75
|
secondary_color?: string | null;
|
|
69
76
|
tagline?: string | null;
|
|
77
|
+
app_title?: string | null;
|
|
78
|
+
footer?: { public?: FooterConfig; app?: FooterConfig } | null;
|
|
70
79
|
};
|
|
71
80
|
|
|
72
81
|
export type ScopeTreeNode = ScopeRecord & {
|
|
@@ -88,6 +97,12 @@ function normalize_scope_record(raw: Record<string, unknown>): ScopeRecord {
|
|
|
88
97
|
primary_color: (raw.primary_color as string | null) || null,
|
|
89
98
|
secondary_color: (raw.secondary_color as string | null) || null,
|
|
90
99
|
tagline: (raw.tagline as string | null) || null,
|
|
100
|
+
app_title: (raw.app_title as string | null) || null,
|
|
101
|
+
footer: (() => {
|
|
102
|
+
const raw_fc = raw.footer_config;
|
|
103
|
+
if (typeof raw_fc !== "string" || raw_fc.length === 0) return null;
|
|
104
|
+
try { return JSON.parse(raw_fc); } catch { return null; }
|
|
105
|
+
})(),
|
|
91
106
|
created_at: raw.created_at as string,
|
|
92
107
|
changed_at: raw.changed_at as string,
|
|
93
108
|
};
|
|
@@ -103,6 +118,8 @@ export function extract_branding(scope: ScopeRecord): FirmBranding | null {
|
|
|
103
118
|
if (scope.primary_color) branding.primary_color = scope.primary_color;
|
|
104
119
|
if (scope.secondary_color) branding.secondary_color = scope.secondary_color;
|
|
105
120
|
if (scope.tagline) branding.tagline = scope.tagline;
|
|
121
|
+
if (scope.app_title) branding.app_title = scope.app_title;
|
|
122
|
+
if (scope.footer) branding.footer = scope.footer;
|
|
106
123
|
return Object.keys(branding).length > 0 ? branding : null;
|
|
107
124
|
}
|
|
108
125
|
|
|
@@ -110,7 +127,7 @@ export function extract_branding(scope: ScopeRecord): FirmBranding | null {
|
|
|
110
127
|
* Checks if a scope has any branding set
|
|
111
128
|
*/
|
|
112
129
|
export function has_branding(scope: ScopeRecord): boolean {
|
|
113
|
-
return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline);
|
|
130
|
+
return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline || scope.app_title || scope.footer);
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/**
|
|
@@ -303,6 +320,8 @@ export async function create_scope(
|
|
|
303
320
|
primary_color: data.primary_color || null,
|
|
304
321
|
secondary_color: data.secondary_color || null,
|
|
305
322
|
tagline: data.tagline || null,
|
|
323
|
+
app_title: data.app_title || null,
|
|
324
|
+
footer_config: data.footer ? JSON.stringify(data.footer) : null,
|
|
306
325
|
created_at: now,
|
|
307
326
|
changed_at: now,
|
|
308
327
|
};
|
|
@@ -427,6 +446,12 @@ export async function update_scope(
|
|
|
427
446
|
if (data.tagline !== undefined) {
|
|
428
447
|
update_data.tagline = data.tagline;
|
|
429
448
|
}
|
|
449
|
+
if (data.app_title !== undefined) {
|
|
450
|
+
update_data.app_title = data.app_title;
|
|
451
|
+
}
|
|
452
|
+
if (data.footer !== undefined) {
|
|
453
|
+
update_data.footer_config = data.footer ? JSON.stringify(data.footer) : null;
|
|
454
|
+
}
|
|
430
455
|
|
|
431
456
|
const updated = await scope_service.updateById(scope_id, update_data);
|
|
432
457
|
|
|
@@ -794,6 +819,8 @@ export async function ensure_default_system_scope(
|
|
|
794
819
|
primary_color: null,
|
|
795
820
|
secondary_color: null,
|
|
796
821
|
tagline: null,
|
|
822
|
+
app_title: null,
|
|
823
|
+
footer_config: null,
|
|
797
824
|
created_at: now,
|
|
798
825
|
changed_at: now,
|
|
799
826
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../src/cli/generate.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;
|
|
1
|
+
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../src/cli/generate.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAuMF,wBAAgB,eAAe,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CA8DnE"}
|
package/dist/cli/generate.js
CHANGED
|
@@ -41,6 +41,7 @@ const ROUTES = [
|
|
|
41
41
|
{ name: "nextauth_post", path: "api/auth/[...nextauth]", method: "POST", export_name: "nextauthPOST" },
|
|
42
42
|
{ name: "oauth_google_callback", path: "api/hazo_auth/oauth/google/callback", method: "GET", export_name: "oauthGoogleCallbackGET" },
|
|
43
43
|
{ name: "set_password", path: "api/hazo_auth/set_password", method: "POST", export_name: "setPasswordPOST" },
|
|
44
|
+
{ name: "create_firm", path: "api/hazo_auth/create_firm", method: "POST", export_name: "createFirmPOST" },
|
|
44
45
|
// Static assets (default auth-page images served from the package's dist/assets/images).
|
|
45
46
|
// Without this route the default image_src (/api/hazo_auth/assets/login_default.jpg) 404s
|
|
46
47
|
// and login/register/etc. render with an empty image panel.
|
|
@@ -53,6 +54,7 @@ const PAGES = [
|
|
|
53
54
|
{ name: "reset_password", path: "hazo_auth/reset_password", component_name: "ResetPasswordPage", import_path: "hazo_auth/pages/reset_password" },
|
|
54
55
|
{ name: "verify_email", path: "hazo_auth/verify_email", component_name: "VerifyEmailPage", import_path: "hazo_auth/pages/verify_email" },
|
|
55
56
|
{ name: "my_settings", path: "hazo_auth/my_settings", component_name: "MySettingsPage", import_path: "hazo_auth/pages/my_settings" },
|
|
57
|
+
{ name: "create_firm", path: "hazo_auth/create_firm", component_name: "CreateFirmPage", import_path: "hazo_auth/pages/create_firm" },
|
|
56
58
|
];
|
|
57
59
|
// Note: Using barrel export "hazo_auth/pages" which maps to dist/server_pages/index.js in package.json exports
|
|
58
60
|
// section: helpers
|
|
@@ -34,6 +34,12 @@ export type CreateFirmLayoutProps = {
|
|
|
34
34
|
org_structure_placeholder?: string;
|
|
35
35
|
/** Default value for org structure */
|
|
36
36
|
default_org_structure?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Show the org structure field. When false, the field is hidden from the
|
|
39
|
+
* form but {@link default_org_structure} is still submitted (the API
|
|
40
|
+
* requires org_structure to be non-empty).
|
|
41
|
+
*/
|
|
42
|
+
show_org_structure?: boolean;
|
|
37
43
|
/** Label for submit button */
|
|
38
44
|
submit_button_label?: string;
|
|
39
45
|
/** Success message shown after firm creation */
|
|
@@ -52,6 +58,6 @@ export type CreateFirmLayoutProps = {
|
|
|
52
58
|
error: (message: string, data?: Record<string, unknown>) => void;
|
|
53
59
|
};
|
|
54
60
|
};
|
|
55
|
-
export default function CreateFirmLayout({ image_src, keyword, keyword_lower, verb, image_alt, image_background_color, banner_body, heading, sub_heading, firm_name_label, firm_name_placeholder, org_structure_label, org_structure_placeholder, default_org_structure, submit_button_label, success_message, redirect_route, apiBasePath, onSuccess, button_colors, logger, }: CreateFirmLayoutProps): React.JSX.Element;
|
|
61
|
+
export default function CreateFirmLayout({ image_src, keyword, keyword_lower, verb, image_alt, image_background_color, banner_body, heading, sub_heading, firm_name_label, firm_name_placeholder, org_structure_label, org_structure_placeholder, default_org_structure, show_org_structure, submit_button_label, success_message, redirect_route, apiBasePath, onSuccess, button_colors, logger, }: CreateFirmLayoutProps): React.JSX.Element;
|
|
56
62
|
export { CreateFirmLayout };
|
|
57
63
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/layouts/create_firm/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAOlD,OAAO,EACL,KAAK,sBAAsB,EAC5B,MAAM,uCAAuC,CAAC;AAQ/C,MAAM,MAAM,qBAAqB,GAAG;IAClC,sCAAsC;IACtC,SAAS,EAAE,MAAM,GAAG,eAAe,CAAC;IACpC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mBAAmB;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gCAAgC;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,oCAAoC;IACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,0CAA0C;IAC1C,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,sCAAsC;IACtC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,8BAA8B;IAC9B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,6BAA6B;IAC7B,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC,2BAA2B;IAC3B,MAAM,CAAC,EAAE;QACP,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;QAChE,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;KAClE,CAAC;CACH,CAAC;AAGF,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACvC,SAAS,EACT,OAAgB,EAChB,aAAsB,EACtB,IAAe,EACf,SAA2C,EAC3C,sBAAkC,EAClC,WAAyK,EACzK,OAAmC,EACnC,WAAuD,EACvD,eAAmC,EACnC,qBAA0D,EAC1D,mBAA8C,EAC9C,yBAA6D,EAC7D,qBAAsC,EACtC,mBAA0C,EAC1C,eAAwE,EACxE,cAAoB,EACpB,WAAW,EACX,SAAS,EACT,aAAa,EACb,MAAM,GACP,EAAE,qBAAqB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/layouts/create_firm/index.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAOlD,OAAO,EACL,KAAK,sBAAsB,EAC5B,MAAM,uCAAuC,CAAC;AAQ/C,MAAM,MAAM,qBAAqB,GAAG;IAClC,sCAAsC;IACtC,SAAS,EAAE,MAAM,GAAG,eAAe,CAAC;IACpC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mBAAmB;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uBAAuB;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gCAAgC;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,oCAAoC;IACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,0CAA0C;IAC1C,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,sCAAsC;IACtC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,8BAA8B;IAC9B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,6BAA6B;IAC7B,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC,2BAA2B;IAC3B,MAAM,CAAC,EAAE;QACP,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;QAChE,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;KAClE,CAAC;CACH,CAAC;AAGF,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACvC,SAAS,EACT,OAAgB,EAChB,aAAsB,EACtB,IAAe,EACf,SAA2C,EAC3C,sBAAkC,EAClC,WAAyK,EACzK,OAAmC,EACnC,WAAuD,EACvD,eAAmC,EACnC,qBAA0D,EAC1D,mBAA8C,EAC9C,yBAA6D,EAC7D,qBAAsC,EACtC,kBAAyB,EACzB,mBAA0C,EAC1C,eAAwE,EACxE,cAAoB,EACpB,WAAW,EACX,SAAS,EACT,aAAa,EACb,MAAM,GACP,EAAE,qBAAqB,qBAyJvB;AAGD,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
|
|
@@ -11,7 +11,7 @@ import { CheckCircle, Building2 } from "lucide-react";
|
|
|
11
11
|
import { use_create_firm_form, } from "./hooks/use_create_firm_form.js";
|
|
12
12
|
import { useHazoAuthConfig } from "../../../contexts/hazo_auth_provider.js";
|
|
13
13
|
// section: component
|
|
14
|
-
export default function CreateFirmLayout({ image_src, keyword = "Firm", keyword_lower = "firm", verb = "Create", image_alt = `${verb} your ${keyword_lower}`, image_background_color = "#f1f5f9", banner_body = `${verb} your ${keyword_lower} to start using the application. You'll become the administrator of your ${keyword_lower} and can invite team members later.`, heading = `${verb} Your ${keyword}`, sub_heading = "Set up your organisation to get started", firm_name_label = `${keyword} Name`, firm_name_placeholder = `Enter your ${keyword_lower} name`, org_structure_label = "Organisation Structure", org_structure_placeholder = "e.g., Headquarters, Head Office", default_org_structure = "Headquarters", submit_button_label = `${verb} ${keyword}`, success_message = `Your ${keyword_lower} has been created successfully!`, redirect_route = "/", apiBasePath, onSuccess, button_colors, logger, }) {
|
|
14
|
+
export default function CreateFirmLayout({ image_src, keyword = "Firm", keyword_lower = "firm", verb = "Create", image_alt = `${verb} your ${keyword_lower}`, image_background_color = "#f1f5f9", banner_body = `${verb} your ${keyword_lower} to start using the application. You'll become the administrator of your ${keyword_lower} and can invite team members later.`, heading = `${verb} Your ${keyword}`, sub_heading = "Set up your organisation to get started", firm_name_label = `${keyword} Name`, firm_name_placeholder = `Enter your ${keyword_lower} name`, org_structure_label = "Organisation Structure", org_structure_placeholder = "e.g., Headquarters, Head Office", default_org_structure = "Headquarters", show_org_structure = true, submit_button_label = `${verb} ${keyword}`, success_message = `Your ${keyword_lower} has been created successfully!`, redirect_route = "/", apiBasePath, onSuccess, button_colors, logger, }) {
|
|
15
15
|
const { apiBasePath: contextApiBasePath } = useHazoAuthConfig();
|
|
16
16
|
const resolvedApiBasePath = apiBasePath || contextApiBasePath;
|
|
17
17
|
const form = use_create_firm_form({
|
|
@@ -33,7 +33,7 @@ export default function CreateFirmLayout({ image_src, keyword = "Firm", keyword_
|
|
|
33
33
|
const value = e.target.value;
|
|
34
34
|
formState.handleFieldChange(field, value);
|
|
35
35
|
};
|
|
36
|
-
return (_jsxs(_Fragment, { children: [_jsx(FormFieldWrapper, { fieldId: "firm_name", label: firm_name_label, input: _jsx(Input, { id: "firm_name", ref: formState.firmNameRef, type: "text", value: formState.values.firm_name, onChange: (e) => handleInput("firm_name", e), onInput: (e) => handleInput("firm_name", e), placeholder: firm_name_placeholder, "aria-label": firm_name_label, className: "cls_create_firm_layout_field_input hazo-autofill-detect", autoComplete: "organization" }), errorMessage: formState.errors.firm_name }), _jsx(FormFieldWrapper, { fieldId: "org_structure", label: org_structure_label, input: _jsx(Input, { id: "org_structure", ref: formState.orgStructureRef, type: "text", value: formState.values.org_structure, onChange: (e) => handleInput("org_structure", e), onInput: (e) => handleInput("org_structure", e), placeholder: org_structure_placeholder, "aria-label": org_structure_label, className: "cls_create_firm_layout_field_input hazo-autofill-detect" }), errorMessage: formState.errors.org_structure })] }));
|
|
36
|
+
return (_jsxs(_Fragment, { children: [_jsx(FormFieldWrapper, { fieldId: "firm_name", label: firm_name_label, input: _jsx(Input, { id: "firm_name", ref: formState.firmNameRef, type: "text", value: formState.values.firm_name, onChange: (e) => handleInput("firm_name", e), onInput: (e) => handleInput("firm_name", e), placeholder: firm_name_placeholder, "aria-label": firm_name_label, className: "cls_create_firm_layout_field_input hazo-autofill-detect", autoComplete: "organization" }), errorMessage: formState.errors.firm_name }), show_org_structure && (_jsx(FormFieldWrapper, { fieldId: "org_structure", label: org_structure_label, input: _jsx(Input, { id: "org_structure", ref: formState.orgStructureRef, type: "text", value: formState.values.org_structure, onChange: (e) => handleInput("org_structure", e), onInput: (e) => handleInput("org_structure", e), placeholder: org_structure_placeholder, "aria-label": org_structure_label, className: "cls_create_firm_layout_field_input hazo-autofill-detect" }), errorMessage: formState.errors.org_structure }))] }));
|
|
37
37
|
};
|
|
38
38
|
// Show success message after firm creation
|
|
39
39
|
if (form.isSuccess) {
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import type { FooterConfig } from "hazo_theme";
|
|
1
2
|
export type FirmBranding = {
|
|
2
3
|
logo_url?: string;
|
|
3
4
|
primary_color?: string;
|
|
4
5
|
secondary_color?: string;
|
|
5
6
|
tagline?: string;
|
|
7
|
+
app_title?: string;
|
|
8
|
+
footer?: {
|
|
9
|
+
public?: FooterConfig;
|
|
10
|
+
app?: FooterConfig;
|
|
11
|
+
};
|
|
6
12
|
};
|
|
7
13
|
export type BrandingEditorProps = {
|
|
8
14
|
scopeId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding_editor.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/scope_management/components/branding_editor.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"branding_editor.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/scope_management/components/branding_editor.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACxD,CAAC;AAQF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,eAAe,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CACvC,CAAC;AAQF,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,SAAS,EACT,MAAM,EACN,OAAO,EACP,MAAM,EACN,eAAe,GAChB,EAAE,mBAAmB,+BA2jBrB"}
|
|
@@ -8,7 +8,7 @@ import { Button } from "../../../ui/button.js";
|
|
|
8
8
|
import { Input } from "../../../ui/input.js";
|
|
9
9
|
import { Label } from "../../../ui/label.js";
|
|
10
10
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../ui/dialog.js";
|
|
11
|
-
import { Loader2, Upload, Trash2, CircleCheck, CircleX, Image as ImageIcon, Palette, } from "lucide-react";
|
|
11
|
+
import { Loader2, Upload, Trash2, CircleCheck, CircleX, Image as ImageIcon, Palette, Plus, } from "lucide-react";
|
|
12
12
|
import { toast } from "sonner";
|
|
13
13
|
import { useHazoAuthConfig } from "../../../../contexts/hazo_auth_provider.js";
|
|
14
14
|
// section: helpers
|
|
@@ -17,6 +17,7 @@ function isValidHexColor(color) {
|
|
|
17
17
|
}
|
|
18
18
|
// section: component
|
|
19
19
|
export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, initialBranding, }) {
|
|
20
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
20
21
|
const { apiBasePath } = useHazoAuthConfig();
|
|
21
22
|
// State
|
|
22
23
|
const [loading, setLoading] = useState(false);
|
|
@@ -27,6 +28,14 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
27
28
|
const [primaryColor, setPrimaryColor] = useState((initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.primary_color) || "");
|
|
28
29
|
const [secondaryColor, setSecondaryColor] = useState((initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.secondary_color) || "");
|
|
29
30
|
const [tagline, setTagline] = useState((initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.tagline) || "");
|
|
31
|
+
const [appTitle, setAppTitle] = useState((initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.app_title) || "");
|
|
32
|
+
// Footer state
|
|
33
|
+
const [publicCopyright, setPublicCopyright] = useState(((_b = (_a = initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.footer) === null || _a === void 0 ? void 0 : _a.public) === null || _b === void 0 ? void 0 : _b.copyright) || "");
|
|
34
|
+
const [publicLinks, setPublicLinks] = useState(((_g = (_f = (_e = (_d = (_c = initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.footer) === null || _c === void 0 ? void 0 : _c.public) === null || _d === void 0 ? void 0 : _d.columns) === null || _e === void 0 ? void 0 : _e[0]) === null || _f === void 0 ? void 0 : _f.links) === null || _g === void 0 ? void 0 : _g.map((l) => ({
|
|
35
|
+
label: l.label,
|
|
36
|
+
href: l.href,
|
|
37
|
+
}))) || []);
|
|
38
|
+
const [appCopyright, setAppCopyright] = useState(((_j = (_h = initialBranding === null || initialBranding === void 0 ? void 0 : initialBranding.footer) === null || _h === void 0 ? void 0 : _h.app) === null || _j === void 0 ? void 0 : _j.copyright) || "");
|
|
30
39
|
// File input ref
|
|
31
40
|
const fileInputRef = useRef(null);
|
|
32
41
|
// Load current branding when dialog opens
|
|
@@ -37,6 +46,7 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
37
46
|
}, [isOpen, scopeId]);
|
|
38
47
|
// Load branding from API
|
|
39
48
|
const loadBranding = useCallback(async () => {
|
|
49
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
40
50
|
setLoading(true);
|
|
41
51
|
try {
|
|
42
52
|
const params = new URLSearchParams({
|
|
@@ -51,6 +61,10 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
51
61
|
setPrimaryColor(branding.primary_color || "");
|
|
52
62
|
setSecondaryColor(branding.secondary_color || "");
|
|
53
63
|
setTagline(branding.tagline || "");
|
|
64
|
+
setAppTitle(branding.app_title || "");
|
|
65
|
+
setPublicCopyright(((_b = (_a = branding.footer) === null || _a === void 0 ? void 0 : _a.public) === null || _b === void 0 ? void 0 : _b.copyright) || "");
|
|
66
|
+
setPublicLinks(((_g = (_f = (_e = (_d = (_c = branding.footer) === null || _c === void 0 ? void 0 : _c.public) === null || _d === void 0 ? void 0 : _d.columns) === null || _e === void 0 ? void 0 : _e[0]) === null || _f === void 0 ? void 0 : _f.links) === null || _g === void 0 ? void 0 : _g.map((l) => ({ label: l.label, href: l.href }))) || []);
|
|
67
|
+
setAppCopyright(((_j = (_h = branding.footer) === null || _h === void 0 ? void 0 : _h.app) === null || _j === void 0 ? void 0 : _j.copyright) || "");
|
|
54
68
|
}
|
|
55
69
|
}
|
|
56
70
|
catch (error) {
|
|
@@ -122,6 +136,17 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
122
136
|
branding.secondary_color = secondaryColor;
|
|
123
137
|
if (tagline)
|
|
124
138
|
branding.tagline = tagline;
|
|
139
|
+
if (appTitle)
|
|
140
|
+
branding.app_title = appTitle;
|
|
141
|
+
const cleanedLinks = publicLinks.filter((l) => l.label.trim() && l.href.trim());
|
|
142
|
+
const publicFooter = publicCopyright.trim() || cleanedLinks.length > 0
|
|
143
|
+
? Object.assign(Object.assign({}, (publicCopyright.trim() ? { copyright: publicCopyright.trim() } : {})), (cleanedLinks.length > 0 ? { columns: [{ links: cleanedLinks }] } : {})) : undefined;
|
|
144
|
+
const appFooter = appCopyright.trim()
|
|
145
|
+
? { copyright: appCopyright.trim() }
|
|
146
|
+
: undefined;
|
|
147
|
+
if (publicFooter || appFooter) {
|
|
148
|
+
branding.footer = Object.assign(Object.assign({}, (publicFooter ? { public: publicFooter } : {})), (appFooter ? { app: appFooter } : {}));
|
|
149
|
+
}
|
|
125
150
|
const hasValues = Object.keys(branding).length > 0;
|
|
126
151
|
const response = await fetch(`${apiBasePath}/scope_management/branding`, {
|
|
127
152
|
method: "PUT",
|
|
@@ -162,6 +187,10 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
162
187
|
setPrimaryColor("");
|
|
163
188
|
setSecondaryColor("");
|
|
164
189
|
setTagline("");
|
|
190
|
+
setAppTitle("");
|
|
191
|
+
setPublicCopyright("");
|
|
192
|
+
setPublicLinks([]);
|
|
193
|
+
setAppCopyright("");
|
|
165
194
|
toast.success("Branding cleared");
|
|
166
195
|
onSave === null || onSave === void 0 ? void 0 : onSave(null);
|
|
167
196
|
}
|
|
@@ -180,7 +209,17 @@ export function BrandingEditor({ scopeId, scopeName, isOpen, onClose, onSave, in
|
|
|
180
209
|
const handleRemoveLogo = () => {
|
|
181
210
|
setLogoUrl("");
|
|
182
211
|
};
|
|
183
|
-
|
|
212
|
+
// Footer link row helpers
|
|
213
|
+
const handleAddFooterLink = () => {
|
|
214
|
+
setPublicLinks((prev) => [...prev, { label: "", href: "" }]);
|
|
215
|
+
};
|
|
216
|
+
const handleRemoveFooterLink = (index) => {
|
|
217
|
+
setPublicLinks((prev) => prev.filter((_, i) => i !== index));
|
|
218
|
+
};
|
|
219
|
+
const handleFooterLinkChange = (index, field, value) => {
|
|
220
|
+
setPublicLinks((prev) => prev.map((link, i) => (i === index ? Object.assign(Object.assign({}, link), { [field]: value }) : link)));
|
|
221
|
+
};
|
|
222
|
+
return (_jsx(Dialog, { open: isOpen, onOpenChange: (open) => !open && onClose(), children: _jsxs(DialogContent, { className: "cls_branding_editor_dialog max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(Palette, { className: "h-5 w-5" }), "Firm Branding"] }), _jsxs(DialogDescription, { children: ["Customize branding for \"", scopeName, "\""] })] }), loading ? (_jsx("div", { className: "flex items-center justify-center p-8", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-slate-400" }) })) : (_jsxs("div", { className: "flex flex-col gap-6 py-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Logo" }), _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: "flex-shrink-0 w-24 h-24 border rounded-lg overflow-hidden bg-muted flex items-center justify-center", children: logoUrl ? (_jsx("img", { src: logoUrl, alt: "Firm logo", className: "w-full h-full object-contain" })) : (_jsx(ImageIcon, { className: "h-8 w-8 text-muted-foreground" })) }), _jsxs("div", { className: "flex flex-col gap-2 flex-1", children: [_jsx("input", { type: "file", ref: fileInputRef, onChange: handleFileChange, accept: "image/png,image/jpeg,image/svg+xml,image/webp", className: "hidden" }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: handleUploadClick, disabled: uploading, children: uploading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }), "Uploading..."] })) : (_jsxs(_Fragment, { children: [_jsx(Upload, { className: "h-4 w-4 mr-2" }), "Upload Logo"] })) }), logoUrl && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: handleRemoveLogo, className: "text-destructive hover:text-destructive", children: [_jsx(Trash2, { className: "h-4 w-4 mr-2" }), "Remove"] })), _jsx("p", { className: "text-xs text-muted-foreground", children: "PNG, JPG, SVG or WebP. Max 500KB." })] })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "primary_color", children: "Primary Color" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: "primary_color", type: "text", value: primaryColor, onChange: (e) => setPrimaryColor(e.target.value), placeholder: "#1a73e8", className: "flex-1" }), _jsx("input", { type: "color", value: primaryColor || "#000000", onChange: (e) => setPrimaryColor(e.target.value), className: "w-10 h-10 rounded border cursor-pointer", title: "Pick color" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "secondary_color", children: "Secondary Color" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: "secondary_color", type: "text", value: secondaryColor, onChange: (e) => setSecondaryColor(e.target.value), placeholder: "#4285f4", className: "flex-1" }), _jsx("input", { type: "color", value: secondaryColor || "#000000", onChange: (e) => setSecondaryColor(e.target.value), className: "w-10 h-10 rounded border cursor-pointer", title: "Pick color" })] })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "tagline", children: "Tagline" }), _jsx(Input, { id: "tagline", type: "text", value: tagline, onChange: (e) => setTagline(e.target.value), placeholder: "Your trusted partner", maxLength: 200 }), _jsx("p", { className: "text-xs text-muted-foreground", children: "A short company tagline (max 200 characters)" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "app_title", children: "App Title" }), _jsx(Input, { id: "app_title", type: "text", value: appTitle, onChange: (e) => setAppTitle(e.target.value), placeholder: "Acme Portal", maxLength: 200 }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Title shown in the app chrome (browser tab, header)" })] }), _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Label, { children: "Footer" }), _jsxs("div", { className: "flex flex-col gap-2 rounded-lg border p-3", children: [_jsx(Label, { htmlFor: "public_footer_copyright", className: "text-xs text-muted-foreground", children: "Public Footer Copyright" }), _jsx(Input, { id: "public_footer_copyright", type: "text", value: publicCopyright, onChange: (e) => setPublicCopyright(e.target.value), placeholder: "\u00A9 2026 Acme Inc." }), _jsxs("div", { className: "flex flex-col gap-2 mt-2", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: "Public Footer Links" }), publicLinks.map((link, index) => (_jsxs("div", { className: "flex gap-2 items-center", children: [_jsx(Input, { type: "text", value: link.label, onChange: (e) => handleFooterLinkChange(index, "label", e.target.value), placeholder: "Label", className: "flex-1" }), _jsx(Input, { type: "text", value: link.href, onChange: (e) => handleFooterLinkChange(index, "href", e.target.value), placeholder: "/privacy", className: "flex-1" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleRemoveFooterLink(index), className: "text-destructive hover:text-destructive", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: handleAddFooterLink, className: "self-start", children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), "Add Link"] })] })] }), _jsxs("div", { className: "flex flex-col gap-2 rounded-lg border p-3", children: [_jsx(Label, { htmlFor: "app_footer_copyright", className: "text-xs text-muted-foreground", children: "App Footer Copyright" }), _jsx(Input, { id: "app_footer_copyright", type: "text", value: appCopyright, onChange: (e) => setAppCopyright(e.target.value), placeholder: "\u00A9 2026 Acme Inc." })] })] }), (logoUrl || primaryColor || tagline) && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Preview" }), _jsx("div", { className: "p-4 rounded-lg border", style: {
|
|
184
223
|
backgroundColor: primaryColor
|
|
185
224
|
? `${primaryColor}10`
|
|
186
225
|
: undefined,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BrandIdentity } from "hazo_theme";
|
|
2
|
+
import type { FirmBranding } from "./hooks/use_firm_branding";
|
|
3
|
+
/**
|
|
4
|
+
* Maps a hazo_auth `FirmBranding` record into a hazo_theme `BrandIdentity`.
|
|
5
|
+
* Returns `{}` for null/undefined input.
|
|
6
|
+
*
|
|
7
|
+
* - `logo_url` → `logoUrl`
|
|
8
|
+
* - `app_title` → `appTitle`
|
|
9
|
+
* - `footer` → `footer` (passed through unchanged)
|
|
10
|
+
* - `primary_color` (hex) → `primary` (HSL channels)
|
|
11
|
+
* - `secondary_color` (hex) → `accent` (HSL channels)
|
|
12
|
+
*
|
|
13
|
+
* Does NOT set `primaryForeground`, `accentForeground`, fonts, or `themeSet` —
|
|
14
|
+
* `FirmBranding` has no source data for those.
|
|
15
|
+
*/
|
|
16
|
+
export declare function firm_branding_to_brand_identity(fb: FirmBranding | null | undefined): BrandIdentity;
|
|
17
|
+
//# sourceMappingURL=firm_branding_to_brand_identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firm_branding_to_brand_identity.d.ts","sourceRoot":"","sources":["../../../../src/components/layouts/shared/firm_branding_to_brand_identity.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAY9D;;;;;;;;;;;;GAYG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAClC,aAAa,CAkBf"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// file_description: Pure mapper from hazo_auth's per-scope FirmBranding into hazo_theme's BrandIdentity contract
|
|
2
|
+
//
|
|
3
|
+
// Direction: hazo_auth owns branding STORAGE (per-scope, hex colors, DB-backed via
|
|
4
|
+
// scope_service/branding_service). hazo_theme owns the RUNTIME whitelabel CONTRACT
|
|
5
|
+
// (BrandIdentity — HSL channel strings + identity data) consumed by `resolveTenantVars`
|
|
6
|
+
// and chrome components. This mapper is a one-way translation: hazo_auth FirmBranding →
|
|
7
|
+
// hazo_theme BrandIdentity. It never flows the other way.
|
|
8
|
+
//
|
|
9
|
+
// Color handling: FirmBranding stores colors as hex strings (e.g. "#1e293b"), matching
|
|
10
|
+
// the `<input type="color">` editor UX. BrandIdentity expects HSL *channel* strings
|
|
11
|
+
// (e.g. "222 47% 11%", no hsl() wrapper) so they can be injected as CSS custom properties.
|
|
12
|
+
// This mapper converts hex → HSL channels via the existing `hex_to_hsl` util. Invalid/
|
|
13
|
+
// malformed hex values are skipped silently (never thrown) so a bad stored value can't
|
|
14
|
+
// crash a page render.
|
|
15
|
+
import { hex_to_hsl } from "../../../theme/hex_to_hsl.js";
|
|
16
|
+
// section: helpers
|
|
17
|
+
const HEX_COLOR_RE = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
|
|
18
|
+
function is_valid_hex_color(value) {
|
|
19
|
+
return HEX_COLOR_RE.test(value);
|
|
20
|
+
}
|
|
21
|
+
// section: mapper
|
|
22
|
+
/**
|
|
23
|
+
* Maps a hazo_auth `FirmBranding` record into a hazo_theme `BrandIdentity`.
|
|
24
|
+
* Returns `{}` for null/undefined input.
|
|
25
|
+
*
|
|
26
|
+
* - `logo_url` → `logoUrl`
|
|
27
|
+
* - `app_title` → `appTitle`
|
|
28
|
+
* - `footer` → `footer` (passed through unchanged)
|
|
29
|
+
* - `primary_color` (hex) → `primary` (HSL channels)
|
|
30
|
+
* - `secondary_color` (hex) → `accent` (HSL channels)
|
|
31
|
+
*
|
|
32
|
+
* Does NOT set `primaryForeground`, `accentForeground`, fonts, or `themeSet` —
|
|
33
|
+
* `FirmBranding` has no source data for those.
|
|
34
|
+
*/
|
|
35
|
+
export function firm_branding_to_brand_identity(fb) {
|
|
36
|
+
if (!fb)
|
|
37
|
+
return {};
|
|
38
|
+
const identity = {};
|
|
39
|
+
if (fb.logo_url)
|
|
40
|
+
identity.logoUrl = fb.logo_url;
|
|
41
|
+
if (fb.app_title)
|
|
42
|
+
identity.appTitle = fb.app_title;
|
|
43
|
+
if (fb.footer)
|
|
44
|
+
identity.footer = fb.footer;
|
|
45
|
+
if (fb.primary_color && is_valid_hex_color(fb.primary_color)) {
|
|
46
|
+
identity.primary = hex_to_hsl(fb.primary_color);
|
|
47
|
+
}
|
|
48
|
+
if (fb.secondary_color && is_valid_hex_color(fb.secondary_color)) {
|
|
49
|
+
identity.accent = hex_to_hsl(fb.secondary_color);
|
|
50
|
+
}
|
|
51
|
+
return identity;
|
|
52
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FooterConfig } from "hazo_theme";
|
|
1
2
|
/**
|
|
2
3
|
* Firm branding data structure
|
|
3
4
|
*/
|
|
@@ -6,6 +7,11 @@ export type FirmBranding = {
|
|
|
6
7
|
primary_color?: string;
|
|
7
8
|
secondary_color?: string;
|
|
8
9
|
tagline?: string;
|
|
10
|
+
app_title?: string;
|
|
11
|
+
footer?: {
|
|
12
|
+
public?: FooterConfig;
|
|
13
|
+
app?: FooterConfig;
|
|
14
|
+
};
|
|
9
15
|
};
|
|
10
16
|
/**
|
|
11
17
|
* Options for use_firm_branding hook
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use_firm_branding.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/shared/hooks/use_firm_branding.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"use_firm_branding.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/shared/hooks/use_firm_branding.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAI/C;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACxD,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;OAEG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC;;OAEG;IACH,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B;;OAEG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;OAEG;IACH,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB;;OAEG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB;;OAEG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAIF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,CAAC,EAAE,sBAAsB,GAC/B,qBAAqB,CAuEvB;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,IAAI,qBAAqB,CAqHjE"}
|
|
@@ -14,7 +14,7 @@ import * as React from "react";
|
|
|
14
14
|
import { cn } from "../../lib/utils.js";
|
|
15
15
|
const Input = React.forwardRef((_a, ref) => {
|
|
16
16
|
var { className, type } = _a, props = __rest(_a, ["className", "type"]);
|
|
17
|
-
return (_jsx("input", Object.assign({ type: type, className: cn("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-
|
|
17
|
+
return (_jsx("input", Object.assign({ type: type, className: cn("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-slate-900 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className), ref: ref }, props)));
|
|
18
18
|
});
|
|
19
19
|
Input.displayName = "Input";
|
|
20
20
|
export { Input };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HazoConnectAdapter } from "hazo_connect";
|
|
2
|
+
import type { FooterConfig } from "hazo_theme";
|
|
2
3
|
import { type FirmBranding, type ScopeRecord } from "./scope_service.js";
|
|
3
4
|
export type BrandingServiceResult = {
|
|
4
5
|
success: boolean;
|
|
@@ -11,6 +12,11 @@ export type UpdateBrandingData = {
|
|
|
11
12
|
primary_color?: string | null;
|
|
12
13
|
secondary_color?: string | null;
|
|
13
14
|
tagline?: string | null;
|
|
15
|
+
app_title?: string | null;
|
|
16
|
+
footer?: {
|
|
17
|
+
public?: FooterConfig;
|
|
18
|
+
app?: FooterConfig;
|
|
19
|
+
} | null;
|
|
14
20
|
};
|
|
15
21
|
/**
|
|
16
22
|
* Validates branding data
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/branding_service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,WAAW,EAOjB,MAAM,iBAAiB,CAAC;AAMzB,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"branding_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/branding_service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,WAAW,EAOjB,MAAM,iBAAiB,CAAC;AAMzB,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,GAAG,IAAI,CAAC;CAC/D,CAAC;AAoDF;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,GAAG;IAC3D,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAoCA;AAID;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,qBAAqB,CAAC,CAmChC;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,qBAAqB,CAAC,CA0EhC;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,qBAAqB,CAAC,CA+EhC;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,YAAY,GAAG,IAAI,GAC5B,OAAO,CAAC,qBAAqB,CAAC,CA8DhC;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,qBAAqB,CAAC,CAEhC;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,qBAAqB,CAAC,CAiChC"}
|
|
@@ -8,6 +8,48 @@ import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
|
|
|
8
8
|
function is_valid_hex_color(color) {
|
|
9
9
|
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Validates an admin-supplied href against a scheme allowlist. Blocks
|
|
13
|
+
* `javascript:`/`data:`/`vbscript:` URIs that would become stored XSS when the
|
|
14
|
+
* value is later rendered into an `<a href>` (defense-in-depth alongside the
|
|
15
|
+
* render-time `safeHref` filter in hazo_ui's chrome components). Allows
|
|
16
|
+
* http(s), mailto, and same-origin absolute paths (`/…` but not `//…`).
|
|
17
|
+
*/
|
|
18
|
+
function is_safe_href(url) {
|
|
19
|
+
const t = url.trim().toLowerCase();
|
|
20
|
+
if (t.startsWith("http://") || t.startsWith("https://") || t.startsWith("mailto:")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return t.startsWith("/") && !t.startsWith("//");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Walks every admin-configurable href in a footer config (column links + social
|
|
27
|
+
* links across both public/app variants) and returns the first unsafe one, or
|
|
28
|
+
* null when all are safe.
|
|
29
|
+
*/
|
|
30
|
+
function first_unsafe_footer_href(footer) {
|
|
31
|
+
var _a, _b;
|
|
32
|
+
if (!footer)
|
|
33
|
+
return null;
|
|
34
|
+
for (const variant of ["public", "app"]) {
|
|
35
|
+
const cfg = footer[variant];
|
|
36
|
+
if (!cfg)
|
|
37
|
+
continue;
|
|
38
|
+
for (const column of (_a = cfg.columns) !== null && _a !== void 0 ? _a : []) {
|
|
39
|
+
for (const link of (_b = column.links) !== null && _b !== void 0 ? _b : []) {
|
|
40
|
+
if ((link === null || link === void 0 ? void 0 : link.href) && !is_safe_href(link.href))
|
|
41
|
+
return link.href;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (cfg.social) {
|
|
45
|
+
for (const handle of Object.values(cfg.social)) {
|
|
46
|
+
if (typeof handle === "string" && handle && !is_safe_href(handle))
|
|
47
|
+
return handle;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
11
53
|
/**
|
|
12
54
|
* Validates branding data
|
|
13
55
|
*/
|
|
@@ -28,6 +70,18 @@ export function validate_branding(data) {
|
|
|
28
70
|
if (data.logo_url && typeof data.logo_url === "string" && data.logo_url.length > 500) {
|
|
29
71
|
return { valid: false, error: "Logo URL must be 500 characters or less" };
|
|
30
72
|
}
|
|
73
|
+
if (data.app_title && typeof data.app_title === "string" && data.app_title.length > 200) {
|
|
74
|
+
return { valid: false, error: "App title must be 200 characters or less" };
|
|
75
|
+
}
|
|
76
|
+
// Reject unsafe footer link/social hrefs (javascript:/data: etc.) before they
|
|
77
|
+
// are persisted to footer_config and later rendered into <a href>.
|
|
78
|
+
const bad_href = first_unsafe_footer_href(data.footer);
|
|
79
|
+
if (bad_href) {
|
|
80
|
+
return {
|
|
81
|
+
valid: false,
|
|
82
|
+
error: "Footer links must use http(s), mailto, or same-origin (/…) URLs",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
31
85
|
return { valid: true };
|
|
32
86
|
}
|
|
33
87
|
// section: core operations
|
|
@@ -177,12 +231,16 @@ export async function update_branding(adapter, scope_id, data) {
|
|
|
177
231
|
const updated_primary = data.primary_color !== undefined ? data.primary_color : current.primary_color;
|
|
178
232
|
const updated_secondary = data.secondary_color !== undefined ? data.secondary_color : current.secondary_color;
|
|
179
233
|
const updated_tagline = data.tagline !== undefined ? data.tagline : current.tagline;
|
|
234
|
+
const updated_app_title = data.app_title !== undefined ? data.app_title : current.app_title;
|
|
235
|
+
const updated_footer = data.footer !== undefined ? data.footer : current.footer;
|
|
180
236
|
// Update the scope with individual fields
|
|
181
237
|
const update_result = await update_scope(adapter, scope_id, {
|
|
182
238
|
logo_url: updated_logo || null,
|
|
183
239
|
primary_color: updated_primary || null,
|
|
184
240
|
secondary_color: updated_secondary || null,
|
|
185
241
|
tagline: updated_tagline || null,
|
|
242
|
+
app_title: updated_app_title || null,
|
|
243
|
+
footer: updated_footer || null,
|
|
186
244
|
});
|
|
187
245
|
if (!update_result.success) {
|
|
188
246
|
return {
|
|
@@ -244,6 +302,8 @@ export async function replace_branding(adapter, scope_id, branding) {
|
|
|
244
302
|
primary_color: (branding === null || branding === void 0 ? void 0 : branding.primary_color) || null,
|
|
245
303
|
secondary_color: (branding === null || branding === void 0 ? void 0 : branding.secondary_color) || null,
|
|
246
304
|
tagline: (branding === null || branding === void 0 ? void 0 : branding.tagline) || null,
|
|
305
|
+
app_title: (branding === null || branding === void 0 ? void 0 : branding.app_title) || null,
|
|
306
|
+
footer: (branding === null || branding === void 0 ? void 0 : branding.footer) || null,
|
|
247
307
|
});
|
|
248
308
|
if (!update_result.success) {
|
|
249
309
|
return {
|
|
@@ -3,7 +3,10 @@ export type InvitationStatus = "PENDING" | "ACCEPTED" | "EXPIRED" | "REVOKED";
|
|
|
3
3
|
export type InvitationRecord = {
|
|
4
4
|
id: string;
|
|
5
5
|
email_address: string;
|
|
6
|
+
/** Opaque unique token identifying this invitation (e.g. for an accept link). */
|
|
7
|
+
token: string;
|
|
6
8
|
scope_id: string;
|
|
9
|
+
root_scope_id: string;
|
|
7
10
|
role_id: string;
|
|
8
11
|
status: InvitationStatus;
|
|
9
12
|
invited_by: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"invitation_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/invitation_service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"invitation_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/invitation_service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAUvD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE9E,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,gBAAgB,CAAC;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAkBF;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,oBAAoB,GACzB,OAAO,CAAC,uBAAuB,CAAC,CAmFlC;AAED;;;GAGG;AACH,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,uBAAuB,CAAC,CAkFlC;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,uBAAuB,CAAC,CAmClC;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,uBAAuB,CAAC,CA8FlC;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,kBAAkB,EAC3B,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,uBAAuB,CAAC,CA0DlC;AAED;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,kBAAkB,EAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,uBAAuB,CAAC,CAoClC;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,uBAAuB,CAAC,CAmClC;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA0CvE"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCrudService } from "hazo_connect/server";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
2
3
|
import { create_app_logger } from "../app_logger.js";
|
|
3
4
|
import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
|
|
4
5
|
import { assign_user_scope } from "./user_scope_service.js";
|
|
@@ -7,6 +8,14 @@ import { get_scope_by_id, get_root_scope_id } from "./scope_service.js";
|
|
|
7
8
|
const DEFAULT_EXPIRY_HOURS = 48;
|
|
8
9
|
const MAX_PENDING_PER_EMAIL = 5;
|
|
9
10
|
// section: helpers
|
|
11
|
+
/**
|
|
12
|
+
* Generates an opaque, unique invitation token.
|
|
13
|
+
* `hazo_invitations.token` is `NOT NULL UNIQUE` in the schema (migration 011 /
|
|
14
|
+
* db_setup_sqlite.sql / db_setup_postgres.sql) — every insert must supply one.
|
|
15
|
+
*/
|
|
16
|
+
function generate_invitation_token() {
|
|
17
|
+
return randomBytes(32).toString("hex");
|
|
18
|
+
}
|
|
10
19
|
/**
|
|
11
20
|
* Creates a new invitation
|
|
12
21
|
*/
|
|
@@ -36,9 +45,18 @@ export async function create_invitation(adapter, data) {
|
|
|
36
45
|
// Calculate expiration
|
|
37
46
|
const expires_hours = data.expires_in_hours || DEFAULT_EXPIRY_HOURS;
|
|
38
47
|
const expires_at = new Date(now.getTime() + expires_hours * 60 * 60 * 1000);
|
|
48
|
+
// `hazo_invitations.root_scope_id` is NOT NULL — same computation
|
|
49
|
+
// `assign_user_scope` (user_scope_service.ts) uses for the analogous
|
|
50
|
+
// `hazo_user_scopes.root_scope_id` column: resolve the scope's root
|
|
51
|
+
// ancestor, falling back to the scope itself if it IS the root (or the
|
|
52
|
+
// computation fails).
|
|
53
|
+
const computed_root_scope_id = await get_root_scope_id(adapter, data.scope_id);
|
|
54
|
+
const root_scope_id = computed_root_scope_id || data.scope_id;
|
|
39
55
|
const inserted = await invitation_service.insert({
|
|
40
56
|
email_address: data.email_address.toLowerCase(),
|
|
57
|
+
token: generate_invitation_token(),
|
|
41
58
|
scope_id: data.scope_id,
|
|
59
|
+
root_scope_id,
|
|
42
60
|
role_id: data.role_id,
|
|
43
61
|
status: "PENDING",
|
|
44
62
|
invited_by: data.invited_by || null,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HazoConnectAdapter } from "hazo_connect";
|
|
2
|
+
import type { FooterConfig } from "hazo_theme";
|
|
2
3
|
/**
|
|
3
4
|
* Default system scope ID - for non-multi-tenancy mode
|
|
4
5
|
* All users are assigned to this scope when multi-tenancy is disabled
|
|
@@ -13,6 +14,11 @@ export type FirmBranding = {
|
|
|
13
14
|
primary_color?: string | null;
|
|
14
15
|
secondary_color?: string | null;
|
|
15
16
|
tagline?: string | null;
|
|
17
|
+
app_title?: string | null;
|
|
18
|
+
footer?: {
|
|
19
|
+
public?: FooterConfig;
|
|
20
|
+
app?: FooterConfig;
|
|
21
|
+
} | null;
|
|
16
22
|
};
|
|
17
23
|
export type ScopeRecord = {
|
|
18
24
|
id: string;
|
|
@@ -23,6 +29,11 @@ export type ScopeRecord = {
|
|
|
23
29
|
primary_color: string | null;
|
|
24
30
|
secondary_color: string | null;
|
|
25
31
|
tagline: string | null;
|
|
32
|
+
app_title: string | null;
|
|
33
|
+
footer: {
|
|
34
|
+
public?: FooterConfig;
|
|
35
|
+
app?: FooterConfig;
|
|
36
|
+
} | null;
|
|
26
37
|
created_at: string;
|
|
27
38
|
changed_at: string;
|
|
28
39
|
};
|
|
@@ -40,6 +51,11 @@ export type CreateScopeData = {
|
|
|
40
51
|
primary_color?: string | null;
|
|
41
52
|
secondary_color?: string | null;
|
|
42
53
|
tagline?: string | null;
|
|
54
|
+
app_title?: string | null;
|
|
55
|
+
footer?: {
|
|
56
|
+
public?: FooterConfig;
|
|
57
|
+
app?: FooterConfig;
|
|
58
|
+
} | null;
|
|
43
59
|
};
|
|
44
60
|
export type UpdateScopeData = {
|
|
45
61
|
name?: string;
|
|
@@ -49,6 +65,11 @@ export type UpdateScopeData = {
|
|
|
49
65
|
primary_color?: string | null;
|
|
50
66
|
secondary_color?: string | null;
|
|
51
67
|
tagline?: string | null;
|
|
68
|
+
app_title?: string | null;
|
|
69
|
+
footer?: {
|
|
70
|
+
public?: FooterConfig;
|
|
71
|
+
app?: FooterConfig;
|
|
72
|
+
} | null;
|
|
52
73
|
};
|
|
53
74
|
export type ScopeTreeNode = ScopeRecord & {
|
|
54
75
|
children?: ScopeTreeNode[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/scope_service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"scope_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/scope_service.ts"],"names":[],"mappings":"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"}
|
|
@@ -21,6 +21,18 @@ function normalize_scope_record(raw) {
|
|
|
21
21
|
primary_color: raw.primary_color || null,
|
|
22
22
|
secondary_color: raw.secondary_color || null,
|
|
23
23
|
tagline: raw.tagline || null,
|
|
24
|
+
app_title: raw.app_title || null,
|
|
25
|
+
footer: (() => {
|
|
26
|
+
const raw_fc = raw.footer_config;
|
|
27
|
+
if (typeof raw_fc !== "string" || raw_fc.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw_fc);
|
|
31
|
+
}
|
|
32
|
+
catch (_a) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
})(),
|
|
24
36
|
created_at: raw.created_at,
|
|
25
37
|
changed_at: raw.changed_at,
|
|
26
38
|
};
|
|
@@ -39,13 +51,17 @@ export function extract_branding(scope) {
|
|
|
39
51
|
branding.secondary_color = scope.secondary_color;
|
|
40
52
|
if (scope.tagline)
|
|
41
53
|
branding.tagline = scope.tagline;
|
|
54
|
+
if (scope.app_title)
|
|
55
|
+
branding.app_title = scope.app_title;
|
|
56
|
+
if (scope.footer)
|
|
57
|
+
branding.footer = scope.footer;
|
|
42
58
|
return Object.keys(branding).length > 0 ? branding : null;
|
|
43
59
|
}
|
|
44
60
|
/**
|
|
45
61
|
* Checks if a scope has any branding set
|
|
46
62
|
*/
|
|
47
63
|
export function has_branding(scope) {
|
|
48
|
-
return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline);
|
|
64
|
+
return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline || scope.app_title || scope.footer);
|
|
49
65
|
}
|
|
50
66
|
/**
|
|
51
67
|
* Checks if the given scope_id is the default system scope
|
|
@@ -208,6 +224,8 @@ export async function create_scope(adapter, data) {
|
|
|
208
224
|
primary_color: data.primary_color || null,
|
|
209
225
|
secondary_color: data.secondary_color || null,
|
|
210
226
|
tagline: data.tagline || null,
|
|
227
|
+
app_title: data.app_title || null,
|
|
228
|
+
footer_config: data.footer ? JSON.stringify(data.footer) : null,
|
|
211
229
|
created_at: now,
|
|
212
230
|
changed_at: now,
|
|
213
231
|
};
|
|
@@ -313,6 +331,12 @@ export async function update_scope(adapter, scope_id, data) {
|
|
|
313
331
|
if (data.tagline !== undefined) {
|
|
314
332
|
update_data.tagline = data.tagline;
|
|
315
333
|
}
|
|
334
|
+
if (data.app_title !== undefined) {
|
|
335
|
+
update_data.app_title = data.app_title;
|
|
336
|
+
}
|
|
337
|
+
if (data.footer !== undefined) {
|
|
338
|
+
update_data.footer_config = data.footer ? JSON.stringify(data.footer) : null;
|
|
339
|
+
}
|
|
316
340
|
const updated = await scope_service.updateById(scope_id, update_data);
|
|
317
341
|
if (!Array.isArray(updated) || updated.length === 0) {
|
|
318
342
|
return {
|
|
@@ -616,6 +640,8 @@ export async function ensure_default_system_scope(adapter) {
|
|
|
616
640
|
primary_color: null,
|
|
617
641
|
secondary_color: null,
|
|
618
642
|
tagline: null,
|
|
643
|
+
app_title: null,
|
|
644
|
+
footer_config: null,
|
|
619
645
|
created_at: now,
|
|
620
646
|
changed_at: now,
|
|
621
647
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create_firm.d.ts","sourceRoot":"","sources":["../../src/page_components/create_firm.tsx"],"names":[],"mappings":"AAEA,OAAO,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"create_firm.d.ts","sourceRoot":"","sources":["../../src/page_components/create_firm.tsx"],"names":[],"mappings":"AAEA,OAAO,aAAa,CAAC;AAWrB,MAAM,MAAM,mBAAmB,GAAG;IAChC,iCAAiC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AA8CF,MAAM,CAAC,OAAO,UAAU,cAAc,CAAC,EAAE,aAAa,EAAE,GAAE,mBAAwB,+BA0BjF;AAGD,OAAO,EAAE,cAAc,EAAE,CAAC"}
|
|
@@ -5,7 +5,7 @@ import "server-only";
|
|
|
5
5
|
// section: imports
|
|
6
6
|
import CreateFirmLayout from "../components/layouts/create_firm/index.js";
|
|
7
7
|
import { AuthPageShell } from "../components/layouts/shared/components/auth_page_shell.js";
|
|
8
|
-
import { get_config_value } from "../lib/config/config_loader.server.js";
|
|
8
|
+
import { get_config_value, get_config_boolean, } from "../lib/config/config_loader.server.js";
|
|
9
9
|
// section: config_loader
|
|
10
10
|
const CREATE_FIRM_SECTION = "hazo_auth__create_firm";
|
|
11
11
|
// Reads an optional label override: returns the configured string, or undefined
|
|
@@ -21,13 +21,14 @@ function get_create_firm_config() {
|
|
|
21
21
|
keyword: get_config_value(CREATE_FIRM_SECTION, "keyword", "Firm"),
|
|
22
22
|
keyword_lower: get_config_value(CREATE_FIRM_SECTION, "keyword_lower", "firm"),
|
|
23
23
|
verb: get_config_value(CREATE_FIRM_SECTION, "verb", "Create"),
|
|
24
|
-
image_src: get_config_value(CREATE_FIRM_SECTION, "image_src", "/hazo_auth/
|
|
24
|
+
image_src: get_config_value(CREATE_FIRM_SECTION, "image_src", "/api/hazo_auth/assets/register_default.jpg"),
|
|
25
25
|
// Explicit per-label overrides (undefined → keyword-derived default).
|
|
26
26
|
heading: get_optional_config_value("heading"),
|
|
27
27
|
sub_heading: get_optional_config_value("sub_heading"),
|
|
28
28
|
firm_name_label: get_optional_config_value("firm_name_label"),
|
|
29
29
|
org_structure_label: get_optional_config_value("org_structure_label"),
|
|
30
30
|
org_structure_default: get_config_value(CREATE_FIRM_SECTION, "org_structure_default", "Headquarters"),
|
|
31
|
+
show_org_structure: get_config_boolean(CREATE_FIRM_SECTION, "show_org_structure", true),
|
|
31
32
|
submit_button_label: get_optional_config_value("submit_button_label"),
|
|
32
33
|
success_message: get_optional_config_value("success_message"),
|
|
33
34
|
redirect_route: get_config_value(CREATE_FIRM_SECTION, "redirect_route", "/"),
|
|
@@ -36,7 +37,7 @@ function get_create_firm_config() {
|
|
|
36
37
|
// section: component
|
|
37
38
|
export default function CreateFirmPage({ disableNavbar } = {}) {
|
|
38
39
|
const config = get_create_firm_config();
|
|
39
|
-
const layoutContent = (_jsx(CreateFirmLayout, { image_src: config.image_src, keyword: config.keyword, keyword_lower: config.keyword_lower, verb: config.verb, heading: config.heading, sub_heading: config.sub_heading, firm_name_label: config.firm_name_label, org_structure_label: config.org_structure_label, default_org_structure: config.org_structure_default, submit_button_label: config.submit_button_label, success_message: config.success_message, redirect_route: config.redirect_route }));
|
|
40
|
+
const layoutContent = (_jsx(CreateFirmLayout, { image_src: config.image_src, keyword: config.keyword, keyword_lower: config.keyword_lower, verb: config.verb, heading: config.heading, sub_heading: config.sub_heading, firm_name_label: config.firm_name_label, org_structure_label: config.org_structure_label, default_org_structure: config.org_structure_default, show_org_structure: config.show_org_structure, submit_button_label: config.submit_button_label, success_message: config.success_message, redirect_route: config.redirect_route }));
|
|
40
41
|
if (disableNavbar) {
|
|
41
42
|
return layoutContent;
|
|
42
43
|
}
|
|
@@ -4,12 +4,14 @@ export { default as ForgotPasswordPage } from "./forgot_password.js";
|
|
|
4
4
|
export { default as ResetPasswordPage } from "./reset_password.js";
|
|
5
5
|
export { default as VerifyEmailPage } from "./verify_email.js";
|
|
6
6
|
export { default as MySettingsPage } from "./my_settings.js";
|
|
7
|
+
export { CreateFirmPage } from "../page_components/create_firm.js";
|
|
7
8
|
export type { LoginPageProps } from "./login";
|
|
8
9
|
export type { RegisterPageProps } from "./register";
|
|
9
10
|
export type { ForgotPasswordPageProps } from "./forgot_password";
|
|
10
11
|
export type { ResetPasswordPageProps } from "./reset_password";
|
|
11
12
|
export type { VerifyEmailPageProps } from "./verify_email";
|
|
12
13
|
export type { MySettingsPageProps } from "./my_settings";
|
|
14
|
+
export type { CreateFirmPageProps } from "../page_components/create_firm";
|
|
13
15
|
export { default as OTPPage } from "./otp.js";
|
|
14
16
|
export type { OTPPageProps } from "./otp";
|
|
15
17
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server_pages/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server_pages/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAGhE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACpD,YAAY,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AACjE,YAAY,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC/D,YAAY,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAC3D,YAAY,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,YAAY,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,OAAO,CAAC;AAC3C,YAAY,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC"}
|
|
@@ -5,4 +5,5 @@ export { default as ForgotPasswordPage } from "./forgot_password.js";
|
|
|
5
5
|
export { default as ResetPasswordPage } from "./reset_password.js";
|
|
6
6
|
export { default as VerifyEmailPage } from "./verify_email.js";
|
|
7
7
|
export { default as MySettingsPage } from "./my_settings.js";
|
|
8
|
+
export { CreateFirmPage } from "../page_components/create_firm.js";
|
|
8
9
|
export { default as OTPPage } from "./otp.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_auth",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.8.0",
|
|
4
4
|
"description": "Zero-config authentication UI components for Next.js with RBAC, OAuth, scope-based multi-tenancy, and invitations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authentication",
|
|
@@ -264,12 +264,13 @@
|
|
|
264
264
|
"@radix-ui/react-tooltip": "^1.2.0",
|
|
265
265
|
"hazo_api": "^2.5.1",
|
|
266
266
|
"hazo_config": "^2.4.1",
|
|
267
|
-
"hazo_connect": "^3.9.
|
|
267
|
+
"hazo_connect": "^3.9.2",
|
|
268
268
|
"hazo_core": "^1.2.1",
|
|
269
269
|
"hazo_logs": "^2.1.1",
|
|
270
270
|
"hazo_notify": "^6.1.4",
|
|
271
271
|
"hazo_secure": "^1.3.0",
|
|
272
|
-
"
|
|
272
|
+
"hazo_theme": "^0.2.0",
|
|
273
|
+
"hazo_ui": "^4.10.0",
|
|
273
274
|
"input-otp": "^1.4.0",
|
|
274
275
|
"lucide-react": "^0.553.0",
|
|
275
276
|
"next": "^14.0.0",
|
|
@@ -301,6 +302,9 @@
|
|
|
301
302
|
"hazo_secure": {
|
|
302
303
|
"optional": true
|
|
303
304
|
},
|
|
305
|
+
"hazo_theme": {
|
|
306
|
+
"optional": true
|
|
307
|
+
},
|
|
304
308
|
"hazo_ui": {
|
|
305
309
|
"optional": true
|
|
306
310
|
},
|
|
@@ -404,11 +408,12 @@
|
|
|
404
408
|
"eslint-plugin-storybook": "^10.0.6",
|
|
405
409
|
"hazo_api": "^2.5.1",
|
|
406
410
|
"hazo_config": "^2.4.1",
|
|
407
|
-
"hazo_connect": "^3.9.
|
|
411
|
+
"hazo_connect": "^3.9.2",
|
|
408
412
|
"hazo_core": "^1.2.1",
|
|
409
413
|
"hazo_logs": "^2.1.1",
|
|
410
414
|
"hazo_notify": "^6.1.4",
|
|
411
|
-
"
|
|
415
|
+
"hazo_theme": "^0.2.0",
|
|
416
|
+
"hazo_ui": "^4.10.0",
|
|
412
417
|
"input-otp": "^1.4.0",
|
|
413
418
|
"jest": "^30.2.0",
|
|
414
419
|
"jest-environment-jsdom": "^30.0.0",
|