diff --git a/packages/fastify/README.md b/packages/fastify/README.md index 5585a8a..2d548d0 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -147,3 +147,17 @@ Install with pnpm: ```bash pnpm add --filter "<@scope/project>" @prefabs.tech/fastify-config @prefabs.tech/fastify-mailer @prefabs.tech/fastify-s3 @prefabs.tech/fastify-slonik @prefabs.tech/fastify-user slonik supertokens-node @prefabs.tech/saas-fastify ``` + +## Testing + +From the monorepo root: + +```bash +pnpm test --filter @prefabs.tech/saas-fastify +``` + +From this package folder: + +```bash +pnpm test +``` diff --git a/packages/react/ANALYSIS.md b/packages/react/ANALYSIS.md new file mode 100644 index 0000000..7e2c943 --- /dev/null +++ b/packages/react/ANALYSIS.md @@ -0,0 +1,265 @@ + + +## Package + +- **Path**: `packages/react` +- **Name**: `@prefabs.tech/saas-react` +- **Type**: ESM package (`"type": "module"`) +- **Runtime deps**: `axios`, `zod` +- **Peer deps (consumed directly by our code)**: `react`, `react-dom`, `react-router-dom`, `react-toastify`, `primereact`, `@prefabs.tech/react-config`, `@prefabs.tech/react-form`, `@prefabs.tech/react-i18n`, `@prefabs.tech/react-ui` + +## Entry points & public exports + +### `src/index.ts` + +- **Module augmentation (OURS)**: augments `@prefabs.tech/react-config`’s `AppConfig` with `saas: SaasConfig`. +- **Re-exports (OURS, as barrels)**: + - `export * from "./api"` + - `export * from "./constants"` + - `export * from "./hooks"` + - `export * from "./routes"` + - `export * from "./types"` + - `export * from "./utils"` + - `export * from "./views"` + - `export * from "./SaasWrapper"` +- **Named exports (OURS)**: + - Components: `AccountSwitcher`, `AccountForm`, `AccountInfo`, `AccountInvitationForm`, `AccountInvitationModal`, `AccountInvitationsTable`, `AccountSignupForm`, `AccountUsersTable`, `AccountsTable`, `MyAccounts`, `UserSignupForm` + - Contexts: `accountsContext`, `AccountsProvider` + +## Base Library Passthrough Analysis + +### `axios` — MODIFIED + +- **Options type**: base library (`axios`) is used directly; we don’t expose/forward `AxiosRequestConfig` types. +- **Options passed**: **transformed** + - We create a preconfigured instance in `src/api/axios/client.ts` with: + - `baseURL` set from our config + - default JSON content type for POST + - optional `x-account-id` header read from storage +- **Features restricted**: partial (callers don’t control the instance config beyond `baseURL`; config is embedded in our wrapper). +- **Features added**: + - Automatic `x-account-id` header injection (account session selection) + - Shared “API base URL” behavior through `useConfig()` + `client(apiBaseUrl)` pattern + +### `zod` — NO WRAPPED DEPENDENCY (used directly) + +No wrapped dependency passthrough surface; `zod` is used directly inside our schemas and form validation logic. + +### `@prefabs.tech/react-form`, `@prefabs.tech/react-ui`, `@prefabs.tech/react-i18n`, `react-router-dom`, `react-toastify` — NO WRAPPED DEPENDENCY (used directly) + +These libraries are consumed directly inside our components/views/routes. We do not provide a thin wrapper that forwards their configuration wholesale; instead we build opinionated components and hooks on top of them. + +## “Ours” vs “Theirs” classification + +### API layer (`src/api/*`) + +- **OURS** + - `client(baseURL: string)` (`src/api/axios/client.ts`): creates an `axios` instance and injects headers. + - `encodeURIParameter(arg)` (`src/api/utilities.ts`): JSON encodes parameters (or returns `undefined`). + - `useQuery(url, parameters?, options?)` (`src/api/common/UseQuery.ts`): React hook wrapping `axios.get` with: + - **defaults**: `lazy=false`, `skip=false` + - **branching**: auto-trigger on mount unless `lazy`/`skip` + - **error normalization**: treats `response.data.status === "ERROR"` as error + - `useMutation(options?)` (`src/api/common/UseMutation.ts`): React hook wrapping `axios.request` with: + - **defaults**: `method="POST"`, `withCredentials=true` + - **error normalization**: treats `response.data.status === "ERROR"` as error + - Account endpoints (`src/api/accounts/index.ts`): + - `doesAccountExist({ apiBaseUrl })` (GET `/`, `withCredentials: true`) + - `getMyAccounts({ apiBaseUrl })` (GET `/my-accounts`, `withCredentials: true`) + - `signup({ apiBaseUrl, path, data, accountSignup=true })`: + - **default**: `accountSignup = true` + - **transformation**: uses `prepareSignupData({ data, accountSignup })` + +- **THEIRS (direct calls)** + - `axios.create(...)`, `.get(...)`, `.post(...)`, `.request(...)` are used as base library invocations inside our wrappers. + +### Context/providers (`src/contexts/*`) + +- **OURS** + - `accountsContext` + `AccountsProvider` (`src/contexts/AccountsProvider.tsx`) + - **defaults** from config: `autoSelectAccount=true`, `allowMultipleSessions=true` + - **branches**: + - Determines `isMainApp` based on `window.location.host === mainApp?.domain` + - Chooses active account based on: + - subdomain match when **not** main app + - saved account id from storage (session wins when enabled) + - `autoSelectAccount` and “only one account” fallback + - **side effects**: + - Writes/removes `x-account-id` in `localStorage` and optionally `sessionStorage` + - Fetches accounts when `userId` is present + - `configContext` + `ConfigProvider` (`src/contexts/ConfigProvider.tsx`) + +### Hooks (`src/hooks/*`) + +- **OURS** + - `useAccounts()` (`src/hooks/UseAccounts.ts`): reads `accountsContext`; throws if missing provider. + - `useConfig()` (`src/hooks/UseConfig.ts`): reads `configContext`; throws if missing provider. + - Account data hooks (thin wrappers over `useQuery`/`useMutation`): + - `useAddAccountMutation` + - `useEditAccountMutation` (**default override**: forces `method: "PUT"` while spreading provided options) + - `useGetAccountQuery` + - `useGetMyAccountQuery` + - Invitation hooks: + - `useAddInvitationMutation` + - `useDeleteInvitationMutation` + - `useGetInvitationQuery` + - `useGetInvitationsQuery` + - `useJoinInvitationMutation` + - `useResendInvitationMutation` + - `useRevokeInvitationMutation` + - `useSignupInvitationMutation` + - User hooks: + - `useDisableUserMutation` + - `useEnableUserMutation` + - `useGetUsersQuery` + +- **THEIRS (direct calls)** + - React hooks (`useContext`, `useCallback`, etc.) are used directly as building blocks. + +### Utils (`src/utils/*`) + +- **OURS** + - `checkIsAdminApp()` (`src/utils/common.ts`): + - **default**: admin subdomain constant `ADMIN_SUBDOMAIN_DFAULT = "admin"` + - **branch**: compares subdomain to admin constant + - `prepareUiConfig(ui?)` (`src/utils/config.ts`): + - **defaults**: merges `CONFIG_UI_DEFAULT` into provided UI config + - `prepareConfig(config)` (`src/utils/config.ts`): + - **defaults**: + - `mainApp.subdomain` defaults to `"app"` + - `mainApp.domain` defaults to `"{subdomain}.{rootDomain}"` (supports deprecated `mainAppSubdomain`) + - `ui` defaults come from `CONFIG_UI_DEFAULT` + - **transformation**: returns a normalized `SaasConfig` used by providers/components + - `prepareSignupData({ data, accountSignup=true })` (`src/utils/account.ts`): + - **branch**: different shape for account vs user signup payload + - **defaults**: + - `accountSignup = true` + - `useSeparateDatabase` becomes `false` when slug missing + +### Routes (`src/routes/*`) + +- **OURS** + - `getSaasAdminRoutes(type="authenticated", options?)` (`src/routes/GetSaasAdminRoutes.tsx`) + - **branch**: for `type === "unauthenticated"` or `"public"`, returns no routes. + - **default paths**: uses `DEFAULT_PATHS.*` (path overwrite is TODO; `element` can be overwritten) + - `GetSaasAppRoutes({ type="authenticated", options })` and `getSaasAppRoutes(type="authenticated", options?)` + - **branch**: + - `"authenticated"`: account settings, join invitation (disabled when not main app), my accounts + - `"unauthenticated"`: invitation signup + signup + - `"public"`: accept invitation + - **dependency**: uses `useAccounts()` to read `isMainApp` meta + +### UI components (`src/components/*`) + +- **OURS** + - `AccountSwitcher` (`src/components/accounts/Switcher.tsx`) + - **defaults**: `noHelperText=false` + - **branch**: returns loading icon when accounts are not loaded + - `AccountsTable` (`src/components/accounts/Table/index.tsx`) + - **defaults**: + - `className="table-accounts"` + - `visibleColumns=["name","registeredNumber","taxId","type"]` + - `persistState=true` + - `initialSorting=[{ id: "name", desc: false }]` + - **branch**: column set changes based on config `entity` + - `MyAccounts` + `Account` (`src/components/MyAccounts/*`) + - `AccountForm` (`src/components/account/Form/AccountForm.tsx`) + - **validation**: zod schema; slug requirements depend on `subdomains === "required"` + - **defaultValues**: derive from existing account or config `entity` + - `AccountInvitationForm` (`src/components/account/Invitations/InvitationForm.tsx`) + - **defaults**: + - `roles`: `customRoles || saasAccountRoles || SAAS_ACCOUNT_ROLES_DEFAULT` + - default values include `expiresAt` only when `expiryDateField.display` + - role auto-selected when exactly one role is available + - **conditional schema**: + - merges role schema if roles exist + - merges expiresAt schema if expiry date displayed + - merges additional schema if provided + - **side effects**: shows toast notifications on success/failure + - Signup components + - `AccountSignupForm` (`src/components/Signup/AccountSignupForm.tsx`) + - **defaults**: + - `activeIndex=0` (2-step flow) + - default form values initialized for account + user fields + - **branching**: + - schema switches by `activeIndex` + - terms & conditions field required only when `termsAndConditionsUrl` present + - submit button label changes between “Next” and “Submit” + - password/confirmPassword must match (refine) + - `UserSignupForm` (`src/components/Signup/UserSignupForm.tsx`) + - **defaults**: email defaults to provided `email || ""` + - **branching**: terms & conditions requirement depends on `termsAndConditionsUrl` + - Other exported component surface (via barrel exports): + - `AccountInfo` + - `AccountInvitationModal` + - `AccountInvitationsTable` + - `AccountUsersTable` + - (and supporting field/table components exported from `src/components/*` barrels) + +- **THEIRS (direct calls)** + - UI, form, i18n libraries are called directly inside components (`@prefabs.tech/react-ui`, `@prefabs.tech/react-form`, `@prefabs.tech/react-i18n`, `react-toastify`). + +### Views/pages (`src/views/*`) + +- **OURS** + - `AccountAddPage`, `AccountEditPage`, `AccountSettingsPage`, `AccountViewPage` + - `AcceptInvitationPage`, `JoinInvitationPage`, `SignupInvitationPage` + - `MyAccountsPage`, `SignupPage` + +These are mostly composition over our components plus Prefabs UI primitives (`Page`, etc.) and translations. + +## Framework constructs / lifecycle / side effects + +- **React context**: `accountsContext`, `configContext` +- **React hooks used for lifecycle**: + - `useEffect` in `SaasWrapper` triggers an API call to check domain registration. + - `useEffect` in `AccountsProvider` triggers `fetchMyAccounts` when `userId` is present. +- **Routing**: returns `` elements for `react-router-dom` integration. +- **Storage side effects**: `AccountsProvider.switchAccount()` writes/removes `x-account-id` to/from local and optional session storage. +- **Notifications**: invitation form uses `react-toastify` to show success/error toasts. + +## Conditional branches & feature flags (observed) + +- **Config normalization** (`prepareConfig`) + - `mainApp.subdomain` fallback (`"app"`) + - `mainApp.domain` computed from subdomain + `rootDomain` when missing + - `ui` merged with `CONFIG_UI_DEFAULT` +- **Admin vs app mode**: + - `checkIsAdminApp()` uses subdomain comparison to `"admin"` + - `SaasWrapper` chooses whether to wrap children in `AccountsProvider` based on admin/app +- **Routes**: + - Admin routes removed for unauthenticated/public mode + - App routes vary by authenticated/unauthenticated/public + - Invitation join route disabled when not `isMainApp` +- **Account selection**: + - auto-select account unless disabled + - saved account id resolution uses session first when enabled + - subdomain-based account selection when not main app +- **Signup forms**: + - optional terms-and-conditions validation based on presence of `termsAndConditionsUrl` + - slug validation strictness depends on `subdomains === "required"` + +## Default values (observed) + +- **Constants** (`src/constants.ts`) + - `ACCOUNT_HEADER_NAME = "x-account-id"` + - `ADMIN_SUBDOMAIN_DFAULT = "admin"` + - `SIGNUP_PATH_DEFAULT = "/auth/signup"` + - `SAAS_ACCOUNT_ROLES_DEFAULT = ["SAAS_ACCOUNT_OWNER", "SAAS_ACCOUNT_MEMBER"]` + - `DEFAULT_PATHS` for app/admin routing + - `CONFIG_UI_DEFAULT` for form action alignment/reversal +- **Hooks** + - `useQuery`: `lazy=false`, `skip=false` + - `useMutation`: `method="POST"`, `withCredentials=true` +- **AccountsProvider** + - `autoSelectAccount=true`, `allowMultipleSessions=true` (from config fallback) +- **prepareConfig** + - `mainApp.subdomain="app"` when missing + +## Completeness checklist + +- [x] Classified every **public export category** as "ours" vs "theirs" +- [x] Listed framework constructs (contexts, hooks, routing) +- [x] Identified conditional branches (config normalization, routing mode, admin/app, signup schema conditions) +- [x] Documented default values we define (constants, hook defaults, provider defaults, config defaults) +- [x] Produced passthrough classification for wrapped dependency (`axios`) diff --git a/packages/react/FEATURES.md b/packages/react/FEATURES.md new file mode 100644 index 0000000..ad8321e --- /dev/null +++ b/packages/react/FEATURES.md @@ -0,0 +1,88 @@ + + +# @prefabs.tech/saas-react — Features + +## Configuration & Defaults + +1. **App config augmentation**: augments `@prefabs.tech/react-config`’s `AppConfig` with `saas: SaasConfig`. + +2. **Default constants**: exports defaults for: + - `ACCOUNT_HEADER_NAME = "x-account-id"` + - `ADMIN_SUBDOMAIN_DFAULT = "admin"` + - `SIGNUP_PATH_DEFAULT = "/auth/signup"` + - `SAAS_ACCOUNT_ROLES_DEFAULT = ["SAAS_ACCOUNT_OWNER", "SAAS_ACCOUNT_MEMBER"]` + - `DEFAULT_PATHS` for app/admin routes + +3. **UI defaults merging**: `prepareConfig` merges `CONFIG_UI_DEFAULT` into `config.ui` (account/invitation/signup form action alignment + reversal). + +## HTTP & API Helpers (axios) + +4. **Axios client factory**: `client(baseURL)` creates a preconfigured axios instance and injects `x-account-id` from storage. + +5. **Domain registration check**: `doesAccountExist({ apiBaseUrl })` calls `GET /` with credentials. + +6. **Fetch user accounts**: `getMyAccounts({ apiBaseUrl })` calls `GET /my-accounts` with credentials. + +7. **Signup API**: `signup({ apiBaseUrl, path, data, accountSignup=true })` shapes payload via `prepareSignupData` and posts to `path`. + +8. **Query hook**: `useQuery(url, parameters?, options?)` wraps `axios.get` and provides: + - defaults: `lazy=false`, `skip=false` + - an imperative `trigger()` + - error normalization when `response.data.status === "ERROR"` + +9. **Mutation hook**: `useMutation(options?)` wraps `axios.request` and provides: + - defaults: `method="POST"`, `withCredentials=true` + - an imperative `trigger(url, data?)` + - error normalization when `response.data.status === "ERROR"` + +## Contexts & Hooks + +10. **Config context**: `ConfigProvider` provides SaaS config and `useConfig()` reads it (throws if missing). + +11. **Accounts context**: `AccountsProvider` provides accounts state and `useAccounts()` reads it (throws if missing). + +12. **Account selection rules**: active account is computed from: + - main app vs tenant app mode + - saved account id in storage (session preferred when enabled) + - `autoSelectAccount` and “single account” fallback + +13. **Account persistence**: switching accounts writes/removes `x-account-id` in `localStorage` and optionally `sessionStorage` (when `allowMultipleSessions` is enabled). + +14. **Accounts fetching lifecycle**: when `userId` is present, `AccountsProvider` fetches accounts and updates provider state. + +## Wrapper & App Mode + +15. **Admin app detection**: `checkIsAdminApp()` checks whether the current subdomain equals `"admin"`. + +16. **SaasWrapper gating**: `SaasWrapper`: + - calls `doesAccountExist` on mount and shows loading / error UI + - normalizes config via `prepareConfig` + - wraps children with `ConfigProvider` + - wraps children with `AccountsProvider` only for non-admin apps + +## Routing + +17. **Admin routes generator**: `getSaasAdminRoutes(type?, options?)` emits `` elements for admin pages and supports element overrides + disabled flags. + +18. **App routes generator**: `getSaasAppRoutes(type?, options?)` emits routes for authenticated/unauthenticated/public modes; join invitation route disables when not in the main app. + +## UI Components + +19. **AccountSwitcher**: switches accounts using `useAccounts()`; shows loading state when accounts aren’t available. + +20. **AccountsTable**: renders a table of accounts; column set changes based on `config.entity`. + +21. **MyAccounts**: renders a list of accounts and triggers account switching. + +22. **AccountForm**: account create/edit form with zod validation; slug validation depends on `subdomains`. + +23. **AccountInvitationForm**: invitation form with conditional schema merging (roles, expiry field, and optional caller-provided schema) and toast notifications. + +24. **Signup forms**: + - `AccountSignupForm` is a 2-step flow with conditional schema and optional terms & conditions validation + - `UserSignupForm` supports optional terms & conditions validation and optional pre-filled email + +## Views/Pages + +25. **Built-in pages**: exports pages for common SaaS flows (accounts add/edit/view/settings, invitations accept/join/signup, my accounts, signup). + diff --git a/packages/react/GUIDE.md b/packages/react/GUIDE.md new file mode 100644 index 0000000..db9c0e2 --- /dev/null +++ b/packages/react/GUIDE.md @@ -0,0 +1,272 @@ + + +# @prefabs.tech/saas-react — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/saas-react +``` + +```bash +pnpm add @prefabs.tech/saas-react +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/saas-react test +pnpm --filter @prefabs.tech/saas-react build +``` + +## Setup + +This is the only “full setup” example. All later snippets assume this wrapper is in place. + +```typescript +import { ToastContainer } from "react-toastify"; +import { SaasWrapper } from "@prefabs.tech/saas-react"; + +import type { SaasConfig } from "@prefabs.tech/saas-react"; + +const saas: SaasConfig = { + apiBaseUrl: "https://api.example.com", + entity: "both", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + mainApp: { subdomain: "app" }, +}; + +export function App() { + return ( + + + + + ); +} +``` + +--- + +## Base Libraries + +### axios — Modified + +HTTP calls use a constrained axios client created via an internal `client(apiBaseUrl)`. + +-> **Their docs:** [axios](https://www.npmjs.com/package/axios) + +**What’s different here:** + +- `baseURL` is taken from your `SaasConfig.apiBaseUrl` +- `x-account-id` is injected from storage +- POST JSON header defaults are set + +**What we add on top:** + +- account switching/persistence in `AccountsProvider` that controls the `x-account-id` header + +--- + +## Features + +### 1) Provide config (`ConfigProvider` / `useConfig`) + +`useConfig()` reads config from context and throws if it is missing. In most apps, prefer `SaasWrapper`, which wraps your tree with `ConfigProvider`. + +```typescript +import { ConfigProvider, useConfig } from "@prefabs.tech/saas-react"; + +function Child() { + const config = useConfig(); + return null; +} + +function Root({ saas }: { saas: any }) { + return ( + + + + ); +} +``` + +### 2) Provide accounts (`AccountsProvider` / `useAccounts`) + +`AccountsProvider` stores the user’s accounts list, active account, and switching/persistence behavior for `x-account-id`. + +```typescript +import { AccountsProvider, useAccounts } from "@prefabs.tech/saas-react"; + +function AccountName() { + const { activeAccount } = useAccounts(); + return {activeAccount?.name}; +} + +function Root({ saas, userId }: { saas: any; userId?: string }) { + return ( + + + + ); +} +``` + +### 3) Use `SaasWrapper` for app gating + +`SaasWrapper` runs the domain registration check, renders loading/error UI when needed, normalizes config defaults, and conditionally installs `AccountsProvider` (non-admin apps only). + +```typescript +import { SaasWrapper } from "@prefabs.tech/saas-react"; + +void SaasWrapper; +``` + +### 4) Generate routes (React Router) + +The package exposes helpers that return `` elements for the built-in pages. + +```typescript +import { getSaasAdminRoutes, getSaasAppRoutes } from "@prefabs.tech/saas-react/routes"; + +const adminRoutes = getSaasAdminRoutes("authenticated"); +const appRoutes = getSaasAppRoutes("authenticated"); +``` + +You can override elements and disable routes via the `options` argument. + +```typescript +import { getSaasAppRoutes } from "@prefabs.tech/saas-react/routes"; + +const routes = getSaasAppRoutes("unauthenticated", { + routes: { + signup: { disabled: true }, + }, +}); + +void routes; +``` + +### 5) Call the API (thin axios helpers) + +```typescript +import { doesAccountExist, getMyAccounts, signup } from "@prefabs.tech/saas-react/api"; + +await doesAccountExist({ apiBaseUrl: "https://api.example.com" }); +await getMyAccounts({ apiBaseUrl: "https://api.example.com" }); +await signup({ + apiBaseUrl: "https://api.example.com", + path: "/auth/signup", + data: { email: "a@b.com", password: "Password1", confirmPassword: "Password1" } as any, + accountSignup: false, +}); +``` + +### 6) Use query/mutation hooks + +`useQuery` auto-runs by default unless `lazy` or `skip` is set; `useMutation` exposes an imperative trigger. + +```typescript +import { useQuery, useMutation } from "@prefabs.tech/saas-react/api"; + +function Example() { + const { data, loading, error, trigger } = useQuery<{ ok: boolean }>( + "my-account", + {}, + { lazy: true }, + ); + + const mutation = useMutation<{ ok: boolean }, { name: string }>({ + method: "POST", + }); + + return null; +} +``` + +### 7) Use the exported components/pages + +```typescript +import { + AccountSwitcher, + AccountsTable, + MyAccounts, + AccountForm, + AccountInvitationForm, + AccountUsersTable, + AccountSignupForm, + UserSignupForm, +} from "@prefabs.tech/saas-react"; + +void AccountSwitcher; +void AccountsTable; +void MyAccounts; +void AccountForm; +void AccountInvitationForm; +void AccountUsersTable; +void AccountSignupForm; +void UserSignupForm; +``` + +--- + +## Use Cases + +### Use case 1: Main app vs tenant app behavior + +```typescript +import { useAccounts } from "@prefabs.tech/saas-react"; + +function TenantAware() { + const { + meta: { isMainApp, subdomain }, + } = useAccounts(); + + if (!isMainApp) { + void subdomain; + } + + return null; +} +``` + +### Use case 2: Persist the active account across sessions + +```typescript +import type { SaasConfig } from "@prefabs.tech/saas-react"; + +const saas: SaasConfig = { + apiBaseUrl: "https://api.example.com", + entity: "both", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + accounts: { + allowMultipleSessions: true, + autoSelectAccount: true, + }, +}; + +void saas; +``` + +### Use case 3: Disable built-in signup routes (SSO-only apps) + +```typescript +import { getSaasAppRoutes } from "@prefabs.tech/saas-react/routes"; + +const routes = getSaasAppRoutes("unauthenticated", { + routes: { + signup: { disabled: true }, + invitationSignup: { disabled: true }, + }, +}); + +void routes; +``` + diff --git a/packages/react/README.md b/packages/react/README.md index 10c655d..d6c7674 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,311 +1,105 @@ # @prefabs.tech/saas-react -This package provides essential tools and components to support SaaS functionality in React applications. It simplifies account management, routing, and configuration for multi-tenant SaaS platforms. +SaaS building blocks for React: account state/context, route helpers, and ready-made components/pages for multi-tenant apps. -## Installation +## Why This Package? -Install with npm: +Multi-tenant SaaS apps tend to rebuild the same pieces: “active account” state, API header wiring, admin vs app routing, and invitation/signup flows. This package provides a consistent contract (contexts + hooks + routes + components) so apps can share the same tenant behavior with less glue code. -```bash -npm install @prefabs.tech/saas-react -``` +## What You Get -Install with pnpm: +### axios — Modified -```bash -pnpm add --filter "@scope/project" @prefabs.tech/saas-react -``` +Wraps [`axios`](https://www.npmjs.com/package/axios) behind a constrained client factory: -## Usage +- **Base URL**: set from your SaaS config (`apiBaseUrl`) +- **Headers**: sets JSON content type for POST and injects `x-account-id` from storage -### Basic setup +### Added by This Package -To use this package, update the App.tsx to wrap your application code with `SaasWrapper`. This wrapper manages account-related states and configurations. +- **Contexts/providers** + - `ConfigProvider` + `useConfig()` for accessing `SaasConfig` + - `AccountsProvider` + `useAccounts()` for accounts list + active account + switching +- **Route helpers** for `react-router-dom` + - `getSaasAdminRoutes(...)` + - `getSaasAppRoutes(...)` +- **API utilities & hooks** + - `useQuery(...)` and `useMutation(...)` wrappers with default behaviors +- **UI components & pages** for common SaaS flows + - account management (forms/tables) + - invitations (invite/join/accept) + - signup (account signup vs user signup) -```typescript -import { useUser } from "@prefabs.tech/react-user"; -import { SaasWrapper } from "@prefabs.tech/saas-react"; +## Usage Guidelines + +- **Always render `SaasWrapper` (or at minimum `ConfigProvider`) above any hooks/components that need config.** + - `useConfig()` throws if `ConfigProvider` is missing. +- **Render `AccountsProvider` (or use `SaasWrapper` in non-admin apps) above anything that calls `useAccounts()`.** + - `useAccounts()` throws if `AccountsProvider` is missing. +- **Be intentional about account persistence behavior.** + - Account switching writes/removes `x-account-id` in `localStorage` and optionally `sessionStorage` (when `allowMultipleSessions` is enabled). + +## Requirements + +- **React**: `react`, `react-dom` +- **Routing**: `react-router-dom` +- **UI dependencies consumed by components**: + - `@prefabs.tech/react-ui`, `@prefabs.tech/react-form`, `@prefabs.tech/react-i18n` + - `react-toastify` + - `primereact` + +## Quick Start + +```ts import { ToastContainer } from "react-toastify"; +import { SaasWrapper } from "@prefabs.tech/saas-react"; -import config from "./config"; -import { AppRouter } from "./Router"; +import type { SaasConfig } from "@prefabs.tech/saas-react"; -function App() { - const { user } = useUser(); +const saas: SaasConfig = { + apiBaseUrl: "https://api.example.com", + entity: "both", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + mainApp: { subdomain: "app" }, +}; +export function App() { return ( - - + + {/* your routes */} ); } - -export default App; -``` - -### Configuration - -The `SaasWrapper` accepts a `config` prop to customize its behavior. Below are the available options with detailed explanations: - -```typescript -export type SaasConfig = { - accounts?: { - autoSelectAccount?: boolean; - allowMultipleSessions?: boolean; - signup?: { - apiPath?: string; - appRedirection?: boolean; - termsAndConditionsUrl?: string; - }; - }; - apiBaseUrl: string; - entity: "both" | "individual" | "organization"; - mainAppSubdomain: string; - rootDomain: string; - multiDatabase: boolean; - saasAccountRoles?: string[]; - subdomains: "required" | "optional" | "disabled"; - ui?: { - account?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - invitation?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - signup?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - }; -}; ``` -### Detailed attribute descriptions - -- **`accounts`**: Contains account-specific configurations. - - **`autoSelectAccount`**: When enabled (default), the system automatically selects an account for the user if they have only one account. - - **`allowMultipleSessions`**: When enabled (default), users can maintain multiple active sessions across different accounts. - - **`signup`**: Configuration for signup-related operations. - - **`apiPath`**: The api path for signup - - **`appRedirection`**: Indicates whether to redirect to the app after signup. - - **`termsAndConditionsUrl`**: url for the terms and conditions page. -- **`apiBaseUrl`**: The base url for all api requests. -- **`entity`**: The type of accounts allowed. -- **`mainAppSubdomain`**: [Depricated: use `mainApp.subdomain`] Specifies the subdomain for the main application. -- **`mainApp`**: Configuration for main app's domain and subdomain - - **`subdomain`**: Specifies the subdomain for the main application - - **`domain`**: Specifies the full domain for the main applications -- **`rootDomain`**: The root domain of your SaaS platform. -- **`multiDatabase`**: Indicates whether the SaaS platform supports multiple databases. -- **`saasAccountRoles`**: A list of roles available for accounts in the SaaS platform. Used when inviting users to an account. -- **`subdomains`**: Specifies the subdomain behavior for the SaaS platform. Options include: - - `"required"`: Subdomains are mandatory for tenant identification. - - `"optional"`: Subdomains are optional and can be used if needed. - - `"disabled"`: Subdomains are not used in the SaaS platform. - **`ui`**: UI related configuration for customization. - -### Routing - -This package provides pre-configured routes for SaaS applications, designed to simplify navigation in multi-tenant platforms. The two main methods for generating routes are: - -#### `getSaasAdminRoutes` - -- **Purpose**: Generates routes for the admin section of a SaaS application. -- **Ideal use case**: Use this method in your **Admin App**. -- **Parameters**: - - `type`: Specifies the type of routes. Options: - - `"authenticated"` (default): Routes for authenticated users. - - `"unauthenticated"`: Routes for unauthenticated users. No routes are returned at the moment. - - `"public"`: Routes for public users. No routes are returned at the moment. - - `options`: An optional object to customize routes. Following route options are available under `options.routes`: - - `accountsAdd`: Customizes the "Add Account" route. - - `accountsEdit`: Customizes the "Edit Account" route. - - `accountsView`: Customizes the "Viefw Account" route. - -#### `getSaasAppRoutes` - -- **Purpose**: Generates routes for the main application section of a SaaS platform. -- **Ideal use case**: Use this method in your **Main App**. -- **Parameters**: - - `type`: Specifies the type of routes. Options: - - `"authenticated"` (default): Routes for authenticated users. - - `"unauthenticated"`: Routes for unauthenticated users. - - `"public"`: Routes for public access. - - `options`: An optional object to customize routes. Following route options are available under `options.routes`: - - `accountSettings`: Customizes the "Account Settings" route. - - `invitationAccept`: Customizes the "Accept Invitation" route. - - `invitationJoin`: Customizes the "Join Invitation" route. - - `invitationSignup`: Customizes the "Signup Invitation" route. - - `myAccounts`: Customizes the "My Accounts" route. - - `signup`: Customizes the "Signup" route. - -### Example usage - -```typescript -import { - getSaasAdminRoutes, - getSaasAppRoutes, -} from "@prefabs.tech/saas-react/routes"; +## Installation -// Admin App -const adminRoutes = getSaasAdminRoutes(); +Install with npm: -// Main App -const appRoutes = getSaasAppRoutes(); +```bash +npm install @prefabs.tech/saas-react ``` -These methods allow you to easily define and manage routes for different sections of your SaaS platform. - -### Components and pages - -This package provides several reusable components and pages to simplify SaaS application development. Below is a list of all exported components and pages: - -#### Components - -- **Account components**: - - `AccountSwitcher`: Allows users to switch between accounts. - - `AccountForm`: A form for creating or editing account details. - - `AccountInfo`: Displays detailed information about an account. - - `AccountInvitationForm`: A form for sending account invitations. - - `AccountInvitationModal`: A modal for managing account invitations. - - `AccountInvitationsTable`: Displays a table of account invitations. - - `AccountSignupForm`: A form for signing up for an account. - - `AccountUsersTable`: Displays a table of users associated with an account. - - `AccountsTable`: Displays a table of all accounts. - - `MyAccounts`: Displays a list of accounts associated with the user. - - `UserSignupForm`: A form for user signup. - -#### Pages - -- **AcceptInvitation pages**: - - `AcceptInvitationPage`: Handles the process of accepting an invitation to join or signup to an account. - - `JoinInvitationPage`: Manages the process of joining an account via an invitation. - - `SignupInvitationPage`: Facilitates signing up for an account through an invitation. - -- **Account pages**: - - `AccountAddPage`: Provides a page for adding a new account. - - `AccountEditPage`: Allows editing of existing account details. - - `AccountSettingsPage`: Displays and manages account information including user and invitations. - - `AccountViewPage`: Shows detailed information about a specific account. - -- **MyAccounts pages**: - - `MyAccountsPage`: Displays a list of accounts associated with the user and allows account switching or management. - -- **Signup pages**: - - `SignupPage`: Facilitates user signup and account creation processes. Dynamically renders either an `AccountSignupForm` or `UserSignupForm` based on the app context (main app or user app). - -These components and pages can be imported and used directly in your application. - -### Customizing tabs in `AccountViewPage` and `AccountSettingsPage` - -To cutomize the tabs in `AccountViewPage` in admin app and `AccountSettingsPage` in user app, you can import these page components in your app and pass it as custom element to the route option in `getSaasAdminRoutes` or `getSaasAppRoutes`. Then you can use the page props to customize the tabs in the page along with other attributes. +Install with pnpm: -```{typescript} -{getSaasAdminRoutes("authenticated", { - routes: { - // accountSettings in case of user app - accountsView: { - element: ( - in case of user app - showToolbar={true} // to show/hide page toolbar, supported only in AccountViewPage - tabs={[ - { - key: "newTab", - label: "New tab", - children:
Content of new tab
, - }, - ]} - activeTab="info" - visibleTabs={["info", "users", "invitations", "newTab"]} - /> - ), - }, - }, -})} +```bash +pnpm add @prefabs.tech/saas-react ``` -- To add new tab, add new tab (type: AccountTab) object in `tabs` prop -- To change the default tab when opening the page, update value in the `activeTab` prop -- To change the order of tabs, change order of elements in the `visibleTabs` prop -- To customize existing tab, add an object in tabs with its key and update any attribute. for example `{key: "info", label: "Information"}` - -See component to check prop types and other options: [AccountViewPage](https://github.com/prefabs-tech/saas/blob/main/packages/react/src/views/Account/AccountView.tsx) [AccountSettingsPage](https://github.com/prefabs-tech/saas/blob/main/packages/react/src/views/Account/AccountSettings.tsx) - -### Customizing form actions aligment +## Testing -To customize the actions alignment in forms, you can pass following config options in saas config +From the monorepo root: -```{typescript} -ui?: { - account?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - invitation?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - signup?: { - form?: { - actionsAlignment?: "center" | "fill" | "left" | "right"; - actionsReverse?: boolean; - }; - }; - }; +```bash +pnpm test --filter @prefabs.tech/saas-react ``` -The current default values are as following +From this package folder: -```{typescript} -export const CONFIG_UI_DEFAULT = { - account: { - form: { - actionsAlignment: "right" as const, - actionsReverse: false, - }, - }, - invitation: { - form: { - actionsAlignment: "fill" as const, - actionsReverse: true, - }, - }, - signup: { - form: { - actionsAlignment: "fill" as const, - actionsReverse: true, - }, - }, -}; +```bash +pnpm test +pnpm test:unit ``` - -You can play around with `actionsAligment` and `actionsReverse` configs to get desired alignment of form actions. - -### i18n support - -This package uses `@prefabs.tech/react-i18n` for translations. By default, it uses the following namespaces: - -- `account` -- `accounts` - -These namespaces are available in the following locales: - -- **English (`en`)**: `locales/en/account.json`, `locales/en/accounts.json` -- **French (`fr`)**: `locales/fr/account.json`, `locales/fr/accounts.json` - -Ensure you register these namespaces in your application's i18n setup. - -Refer to the `locales/en` and `locales/fr` folders for the required translation keys. diff --git a/packages/react/setup-test.ts b/packages/react/setup-test.ts new file mode 100644 index 0000000..6601d1f --- /dev/null +++ b/packages/react/setup-test.ts @@ -0,0 +1,8 @@ +// Intentionally minimal. +// This file exists because `vitest.config.ts` references it via `setupFiles`. +// Add shared test polyfills/mocks here if needed by future tests. + +// Silence React 18 act() environment warnings in Vitest/jsdom. +// See https://react.dev/reference/react/act +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/react/unit/account.test.ts b/packages/react/unit/account.test.ts new file mode 100644 index 0000000..eb050f9 --- /dev/null +++ b/packages/react/unit/account.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { prepareSignupData } from "@/utils/account"; + +import type { AccountSignupData, UserSignupData } from "@/types/account"; + +describe("prepareSignupData", () => { + it("creates user signup payload when accountSignup=false", () => { + const payload = prepareSignupData({ + accountSignup: false, + data: { email: "a@b.com", password: "secret" } satisfies UserSignupData, + }); + + expect(payload).toEqual({ + formFields: [ + { id: "email", value: "a@b.com" }, + { id: "password", value: "secret" }, + ], + }); + }); + + it("nulls company fields when individual=true", () => { + const payload = prepareSignupData({ + data: { + email: "a@b.com", + password: "secret", + name: "Name", + individual: true, + registeredNumber: "RN", + taxId: "TAX", + slug: "s", + useSeparateDatabase: true, + } satisfies AccountSignupData, + }); + + expect(payload.accountFormFields).toEqual( + expect.arrayContaining([ + { id: "registeredNumber", value: null }, + { id: "taxId", value: null }, + ]), + ); + }); + + it("forces useSeparateDatabase=false when slug is missing", () => { + const payload = prepareSignupData({ + data: { + email: "a@b.com", + password: "secret", + name: "Name", + individual: false, + registeredNumber: "RN", + taxId: "TAX", + slug: "", + useSeparateDatabase: true, + } satisfies AccountSignupData, + }); + + expect(payload.accountFormFields).toEqual( + expect.arrayContaining([{ id: "useSeparateDatabase", value: false }]), + ); + }); +}); diff --git a/packages/react/unit/common.test.ts b/packages/react/unit/common.test.ts new file mode 100644 index 0000000..30aadd5 --- /dev/null +++ b/packages/react/unit/common.test.ts @@ -0,0 +1,76 @@ +import axios from "axios"; +import { JSDOM } from "jsdom"; +import { describe, expect, it, vi } from "vitest"; + +import { client } from "@/api/axios/client"; +import { ACCOUNT_HEADER_NAME } from "@/constants"; +import { checkIsAdminApp } from "@/utils/common"; + +describe("checkIsAdminApp", () => { + it("returns true when the subdomain is admin", () => { + const dom = new JSDOM("", { url: "https://admin.example.com/" }); + const windowObject = dom.window as unknown as Window & typeof globalThis; + const documentObject = dom.window.document as unknown as Document; + + vi.stubGlobal("window", windowObject); + vi.stubGlobal("document", documentObject); + expect(checkIsAdminApp()).toBe(true); + vi.unstubAllGlobals(); + }); + + it("returns false when the subdomain is not admin", () => { + const dom = new JSDOM("", { url: "https://app.example.com/" }); + const windowObject = dom.window as unknown as Window & typeof globalThis; + const documentObject = dom.window.document as unknown as Document; + + vi.stubGlobal("window", windowObject); + vi.stubGlobal("document", documentObject); + expect(checkIsAdminApp()).toBe(false); + vi.unstubAllGlobals(); + }); +}); + +describe("client (axios factory)", () => { + it("creates axios instance with baseURL and default post JSON content-type", () => { + const createSpy = vi.spyOn(axios, "create").mockReturnValue({} as never); + + client("https://api.example.com"); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://api.example.com", + headers: expect.objectContaining({ + post: { "Content-Type": "application/json" }, + }), + }), + ); + }); + + it("prefers localStorage account id when sessionStorage has any value (current behavior)", () => { + const createSpy = vi.spyOn(axios, "create").mockReturnValue({} as never); + + sessionStorage.setItem(ACCOUNT_HEADER_NAME, "session-1"); + localStorage.setItem(ACCOUNT_HEADER_NAME, "local-1"); + + client("https://api.example.com"); + + const argument = createSpy.mock.calls[0]?.[0] as unknown as { + headers?: Record; + }; + expect(argument.headers?.[ACCOUNT_HEADER_NAME]).toBe("local-1"); + }); + + it("does not read localStorage when sessionStorage is empty (current behavior)", () => { + const createSpy = vi.spyOn(axios, "create").mockReturnValue({} as never); + + sessionStorage.removeItem(ACCOUNT_HEADER_NAME); + localStorage.setItem(ACCOUNT_HEADER_NAME, "local-1"); + + client("https://api.example.com"); + + const argument = createSpy.mock.calls[0]?.[0] as unknown as { + headers?: Record; + }; + expect(argument.headers?.[ACCOUNT_HEADER_NAME]).toBeUndefined(); + }); +}); diff --git a/packages/vue/ANALYSIS.md b/packages/vue/ANALYSIS.md new file mode 100644 index 0000000..2cba6b9 --- /dev/null +++ b/packages/vue/ANALYSIS.md @@ -0,0 +1,232 @@ + + +## Package + +- **Path**: `packages/vue` +- **Name**: `@prefabs.tech/saas-vue` +- **Type**: ESM package (`"type": "module"`) +- **Runtime deps**: (none declared in `dependencies`, but code consumes peer/dev deps such as `vue`, `vue-router`, `pinia`, `axios`, `zod`, `vee-validate`, `@vee-validate/zod`, and Prefabs Vue packages) +- **Peer deps (consumed by our code)**: `vue`, `vue-router`, `pinia`, `axios`, `zod`, `vee-validate`, `@vee-validate/zod`, `@vueuse/core`, `@prefabs.tech/vue3-config`, `@prefabs.tech/vue3-i18n`, `@prefabs.tech/vue3-layout`, `@prefabs.tech/vue3-ui`, `@prefabs.tech/vue3-user` + +## Entry points & public exports + +### `src/index.ts` + +- **Injection keys / symbols (OURS)**: + - `Symbol.for("saas.config")` + - `Symbol.for("saas.eventHandlers")` + - `Symbol.for("saas.accountTabs")` + - `Symbol.for("saas.vue.translations")` +- **Default translations (OURS)**: + - `defaultMessages = { en: enMessages, fr: frMessages }` +- **Vue plugin (OURS)**: default export `plugin` + - `install(app, options)`: + - prepares config via `prepareConfig(options.saasConfig)` + - provides config + translations + event handlers + account tab config +- **Composables / exports (OURS)**: + - `useTranslations()` (injects `saas.vue.translations` with fallback to defaults) + - routes: `export * from "./routes"` + - types: `export * from "./types/routes"` + - account management exports: + - `useMyAccountsStore` (Pinia store) + - `useMyAccounts` (composable) + - `AccountSwitcher` component + - `SaasAccountsProvider` + - `SaasWrapper` + - `ConfigProvider` + - error handling exports: + - `useGlobalAccountError` + - `NotFoundMessage` + - views: + - `AccountSettings` + - `MyAccounts` + - `DEFAULT_PATHS` + - utilities: + - `checkIsAdminApp` + +## Base Library Passthrough Analysis + +### `axios` — MODIFIED + +- **Options type**: base library used directly; we don’t expose `AxiosRequestConfig`. +- **Options passed**: **transformed** + - `client(baseURL)` creates an axios instance with: + - `baseURL` + - POST JSON header + - `x-account-id` header from `sessionStorage` (or empty string) +- **Features restricted**: callers use our fixed instance config (no per-call customization besides request args). +- **Features added**: + - account header wiring via `x-account-id` + +### `vue-router` — PARTIAL PASSTHROUGH + +- **Options type**: `RouteRecordRaw`, `Router` from `vue-router` +- **Options passed**: **modified/merged** + - We define default route records and merge overrides into them (including `meta` deep-merge). +- **Features restricted**: `getSaas*Routes` returns only the routes for the selected “type”; override shape is limited to `RouteOverwrite`. +- **Features added**: + - `addSaasAdminRoutes(router, ...)` / `addSaasAppRoutes(router, ...)` convenience helpers + +### `zod` / `vee-validate` / `@vee-validate/zod` — NO WRAPPED DEPENDENCY (used directly) + +Used directly in validation helper files and form components; we don’t wrap these libraries as a separate abstraction layer. + +### Prefabs Vue packages (`@prefabs.tech/vue3-*`) — NO WRAPPED DEPENDENCY (used directly) + +Consumed directly in components/views (UI/form/i18n/config/user). The “package” surface we expose is our own components/composables/routes/plugin. + +## “Ours” vs “Theirs” classification + +### Plugin & injection (`src/index.ts`) + +- **OURS** + - `plugin.install`: + - **conditional**: `options.translations ? prependMessages(defaultMessages, options.translations) : defaultMessages` + - **provides**: + - config (prepared) + - translations + - event handlers (`notification`) + - account tabs configuration + - `useTranslations()` injects translations with default fallback + +- **THEIRS** + - `prependMessages(...)` is a direct helper from `@prefabs.tech/vue3-i18n` (used as intended). + - `app.provide`, `inject` are Vue framework primitives. + +### Config normalization (`src/utils/config.ts`) + +- **OURS** + - `prepareUiConfig(ui = {})`: merges `CONFIG_UI_DEFAULT` with provided UI config + - `prepareConfig(config)`: returns config with `ui` normalized via `prepareUiConfig` + - **branching**: none beyond defaulting `ui` parameter + +### Constants & defaults (`src/constant.ts`) + +- **OURS** + - `ACCOUNT_HEADER_NAME = "x-account-id"` + - `ADMIN_SUBDOMAIN_DEFAULT = "admin"` + - `SIGNUP_PATH_DEFAULT = "/auth/signup"` + - `SAAS_ACCOUNT_ROLES_DEFAULT` (owner/member) + - `DEFAULT_PATHS` routing defaults + - `REDIRECT_AFTER_LOGIN_KEY = "saas.redirectAfterLogin"` + - `CONFIG_UI_DEFAULT` for form action alignment/reversal (**note**: `"filled"` in Vue vs `"fill"` in React) + +### Utilities (`src/utils/common.ts`, `src/utils/account.ts`) + +- **OURS** + - `checkIsAdminApp()` subdomain comparison to `"admin"` + - `prepareSignupData({ data, accountSignup=true })` + - **branch**: account vs user signup payload shape + - **defaults**: `accountSignup=true`, `useSeparateDatabase=false` if no slug + +### API wrappers (`src/api/*`) + +- **OURS** + - `client(baseURL)` wraps `axios.create` with default headers and account-id injection + - Accounts API (`src/api/accounts.ts`): + - CRUD + “my account(s)” calls + - `signup({ accountSignup=true, ... })` uses `prepareSignupData` + - Invitation API (`src/api/AccountInvitations.ts`): + - **branch**: token endpoints vary based on optional `accountId` (builds URL differently) + - `withCredentials: false` for token-based flows + - Users API (`src/api/AccountUsers.ts`): enable/disable and list users + +- **THEIRS** + - `axios` request execution is direct base library usage inside our thin wrappers. + +### Stores & composables + +- **OURS** + - `useMyAccountsStore` (`src/stores/MyAccounts.ts`): + - **defaults**: `autoSelectAccount=true`, `allowMultipleSessions=true` + - **branching**: + - compute `meta.isMainApp` from subdomain vs `mainAppSubdomain` + - non-main app requires slug match; throws if not found + - main app selects default account based on auto-select / saved account id + - saved account id prefers session when enabled + - **side effects**: storage writes/removals for `x-account-id` + - **wiring**: calls API helpers `getMyAccounts`, `getMyAccount`, `updateMyAccount` + - `useMyAccounts(config?)` (`src/composables/UseMyAccounts.ts`): + - **branching**: + - config resolution order: argument → injected `saas.config` → existing store + - throws when config missing and store not initialized + - initializes store once config is available + - `useGlobalAccountError` (`src/composables/UseGlobalAccountError.ts`): + - **branch**: sets global flag only for 404 + `"Account not found"` message + - provides `clearError()` + - `useConfig()` (`src/composables/UseConfig.ts`): + - injects `"config"` with default object + - **note**: this is separate from `saas.config` symbol injection used elsewhere + +### Routes (`src/routes/*`) + +- **OURS** + - `getSaasAdminRoutes(type="authenticated", options?)` + - includes 4 default admin routes; for unauthenticated/public returns [] + - supports route override merging via `getRoute(...)` + - filters `disabled` routes out + - `addSaasAdminRoutes(router, ...)` adds computed routes to router + - `getSaasAppRoutes(type="authenticated", options?)` + - **branches**: authenticated vs unauthenticated vs public route sets + - supports override merging + disabled filtering + - `addSaasAppRoutes(router, ...)` + +### Components / views (selected key behavior) + +- **OURS** + - `SaasWrapper.vue`: + - **branches**: + - shows loading, not-found (global account error), 404 page, generic error page + - wraps slot with `ConfigProvider` always; wraps with `SaasAccountsProvider` only when not admin app + - **lifecycle**: `onMounted` calls `doesAccountExist`, stores `error`, toggles loading + - **requires injection**: `saas.config` must exist (throws otherwise) + - `SaasAccountsProvider.vue`: + - **branch**: renders slot immediately if no user; otherwise waits for accounts load + - **lifecycle**: `watch(userId)` fetches accounts; on sign-out clears store state + - integrates `useGlobalAccountError().checkForAccountError` + - `ConfigProvider.vue` provides `saas.config` + - `AccountSwitcher.vue`: + - **defaults**: `emptyLabel=""`, `noHelperText=false` + - **branch**: no-op when selecting current account + - **side effect**: `window.location.reload()` after switching accounts + - `AccountSettings.vue`: + - **tabs**: merges default tabs with injected `saas.accountTabs` (function or array) + - **branch**: fetches my account only when activeAccount missing; uses global account error detection + - Signup view (`views/Signup/Index.vue`): + - **branch**: uses account signup form in main app, otherwise user signup form + - **defaults**: `apiPath = SIGNUP_PATH_DEFAULT`, `appRedirection=true` + - **branch**: redirects to `{slug}.{rootDomain}` after successful signup when enabled and slug present + - uses injected `saas.eventHandlers.notification` for error messaging + +## Framework constructs / lifecycle / side effects + +- **Vue plugin**: `install(app, options)` with multiple `app.provide` calls +- **Injection**: + - config: `Symbol.for("saas.config")` + - translations: `Symbol.for("saas.vue.translations")` + - event handlers: `Symbol.for("saas.eventHandlers")` + - account tabs: `Symbol.for("saas.accountTabs")` +- **Pinia store**: `defineStore("myAccounts", () => ...)` +- **Routing**: returns `RouteRecordRaw[]` and provides router add helpers +- **Side effects**: + - local/session storage for `x-account-id` + - `window.location.reload()` on account switcher select + - `window.location.replace(...)` for post-signup redirection + +## Conditional branches & defaults (high-signal) + +- **Translations**: merge additional messages only when `options.translations` exists +- **Admin vs app**: `checkIsAdminApp()` compares subdomain to `"admin"` +- **Route sets**: depend on `type` in `getSaasAdminRoutes` / `getSaasAppRoutes` +- **Account selection**: slug-based when not main app; saved-account precedence; auto-select fallback +- **Account error global flag**: only for \(404 + "Account not found"\) +- **Signup redirect**: only when `appRedirection && isMainApp && data.slug` + +## Completeness checklist + +- [x] Classified every public export category as "ours" or "theirs" +- [x] Listed framework constructs added (plugin, provide/inject, Pinia store, router helpers) +- [x] Identified conditional branches (routing type, translation merge, admin/app, account selection, signup redirect, error flag) +- [x] Documented default values we define (constants, config UI defaults, store defaults, route defaults) +- [x] Produced passthrough classification for wrapped dependencies (`axios`, `vue-router`) + diff --git a/packages/vue/FEATURES.md b/packages/vue/FEATURES.md new file mode 100644 index 0000000..00f4026 --- /dev/null +++ b/packages/vue/FEATURES.md @@ -0,0 +1,149 @@ + + +# @prefabs.tech/saas-vue — Features + +## Plugin & Injection + +1. **Vue plugin install hook**: `app.use(SaasVue, options)` prepares `options.saasConfig` and provides it via `Symbol.for("saas.config")`. + +```typescript +import SaasVue from "@prefabs.tech/saas-vue"; + +app.use(SaasVue, { + pinia, + router, + config, + saasConfig: { + apiBaseUrl: "https://api.example.com", + entity: "both", + mainAppSubdomain: "app", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + }, +}); +``` + +2. **Translations provider**: plugin provides translations at `Symbol.for("saas.vue.translations")` and exports `useTranslations()` (with default fallback). + +```typescript +import { useTranslations } from "@prefabs.tech/saas-vue"; + +const messages = useTranslations(); +``` + +3. **Event handlers provider**: plugin provides event handlers at `Symbol.for("saas.eventHandlers")` (supports `notification`). + +```typescript +app.use(SaasVue, { + // ... + notification: (message) => console.log(message.type, message.message), +}); +``` + +4. **Account tab customization provider**: plugin provides optional `accountTabs` at `Symbol.for("saas.accountTabs")` (consumed by `AccountSettings`). + +```typescript +app.use(SaasVue, { + // ... + accountTabs: (defaultTabs) => [...defaultTabs], +}); +``` + +## Routing + +5. **Admin route records**: `getSaasAdminRoutes(type?, options?)` returns `RouteRecordRaw[]` with override merging and `disabled` filtering. + +```typescript +import { getSaasAdminRoutes } from "@prefabs.tech/saas-vue"; + +const routes = getSaasAdminRoutes("authenticated", { + routes: { accountsView: { disabled: true } }, +}); +``` + +6. **Admin route registration helper**: `addSaasAdminRoutes(router, type?, options?)` adds routes to a `vue-router` instance. + +```typescript +import { addSaasAdminRoutes } from "@prefabs.tech/saas-vue"; + +addSaasAdminRoutes(router); +``` + +7. **App route records**: `getSaasAppRoutes(type?, options?)` returns app routes for authenticated/unauthenticated/public modes with override merging and `disabled` filtering. + +```typescript +import { getSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +const routes = getSaasAppRoutes("public"); +``` + +8. **App route registration helper**: `addSaasAppRoutes(router, type?, options?)` adds app routes to a router. + +```typescript +import { addSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +addSaasAppRoutes(router, "authenticated"); +``` + +9. **Default paths**: exports `DEFAULT_PATHS` for built-in route paths. + +```typescript +import { DEFAULT_PATHS } from "@prefabs.tech/saas-vue"; + +DEFAULT_PATHS.ACCOUNT_SETTINGS; +``` + +## Account State (Pinia) & Composables + +10. **Accounts store**: exports `useMyAccountsStore()` with state + actions to fetch/update/switch accounts. + +```typescript +import { useMyAccountsStore } from "@prefabs.tech/saas-vue"; + +const store = useMyAccountsStore(); +store.switchAccount(null); +``` + +11. **Config resolution helper**: exports `useMyAccounts(config?)` which resolves config from parameter → injection (`saas.config`) → existing store initialization. + +```typescript +import { useMyAccounts } from "@prefabs.tech/saas-vue"; + +const store = useMyAccounts(); +``` + +12. **Account persistence**: switching accounts writes `x-account-id` to `localStorage` and optionally `sessionStorage` (based on `accounts.allowMultipleSessions`). + +13. **Main app vs tenant app detection**: store exposes `meta.isMainApp` based on current subdomain vs `mainAppSubdomain`. + +## Components & Views + +14. **Wrapper component**: `SaasWrapper` performs a domain-registration check (`doesAccountExist`) and renders loading/not-found/error UI before providing config and account context. + +15. **Accounts provider component**: `SaasAccountsProvider` initializes the store from config and fetches accounts when `userId` is present; clears account state on sign-out. + +16. **Config provider component**: `ConfigProvider` provides SaaS config via `Symbol.for("saas.config")`. + +17. **Account switcher component**: `AccountSwitcher` switches accounts; selecting a different account triggers `window.location.reload()`. + +18. **Not-found component**: exports `NotFoundMessage` for the “account not found” global error case. + +19. **Views**: exports `AccountSettings` and `MyAccounts`. + +## Error Handling + +20. **Global account error latch**: exports `useGlobalAccountError()` which flips a global flag for \(404 + `"Account not found"`\) and exposes `clearError()`. + +## Utilities & Config Defaults + +21. **Admin app detection utility**: exports `checkIsAdminApp()` (subdomain equals `"admin"`). + +22. **UI defaults merging**: SaaS config UI is normalized by merging against `CONFIG_UI_DEFAULT`. + +## Signup Flow + +23. **Signup payload shaping**: signup uses `prepareSignupData({ accountSignup })` to send user-only or account+user field payloads. + +24. **Post-signup redirect (main app)**: signup view can redirect to `{slug}.{rootDomain}` when enabled and a `slug` exists. + diff --git a/packages/vue/GUIDE.md b/packages/vue/GUIDE.md new file mode 100644 index 0000000..337725f --- /dev/null +++ b/packages/vue/GUIDE.md @@ -0,0 +1,272 @@ + + +# @prefabs.tech/saas-vue — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/saas-vue +``` + +```bash +pnpm add @prefabs.tech/saas-vue +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/saas-vue typecheck +pnpm --filter @prefabs.tech/saas-vue build +``` + +## Setup + +This is the only “full setup” example. All later snippets assume the plugin is installed and you have a router + pinia. + +```typescript +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import { createRouter, createWebHistory } from "vue-router"; + +import SaasVue, { addSaasAppRoutes, addSaasAdminRoutes } from "@prefabs.tech/saas-vue"; + +const app = createApp(App); +const pinia = createPinia(); +const router = createRouter({ history: createWebHistory(), routes: [] }); + +app.use(pinia); +app.use(router); + +app.use(SaasVue, { + pinia, + router, + config: {} as any, // AppConfig from @prefabs.tech/vue3-config + saasConfig: { + apiBaseUrl: "https://api.example.com", + entity: "both", + mainAppSubdomain: "app", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + }, +}); + +addSaasAppRoutes(router, "authenticated"); +addSaasAdminRoutes(router, "authenticated"); + +app.mount("#app"); +``` + +--- + +## Base Libraries + +### axios — Modified + +HTTP calls are made via an internal `client(baseURL)` that creates an axios instance with opinionated defaults. + +-> **Their docs:** [axios](https://www.npmjs.com/package/axios) + +**What’s different here:** + +- **`baseURL`** comes from your SaaS config (`apiBaseUrl`) +- **`x-account-id`** is injected from `sessionStorage` (or `""`) +- **POST JSON header** is set by default + +**What we add on top:** + +- account switching/persistence that drives the `x-account-id` header + +### vue-router — Partial passthrough + +Routes are expressed as normal `RouteRecordRaw`s, but the package provides prebuilt route sets and helpers. + +-> **Their docs:** [vue-router](https://router.vuejs.org/) + +**What we change/add:** + +- prebuilt SaaS route sets (admin/app; authenticated/unauthenticated/public) +- override merging (including `meta` merge) +- `disabled` filtering +- helpers to add routes to a router + +--- + +## Features + +### 1) Install the SaaS plugin + +The default export is a Vue plugin. Installing it prepares your SaaS config (merging UI defaults) and `provide`s it and other values via `Symbol.for(...)` keys. + +```typescript +import SaasVue from "@prefabs.tech/saas-vue"; + +app.use(SaasVue, { + pinia, + router, + config: {} as any, + saasConfig: { + apiBaseUrl: "https://api.example.com", + entity: "both", + mainAppSubdomain: "app", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + }, +}); +``` + +### 2) Add routes (admin + app) + +You can either **get** route records or **add** them directly to a router instance. + +```typescript +import { addSaasAdminRoutes, addSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +addSaasAdminRoutes(router, "authenticated"); +addSaasAppRoutes(router, "authenticated"); +``` + +To customize routes, pass overrides under `options.routes` and/or set `disabled: true`. + +```typescript +import { getSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +const appRoutes = getSaasAppRoutes("unauthenticated", { + routes: { + signup: { disabled: true }, + invitationSignup: { meta: { marketingCampaign: "spring" } }, + }, +}); +``` + +### 3) Use account state (Pinia store) + +Account state lives in a Pinia store exported as `useMyAccountsStore()`. + +```typescript +import { useMyAccountsStore } from "@prefabs.tech/saas-vue"; + +const store = useMyAccountsStore(); +store.switchAccount(null); +``` + +### 4) Resolve config + initialize the store (`useMyAccounts`) + +`useMyAccounts(config?)` resolves config from parameter → injection → store initialization. + +```typescript +import { useMyAccounts } from "@prefabs.tech/saas-vue"; + +const store = useMyAccounts(); +``` + +### 5) Handle the “account not found” case globally + +`useGlobalAccountError()` flips a global reactive flag only for the specific error condition \(404 + `"Account not found"`\). + +```typescript +import { useGlobalAccountError } from "@prefabs.tech/saas-vue"; + +const { showAccountError, clearError } = useGlobalAccountError(); + +if (showAccountError.value) { + clearError(); +} +``` + +### 6) Use the wrapper/provider components + +```typescript +import { SaasWrapper } from "@prefabs.tech/saas-vue"; + +void SaasWrapper; +``` + +### 7) Customize tabs in `AccountSettings` + +`AccountSettings` merges default tabs with `accountTabs` provided during plugin install (function or array). + +```typescript +app.use(SaasVue, { + // ... + accountTabs: (defaultTabs) => [ + ...defaultTabs, + { + key: "billing", + label: "Billing", + component: {} as any, + }, + ], +}); +``` + +### 8) Customize translations + +If you pass `translations` to the plugin, they are prepended to the built-in `en`/`fr` messages. + +```typescript +app.use(SaasVue, { + // ... + translations: { + en: { accounts: { error: { title: "Custom error title" } } } as any, + }, +}); +``` + +### 9) Admin/app detection utility + +`checkIsAdminApp()` returns true when the current subdomain is `"admin"`. + +```typescript +import { checkIsAdminApp } from "@prefabs.tech/saas-vue"; + +const isAdmin = checkIsAdminApp(); +void isAdmin; +``` + +--- + +## Use Cases + +### Use case 1: “Main app” vs tenant app behavior + +```typescript +import { useMyAccounts } from "@prefabs.tech/saas-vue"; + +const store = useMyAccounts(); + +if (store.meta.isMainApp) { + // main app behavior +} else { + // tenant app behavior +} +``` + +### Use case 2: Disable built-in signup routes for SSO-only apps + +```typescript +import { addSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +addSaasAppRoutes(router, "unauthenticated", { + routes: { + signup: { disabled: true }, + invitationSignup: { disabled: true }, + }, +}); +``` + +### Use case 3: Show toast notifications on signup failure + +```typescript +app.use(SaasVue, { + // ... + notification: ({ type, message }) => { + console.log(`[${type}] ${message}`); + }, +}); +``` + diff --git a/packages/vue/README.md b/packages/vue/README.md index e5a4587..9cfc174 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -1,6 +1,104 @@ # @prefabs.tech/saas-vue -This package provides essential tools and components to support SaaS functionality in Vue applications. It simplifies account management, routing, and configuration for multi-tenant SaaS platforms. +SaaS plugin for Vue that provides account switching/state, SaaS route helpers, and ready-made views/components for multi-tenant apps. + +## Why This Package? + +Multi-tenant SaaS apps tend to re-implement the same plumbing: “active account” state, per-account API headers, invitation flows, and separate admin vs app routing. This package bundles those pieces into a single Vue plugin + composables/components so you can standardize behavior across apps. + +## What You Get + +### axios — Modified + +Wraps [`axios`](https://www.npmjs.com/package/axios) with a constrained client factory: + +- **Base URL**: taken from your SaaS config (`apiBaseUrl`) +- **Headers**: always sets JSON content type for POST and injects `x-account-id` from `sessionStorage` + +### vue-router — Partial passthrough + +Uses [`vue-router`](https://router.vuejs.org/) route records, but provides opinionated route helpers: + +- **Default routes**: prebuilt admin/app route sets (`getSaasAdminRoutes`, `getSaasAppRoutes`) +- **Overrides**: you can override `component`, `path`, `meta`, and/or mark routes as `disabled` +- **Convenience**: `addSaasAdminRoutes(router, ...)` and `addSaasAppRoutes(router, ...)` + +### Added by This Package + +- **Vue plugin** that `provide`s: + - SaaS config (`Symbol.for("saas.config")`) + - translations (`Symbol.for("saas.vue.translations")`) + - event handlers (`Symbol.for("saas.eventHandlers")`) + - account tab customization (`Symbol.for("saas.accountTabs")`) +- **Account state** via Pinia: + - `useMyAccountsStore()` store + `useMyAccounts()` composable + - active account selection (subdomain-based vs main app) + persistence via `x-account-id` +- **UI building blocks**: + - `SaasWrapper`, `SaasAccountsProvider`, `ConfigProvider`, `AccountSwitcher`, `NotFoundMessage` +- **Views**: + - `AccountSettings`, `MyAccounts` +- **Signup flow**: + - account vs user signup pages and optional redirect to `{slug}.{rootDomain}` after signup + +## Usage Guidelines + +- **You must install the plugin (or provide `saas.config`) before using most components/composables**. + - `SaasWrapper` throws if `Symbol.for("saas.config")` is missing. + - `useMyAccounts()` will throw if it cannot resolve config from a parameter, injection, or the store. +- **Account switching reloads the page by default**. + - `AccountSwitcher` calls `window.location.reload()` after switching accounts. +- **Be aware of the “account not found” global error latch**. + - `useGlobalAccountError()` sets a global flag only for a 404 response with message `"Account not found"`, which `SaasWrapper` uses to show the not-found UI. + +## Requirements + +At minimum, you’ll need: + +- **Vue**: `vue` (3.x) +- **Routing**: `vue-router` (4.x) +- **State**: `pinia` +- **HTTP**: `axios` +- **Prefabs peer packages**: + - `@prefabs.tech/vue3-config` + - `@prefabs.tech/vue3-i18n` + - `@prefabs.tech/vue3-layout` + - `@prefabs.tech/vue3-ui` + - `@prefabs.tech/vue3-user` + +## Quick Start + +```ts +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import { createRouter, createWebHistory } from "vue-router"; + +import SaasVue from "@prefabs.tech/saas-vue"; +import { addSaasAppRoutes } from "@prefabs.tech/saas-vue"; + +const app = createApp(App); +const pinia = createPinia(); +const router = createRouter({ history: createWebHistory(), routes: [] }); + +app.use(pinia); +app.use(router); + +app.use(SaasVue, { + pinia, + router, + config: {} as any, // your @prefabs.tech/vue3-config AppConfig + saasConfig: { + apiBaseUrl: "https://api.example.com", + entity: "both", + mainAppSubdomain: "app", + multiDatabase: false, + rootDomain: "example.com", + subdomains: "required", + }, +}); + +addSaasAppRoutes(router); +app.mount("#app"); +``` ## Installation @@ -15,3 +113,23 @@ Install with pnpm: ```bash pnpm add --filter "@scope/project" @prefabs.tech/saas-vue ``` + +## Testing + +From the monorepo root (runs all packages with tests): + +```bash +pnpm test +``` + +To run only this package’s tests from the monorepo root: + +```bash +pnpm --filter @prefabs.tech/saas-vue vitest run --coverage +``` + +From this package folder: + +```bash +pnpm vitest run --coverage +``` diff --git a/packages/vue/src/__test__/utilities.test.ts b/packages/vue/src/__test__/utilities.test.ts new file mode 100644 index 0000000..f4e6223 --- /dev/null +++ b/packages/vue/src/__test__/utilities.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +import { prepareSignupData } from "@/utils/account"; +import { checkIsAdminApp } from "@/utils/common"; + +import type { AccountSignupData, UserSignupData } from "../types/user"; + +describe("prepareSignupData", () => { + it("creates user signup payload when accountSignup=false", () => { + const payload = prepareSignupData({ + accountSignup: false, + data: { email: "a@b.com", password: "secret" } satisfies UserSignupData, + }); + + expect(payload).toEqual({ + formFields: [ + { id: "email", value: "a@b.com" }, + { id: "password", value: "secret" }, + ], + }); + }); + + it("forces useSeparateDatabase=false when slug is missing", () => { + const payload = prepareSignupData({ + data: { + email: "a@b.com", + password: "secret", + name: "Name", + individual: false, + registeredNumber: "RN", + taxId: "TAX", + slug: "", + useSeparateDatabase: true, + } satisfies AccountSignupData, + }); + + expect(payload.accountFormFields).toEqual( + expect.arrayContaining([{ id: "useSeparateDatabase", value: false }]), + ); + }); +}); + +describe("checkIsAdminApp", () => { + it("returns true when the subdomain is admin", () => { + const windowObject = { + location: { hostname: "admin.example.com" }, + } as unknown as Window & typeof globalThis; + + vi.stubGlobal("window", windowObject); + expect(checkIsAdminApp()).toBe(true); + vi.unstubAllGlobals(); + }); +});