@kindraos/ui
Advanced tools
| export { | ||
| SupportChat, | ||
| default, | ||
| } from "./SupportChat"; | ||
| export type { | ||
| SupportChatProps, | ||
| SupportChatSubApp, | ||
| SupportChatUser, | ||
| } from "./SupportChat"; |
| "use client"; | ||
| import { useEffect, useRef } from "react"; | ||
| declare global { | ||
| interface Window { | ||
| chatwootSDK?: { | ||
| run: (config: { websiteToken: string; baseUrl: string }) => void; | ||
| }; | ||
| $chatwoot?: { | ||
| setUser: ( | ||
| identifier: string, | ||
| attrs: { email?: string; name?: string }, | ||
| ) => void; | ||
| setCustomAttributes: (attrs: Record<string, string>) => void; | ||
| reset: () => void; | ||
| }; | ||
| __kindraSupportChatLoaded?: boolean; | ||
| } | ||
| } | ||
| export type SupportChatUser = { | ||
| id: string; | ||
| email: string; | ||
| name: string; | ||
| role?: string; | ||
| }; | ||
| export type SupportChatSubApp = "landing" | "bookkeeper" | "crm" | "website"; | ||
| export type SupportChatProps = { | ||
| websiteToken: string; | ||
| baseUrl: string; | ||
| subApp: SupportChatSubApp; | ||
| user?: SupportChatUser; | ||
| businessId?: string; | ||
| pathname?: string; | ||
| }; | ||
| function buildCustomAttributes( | ||
| subApp: SupportChatSubApp, | ||
| user: SupportChatUser | undefined, | ||
| businessId: string | undefined, | ||
| pathname: string | undefined, | ||
| ): Record<string, string> { | ||
| const attrs: Record<string, string> = { sub_app: subApp }; | ||
| if (businessId) attrs.business_id = businessId; | ||
| if (pathname) attrs.current_path = pathname; | ||
| if (user?.role) attrs.user_role = user.role; | ||
| return attrs; | ||
| } | ||
| export function SupportChat({ | ||
| websiteToken, | ||
| baseUrl, | ||
| subApp, | ||
| user, | ||
| businessId, | ||
| pathname, | ||
| }: SupportChatProps) { | ||
| const readyListenerAttached = useRef(false); | ||
| const lastIdentityRef = useRef<string | null>(null); | ||
| useEffect(() => { | ||
| if (typeof window === "undefined") return; | ||
| if (!websiteToken || !baseUrl) return; | ||
| const applyIdentity = () => { | ||
| if (!window.$chatwoot) return; | ||
| if (user) { | ||
| const identityKey = `${user.id}|${user.email}|${user.name}`; | ||
| if (lastIdentityRef.current !== identityKey) { | ||
| window.$chatwoot.setUser(user.id, { | ||
| email: user.email, | ||
| name: user.name, | ||
| }); | ||
| lastIdentityRef.current = identityKey; | ||
| } | ||
| } | ||
| window.$chatwoot.setCustomAttributes( | ||
| buildCustomAttributes(subApp, user, businessId, pathname), | ||
| ); | ||
| }; | ||
| if (window.$chatwoot) { | ||
| applyIdentity(); | ||
| } else if (!readyListenerAttached.current) { | ||
| readyListenerAttached.current = true; | ||
| window.addEventListener("chatwoot:ready", applyIdentity); | ||
| } | ||
| if (window.__kindraSupportChatLoaded) { | ||
| return () => { | ||
| window.removeEventListener("chatwoot:ready", applyIdentity); | ||
| }; | ||
| } | ||
| window.__kindraSupportChatLoaded = true; | ||
| const script = document.createElement("script"); | ||
| script.src = `${baseUrl.replace(/\/$/, "")}/packs/js/sdk.js`; | ||
| script.async = true; | ||
| script.defer = true; | ||
| script.onload = () => { | ||
| if (window.chatwootSDK) { | ||
| window.chatwootSDK.run({ websiteToken, baseUrl }); | ||
| } | ||
| }; | ||
| document.body.appendChild(script); | ||
| return () => { | ||
| window.removeEventListener("chatwoot:ready", applyIdentity); | ||
| }; | ||
| }, [ | ||
| websiteToken, | ||
| baseUrl, | ||
| subApp, | ||
| user?.id, | ||
| user?.email, | ||
| user?.name, | ||
| user?.role, | ||
| businessId, | ||
| pathname, | ||
| ]); | ||
| return null; | ||
| } | ||
| export default SupportChat; |
+1
-1
| { | ||
| "name": "@kindraos/ui", | ||
| "version": "0.1.2", | ||
| "version": "0.2.0", | ||
| "main": "./src/index.ts", | ||
@@ -5,0 +5,0 @@ "types": "./src/index.ts", |
@@ -343,6 +343,3 @@ # Prompt: Build the Kindra Dashboard Shell at `dashboard.kindraos.com` | ||
| | `greenforest_current_business` | Selected business UUID | BusinessSwitcher in any app | All apps + shell | | ||
| | `greenforest_access_token` | Owner JWT (impersonation flow) | Backoffice impersonation handler | Owner `AuthProvider` in any sub-app | | ||
| | `greenforest_role_id` | Active role ID for `X-Role` header | Auth flow | API client across all apps | | ||
| | `greenforest_impersonated` | `"true"` while owner session is impersonated | Backoffice impersonation handler | Every sub-app's auth provider | | ||
| | `greenforest_impersonation_*` | Staff fallback tokens during impersonation | Backoffice impersonation handler | End-impersonate handler | | ||
@@ -349,0 +346,0 @@ Sub-app-private state can use any namespace (`payroll_*`, `crm_*`, etc.), but **anything cross-app keeps the `greenforest_*` prefix**. Don't introduce a `kindra_*` prefix — it would silently fork the shared state. |
+22
-29
@@ -80,3 +80,3 @@ # Kindra Sub-App Playbook | ||
| | Root redirect | none | `/` → `/<role>/login` | | ||
| | Auth provider | `<AuthProvider>` (Clerk + impersonation) | `<StaffAuthProvider role="…">` (JWT) | | ||
| | Auth provider | `<AuthProvider>` (Clerk) | `<StaffAuthProvider role="…">` (JWT) | | ||
| | `NEXT_PUBLIC_API_URL` | `/<slug>/api/v1` | `/api/v1` | | ||
@@ -111,3 +111,3 @@ | `API_BASE_URL` (server) | `https://api.kindraos.com/<slug>` | same | | ||
| **Implementation:** [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) — wraps Clerk's `useUser`/`useAuth`, attaches the token via `apiClient.setTokenGetter(() => getToken())`, calls `authApi.getMe()` (with retry — Clerk webhook → Django provisioning is eventually consistent), exposes `user`, `isAuthenticated`, `signOut`, and impersonation state. | ||
| **Implementation:** [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) — wraps Clerk's `useUser`/`useAuth`, attaches the token via `apiClient.setTokenGetter(() => getToken())`, calls `authApi.getMe()` (with retry — Clerk webhook → Django provisioning is eventually consistent), exposes `user`, `isAuthenticated`, and `signOut`. | ||
@@ -150,4 +150,4 @@ **Why a *manual* redirect in middleware** (not `auth.protect()`): in the multi-zone proxy setup, `auth.protect()` constructs the redirect URL using the sub-app's internal Vercel host (e.g. `bookkeeper-frontend-priyoinc.vercel.app`) instead of `dashboard.kindraos.com`, causing a sign-in loop. Read [`middleware.ts:32-52`](../../middleware.ts) — copy the manual redirect verbatim. | ||
| // localStorage keys are PREFIXED `greenforest_*` for backward compat with the | ||
| // bookkeeper rebrand. Cross-app shared state (impersonation, current business) | ||
| // MUST use this prefix. New sub-apps may use their own slug for private keys. | ||
| // bookkeeper rebrand. Cross-app shared state (current business) MUST use this | ||
| // prefix. New sub-apps may use their own slug for private keys. | ||
| accountant: { | ||
@@ -238,3 +238,3 @@ access: "greenforest_accountant_access_token", | ||
| - `POST /auth/validate-invitation?token=<token>` → `{ is_valid, email, user_type }` | ||
| - `POST /auth/accept-invitation` body `{ token, password, first_name, last_name }` → `{ access, user, is_impersonation }` | ||
| - `POST /auth/accept-invitation` body `{ token, password, first_name, last_name }` → `{ access, user }` | ||
@@ -256,6 +256,12 @@ **Routing logic post-accept** (replicate exactly): | ||
| ### 5d. Impersonation (staff → owner) | ||
| ### 5d. Accountant business-selection flow (no token swap) | ||
| Lets an accountant or supervisor view the platform as a specific owner, with a fallback path to restore the staff session. Implementation lives in [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) (owner-side) and [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) (staff-side, `handleImpersonate`). | ||
| Accountants and supervisors do **not** swap tokens. They keep using their own staff JWT throughout and pick a business to operate on. Implementation lives in [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) (staff-side). | ||
| **Flow:** | ||
| 1. Staff signs in via `POST /auth/staff/login` and lands at `/accountant/business` — a searchable selector backed by `GET /businesses/search/all`. | ||
| 2. Picking a business routes to `/accountant/business/<id>/overview`. Every owner page is mirrored beneath `/accountant/business/<id>/*` (except Agent and non-bookkeeper sub-app placeholders) using the same view components from `components/views/*-view.tsx`. | ||
| 3. The accountant zone calls `apiClient.setBusinessId(<id>)` so every API call carries `X-Business-ID: <id>` alongside the staff JWT. | ||
| 4. Writes (journal entries, invoices, etc.) record `created_by` as the staff user — there is no owner-acting-as. | ||
| **localStorage keys involved** (all `greenforest_*` for cross-app sharing): | ||
@@ -265,18 +271,7 @@ | ||
| |---|---|---| | ||
| | `greenforest_access_token` | staff impersonating | Owner's JWT (read by owner `AuthProvider`) | | ||
| | `greenforest_role_id` | staff impersonating | Owner's role ID for `X-Role` header | | ||
| | `greenforest_impersonated` | staff impersonating | Flag (`"true"`) — owner provider checks this before falling back to Clerk | | ||
| | `greenforest_impersonation_access` | staff impersonating | Staff's original token (fallback if end-impersonate API fails) | | ||
| | `greenforest_impersonation_role_id` | staff impersonating | Staff's original role ID | | ||
| | `greenforest_impersonation_role` | staff impersonating | Staff's role (`"accountant"` or `"accountant_supervisor"`) | | ||
| | `greenforest_current_business` | Business selector | Selected business UUID | | ||
| | `greenforest_role_id` | Auth flow | Role ID for `X-Role` header | | ||
| **Token swap pattern:** | ||
| 1. Staff calls `POST /users/impersonate { email }` → backend returns owner JWT. | ||
| 2. Staff stashes their own token in `greenforest_impersonation_*` keys. | ||
| 3. Staff writes owner JWT to `greenforest_access_token` and sets `greenforest_impersonated="true"`. | ||
| 4. Hard-redirects to `/overview` (owner dashboard). | ||
| 5. Owner `AuthProvider` boots, sees the flag, uses the stored token instead of Clerk. | ||
| No owner-token or "viewing as" flag is needed — the accountant zone is a distinct set of routes with its own layout chrome. | ||
| **End-impersonation:** `POST /users/end-impersonate` returns a fresh staff JWT. Restore staff token to its role-specific key, clear all `greenforest_impersonation_*` and `greenforest_impersonated`, redirect back to staff dashboard. If the end API fails, fall back to `greenforest_impersonation_access`. | ||
| --- | ||
@@ -399,3 +394,3 @@ | ||
| ### Step 6 — `lib/hooks/use-auth.tsx` (Clerk + impersonation) | ||
| ### Step 6 — `lib/hooks/use-auth.tsx` (Clerk) | ||
@@ -406,3 +401,3 @@ Copy [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx). Adjust only: | ||
| The impersonation logic depends on the `greenforest_*` localStorage keys (see § 5d). **Do not rename these** unless you also coordinate with every other Kindra sub-app — they share state across the parent domain. | ||
| Cross-app state (e.g. current business) reads/writes the `greenforest_*` localStorage keys (see § 5d). **Do not rename these** unless you also coordinate with every other Kindra sub-app — they share state across the parent domain. | ||
@@ -434,3 +429,2 @@ ### Step 7 — `lib/hooks/use-staff-auth.tsx` (JWT) | ||
| import { UserNav } from "@/components/layout/user-nav"; | ||
| import { ImpersonationBanner } from "@/components/layout/impersonation-banner"; | ||
@@ -450,3 +444,2 @@ export default function DashboardLayout({ children }: { children: React.ReactNode }) { | ||
| topBarRight={<UserNav />} | ||
| banner={<ImpersonationBanner />} | ||
| > | ||
@@ -459,3 +452,3 @@ {children} | ||
| Reference: [`app/(dashboard)/layout.tsx`](../../app/(dashboard)/layout.tsx). The `BusinessSwitcher`, `UserNav`, and `ImpersonationBanner` are app-specific adapters that wrap shared `@kindraos/ui` primitives — keep that boundary, don't push business logic into the package. | ||
| Reference: [`app/(dashboard)/layout.tsx`](../../app/(dashboard)/layout.tsx). `BusinessSwitcher` and `UserNav` are app-specific adapters that wrap shared `@kindraos/ui` primitives — keep that boundary, don't push business logic into the package. | ||
@@ -538,3 +531,3 @@ ### Step 10 — Auth pages | ||
| 4. **The accept-invitation page works on both surfaces.** Don't gate it by deployment target. | ||
| 5. **Cross-tenant impersonation flows** through `greenforest_impersonation_*` localStorage keys (§ 5d). For these to work across backoffice → Kindra builds (e.g. accountant on `bookkeeper.kindraspace.com` impersonates an owner who lands on `dashboard.kindraos.com`), the impersonation pivot is a hard redirect to `https://dashboard.kindraos.com/<slug>/overview` — localStorage doesn't cross domains, so the backend issues a one-time owner JWT in the impersonation response and the redirect carries the session via that JWT. | ||
| 5. **Cross-tenant staff workflows stay on their own domain.** An accountant on `bookkeeper.kindraspace.com` operates on owner businesses **in place** under the `/accountant/business/<id>/*` mirror routes using their own staff JWT plus `X-Business-ID` — there is no cross-domain hand-off and no token swap. `localStorage` is per-origin, but the accountant zone doesn't need it to cross domains. | ||
@@ -613,3 +606,3 @@ --- | ||
| 5. **Backoffice builds must keep Clerk env vars present** even though the runtime never invokes Clerk — the SDK reads them at module-load. | ||
| 6. **Cross-app shared localStorage uses `greenforest_*` keys**, not `kindra_*` (the rebrand kept the keys unchanged for backward compat). New sub-apps reading impersonation or current-business state MUST use these exact key names. | ||
| 6. **Cross-app shared localStorage uses `greenforest_*` keys**, not `kindra_*` (the rebrand kept the keys unchanged for backward compat). New sub-apps reading current-business state MUST use these exact key names. | ||
| 7. **Sub-app slugs must be added to the shell** in two places: `kindra-dashboard/next.config.js` rewrites AND `kindra-dashboard/middleware.ts` `isSubAppRoute` matcher. Forgetting the matcher means Clerk middleware tries to protect the sub-app's routes and fails on the proxied host. | ||
@@ -667,3 +660,3 @@ 8. **Use `auth()` + manual redirect in middleware**, not `auth.protect()`. Auto-redirect targets the wrong host inside the proxy. See § 5a. | ||
| | [`lib/api/client.ts`](../../lib/api/client.ts) | `tokenGetter` (Clerk) + `accessToken` (JWT) duality | | ||
| | [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) | Clerk auth + impersonation pivot | | ||
| | [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) | Clerk auth | | ||
| | [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) | Reference impl for the staff JWT provider (generalize this) | | ||
@@ -670,0 +663,0 @@ | [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx) | Nav config + `filterNavByRole` | |
+6
-0
| // Components | ||
| export { PlatformLayout } from "./components/PlatformLayout/PlatformLayout"; | ||
| export { BusinessSwitcher } from "./components/BusinessSwitcher/BusinessSwitcher"; | ||
| export { SupportChat } from "./components/SupportChat"; | ||
| export type { | ||
| SupportChatProps, | ||
| SupportChatSubApp, | ||
| SupportChatUser, | ||
| } from "./components/SupportChat"; | ||
@@ -5,0 +11,0 @@ // Hooks |
123185
1.8%33
6.45%1677
8.26%