🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@kindraos/ui

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@kindraos/ui - npm Package Compare versions

Comparing version
0.1.2
to
0.2.0
+9
src/components/SupportChat/index.ts
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.

@@ -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` |

// 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