Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@kindraos/ui

Package Overview
Dependencies
Maintainers
1
Versions
3
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.0
to
0.1.1
+49
CLAUDE.md
# `@kindraos/ui` — Package Author Notes
This is the shared layout package consumed by every Kindra app (shell + sub-apps + standalone backoffice builds). When you're editing files in this folder, you're changing UX for **every** consumer simultaneously.
## Long-form docs
| Read this when… | File |
|---|---|
| You need the public component / hook / type API | [`prompt.md`](./prompt.md) |
| You're building or touching the shell app (`kindra-dashboard`) | [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) |
| You're bootstrapping a new Kindra sub-app from scratch | [`prompt-new-subapp.md`](./prompt-new-subapp.md) |
## Hard rules for this package
The reason these exist: `@kindraos/ui` ships into many different runtime environments (Clerk-protected, JWT-protected, no-auth marketing) and across multiple Next.js versions. Anything app-specific that leaks into the package immediately breaks at least one consumer.
1. **No Clerk imports.** Auth UI flows in via the `topBarRight` and `banner` slots from the consumer.
2. **No `next/navigation` imports.** The current pathname comes in as a `pathname` prop.
3. **No `next/link` imports.** The link component comes in as the `linkComponent` prop; default is plain `<a>`.
4. **No `lucide-react` imports.** Icons come in as `ReactNode`. Inline SVGs only for things the package owns (chevron, menu, close).
5. **No app-specific terms.** Don't reference "bookkeeping", "payroll", "accountant", or any product slug — keep names generic (`appId`, `appName`).
6. **No business logic.** Filtering nav by role, fetching businesses, deciding when to impersonate — all of that lives in the consumer.
7. **Shadcn primitives stay internal.** `primitives/` is not re-exported; consumers have their own shadcn copies and double-imports cause Tailwind class collisions.
8. **CSS variables for theming.** Apps override `--kindra-*` in their own `globals.css`; never hard-code consumer-facing colors.
## After making changes
1. Bump `version` in [`package.json`](./package.json) — the publish workflow refuses to re-publish the same version.
2. Push to `main`. [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) auto-publishes on changes inside `packages/ui/**`.
3. Verify in a consumer: `npm install @kindraos/ui@latest` and rebuild. The shell at `../kindra-dashboard` and the bookkeeper (this repo's app at `app/`) should both build cleanly — if either breaks, the package change is leaking app-specific behavior or assumptions.
## Package layout
```
src/
├── index.ts # Public exports (PlatformLayout, BusinessSwitcher, hooks, types)
├── types/index.ts # NavItem, AppConfig, PlatformLayoutProps, …
├── hooks/use-sidebar-state.tsx # SidebarProvider + useSidebarState
├── lib/
│ ├── cn.ts # clsx + tailwind-merge
│ ├── nav-utils.ts # isLinkActive, isGroupActive
│ └── icons.tsx # Inline SVGs (chevron, menu, X)
├── primitives/ # Internal shadcn copies — NOT exported
│ ├── scroll-area.tsx, tooltip.tsx, collapsible.tsx, sheet.tsx, button.tsx, skeleton.tsx
├── components/PlatformLayout/ # The main layout
│ ├── PlatformLayout.tsx, Sidebar.tsx, SidebarNav.tsx, SidebarItem.tsx,
│ ├── TopBar.tsx, MobileDrawer.tsx
└── styles/tokens.css # CSS custom properties (--kindra-*)
```
# Kindra Sub-App Playbook
> Read this end-to-end before bootstrapping a new sub-app, or jump to a section by anchor when you know what you need.
This is the canonical instruction set for building a new Kindra sub-app — the kind of app that lives at `dashboard.kindraos.com/<slug>` for clients (owner/manager via Clerk) and at `<slug>.kindraspace.com` for backoffice staff (email + password JWT). Every existing pattern referenced here is implemented in **greenForestAdmin** (the bookkeeper sub-app); when in doubt, read the file linked in the section.
The companion docs in this folder are still authoritative for their narrower topics:
- [`prompt.md`](./prompt.md) — `@kindraos/ui` component API (props, slots, types)
- [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) — shell-side setup (the `kindra-dashboard` repo)
---
## 1. Overview
The Kindra platform is a **Multi-Zone Next.js architecture**. One shell app at `dashboard.kindraos.com` proxies sub-apps via `next.config.js` rewrites; each sub-app is an independent Next.js deployment with its own `basePath`. The same sub-app codebase is built **twice** — once for the client zone (Clerk auth, mounted in the shell) and once standalone for backoffice staff (JWT auth, served at `<slug>.kindraspace.com`). One env var (`NEXT_PUBLIC_DEPLOYMENT_TARGET`) flips the build between the two surfaces.
```
kindraos.com (marketing landing)
┌───────────────────────────────────────────────────────────────────┐
│ dashboard.kindraos.com ← shell app (kindra-dashboard, port 3000)
│ ───────────────────── │
│ / Clerk-protected platform overview │
│ /bookkeeper/* ◀─── rewrite ──▶ bookkeeper sub-app (port 3001) │
│ /payroll/* ◀─── rewrite ──▶ payroll sub-app (port 3002) │
│ /<slug>/* ◀─── rewrite ──▶ any sub-app (port 30NN) │
│ /api/v1/* ◀─── rewrite ──▶ api.kindraos.com │
└───────────────────────────────────────────────────────────────────┘
kindraspace.com (backoffice domain)
┌───────────────────────────────────────────────────────────────────┐
│ bookkeeper.kindraspace.com ← same sub-app, NEXT_PUBLIC_DEPLOYMENT_TARGET=accountant
│ payroll.kindraspace.com ← same payroll sub-app, target=payroll-admin
│ <slug>.kindraspace.com ← same <slug> sub-app, target=<role-slug>
│ │
│ Auth: email + password JWT, no Clerk middleware on this build │
└───────────────────────────────────────────────────────────────────┘
```
---
## 2. Domain & Routing Topology
| Domain | Purpose | Auth | Repo |
|---|---|---|---|
| `kindraos.com` | Marketing landing | none | `kindra/kindraos-landing` |
| `dashboard.kindraos.com` | Shell + sub-app proxy | Clerk (owner/manager) | `kindra-dashboard` |
| `dashboard.kindraos.com/<slug>` | Sub-app via Multi Zone | Clerk (inherited from shell) | sub-app repo |
| `<slug>.kindraspace.com` | Standalone backoffice | Email+password JWT | same sub-app repo, different build |
| `api.kindraos.com` | Django Ninja backend | Bearer token | `priyo-inc-financial-management-system-backend` |
**Local dev port assignments** (keep this list authoritative — append your slug):
| Port | App |
|------|-----|
| 3000 | `kindra-dashboard` (shell) |
| 3001 | `greenForestAdmin` (bookkeeper) |
| 3002 | payroll *(reserved)* |
| 3003 | tax *(reserved)* |
| 3004 | crm *(reserved)* |
| 3005 | marketing *(reserved)* |
| 3006 | website *(in `kindra-website-frontend`)* |
| 3007+ | next free for new sub-apps |
---
## 3. The Two-Build Pattern
A single sub-app codebase produces two deployments by branching on `NEXT_PUBLIC_DEPLOYMENT_TARGET`. Canonical implementation: [`next.config.js`](../../next.config.js) and [`middleware.ts`](../../middleware.ts).
| Concern | Kindra build (`NEXT_PUBLIC_DEPLOYMENT_TARGET` unset) | Backoffice build (`NEXT_PUBLIC_DEPLOYMENT_TARGET=<role>`) |
|---|---|---|
| Domain | `dashboard.kindraos.com/<slug>` | `<slug>.kindraspace.com` |
| `basePath` | `/<slug>` | `''` (empty) |
| Middleware | Clerk (with manual redirect — see § 5a) | No-op pass-through |
| Root redirect | none | `/` → `/<role>/login` |
| Auth provider | `<AuthProvider>` (Clerk + impersonation) | `<StaffAuthProvider role="…">` (JWT) |
| `NEXT_PUBLIC_API_URL` | `/<slug>/api/v1` | `/api/v1` |
| `API_BASE_URL` (server) | `https://api.kindraos.com/<slug>` | same |
| Clerk env keys | required and used | required for build, runtime-skipped |
**Rule:** `next.config.js` and `middleware.ts` MUST branch together on the same env var. Drift between the two is the #1 source of sign-in loops.
---
## 4. Shell App Multi Zones — Consumer-Side Rules
The shell (`kindra-dashboard`) owns rewrites and the parent domain. Inside the sub-app you only need to obey four rules:
1. **`basePath` is automatic.** Set it once in `next.config.js`. After that, `next/link`, `next/navigation`'s `useRouter()`, and asset URLs auto-prepend `/<slug>`. Never hardcode the prefix.
2. **`usePathname()` returns paths *without* basePath** (e.g. `/accounts`, not `/<slug>/accounts`). Pass it straight into `<PlatformLayout pathname={...}>` — `@kindraos/ui` matches against unprefixed paths.
3. **Intra-app nav uses `href`**, cross-app nav uses `externalHref`. The latter renders as `<a>` (full page navigation between zones); the former uses your `linkComponent` (soft nav). Cross-app paths are absolute and **not** basePath-prefixed (`/payroll`, not `/payroll/...` with a leading slug). The shell owns the parent domain.
4. **Role filtering happens in the consumer**, never inside `@kindraos/ui`. Wrap your nav in `filterNavByRole(sections, user.user_type)` before passing it to `<PlatformLayout>`. See [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx).
Shell-side wiring (rewrites, port env vars, `isSubAppRoute` matcher) is documented in [`prompt-kindra-shell.md`](./prompt-kindra-shell.md). When you ship a new sub-app, you must update both the shell's `next.config.js` rewrites and its middleware route matcher (§ 6 step 12).
---
## 5. Dual Authentication Contract
Two completely separate auth systems coexist in one codebase. They never run at the same time on the same deployment.
### 5a. Clerk (owner / manager) — `*.kindraos.com` only
Used by client-facing builds. Cookie domain in Clerk is set to `.kindraos.com` so the session is shared across `kindraos.com`, `dashboard.kindraos.com`, and every sub-app served beneath the shell.
**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.
**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.
**Required env (Kindra build):**
```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/select-role
```
**Post-sign-up flow:** Clerk redirects to `/select-role` → user picks `owner` or `manager` → frontend calls `authApi.setRole({ user_type })` → Django persists → redirect to `/`. The `/select-role` page is part of the Kindra build only.
### 5b. JWT staff login (backoffice) — `*.kindraspace.com` only
Used by backoffice builds. No Clerk on this surface; middleware is a no-op pass-through.
**Implementation pattern:** [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) is the reference. For a new sub-app, generalize this into `lib/hooks/use-staff-auth.tsx` with the staff role passed as a prop.
**Generalized template** (drop into a new sub-app, replace `accountant` examples with your role slugs):
```tsx
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiClient, ApiError } from "@/lib/api/client";
import { authApi } from "@/lib/api/auth";
import type { User } from "@/lib/types";
import { toast } from "sonner";
type StaffRole = "accountant" | "accountant_supervisor" | "payroll_admin" | "<role>";
const STORAGE_KEYS: Record<StaffRole, { access: string; role: string }> = {
// 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.
accountant: {
access: "greenforest_accountant_access_token",
role: "greenforest_role_id",
},
accountant_supervisor: {
access: "greenforest_supervisor_access_token",
role: "greenforest_supervisor_role_id",
},
// Add new staff roles here:
payroll_admin: {
access: "greenforest_payroll_admin_access_token",
role: "greenforest_payroll_admin_role_id",
},
};
export function StaffAuthProvider({ children, role }: { children: React.ReactNode; role: StaffRole }) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const keys = STORAGE_KEYS[role];
const loginPath = `/${role.replace("_", "-")}/login`;
const clearAuth = useCallback(() => {
localStorage.removeItem(keys.access);
localStorage.removeItem(keys.role);
apiClient.setAccessToken(null);
apiClient.setTokenGetter(null);
apiClient.setRoleId(null);
}, [keys]);
useEffect(() => {
const init = async () => {
const token = localStorage.getItem(keys.access);
const roleId = localStorage.getItem(keys.role);
if (!token) { router.push(loginPath); return; }
apiClient.setTokenGetter(null); // staff = static token, not Clerk
apiClient.setAccessToken(token);
if (roleId) apiClient.setRoleId(roleId);
try {
const me = await authApi.getMe();
if (me.user_type !== role) { clearAuth(); router.push(loginPath); return; }
setUser(me);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
toast.error("Session expired. Please sign in again.");
}
clearAuth();
router.push(loginPath);
} finally {
setIsLoading(false);
}
};
init();
}, [router, keys, role, loginPath, clearAuth]);
const logout = useCallback(async () => {
clearAuth();
window.location.href = "/"; // backoffice build redirects "/" → /<role>/login
}, [clearAuth]);
// Provider value omitted for brevity — see use-accountant-auth.tsx for full impl
}
```
**Login endpoint contract:**
- `POST /auth/staff/login` with `{ email, password }` returns `{ access: string; user: User }`.
- Token has no refresh; on 401 the user is redirected to the role-specific login page.
**Login pages live at** `/<role>/login` (e.g. `/accountant/login`, `/payroll-admin/login`). Each role gets its own page because branding/messaging may differ. See [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) as the reference.
### 5c. Invitation flow (admin → staff)
Backoffice users never self-register. An admin invites them via the backend, which emails a link of the form:
```
https://<domain>/accept-invitation?token=<jwt>
```
The accept-invitation page is **dual-purpose** (works for both Kindra and backoffice builds — the public route bypasses both auth systems). See [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx).
**Endpoints:**
- `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 }`
**Routing logic post-accept** (replicate exactly):
```ts
if (user.user_type === "owner" || user.user_type === "manager") {
// Owner/manager will sign in via Clerk separately — invite only validates the
// backend account. Don't store any JWT for them; redirect to Clerk sign-in.
router.push("/sign-in");
} else {
// Staff: store JWT under the role-specific key, redirect to their dashboard.
const keys = STAFF_STORAGE_KEYS[user.user_type];
localStorage.setItem(keys.access, response.access);
router.push(`/${user.user_type.replace("_", "-")}/dashboard`);
}
```
### 5d. Impersonation (staff → owner)
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`).
**localStorage keys involved** (all `greenforest_*` for cross-app sharing):
| Key | Set by | Purpose |
|---|---|---|
| `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"`) |
**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.
**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`.
---
## 6. Bootstrapping a New Sub-App — 12 Steps
Follow these in order. Each step references the canonical file in this repo to copy from.
### Step 1 — Create the project
```bash
cd ~/ # sibling to greenForestAdmin
npx create-next-app@latest <slug>-frontend \
--typescript --tailwind --app --src-dir=false --eslint --import-alias="@/*"
cd <slug>-frontend
```
### Step 2 — Install dependencies
```bash
npm i @kindraos/ui@file:../greenForestAdmin/packages/ui # local during dev
# or in CI / Vercel: npm i @kindraos/ui (pulls from NPM)
npm i @clerk/nextjs @tanstack/react-query react-hook-form zod sonner lucide-react
npm i -D @types/node
```
### Step 3 — `next.config.js`
Replace the generated file with this template (substitute `<slug>` and your role slugs):
```js
/** @type {import('next').NextConfig} */
const STAFF_TARGETS = ["<role-1>", "<role-2>"]; // e.g. ["accountant", "accountant-supervisor"]
const isBackoffice = STAFF_TARGETS.includes(process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET);
const nextConfig = {
basePath: isBackoffice ? '' : '/<slug>',
reactStrictMode: true,
transpilePackages: ["@kindraos/ui"],
async rewrites() {
const apiBase = process.env.API_BASE_URL || 'http://localhost:8001/<slug>';
return [
{ source: '/api/v1/:path*', destination: `${apiBase}/api/v1/:path*` },
];
},
async redirects() {
if (isBackoffice) {
const role = process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET;
const loginPath = `/${role}/login`;
return [
{ source: '/', destination: loginPath, permanent: false },
{ source: '/<slug>/:path*', destination: loginPath, permanent: false },
{ source: '/sign-in', destination: loginPath, permanent: false },
{ source: '/sign-up', destination: loginPath, permanent: false },
{ source: '/login', destination: loginPath, permanent: false },
{ source: '/register', destination: loginPath, permanent: false },
];
}
return [
{ source: '/login', destination: '/sign-in', permanent: true },
{ source: '/register', destination: '/sign-up', permanent: true },
];
},
};
module.exports = nextConfig;
```
Reference: [`next.config.js`](../../next.config.js).
### Step 4 — `middleware.ts`
```ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse, type NextRequest } from "next/server";
const STAFF_TARGETS = ["<role-1>", "<role-2>"];
const isBackoffice = STAFF_TARGETS.includes(process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET ?? "");
const isPublicRoute = createRouteMatcher([
"/sign-in(.*)", "/sign-up(.*)", "/accept-invitation(.*)",
"/select-role(.*)", "/login", "/register",
]);
const isStaffRoute = createRouteMatcher([
"/<role-1>(.*)", "/<role-2>(.*)", "/staff(.*)",
]);
const backofficeMiddleware = (_req: NextRequest) => NextResponse.next();
const kindraMiddleware = clerkMiddleware(async (auth, req) => {
if (isPublicRoute(req) || isStaffRoute(req)) return;
const { userId } = await auth();
if (!userId) {
const signInUrl = new URL(
process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL || "/sign-in",
process.env.NEXT_PUBLIC_APP_URL || req.nextUrl.origin
);
const returnPath = req.nextUrl.basePath + req.nextUrl.pathname + req.nextUrl.search;
const returnUrl = new URL(returnPath, process.env.NEXT_PUBLIC_APP_URL || req.nextUrl.origin);
signInUrl.searchParams.set("redirect_url", returnUrl.toString());
return NextResponse.redirect(signInUrl);
}
});
export default isBackoffice ? backofficeMiddleware : kindraMiddleware;
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|icon.svg).*)"],
};
```
Reference: [`middleware.ts`](../../middleware.ts).
### Step 5 — `lib/api/client.ts`
Copy [`lib/api/client.ts`](../../lib/api/client.ts) verbatim. The `tokenGetter` (Clerk async) + `accessToken` (static JWT) duality is the linchpin of the dual-auth contract — don't reimplement.
### Step 6 — `lib/hooks/use-auth.tsx` (Clerk + impersonation)
Copy [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx). Adjust only:
- The `getMe` retry shape if your API differs.
- The post-`signOut` redirect URL if your sub-app should land somewhere other than `kindraos.com`.
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.
### Step 7 — `lib/hooks/use-staff-auth.tsx` (JWT)
Use the generalized template in § 5b. Define your `StaffRole` union and `STORAGE_KEYS` map for the role(s) this sub-app exposes.
### Step 8 — `lib/constants/platform-nav.tsx`
Copy [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx) and edit the nav items. Two rules:
- **Intra-app entries** use `href` (relative path, basePath auto-prepended).
- **Cross-app entries** use `externalHref` with absolute paths matching shell rewrites (`/bookkeeper`, `/payroll`, `/<slug>`).
Each item supports `allowedUserTypes?: UserType[]`. The shipped `filterNavByRole` recursively strips items the current user can't access — call it before passing nav to `<PlatformLayout>`.
### Step 9 — `app/(dashboard)/layout.tsx`
```tsx
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { PlatformLayout } from "@kindraos/ui";
import "@kindraos/ui/styles.css";
import { useAuth } from "@/lib/hooks/use-auth";
import { platformNav, bottomNav, filterNavByRole } from "@/lib/constants/platform-nav";
import { BusinessSwitcher } from "@/components/layout/business-switcher";
import { UserNav } from "@/components/layout/user-nav";
import { ImpersonationBanner } from "@/components/layout/impersonation-banner";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
return (
<PlatformLayout
app={{ appId: "<slug>", appName: "<App Name>" }}
platformNav={filterNavByRole(platformNav, user?.user_type)}
bottomNav={filterNavByRole(bottomNav, user?.user_type)}
pathname={pathname}
linkComponent={Link}
sidebarHeader={<BusinessSwitcher />}
topBarRight={<UserNav />}
banner={<ImpersonationBanner />}
>
{children}
</PlatformLayout>
);
}
```
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.
### Step 10 — Auth pages
For the **Kindra build** (client-facing), create:
```
app/(auth)/sign-in/[[...sign-in]]/page.tsx — Clerk <SignIn /> embed
app/(auth)/sign-up/[[...sign-up]]/page.tsx — Clerk <SignUp /> embed
app/select-role/page.tsx — owner/manager picker → authApi.setRole
app/(auth)/accept-invitation/page.tsx — copy from this repo verbatim
```
For the **backoffice build**, create one login page per staff role:
```
app/<role>/(auth)/login/page.tsx — email + password → authApi.staffLogin
app/<role>/(dashboard-view)/dashboard/page.tsx — landing page after login
```
Reference: [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) and [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx).
### Step 11 — Environment files
Create three `.env` files. The exact env-var matrix is in § 9.
```
.env.local — local dev (Kindra build by default)
.env.production — Kindra deploy (committed only as .env.example)
.env.prod.<role> — backoffice deploy per role
```
Vercel build env vars override these — set them per-project in the Vercel dashboard.
### Step 12 — Wire the shell
In `kindra-dashboard/next.config.js`, add a rewrite block:
```js
const subAppUrl = process.env.<SLUG>_URL || 'http://localhost:30NN';
beforeFiles: [
// …existing…
{ source: '/<slug>', destination: `${subAppUrl}/<slug>` },
{ source: '/<slug>/:path*', destination: `${subAppUrl}/<slug>/:path*` },
],
```
In `kindra-dashboard/middleware.ts`, add the slug to `isSubAppRoute`:
```ts
const isSubAppRoute = createRouteMatcher([
'/bookkeeper(.*)',
'/<slug>(.*)', // ← new
// …
]);
```
Set the new env var (`<SLUG>_URL`) in the shell's Vercel project (production) and `.env.local` (dev).
---
## 7. Backoffice Subdomain Convention (Kindraspace)
Every sub-app may expose one or more backoffice surfaces at `<sub-domain>.kindraspace.com`. Each subdomain serves a distinct staff role. Examples:
| Subdomain | Sub-app | Staff role | `NEXT_PUBLIC_DEPLOYMENT_TARGET` |
|---|---|---|---|
| `bookkeeper.kindraspace.com` | bookkeeper | accountant + accountant-supervisor (combined) | `accountant` |
| `payroll.kindraspace.com` | payroll | payroll admin | `payroll-admin` |
| `crm.kindraspace.com` | crm | CRM agent | `crm-agent` |
**Rules:**
1. **One Vercel project per backoffice deploy** — same git repo, separate project IDs, distinct env-var sets.
2. **`NEXT_PUBLIC_DEPLOYMENT_TARGET` is the role slug** — kebab-case, used as both the env value and the URL segment for the login page (`/payroll-admin/login`). The value MUST be in the `STAFF_TARGETS` array in `next.config.js` and `middleware.ts`.
3. **Clerk env vars must be present** in the Vercel project even for backoffice builds. The Clerk SDK reads them at module-load time during `next build`; absence breaks the build even though middleware is a no-op at runtime.
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.
---
## 8. GitHub Actions: Dual-Hook Deploy Pattern
Reference: [`.github/workflows/deploy-production.yml`](../../.github/workflows/deploy-production.yml).
Each sub-app gets one workflow that fires N Vercel deploy hooks in parallel — one per build target. Hooks are stored as plain URLs in the matrix (they're per-project secrets but not credentials; rotation is via Vercel UI).
**Template** (drop into `.github/workflows/deploy-production.yml`):
```yaml
name: Deploy Production
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy ${{ matrix.project }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- project: <slug> (kindraos)
hook: https://api.vercel.com/v1/integrations/deploy/prj_<KINDRA_ID>/<TOKEN>
- project: <role> portal (kindraspace)
hook: https://api.vercel.com/v1/integrations/deploy/prj_<BACKOFFICE_ID>/<TOKEN>
steps:
- name: Trigger Vercel
run: curl -fsS -X POST "${{ matrix.hook }}"
```
**Why `fail-fast: false`:** a Vercel hook failure for one target shouldn't cancel the other deploys.
**UI package publishing:** `@kindraos/ui` is published to NPM by [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) on changes inside `packages/ui/`. Sub-app repos consume it via `file:` (during dev when working on shared UI) or NPM (in CI). Always run `transpilePackages: ["@kindraos/ui"]` in your sub-app's `next.config.js`.
---
## 9. Environment Variable Matrix
| Variable | Kindra build | Backoffice build | Where used |
|---|---|---|---|
| `NEXT_PUBLIC_DEPLOYMENT_TARGET` | unset | role slug (`accountant`, `payroll-admin`, …) | `next.config.js`, `middleware.ts` |
| `NEXT_PUBLIC_API_URL` | `/<slug>/api/v1` | `/api/v1` | `lib/api/client.ts` (frontend fetches) |
| `API_BASE_URL` | `https://api.kindraos.com/<slug>` | same | server-side rewrite target |
| `NEXT_PUBLIC_APP_URL` | `https://dashboard.kindraos.com` | `https://<slug>.kindraspace.com` | Clerk redirect base, manual middleware redirect |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | required + used | required for build, runtime-skipped | Clerk SDK |
| `CLERK_SECRET_KEY` | required + used | required for build, runtime-skipped | Clerk SDK |
| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | `/sign-in` | `https://dashboard.kindraos.com/sign-in` (any value works — middleware skipped) | manual redirect URL |
| `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | `/sign-up` | unused | Clerk SDK |
| `NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL` | `/` | unused | Clerk SDK |
| `NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL` | `/select-role` | unused | Clerk SDK |
| `<SLUG>_URL` (in shell repo only) | `https://<slug>-frontend-<vercel-id>.vercel.app` | n/a | shell rewrites |
**Local dev defaults:**
- `API_BASE_URL=http://localhost:8001/<slug>` (or whatever port the Django backend listens on)
- `NEXT_PUBLIC_APP_URL=http://localhost:3000` (when running through the shell) or `http://localhost:30NN` (when running standalone)
---
## 10. Critical Rules (the "do not break" list)
Imperative checklist. Violating any of these will produce hard-to-debug breakage.
1. **Never manually prepend `basePath`** to `href` values. Next.js auto-prepends; double-prefixing produces `/bookkeeper/bookkeeper/foo`.
2. **Never put auth, business, or domain logic inside `@kindraos/ui`.** All app-specific data flows in via props and slots (`sidebarHeader`, `topBarRight`, `banner`, `linkComponent`, `pathname`, `platformNav`).
3. **Always branch `next.config.js` and `middleware.ts` together** on `NEXT_PUBLIC_DEPLOYMENT_TARGET`. Drift between the two is the root cause of sign-in loops.
4. **Always update [`.claude/docs/structure.md`](../../.claude/docs/structure.md)** when adding files or folders, per the project's CLAUDE.md.
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.
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.
8. **Use `auth()` + manual redirect in middleware**, not `auth.protect()`. Auto-redirect targets the wrong host inside the proxy. See § 5a.
9. **Cross-app links use `externalHref` with absolute paths** (`/payroll`), never with the sub-app's basePath prefix. The shell owns the parent domain.
10. **Login pages live at `/<role>/login`**, not `/login`. The plain `/login` path redirects to either `/sign-in` (Kindra build) or the role-specific login (backoffice build).
---
## 11. Verification Checklist
After bootstrapping, verify in this order:
**Local dev — Kindra build (sub-app inside shell):**
1. Start backend: `python manage.py runserver 8001` in the Django repo.
2. Start sub-app: `PORT=30NN npm run dev` in the sub-app repo.
3. Start shell: `PORT=3000 npm run dev` in `kindra-dashboard`.
4. Visit `http://localhost:3000/<slug>` → should render the sub-app inside the shell layout.
5. Sign in with a Clerk test user → land on `/<slug>/...` after sign-in.
6. Click a `href` nav item → soft nav (no full reload).
7. Click an `externalHref` nav item → full page reload.
**Local dev — backoffice build:**
1. `NEXT_PUBLIC_DEPLOYMENT_TARGET=<role> PORT=30NN npm run dev`.
2. Visit `http://localhost:30NN/` → redirects to `/<role>/login`.
3. Submit valid email + password → land on `/<role>/dashboard`.
4. Hard-refresh — session persists (token in localStorage).
5. Wait for / force a 401 → redirects to login with toast.
**API path check (both builds):**
- Kindra build: open devtools → fetch a page → confirm requests go to `/<slug>/api/v1/...` and resolve through the rewrite to `${API_BASE_URL}/api/v1/...`.
- Backoffice build: requests go to `/api/v1/...` and resolve to the same backend.
- Both: `Authorization: Bearer …` header present on every authenticated request.
**Build:**
- `npm run build` with no env → succeeds (Kindra build).
- `NEXT_PUBLIC_DEPLOYMENT_TARGET=<role> npm run build` → succeeds (backoffice build).
- Both produce the expected `basePath` (check the build manifest or `_next/static` paths).
**Cross-app sanity:**
- From inside the shell at `/<slug>/...`, `usePathname()` returns paths *without* the basePath.
- Clicking back to `/` (the shell root) goes through a full reload (cross-zone).
---
## 12. Critical Files Index
Every file referenced in this playbook, with one-line summaries:
| File | What's in it |
|---|---|
| [`next.config.js`](../../next.config.js) | `basePath` + rewrites, conditional on deployment target |
| [`middleware.ts`](../../middleware.ts) | Clerk vs no-op middleware switch + manual redirect |
| [`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-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) | Reference impl for the staff JWT provider (generalize this) |
| [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx) | Nav config + `filterNavByRole` |
| [`app/(dashboard)/layout.tsx`](../../app/(dashboard)/layout.tsx) | `<PlatformLayout>` wiring |
| [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx) | Invitation accept page (works for both builds) |
| [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) | Reference staff login page |
| [`.github/workflows/deploy-production.yml`](../../.github/workflows/deploy-production.yml) | Dual-hook Vercel deploy |
| [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) | `@kindraos/ui` NPM publish |
| [`.claude/docs/architecture.md`](../../.claude/docs/architecture.md) | Existing platform architecture (cross-app state, ports) |
| [`.claude/docs/api-client.md`](../../.claude/docs/api-client.md) | API client header/auth contract |
| [`prompt.md`](./prompt.md) | `@kindraos/ui` component API |
| [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) | Shell-side setup (rewrites, matcher) |
+1
-1
{
"name": "@kindraos/ui",
"version": "0.1.0",
"version": "0.1.1",
"main": "./src/index.ts",

@@ -5,0 +5,0 @@ "types": "./src/index.ts",

@@ -1,133 +0,97 @@

# Prompt: Create Kindra Dashboard Shell App at `dashboard.kindraos.com`
# Prompt: Build the Kindra Dashboard Shell at `dashboard.kindraos.com`
## Architecture Overview
> Read this if you're building or editing the **shell app** (the `kindra-dashboard` repo). For the broader platform topology — dual auth, dual build per sub-app, deploy contract — see [`prompt-new-subapp.md`](./prompt-new-subapp.md).
```
kindraos.com → Kindra landing page (marketing + login)
kindraos.com/login → Clerk sign-in page
kindraos.com/signup → Clerk sign-up page
kindraos.com/accounting → Product page (marketing)
## What the shell is
dashboard.kindraos.com → Dashboard shell app (NEW — this is what we're building)
dashboard.kindraos.com/ → Platform dashboard (overview across all apps)
dashboard.kindraos.com/bookkeeper/ → Bookkeeper app (greenForestAdmin, via Multi Zone)
dashboard.kindraos.com/payroll/ → Payroll app (future, via Multi Zone)
dashboard.kindraos.com/tax/ → Tax app (future, via Multi Zone)
dashboard.kindraos.com/crm/ → CRM app (future, via Multi Zone)
dashboard.kindraos.com/marketing/ → Marketing app (future, via Multi Zone)
dashboard.kindraos.com/website/ → Website builder app (future, via Multi Zone)
```
The shell is a Next.js 15 app deployed at `dashboard.kindraos.com`. It does three things:
**Key facts:**
- `kindraos.com` and `dashboard.kindraos.com` are different subdomains but share `.kindraos.com` — Clerk cookies can be shared with cross-subdomain config
- All apps under `dashboard.kindraos.com` share the same origin — `localStorage` is shared automatically
- Each sub-app (bookkeeper, payroll, etc.) is a standalone Next.js project with its own `basePath`
- The shell app uses **Next.js Multi Zones** (rewrites) to proxy sub-app routes
1. **Auth gateway.** Users sign in via Clerk. The shell's middleware enforces the session for every page that's not a sub-app route or a known public path.
2. **Multi-Zone proxy.** `next.config.js` rewrites send `/bookkeeper/*`, `/payroll/*`, etc. to each sub-app's own Next.js deployment. Each sub-app is independent and sets its own `basePath`.
3. **Platform overview.** When the user lands on `/`, the shell renders its own dashboard page using `<PlatformLayout>` from `@kindraos/ui` so the chrome looks identical to every sub-app.
## Diagram
## Architecture overview
```
kindraos.com dashboard.kindraos.com
│ │
┌───────┴───────┐ ┌───────┴────────┐
│ Kindra Landing│ │ Dashboard Shell │
│ (existing) │ ──login───→ │ (NEW app) │
│ marketing │ │ │
│ + sign-in │ │ Multi Zones: │
└───────────────┘ │ / │ → Shell's own dashboard page
│ /bookkeeper/* │ → Proxy to Bookkeeper app
│ /payroll/* │ → Proxy to Payroll app
│ /tax/* │ → Proxy to Tax app
│ /crm/* │ → Proxy to CRM app
│ /marketing/* │ → Proxy to Marketing app
│ /website/* │ → Proxy to Website Builder
└───────┬────────┘
┌──────────┬───────┼───────┬──────────┐
│ │ │ │ │
Bookkeeper Payroll Tax CRM Marketing Website
(basePath: (base: (base: (base: (base: (base:
/bookkeeper /payroll /tax /crm /marketing /website)
│ │ │ │ │
└────┬─────┘───────┘───────┘──────────┘
@kindra/ui
(shared layout)
Same localStorage
Same Clerk session
kindraos.com → Marketing landing (separate repo, no auth)
dashboard.kindraos.com → Shell app (THIS repo)
/ → Shell's own platform overview
/sign-in, /sign-up → Clerk pages
/bookkeeper/* ◀── rewrite ──▶ bookkeeper sub-app (greenForestAdmin, port 3001)
/payroll/* ◀── rewrite ──▶ payroll sub-app (port 3002, future)
/tax/* ◀── rewrite ──▶ tax sub-app (port 3003, future)
/crm/* ◀── rewrite ──▶ crm sub-app (port 3004, future)
/marketing/* ◀── rewrite ──▶ marketing sub-app (port 3005, future)
/website/* ◀── rewrite ──▶ website builder (port 3006)
<slug>.kindraspace.com → Per-sub-app standalone backoffice (separate Vercel projects)
Same codebase as the matching sub-app, NEXT_PUBLIC_DEPLOYMENT_TARGET set.
Does NOT involve the shell.
```
## Step-by-Step Implementation
**Key facts:**
- All paths under `dashboard.kindraos.com` share the same origin → `localStorage` is shared automatically across sub-apps.
- Clerk session cookies are scoped to `.kindraos.com`, so the session covers the marketing landing + the shell + every sub-app served beneath the shell.
- Each sub-app sets its own `basePath` (e.g. `/bookkeeper`). Next.js auto-prepends the prefix on all internal links and assets — never hardcode it.
- Backoffice deployments at `<slug>.kindraspace.com` are *not* part of the shell. They serve from a different domain with no Clerk middleware.
---
## Step-by-step
### STEP 1: Create the Dashboard Shell App
### Step 1 — Create the shell app
Create a new Next.js project for the dashboard shell.
```bash
cd /Users/rubaiyatnoman
npx create-next-app@latest kindra-dashboard --typescript --tailwind --app --src-dir=false --import-alias="@/*"
cd ~/ # sibling to greenForestAdmin
npx create-next-app@latest kindra-dashboard \
--typescript --tailwind --app --src-dir=false --import-alias="@/*"
cd kindra-dashboard
```
**Tech stack should match the ecosystem:**
Tech stack must match the rest of the platform:
- Next.js 15+ (App Router)
- TypeScript
- Tailwind CSS v3 (to match bookkeeper, not v4 like the landing page)
- Clerk for auth (`@clerk/nextjs`)
- `@kindra/ui` for shared layout
- TypeScript strict
- Tailwind CSS v3 (not v4 — the shared package is v3)
- `@clerk/nextjs`
- `@kindraos/ui`
**Install dependencies:**
```bash
npm install @clerk/nextjs @tanstack/react-query lucide-react
npm install @kindra/ui@file:../greenForestAdmin/packages/ui
npm install @kindraos/ui # NPM (production)
# or, when working on shared UI locally:
# npm install @kindraos/ui@file:../greenForestAdmin/packages/ui
```
---
### Step 2 — Project layout
### STEP 2: Project Structure
```
kindra-dashboard/
├── app/
│ ├── layout.tsx # Root layout: ClerkProvider + fonts
│ ├── globals.css # Tailwind + CSS variables (copy from bookkeeper)
│ ├── (marketing)/
│ │ └── page.tsx # Redirect to first app or show platform dashboard
│ ├── layout.tsx # Root: ClerkProvider + fonts
│ ├── globals.css # Tailwind + CSS vars (copy from bookkeeper)
│ ├── (dashboard)/
│ │ ├── layout.tsx # PlatformLayout shell with @kindra/ui
│ │ └── page.tsx # Platform dashboard (overview page)
│ │ ├── layout.tsx # PlatformLayout shell
│ │ └── page.tsx # Platform overview
│ ├── (auth)/
│ │ ├── layout.tsx # Centered auth layout
│ │ ├── sign-in/
│ │ │ └── [[...sign-in]]/page.tsx
│ │ └── sign-up/
│ │ └── [[...sign-up]]/page.tsx
│ └── api/ # Any shell-specific API routes
├── components/
│ └── layout/
│ └── business-switcher.tsx # Connects @kindra/ui BusinessSwitcher to data
│ │ ├── sign-in/[[...sign-in]]/page.tsx
│ │ └── sign-up/[[...sign-up]]/page.tsx
│ └── api/ # Shell-specific API routes (rare)
├── components/layout/
│ ├── business-switcher.tsx # Wraps @kindraos/ui BusinessSwitcher with data
│ └── user-nav.tsx # Clerk <UserButton /> in topbar slot
├── lib/
│ ├── api/
│ │ └── client.ts # API client (shared backend)
│ ├── api/client.ts # API client (shared with sub-apps)
│ ├── hooks/
│ │ ├── use-auth.ts # Auth hook (Clerk-based)
│ │ └── use-business.ts # Business hook (fetches from API)
│ └── constants/
│ └── platform-nav.tsx # Platform navigation config
├── middleware.ts # Clerk middleware + sub-app passthrough
├── next.config.js # Multi Zone rewrites
│ │ ├── use-auth.ts # Clerk-based, mirrors sub-app pattern
│ │ └── use-business.ts # Business list + selection
│ └── constants/platform-nav.tsx # Nav with externalHref to each sub-app
├── middleware.ts # Clerk + sub-app passthrough
├── next.config.js # Multi-Zone rewrites
├── tailwind.config.ts
├── tsconfig.json
└── package.json
```
---
### Step 3 — `next.config.js` (Multi-Zone rewrites)
### STEP 3: `next.config.js` — Multi Zone Rewrites
Each sub-app has its own URL env var. In dev these point to local ports; in production they point to the sub-app's Vercel deployment URL.
This is the core routing logic. The shell proxies sub-app paths to their respective deployments.
```js

@@ -137,73 +101,33 @@ /** @type {import('next').NextConfig} */

reactStrictMode: true,
transpilePackages: ["@kindra/ui"],
transpilePackages: ["@kindraos/ui"],
async rewrites() {
const bookkeepingUrl = process.env.BOOKKEEPER_URL || 'http://localhost:3001';
// const payrollUrl = process.env.PAYROLL_URL || 'http://localhost:3002';
// const taxUrl = process.env.TAX_URL || 'http://localhost:3003';
// const crmUrl = process.env.CRM_URL || 'http://localhost:3004';
// const marketingUrl = process.env.MARKETING_URL || 'http://localhost:3005';
// const websiteUrl = process.env.WEBSITE_URL || 'http://localhost:3006';
const bookkeeperUrl = process.env.BOOKKEEPER_URL || 'http://localhost:3001';
const payrollUrl = process.env.PAYROLL_URL || 'http://localhost:3002';
const taxUrl = process.env.TAX_URL || 'http://localhost:3003';
const crmUrl = process.env.CRM_URL || 'http://localhost:3004';
const marketingUrl = process.env.MARKETING_URL || 'http://localhost:3005';
const websiteUrl = process.env.WEBSITE_URL || 'http://localhost:3006';
// For each sub-app: rewrite both "/<slug>" (the index) and "/<slug>/:path*"
// (everything beneath). Add a new pair when a new sub-app ships.
return {
beforeFiles: [
// Bookkeeper app — all requests under /bookkeeper/*
{
source: '/bookkeeper',
destination: `${bookkeepingUrl}/bookkeeper`,
},
{
source: '/bookkeeper/:path*',
destination: `${bookkeepingUrl}/bookkeeper/:path*`,
},
{ source: '/bookkeeper', destination: `${bookkeeperUrl}/bookkeeper` },
{ source: '/bookkeeper/:path*', destination: `${bookkeeperUrl}/bookkeeper/:path*` },
// Payroll app (future)
// {
// source: '/payroll',
// destination: `${payrollUrl}/payroll`,
// },
// {
// source: '/payroll/:path*',
// destination: `${payrollUrl}/payroll/:path*`,
// },
{ source: '/payroll', destination: `${payrollUrl}/payroll` },
{ source: '/payroll/:path*', destination: `${payrollUrl}/payroll/:path*` },
// Tax app (future)
// {
// source: '/tax',
// destination: `${taxUrl}/tax`,
// },
// {
// source: '/tax/:path*',
// destination: `${taxUrl}/tax/:path*`,
// },
{ source: '/tax', destination: `${taxUrl}/tax` },
{ source: '/tax/:path*', destination: `${taxUrl}/tax/:path*` },
// CRM app (future)
// {
// source: '/crm',
// destination: `${crmUrl}/crm`,
// },
// {
// source: '/crm/:path*',
// destination: `${crmUrl}/crm/:path*`,
// },
{ source: '/crm', destination: `${crmUrl}/crm` },
{ source: '/crm/:path*', destination: `${crmUrl}/crm/:path*` },
// Marketing app (future)
// {
// source: '/marketing',
// destination: `${marketingUrl}/marketing`,
// },
// {
// source: '/marketing/:path*',
// destination: `${marketingUrl}/marketing/:path*`,
// },
{ source: '/marketing', destination: `${marketingUrl}/marketing` },
{ source: '/marketing/:path*', destination: `${marketingUrl}/marketing/:path*` },
// Website builder app (future)
// {
// source: '/website',
// destination: `${websiteUrl}/website`,
// },
// {
// source: '/website/:path*',
// destination: `${websiteUrl}/website/:path*`,
// },
{ source: '/website', destination: `${websiteUrl}/website` },
{ source: '/website/:path*', destination: `${websiteUrl}/website/:path*` },
],

@@ -217,49 +141,49 @@ };

**Environment variables** (`.env.local`):
`.env.local` for dev (`.env.production` in Vercel for prod):
```
# Sub-app URLs (internal — these are NOT exposed to the browser)
# Sub-app URLs — server-only; never NEXT_PUBLIC_-prefixed
BOOKKEEPER_URL=http://localhost:3001
# PAYROLL_URL=http://localhost:3002
# TAX_URL=http://localhost:3003
# CRM_URL=http://localhost:3004
# MARKETING_URL=http://localhost:3005
# WEBSITE_URL=http://localhost:3006
PAYROLL_URL=http://localhost:3002
# …others as they ship
# Clerk (SAME keys as all other apps)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx
CLERK_SECRET_KEY=sk_live_xxxxx
# Clerk — same keys as every other Kindra app
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/select-role
# Used by sub-app middleware to construct the correct external redirect
NEXT_PUBLIC_APP_URL=https://dashboard.kindraos.com
```
---
### Step 4 — `middleware.ts` (Clerk + sub-app passthrough)
### STEP 4: `middleware.ts` — Let Sub-App Routes Pass Through
The shell's middleware does Clerk on its own pages and **passes through** sub-app routes — each sub-app runs its own middleware after the proxy hop. Crucially, sub-app routes also bypass `auth.protect()` here so Clerk doesn't try to redirect using the proxied internal Vercel hostname.
The shell's Clerk middleware must NOT interfere with sub-app routes. Sub-apps have their own middleware.
```ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
```typescript
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isPublicRoute = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
"/sign-in(.*)",
"/sign-up(.*)",
"/accept-invitation(.*)",
"/select-role(.*)",
]);
// Sub-app routes are proxied to other Next.js apps — let them through
// Sub-app routes are proxied to other Next.js apps; let them through.
// Add the slug here when a new sub-app ships.
const isSubAppRoute = createRouteMatcher([
'/bookkeeper(.*)',
'/payroll(.*)',
'/tax(.*)',
'/crm(.*)',
'/marketing(.*)',
'/website(.*)',
"/bookkeeper(.*)",
"/payroll(.*)",
"/tax(.*)",
"/crm(.*)",
"/marketing(.*)",
"/website(.*)",
]);
export default clerkMiddleware(async (auth, req) => {
if (isPublicRoute(req) || isSubAppRoute(req)) {
return;
}
if (isPublicRoute(req) || isSubAppRoute(req)) return;
await auth.protect();

@@ -270,4 +194,4 @@ });

matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],

@@ -277,5 +201,5 @@ };

---
> **Why `auth.protect()` is OK here but not in sub-apps.** The shell IS the canonical host (`dashboard.kindraos.com`) — Clerk's auto-redirect builds the correct URL. Inside a proxied sub-app, `auth.protect()` would build the redirect from the sub-app's internal Vercel hostname and cause a sign-in loop. The sub-app's own middleware does a manual redirect for that reason; see [`middleware.ts`](../../middleware.ts) in this repo.
### STEP 5: Root Layout with Clerk
### Step 5 — Root layout with Clerk

@@ -289,14 +213,5 @@ ```tsx

const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
weight: ["400", "500", "600", "700"],
});
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-dm-sans", weight: ["400","500","600","700"] });
const spaceMono = Space_Mono({ subsets: ["latin"], variable: "--font-space-mono", weight: ["400","700"] });
const spaceMono = Space_Mono({
subsets: ["latin"],
variable: "--font-space-mono",
weight: ["400", "700"],
});
export const metadata: Metadata = {

@@ -311,5 +226,3 @@ title: "Kindra Dashboard",

<body className={`${dmSans.variable} ${spaceMono.variable} font-sans`}>
<ClerkProvider>
{children}
</ClerkProvider>
<ClerkProvider>{children}</ClerkProvider>
</body>

@@ -321,15 +234,13 @@ </html>

---
### Step 6 — Dashboard layout with `@kindraos/ui`
### STEP 6: Dashboard Layout with `@kindra/ui`
The shell shows `<PlatformLayout>` on its own pages so users see the same sidebar/topbar before navigating into a specific app.
The shell's dashboard page uses the shared PlatformLayout. This gives the user the same sidebar/topbar experience when they land on `dashboard.kindraos.com/` before navigating into a specific app.
```tsx
// app/(dashboard)/layout.tsx
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { PlatformLayout } from "@kindra/ui";
import { PlatformLayout } from "@kindraos/ui";
import "@kindraos/ui/styles.css";
import { platformNav, bottomNav } from "@/lib/constants/platform-nav";

@@ -358,8 +269,6 @@ import { BusinessSwitcher } from "@/components/layout/business-switcher";

---
### Step 7 — Platform navigation config
### STEP 7: Platform Navigation Config
Cross-app entries use `externalHref` so they render as `<a>` (full page navigation between zones). The shell's own nav items use `href`.
The shell's nav config uses `externalHref` for sub-app links (these cause full page navigation to the other Next.js zone) and `href` for internal shell routes.
```tsx

@@ -371,3 +280,3 @@ // lib/constants/platform-nav.tsx

} from "lucide-react";
import type { NavSection, NavItem } from "@kindra/ui";
import type { NavSection, NavItem } from "@kindraos/ui";

@@ -377,49 +286,9 @@ export const platformNav: NavSection[] = [

items: [
{
id: "dashboard",
label: "Dashboard",
icon: <LayoutDashboard className="h-4 w-4" />,
href: "/", // Shell's own page
},
{
id: "bookkeeper",
label: "Bookkeeping",
icon: <BookOpen className="h-4 w-4" />,
externalHref: "/bookkeeper", // Full nav to bookkeeper zone
},
{
id: "payroll",
label: "Payroll",
icon: <Wallet className="h-4 w-4" />,
externalHref: "/payroll",
comingSoon: true,
},
{
id: "tax",
label: "Tax",
icon: <Calculator className="h-4 w-4" />,
externalHref: "/tax",
comingSoon: true,
},
{
id: "crm",
label: "CRM",
icon: <UsersRound className="h-4 w-4" />,
externalHref: "/crm",
comingSoon: true,
},
{
id: "marketing",
label: "Marketing",
icon: <Megaphone className="h-4 w-4" />,
externalHref: "/marketing",
comingSoon: true,
},
{
id: "website",
label: "Website Builder",
icon: <Globe className="h-4 w-4" />,
externalHref: "/website",
comingSoon: true,
},
{ id: "dashboard", label: "Dashboard", icon: <LayoutDashboard className="h-4 w-4" />, href: "/" },
{ id: "bookkeeper", label: "Bookkeeping", icon: <BookOpen className="h-4 w-4" />, externalHref: "/bookkeeper" },
{ id: "payroll", label: "Payroll", icon: <Wallet className="h-4 w-4" />, externalHref: "/payroll", comingSoon: true },
{ id: "tax", label: "Tax", icon: <Calculator className="h-4 w-4" />, externalHref: "/tax", comingSoon: true },
{ id: "crm", label: "CRM", icon: <UsersRound className="h-4 w-4" />, externalHref: "/crm", comingSoon: true },
{ id: "marketing", label: "Marketing", icon: <Megaphone className="h-4 w-4" />, externalHref: "/marketing", comingSoon: true },
{ id: "website", label: "Website Builder", icon: <Globe className="h-4 w-4" />, externalHref: "/website", comingSoon: true },
],

@@ -430,214 +299,127 @@ },

export const bottomNav: NavItem[] = [
{
id: "reports",
label: "Reports",
icon: <BarChart3 className="h-4 w-4" />,
href: "/reports",
},
{
id: "settings",
label: "Settings",
icon: <Settings className="h-4 w-4" />,
href: "/settings",
},
{ id: "reports", label: "Reports", icon: <BarChart3 className="h-4 w-4" />, href: "/reports" },
{ id: "settings", label: "Settings", icon: <Settings className="h-4 w-4" />, href: "/settings" },
];
```
---
### Step 8 — Sub-app side: set `basePath`
### STEP 8: Update Bookkeeper App — Add `basePath`
Each sub-app must serve under `/<slug>` so the shell's rewrite lands on a matching path. In the sub-app repo's `next.config.js`:
The bookkeeper must serve under `/bookkeeper` so the shell can route to it.
**File: `/Users/rubaiyatnoman/greenForestAdmin/next.config.js`**
```js
const nextConfig = {
basePath: '/bookkeeper',
basePath: process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET ? '' : '/<slug>',
reactStrictMode: true,
transpilePackages: ["@kindra/ui"],
// ... existing rewrites, redirects
transpilePackages: ["@kindraos/ui"],
// …rewrites, redirects per the sub-app playbook
};
```
**What `basePath: '/bookkeeper'` does automatically:**
- `next/link` prepends `/bookkeeper` to all `href` values
- `next/router` and `usePathname()` return paths WITHOUT the basePath (e.g., `/accounts`, not `/bookkeeper/accounts`)
- Static assets are served from `/bookkeeper/_next/...`
- API routes move to `/bookkeeper/api/...`
The `basePath` is conditional because the same sub-app is also built standalone for `<slug>.kindraspace.com` (the backoffice surface), where it runs at the root. See [`prompt-new-subapp.md`](./prompt-new-subapp.md) §3 for the full two-build pattern.
**What you need to update manually:**
- Clerk env vars:
```
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
```
(These are relative to basePath, so `/sign-in` actually becomes `/bookkeeper/sign-in`)
What `basePath: '/<slug>'` does automatically:
- `next/link` and `useRouter()` prepend `/<slug>` to all `href` values
- `usePathname()` returns paths *without* basePath — `<PlatformLayout pathname={...}>` gets a clean path and `isLinkActive()` matches as expected
- Static assets serve from `/<slug>/_next/...`
- API routes move to `/<slug>/api/...`
- Update `platform-nav.tsx` — cross-app links use `externalHref` without basePath prefix since they're absolute paths on the domain:
```tsx
{ id: "payroll", label: "Payroll", externalHref: "/payroll", ... }
{ id: "website", label: "Website", externalHref: "/website", ... }
```
The bookkeeper's own nav items keep `href` (relative, basePath is auto-prepended):
```tsx
{ id: "bk-accounts", label: "Chart of Accounts", href: "/accounts", ... }
```
### Step 9 — Cross-subdomain Clerk auth
- The `@kindra/ui` PlatformLayout receives `pathname` from `usePathname()`, which returns the path WITHOUT basePath. So active state detection continues to work — `/accounts` matches `href: "/accounts"`.
Clerk session must be valid on both the marketing landing (`kindraos.com`) and the shell (`dashboard.kindraos.com`). Configuration:
---
**In the Clerk dashboard:**
1. Application Settings → Paths: set Sign-in URL to `https://dashboard.kindraos.com/sign-in`
2. Sessions → Cookie domain: `.kindraos.com` (with leading dot)
### STEP 9: Cross-Subdomain Clerk Auth
**Same Clerk keys in every Kindra app** (`@clerk/nextjs` package, marketing, shell, every sub-app). The cookie on `.kindraos.com` is the shared session.
Since login happens on `kindraos.com` but the dashboard is on `dashboard.kindraos.com`, Clerk needs cross-subdomain session sharing.
**In the marketing landing app** (`kindraos.com`):
**In Clerk Dashboard (clerk.com):**
1. Go to your application settings
2. Under "Paths" → set Sign-in URL to `https://kindraos.com/login`
3. Under "Sessions" → set Cookie domain to `.kindraos.com` (with the leading dot)
This makes the Clerk session cookie accessible to all `*.kindraos.com` subdomains.
**In the Kindra landing page app** (`kindraos.com`):
```
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx
CLERK_SECRET_KEY=sk_live_xxxxx
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=https://dashboard.kindraos.com
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=https://dashboard.kindraos.com
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=https://dashboard.kindraos.com/select-role
```
**In the dashboard shell app** (`dashboard.kindraos.com`):
```
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx # SAME key
CLERK_SECRET_KEY=sk_live_xxxxx # SAME key
NEXT_PUBLIC_CLERK_SIGN_IN_URL=https://kindraos.com/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=https://kindraos.com/signup
```
**In the shell + every sub-app** (Kindra build): same keys, sign-in/up URLs are relative (`/sign-in`, `/sign-up`).
**In the bookkeeper app** (runs behind the shell):
```
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx # SAME key
CLERK_SECRET_KEY=sk_live_xxxxx # SAME key
```
### Step 10 — `localStorage` strategy
All three apps use the **same Clerk application** with the **same keys**. The cookie on `.kindraos.com` is shared.
All paths under `dashboard.kindraos.com` share the same origin, so `localStorage` is shared across the shell and every sub-app. The platform standardized on the `greenforest_*` prefix (kept unchanged through the bookkeeper rebrand for backward compatibility — every consumer is still reading these keys).
---
### STEP 10: localStorage Strategy
Since all sub-apps run under `dashboard.kindraos.com` (same origin), localStorage is shared:
| Key | Purpose | Set by | Read by |
|-----|---------|--------|---------|
| `kindra_current_business` | Selected business ID | BusinessSwitcher (any app) | All apps |
| `kindra_access_token` | JWT for API calls (impersonation) | Auth flow | All apps |
| `kindra_role_id` | User's role ID | Auth flow | All apps |
| `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 |
**Note:** `kindraos.com` (landing page) does NOT share localStorage with `dashboard.kindraos.com` — they're different origins. But that's fine since the landing page doesn't need business state.
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.
---
`kindraos.com` (marketing) is a different origin and does **not** share `localStorage` with the shell. That's fine — the landing page doesn't read business state.
### STEP 11: Local Development
### Step 11 — Local development
Run all apps simultaneously with different ports:
Run apps in parallel on different ports. The shell at `:3000` rewrites each sub-app path to its respective port.
```bash
# Terminal 1: Dashboard shell (port 3000)
cd /Users/rubaiyatnoman/kindra-dashboard
PORT=3000 npm run dev
# Terminal 1 — Shell
cd ~/kindra-dashboard && PORT=3000 npm run dev
# Terminal 2: Bookkeeper (port 3001)
cd /Users/rubaiyatnoman/greenForestAdmin
PORT=3001 npm run dev
# Terminal 2 — Bookkeeper
cd ~/greenForestAdmin && PORT=3001 npm run dev
# Terminal 3: Payroll (port 3002) — when built
# cd /Users/rubaiyatnoman/kindra-payroll
# PORT=3002 npm run dev
# Terminal 4: Tax (port 3003) — when built
# cd /Users/rubaiyatnoman/kindra-tax
# PORT=3003 npm run dev
# Terminal 5: CRM (port 3004) — when built
# cd /Users/rubaiyatnoman/kindra-crm
# PORT=3004 npm run dev
# Terminal 6: Marketing (port 3005) — when built
# cd /Users/rubaiyatnoman/kindra-marketing
# PORT=3005 npm run dev
# Terminal 7: Website Builder (port 3006) — when built
# cd /Users/rubaiyatnoman/kindra-website
# PORT=3006 npm run dev
# Terminal 8: Kindra landing page (port 4000) — optional, separate concern
cd /Users/rubaiyatnoman/kindra/kindraos-landing
PORT=4000 npm run dev
# Terminal 3+ — additional sub-apps as they ship (3002, 3003, …)
```
The dashboard shell at `localhost:3000` rewrites each sub-app path to its respective port (e.g., `/bookkeeper/*` → `localhost:3001/bookkeeper/*`).
Visit `http://localhost:3000/bookkeeper` — the shell proxies to the bookkeeper at `:3001/bookkeeper/...` and you should see the sub-app inside the shared chrome.
---
## File checklist for the shell
## File Checklist for the Dashboard Shell App
| File | Purpose |
|------|---------|
| `package.json` | Dependencies: next, react, @clerk/nextjs, @tanstack/react-query, @kindra/ui, lucide-react |
| `next.config.js` | Multi Zone rewrites for `/bookkeeper/*`, `/payroll/*`, etc. + `transpilePackages` |
| `tsconfig.json` | Path aliases for `@/*` and `@kindra/ui` |
| `tailwind.config.ts` | Copy from bookkeeper (same design tokens) + `@kindra/ui` content path |
| `middleware.ts` | Clerk middleware — let sub-app routes pass through |
| `app/layout.tsx` | Root: ClerkProvider + fonts |
| `app/globals.css` | Copy from bookkeeper (same CSS variables) |
| `app/(auth)/layout.tsx` | Centered auth layout (if shell has its own sign-in pages) |
| `app/(dashboard)/layout.tsx` | PlatformLayout from @kindra/ui |
| `app/(dashboard)/page.tsx` | Platform overview dashboard |
| `lib/constants/platform-nav.tsx` | Nav config with `externalHref` for sub-apps |
| `lib/hooks/use-auth.ts` | Clerk-based auth hook |
| `lib/hooks/use-business.ts` | Business list + selection (same API as bookkeeper) |
| `package.json` | Deps: next, react, @clerk/nextjs, @tanstack/react-query, @kindraos/ui, lucide-react |
| `next.config.js` | Multi-Zone rewrites + `transpilePackages: ["@kindraos/ui"]` |
| `tsconfig.json` | Path aliases (`@/*`) |
| `tailwind.config.ts` | Match bookkeeper tokens; include `@kindraos/ui` in `content` |
| `middleware.ts` | Clerk middleware with sub-app passthrough |
| `app/layout.tsx` | ClerkProvider + fonts |
| `app/globals.css` | CSS variables (copy from bookkeeper) |
| `app/(auth)/sign-in/[[...sign-in]]/page.tsx` | Clerk `<SignIn />` |
| `app/(auth)/sign-up/[[...sign-up]]/page.tsx` | Clerk `<SignUp />` |
| `app/(dashboard)/layout.tsx` | `<PlatformLayout>` |
| `app/(dashboard)/page.tsx` | Platform overview |
| `lib/constants/platform-nav.tsx` | Nav with `externalHref` to each sub-app |
| `lib/hooks/use-auth.ts`, `lib/hooks/use-business.ts` | Auth + business selection |
| `lib/api/client.ts` | API client for the shared backend |
| `components/layout/business-switcher.tsx` | Wrapper connecting @kindra/ui BusinessSwitcher to data |
| `components/layout/user-nav.tsx` | User avatar dropdown |
| `components/layout/business-switcher.tsx`, `components/layout/user-nav.tsx` | Slot adapters |
## Important Rules
## Critical rules
1. **Same Clerk app** — All three deployments (landing, shell, bookkeeper) use the same Clerk publishable key and secret key
2. **Cookie domain `.kindraos.com`** — Configured in Clerk dashboard so session works across subdomains
3. **basePath in sub-apps** — Each sub-app sets `basePath` in its `next.config.js` (e.g., `/bookkeeper`)
4. **usePathname() returns path without basePath** — Active state detection in `@kindra/ui` works automatically
5. **Cross-app links use `externalHref`** — These render as `<a>` tags, causing full page navigation between zones
6. **Intra-app links use `href`** — These render via Next.js `<Link>`, getting basePath auto-prepended
7. **localStorage shared under dashboard.kindraos.com** — All sub-apps can read/write `kindra_*` keys
8. **Don't duplicate @kindra/ui** — The shell and all sub-apps import from the same package
9. **Shell middleware must pass through sub-app routes** — Otherwise Clerk in the shell will block requests meant for the bookkeeper's own Clerk middleware
1. **Same Clerk app, same keys**, everywhere — marketing, shell, every sub-app. Cookie domain `.kindraos.com`.
2. **Each sub-app sets its own `basePath`** in its own `next.config.js`. The shell does not configure sub-app basePaths.
3. **`usePathname()` returns the path without basePath** — every active-state check in `@kindraos/ui` already accounts for that. Don't strip or prepend manually.
4. **Cross-app links use `externalHref`** with absolute paths (`/payroll`), never with the sub-app's basePath prefix. The shell owns the parent domain.
5. **Intra-app links use `href`** and pass through `linkComponent` (Next.js `<Link>`).
6. **Shell middleware MUST pass through sub-app routes.** Otherwise the shell's Clerk middleware blocks requests meant for the sub-app, and the sub-app's own middleware never runs.
7. **Sub-app middleware does a manual redirect, not `auth.protect()`.** The shell can use `auth.protect()` because it IS the canonical host; sub-apps cannot.
8. **`localStorage` keys for cross-app state use the `greenforest_*` prefix.** Do not introduce a new prefix unless every sub-app is migrating in lockstep.
9. **One `@kindraos/ui` source of truth.** Shell and every sub-app import from the same NPM package; running multiple copies causes Tailwind class collisions.
10. **Updating the shell when a new sub-app ships:** add the rewrite pair in `next.config.js` AND the slug in the `isSubAppRoute` matcher in `middleware.ts`. Forgetting the matcher means Clerk tries to protect the sub-app's routes and fails on the proxied host.
## All Platform Apps
## All platform apps
| App | basePath | Port (dev) | Status |
|-----|----------|------------|--------|
| Dashboard Shell | `/` (root) | 3000 | To build |
| Bookkeeper | `/bookkeeper` | 3001 | Existing (greenForestAdmin) |
| Payroll | `/payroll` | 3002 | Future |
| Tax | `/tax` | 3003 | Future |
| CRM | `/crm` | 3004 | Future |
| Marketing | `/marketing` | 3005 | Future |
| Website Builder | `/website` | 3006 | Future |
| Landing Page | N/A (kindraos.com) | 4000 | Existing |
| App | basePath | Port (dev) | Status | Backoffice subdomain |
|-----|----------|------------|--------|---------------------|
| Dashboard shell | `/` (root) | 3000 | this repo's target | n/a |
| Bookkeeper | `/bookkeeper` | 3001 | shipped (`greenForestAdmin`) | `bookkeeper.kindraspace.com` |
| Payroll | `/payroll` | 3002 | future | `payroll.kindraspace.com` |
| Tax | `/tax` | 3003 | future | `tax.kindraspace.com` |
| CRM | `/crm` | 3004 | future | `crm.kindraspace.com` |
| Marketing | `/marketing` | 3005 | future | (n/a — no backoffice yet) |
| Website Builder | `/website` | 3006 | shipped (`kindra-website-frontend`) | future |
| Marketing landing | n/a (`kindraos.com`) | 4000 | shipped | n/a |
Each future app follows the same pattern as the bookkeeper:
1. Create a Next.js project
2. Set `basePath` in `next.config.js`
3. Install `@kindra/ui` and use `PlatformLayout`
4. Define app-specific nav in `platform-nav.tsx` (with `externalHref` for cross-app links)
5. Add the rewrite in the dashboard shell's `next.config.js`
6. Add the route to the shell's `middleware.ts` passthrough list
Each new sub-app follows the same template — see [`prompt-new-subapp.md`](./prompt-new-subapp.md) for the full bootstrap recipe (dual auth, two builds, GitHub Actions, env-var matrix).

@@ -1,2 +0,2 @@

# @kindra/ui — Platform Shell Layout Package
# @kindraos/ui — Platform Shell Layout Package

@@ -40,3 +40,3 @@ ## What This Package Does

```tsx
import { PlatformLayout } from "@kindra/ui";
import { PlatformLayout } from "@kindraos/ui";
import { usePathname } from "next/navigation";

@@ -64,3 +64,3 @@ import Link from "next/link";

},
{ id: "payroll", label: "Payroll", icon: <PayrollIcon />, externalHref: "https://payroll.kindra.com" },
{ id: "payroll", label: "Payroll", icon: <PayrollIcon />, externalHref: "/payroll" },
],

@@ -88,3 +88,3 @@ },

```tsx
import { PlatformLayout } from "@kindra/ui";
import { PlatformLayout } from "@kindraos/ui";
import { usePathname } from "next/navigation";

@@ -97,4 +97,4 @@ import Link from "next/link";

items: [
{ id: "dashboard", label: "Dashboard", icon: <DashboardIcon />, externalHref: "https://app.kindra.com" },
{ id: "bookkeeper", label: "Bookkeeping", icon: <BookIcon />, externalHref: "https://bookkeeping.kindra.com" },
{ id: "dashboard", label: "Dashboard", icon: <DashboardIcon />, externalHref: "/" },
{ id: "bookkeeper", label: "Bookkeeping", icon: <BookIcon />, externalHref: "/bookkeeper" },
{

@@ -139,3 +139,3 @@ id: "website-builder",

```tsx
import { BusinessSwitcher } from "@kindra/ui";
import { BusinessSwitcher } from "@kindraos/ui";

@@ -164,3 +164,3 @@ // In your app's wrapper component:

For cross-app state sync, persist the selected business ID in a cookie on the shared parent domain (e.g., `.kindra.com`) so all subdomains can read it.
For cross-app state sync, persist the selected business ID in `localStorage` on the shared parent domain (`dashboard.kindraos.com`) so all sub-app paths under the shell read the same value. Use the `greenforest_current_business` key to interop with existing apps.

@@ -167,0 +167,0 @@ ## PlatformLayout Props

@@ -5,3 +5,3 @@ "use client";

import { cn } from "../../lib/cn";
import { MenuIcon } from "../../lib/icons";
import { ArrowLeftIcon, MenuIcon } from "../../lib/icons";
import { isGroupActive } from "../../lib/nav-utils";

@@ -22,2 +22,4 @@ import { ScrollArea } from "../../primitives/scroll-area";

sidebarFooter?: React.ReactNode;
homeHref?: string;
homeLabel?: string;
}

@@ -32,2 +34,4 @@

sidebarFooter,
homeHref,
homeLabel = "Back to Home",
}: MobileDrawerProps) {

@@ -74,2 +78,15 @@ const { isMobileOpen, setMobileOpen } = useSidebarState();

{/* Back to home link */}
{homeHref && (
<div className="border-b border-black/[0.06] dark:border-white/10 px-2 py-2">
<a
href={homeHref}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200"
>
<ArrowLeftIcon className="h-4 w-4 shrink-0" />
<span>{homeLabel}</span>
</a>
</div>
)}
{/* Navigation */}

@@ -76,0 +93,0 @@ <ScrollArea className="flex-1 py-2">

@@ -25,2 +25,4 @@ "use client";

mainClassName,
homeHref,
homeLabel,
}: PlatformLayoutProps) {

@@ -39,2 +41,4 @@ return (

sidebarFooter={sidebarFooter}
homeHref={homeHref}
homeLabel={homeLabel}
/>

@@ -50,2 +54,4 @@ <div className="flex flex-1 flex-col overflow-hidden">

sidebarFooter={sidebarFooter}
homeHref={homeHref}
homeLabel={homeLabel}
/>

@@ -52,0 +58,0 @@ </TopBar>

@@ -5,5 +5,11 @@ "use client";

import { cn } from "../../lib/cn";
import { ArrowLeftIcon } from "../../lib/icons";
import { isGroupActive } from "../../lib/nav-utils";
import { ScrollArea } from "../../primitives/scroll-area";
import { TooltipProvider } from "../../primitives/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "../../primitives/tooltip";
import { useSidebarState } from "../../hooks/use-sidebar-state";

@@ -20,2 +26,4 @@ import { SidebarNav } from "./SidebarNav";

sidebarFooter?: React.ReactNode;
homeHref?: string;
homeLabel?: string;
}

@@ -30,2 +38,4 @@

sidebarFooter,
homeHref,
homeLabel = "Back to Home",
}: SidebarProps) {

@@ -70,2 +80,36 @@ const { isCollapsed } = useSidebarState();

{/* Back to home link */}
{homeHref && (
<div
className={cn(
"border-b border-black/[0.06] dark:border-white/10",
isCollapsed ? "px-1 py-2" : "px-2 py-2"
)}
>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<a
href={homeHref}
className="flex items-center justify-center rounded-lg h-10 w-10 mx-auto text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200"
>
<ArrowLeftIcon className="h-4 w-4" />
</a>
</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{homeLabel}
</TooltipContent>
</Tooltip>
) : (
<a
href={homeHref}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200"
>
<ArrowLeftIcon className="h-4 w-4 shrink-0" />
<span>{homeLabel}</span>
</a>
)}
</div>
)}
{/* Navigation */}

@@ -72,0 +116,0 @@ <ScrollArea className="flex-1 py-2">

@@ -113,2 +113,20 @@ export function ChevronRightIcon({ className }: { className?: string }) {

export function ArrowLeftIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
);
}
export function PlusIcon({ className }: { className?: string }) {

@@ -115,0 +133,0 @@ return (

@@ -120,2 +120,6 @@ import type { ReactNode, ComponentType } from "react";

mainClassName?: string;
/** Cross-app href for "Back to Home" link. When provided, renders above nav. Always uses plain <a> for full page navigation. */
homeHref?: string;
/** Label for the home link. Defaults to "Back to Home". */
homeLabel?: string;
}