# Client Portal

Standalone portal backed by portal tokens


Build a customer-facing site on top of an erxes Client Portal using the standalone Next.js template.

See [Authentication & Permissions](https://erxes.io/docs/api-authentication) for the credential mechanics behind these headers. For a production portal implementation, compare [Help Center](https://erxes.io/docs/app-help-center), which applies the same pattern.

A Client Portal is a configuration record ([`ClientPortals` collection](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/modules/clientportal/db/models/ClientPortal.ts)) in the `clientportal` module in Core API. It carries a portal token plus per-portal settings: authentication options (OTP, verification, token lifetimes, delivery method), styles, and plugin-specific config such as the CMS `clientPortalId` that scopes public content.

Plugins expose `cp*` GraphQL operations (`cpPosts`, `cpGetTickets`, `cpForms`, `clientPortalCurrentUser`, …) that look up the portal from the request context instead of from a logged-in team member. That makes the portal token the only credential a public website needs.

## Two credentials, not one

| Credential | What it is | Transport | Becomes |
| --- | --- | --- | --- |
| Portal token | JWT `{ clientPortalId }` signed with `JWT_TOKEN_SECRET`, issued by `clientPortalAdd` / `clientPortalChangeToken` | `x-app-token` header | `req.clientPortal` at the gateway, forwarded as the base64 `clientportal` header |
| Portal-user session | JWT `{ userId, clientPortalId }` issued on login/verify/reset; separate refresh token (`type: "refresh"`) | `client-auth-token` cookie (httpOnly) or header when the portal's `deliveryMethod` is `header` | `req.cpUser`, forwarded as the base64 `cpuser` header |

The gateway only identifies a portal user **after** the `x-app-token` has identified a valid portal, and the user must belong to that portal. Default lifetimes are 1 day (access) and 7 days (refresh), overridable per portal via `auth.authConfig`. `clientPortalUserRefreshToken` exchanges a refresh token for a new access token.

Portal-user auth flows (register, verify, credentials or OTP login, forgot/reset password, change email/phone) are implemented in `backend/core-api/src/modules/clientportal/services/` and documented in [`docs/auth-flows.md`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/modules/clientportal/docs/auth-flows.md) inside that module. The mutations are the `clientPortalUser*` family plus `clientPortalCurrentUser`.

## The template app

`apps/client-portal-template` is a standalone Next.js 16.0.3 (React 19) starter, not an Nx project, with its own `package-lock.json`. Apollo Client 4 plus `@apollo/client-integration-nextjs` provide both an RSC client and a browser client.

```bash
cd apps/client-portal-template
npm install
npm run dev   # next dev --port 3800
```

Two Apollo entry points exist:

- [`modules/apollo/apolloClient.ts`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/apps/client-portal-template/modules/apollo/apolloClient.ts): server components. Sends `x-app-token: NEXT_PUBLIC_ERXES_CP_TOKEN` to `${ERXES_API_URL}/graphql` with `credentials: 'include'` (needed for the `client-auth-token` cookie round trip).
- [`modules/apollo/ApolloWrapper.tsx`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/apps/client-portal-template/modules/apollo/ApolloWrapper.tsx): client components (`"use client"`). Same token header against `${NEXT_PUBLIC_ERXES_API_URL}/graphql`.

`modules/cpposts/` is a worked example: `getExamplePosts.ts` calls the `getCPExamplePosts` query exposed by the clientportal module, and `CPPosts.tsx` renders it.

### Environment variables

| Variable | Used by | Purpose |
| --- | --- | --- |
| `ERXES_API_URL` | server Apollo client | Gateway base URL, no trailing slash (e.g. `http://localhost:4000`) |
| `NEXT_PUBLIC_ERXES_API_URL` | browser Apollo client | Same gateway, reachable from the browser |
| `NEXT_PUBLIC_ERXES_CP_TOKEN` | both clients | Portal token sent as `x-app-token` |

> [!WARNING]
> **The template leaks the portal token to the browser**
>
> `NEXT_PUBLIC_*` variables end up in the client bundle, so the template sends `x-app-token` from the browser. The gateway CORS list must allow the portal's origin. For a hardened deployment move the token to a non-public variable read only by the RSC client (`apolloClient.ts`), or proxy all GraphQL through your own route handler. Never use an Apps token (`erxes-app-token`) in a public app.

There is no `.env.example` or Dockerfile in the template; copy `apps/help-center`'s `docker-entrypoint.sh` pattern if you want one image to serve multiple gateways.

## Walkthrough

### 1. Create the portal and copy its token

In erxes, create a Client Portal (Settings → Client portal, or via `clientPortalAdd`). The creation call signs and stores the portal token; rotate it later with `clientPortalChangeToken`. If you use the CMS or Frontline plugins, link them to this portal in their own settings.

### 2. Point the template at your gateway

```dotenv
ERXES_API_URL=http://localhost:4000
NEXT_PUBLIC_ERXES_API_URL=http://localhost:4000
NEXT_PUBLIC_ERXES_CP_TOKEN=<portal token>
```

Restart `npm run dev`; `loadEnvConfig` in `apolloClient.ts` picks up `.env.local`.

### 3. Exercise a query and a session

`getCPExamplePosts` renders on the home page. Then exercise a session: call `clientPortalUserRegister` + `clientPortalUserVerify` (or `clientPortalUserLoginWithCredentials`) and confirm the `client-auth-token` cookie is set, then `clientPortalCurrentUser` returns the visitor.

## Check it works

1. With only `x-app-token` set, a `cp*` query works but `clientPortalCurrentUser` returns null; the portal is known, the visitor is anonymous.
2. After login, the same `client-auth-token` cookie works from the browser and from curl (`-H "client-auth-token: <jwt>"`).
3. A token from a different portal sees only its own data; the gateway binds `cpUser` to `clientPortalId`.

