# Project settings

Source: https://docs.qiick.io/externa/project-settings

Project settings are the operator-facing configuration for the whole Externa install. From externa-core **1.3.0** ([#201](https://github.com/qiick-io/externa-core/issues/201)), `/settings/project` is split into dedicated sections (General, Localization, Auth & Security, Content, Files & Media, API, Integrations). Sidebar module order lives under **Appearance → Sidebar**. They require **`can-manage-project-settings`**.

<!-- screenshot omitted -->

> **Related**
> Personal profile/password/passkeys: [Account settings](https://docs.qiick.io/externa/account-settings) · [Passkeys](https://docs.qiick.io/externa/passkeys) · [SSO / OIDC](https://docs.qiick.io/externa/sso-oidc). Content locale resolution: [Configuration](https://docs.qiick.io/externa/configuration) · [Collections data model](https://docs.qiick.io/externa/collections-data-model). Public API origins: [Client types — Origin allowlist](https://docs.qiick.io/externa/public-cms-api-types#origin-allowlist). Outbound delivery: [Outbound webhooks](https://docs.qiick.io/externa/webhooks) · [Slack & Telegram notifiers](https://docs.qiick.io/externa/integrations). HTTP overview: [Routing](https://docs.qiick.io/externa/routing).

> Deep links that pointed at a single `#section` on `/settings/project` no longer apply. Use the paths in the [HTTP](#http) table (for example `/settings/project/security` for SSO, `/settings/project/integrations` for webhooks/notifiers, `/settings/appearance/sidebar` for sidebar modules). Deprecated test aliases (`project.webhook-test`, notifier tests under `/settings/project/*-test`) stay for **one minor** — prefer the `project.integrations.*` names.

## Access

| Item        | Detail                                                                                                 |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| Permission  | `can-manage-project-settings`                                                                          |
| Routes      | `routes/settings.php` (`auth` + `verified` + `permission:…`)                                           |
| Controllers | `Project*SettingsController`, `AppearanceSettingsController`, `AppearanceSidebarSettingsController`    |
| Pages       | `resources/js/pages/settings/project.tsx`, `project/*.tsx`, `appearance.tsx`, `appearance/sidebar.tsx` |
| Storage     | `SettingsRepository` project scope (`config/settings.php` keys)                                        |

The same `can-manage-project-settings` permission also grants access to **Settings → Performance** (`/settings/performance`) — cache ops status and targeted flush actions. See [Operations](https://docs.qiick.io/externa/operations).

## HTTP

| Method | Path                                                    | Name                                          | Purpose                                          |
| ------ | ------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| `GET`  | `/settings/project`                                     | `project.edit`                                | General (identity + report URLs)                 |
| `PUT`  | `/settings/project`                                     | `project.update`                              | Persist General keys                             |
| `GET`  | `/settings/project/localization`                        | `project.localization.edit`                   | UI language + content locales                    |
| `PUT`  | `/settings/project/localization`                        | `project.localization.update`                 | Persist Localization keys                        |
| `GET`  | `/settings/project/security`                            | `project.security.edit`                       | Auth, registration, SSO/OIDC                     |
| `PUT`  | `/settings/project/security`                            | `project.security.update`                     | Persist Auth & Security keys                     |
| `GET`  | `/settings/project/content`                             | `project.content.edit`                        | Live Preview default + revision retention        |
| `PUT`  | `/settings/project/content`                             | `project.content.update`                      | Persist Content keys                             |
| `GET`  | `/settings/project/files`                               | `project.files.edit`                          | Upload caps + image transforms                   |
| `PUT`  | `/settings/project/files`                               | `project.files.update`                        | Persist Files & Media keys                       |
| `GET`  | `/settings/project/api`                                 | `project.api.edit`                            | Public API allowed origins                       |
| `PUT`  | `/settings/project/api`                                 | `project.api.update`                          | Persist API keys                                 |
| `GET`  | `/settings/project/integrations`                        | `project.integrations.edit`                   | Webhooks + Slack/Telegram notifiers              |
| `PUT`  | `/settings/project/integrations`                        | `project.integrations.update`                 | Persist Integrations keys                        |
| `POST` | `/settings/project/integrations/webhook-test`           | `project.integrations.webhook-test`           | Queue a signed `ping` event                      |
| `POST` | `/settings/project/integrations/slack-notifier-test`    | `project.integrations.slack-notifier-test`    | Send a test Slack message                        |
| `POST` | `/settings/project/integrations/telegram-notifier-test` | `project.integrations.telegram-notifier-test` | Send a test Telegram message                     |
| `POST` | `/settings/project/webhook-test`                        | `project.webhook-test`                        | **Deprecated alias** (one minor) → webhook test  |
| `POST` | `/settings/project/slack-notifier-test`                 | `project.slack-notifier-test`                 | **Deprecated alias** (one minor) → Slack test    |
| `POST` | `/settings/project/telegram-notifier-test`              | `project.telegram-notifier-test`              | **Deprecated alias** (one minor) → Telegram test |
| `POST` | `/settings/project/integrations/deploy`                 | `project.integrations.deploy`                 | Trigger deploy hooks (`deploy.trigger`, 1.4.0)   |
| `GET`  | `/settings/ai-tools`                                    | `ai-tools.edit`                               | AI write tools allowlist (1.4.0)                 |
| `PUT`  | `/settings/ai-tools`                                    | `ai-tools.update`                             | Persist `ai.write_tools`                         |
| `GET`  | `/settings/appearance`                                  | `appearance.edit`                             | Branding form                                    |
| `PUT`  | `/settings/appearance`                                  | `appearance.update`                           | Persist appearance keys                          |
| `GET`  | `/settings/appearance/sidebar`                          | `appearance.sidebar.edit`                     | Sidebar module order / visibility                |
| `PUT`  | `/settings/appearance/sidebar`                          | `appearance.sidebar.update`                   | Persist `sidebar_modules`                        |

## Unsaved changes

Each section form shows an **Unsaved** badge and **Discard** on the sticky Save bar while dirty. Discard confirms, resets fields to the last loaded values, and stays on the page. Leaving via navigation while dirty uses the same Keep editing / Discard dialog.

## Sections and keys

Every writable project setting key appears **exactly once** below (matched to the merged Form Requests). Write-only secrets never round-trip on Inertia — the UI shows `*_configured` booleans instead.

### General — `/settings/project`

Request: `UpdateProjectSettingsRequest`. Page: `resources/js/pages/settings/project.tsx`.

| Key                | Notes                       |
| ------------------ | --------------------------- |
| `name`             | Project display name        |
| `description`      | Short description           |
| `url`              | Public / marketing site URL |
| `report_issue_url` | “Report issue” deep link    |
| `report_bug_url`   | “Report bug” deep link      |
| `report_error_url` | “Report error” deep link    |

### Localization — `/settings/project/localization`

Request: `UpdateProjectLocalizationSettingsRequest`. Page: `resources/js/pages/settings/project/localization.tsx`.

| Key                        | Notes                                                  |
| -------------------------- | ------------------------------------------------------ |
| `default_language`         | Admin UI shell locale                                  |
| `content_locales`          | Enabled content languages (source of truth once saved) |
| `default_content_locale`   | Default content locale                                 |
| `fallback_content_locales` | Ordered fallback chain for missing translations        |

Content locales are the **source of truth** for translatable collection field values and labels once saved. Until then, seed/fallback comes from `config/collections.php`. Catalog codes live in `resources/data/content-locales-catalog.json` (`ContentLocaleCatalog`).

Disabling a content locale does **not** delete existing translation rows.

Editing **Content languages** rewrites `fallback_content_locales` to `[default, …other enabled locales in list order]` before save — see [Locale fallback](https://docs.qiick.io/externa/configuration#locale-fallback) for the read-time chain (`fallback_content_locales` → `APP_FALLBACK_LOCALE` → `null`) and empty-list normalization.

On the item form, editors switch locales from the toolbar, copy or apply values across locales, and optionally translate with AI — see [Translation workspace](https://docs.qiick.io/externa/translation-workspace).

### Auth & Security — `/settings/project/security`

Request: `UpdateProjectSecuritySettingsRequest`. Page: `resources/js/pages/settings/project/security.tsx`. Includes SSO/OIDC.

| Key                           | Notes                                                         |
| ----------------------------- | ------------------------------------------------------------- |
| `password_policy`             | `weak` / `medium` / `strong`                                  |
| `login_max_attempts`          | Throttle window for login                                     |
| `registration_enabled`        | Public self-registration                                      |
| `default_user_role`           | Role assigned on register / JIT                               |
| `email_verification_required` | Require verified email                                        |
| `two_factor_required`         | Enforce MFA (TOTP or passkey)                                 |
| `allowed_domains`             | Email domain allowlist (empty = no domain gate)               |
| `oidc_enabled`                | Show SSO button on login                                      |
| `oidc_issuer`                 | Issuer URL (discovery at `/.well-known/openid-configuration`) |
| `oidc_client_id`              | OIDC client id                                                |
| `oidc_client_secret`          | **Write-only** — encrypted at rest; never shared on Inertia   |
| `oidc_button_label`           | Optional custom button text                                   |
| `oidc_jit_provisioning`       | Create users on first login (default **off**)                 |

Full SSO guide: [SSO / OIDC](https://docs.qiick.io/externa/sso-oidc).

#### Require two-factor authentication

When `two_factor_required` is enabled, `EnsureTwoFactorIsEnabled` (web middleware) redirects authenticated users who have neither confirmed Fortify TOTP (`hasEnabledTwoFactorAuthentication()`) nor at least one registered passkey (`hasPasskeysEnabled()`) to **Settings → Security** (`security.edit`). That page passes `twoFactorRequired` / `twoFactorEnforcedForUser` and shows an alert banner until the user finishes enrollment (TOTP **or** a passkey).

| Satisfies the gate? | Condition                                                                          |
| ------------------- | ---------------------------------------------------------------------------------- |
| Yes                 | Confirmed Fortify TOTP                                                             |
| Yes                 | ≥1 registered passkey (`Features::canManagePasskeys()` and `hasPasskeysEnabled()`) |
| Yes                 | Both of the above                                                                  |
| No                  | Neither — forced to Security with the required-MFA banner                          |
| N/A (gate off)      | `two_factor_required` off — personal TOTP/passkeys stay optional                   |
| N/A (no-op)         | Fortify two-factor feature disabled — middleware does not enforce                  |

Allowlisted so enrollment works: security/password/profile/locale settings, Fortify 2FA + passkey registration/store/destroy + password-confirm routes, and logout. Deleting a user’s last passkey while TOTP is off and the setting is on returns them to the enrollment gate.

Passkey register / passwordless login / RP config: [Passkeys](https://docs.qiick.io/externa/passkeys). Pre-release checks: [Security checklist](https://docs.qiick.io/externa/security-checklist).

### Content — `/settings/project/content`

Request: `UpdateProjectContentSettingsRequest`. Page: `resources/js/pages/settings/project/content.tsx`. Includes Live Preview default URL.

| Key                        | Notes                                                        |
| -------------------------- | ------------------------------------------------------------ |
| `preview_url_default`      | Fallback Live Preview URL when the collection field is empty |
| `revision_retention_count` | Max revisions to keep (optional)                             |
| `revision_retention_days`  | Max age in days (optional)                                   |

See [Live Preview](https://docs.qiick.io/externa/live-preview) · [Content versioning](https://docs.qiick.io/externa/content-versioning).

### Files & Media — `/settings/project/files`

Request: `UpdateProjectFilesSettingsRequest`. Page: `resources/js/pages/settings/project/files.tsx`.

| Key                         | Notes                                                                   |
| --------------------------- | ----------------------------------------------------------------------- |
| `files_max_upload_bytes`    | Soft cap for File Manager uploads (`null` = unlimited aside from PHP)   |
| `chat_max_upload_bytes`     | Soft cap for team chat attachments                                      |
| `upload_allowed_extensions` | Optional extension allowlist (`null`/empty = no extra extension filter) |
| `upload_allowed_mimes`      | Optional MIME allowlist (`null`/empty = `config/files.php` defaults)    |
| `upload_allow_svg`          | Explicit SVG opt-in (`false` by default; XSS risk when served/inlined)  |
| `allowed_transformations`   | Enabled transform operations                                            |
| `preset_transformations`    | Named presets (`key`, `fit`, `width`, `height`, `quality`, …)           |

Named presets, focal point, Public API URLs, caching, and a Next `<Image>` loader: [Image transforms](https://docs.qiick.io/externa/image-transforms). Disk / MIME / magic-byte rules: [Files configuration](https://docs.qiick.io/externa/files-configuration) · [File manager — Upload security](https://docs.qiick.io/externa/file-manager#upload-security).

### API — `/settings/project/api`

Request: `UpdateProjectApiSettingsRequest`. Page: `resources/js/pages/settings/project/api.tsx`.

| Key                          | Notes                                                                                    |
| ---------------------------- | ---------------------------------------------------------------------------------------- |
| `public_api_allowed_origins` | One origin per line in the UI; empty = no origin gate (CORS from `CORS_ALLOWED_ORIGINS`) |

When `public_api_allowed_origins` is non-empty, Externa:

- Sets CORS `allowed_origins` for `api/*` to that list
- Enforces `EnforcePublicApiOrigin` on REST and GraphQL (matching `Origin`, or a valid Bearer API key when `Origin` is absent)

Format: `https://host` or `http://host[:port]` — no path. Details: [Origin allowlist](https://docs.qiick.io/externa/public-cms-api-types#origin-allowlist).

### Integrations — `/settings/project/integrations`

Request: `UpdateProjectIntegrationsSettingsRequest`. Page: `resources/js/pages/settings/project/integrations.tsx`. Webhooks + Slack/Telegram notifiers.

| Key                           | Notes                                                           |
| ----------------------------- | --------------------------------------------------------------- |
| `webhook_url`                 | Outbound webhook endpoint                                       |
| `webhook_secret`              | **Write-only** — encrypted at rest; never shared on Inertia     |
| `notifier_slack_webhook_url`  | Slack Incoming Webhook URL                                      |
| `notifier_telegram_bot_token` | **Write-only** — encrypted; BotFather `digits:token` format     |
| `notifier_telegram_chat_id`   | Telegram chat / channel id                                      |
| `notifier_event_types`        | Which events fan out to notifiers (`null` / empty choice = all) |
| `deploy_hooks`                | Named deploy hooks (1.4.0); each URL **encrypted** at rest      |
| `deploy_on_publish`           | Trigger every deploy hook when an item is published (1.4.0)     |

Guides: [Outbound webhooks](https://docs.qiick.io/externa/webhooks) · [Slack & Telegram notifiers](https://docs.qiick.io/externa/integrations).

#### Notifiers (Slack, Telegram)

Outbound **chat notifications** for domain events (1.2.0+). They run in parallel with [outbound webhooks](https://docs.qiick.io/externa/webhooks), which stay the machine-to-machine channel.

- **Event types:** `notifier_event_types` picks which events are sent; an empty choice (`null`) means all selectable events. `ping` (test) is always allowed.
- Deliveries are queued jobs (`DeliverSlackOutboundNotifierJob`, `DeliverTelegramOutboundNotifierJob`) dispatched after commit, so a queue worker must be running.
- **Test** buttons hit `project.integrations.*-notifier-test` (deprecated aliases still work for one minor).
- Messages are formatted per channel (Telegram uses HTML parse mode) with item/collection context.

#### Deploy hooks

From externa-core **1.4.0** ([#196](https://github.com/qiick-io/externa-core/issues/196)), store build hooks from Vercel, Netlify, Cloudflare Pages, Laravel Forge, or any service that starts a deploy on `POST`.

| Item              | Detail                                                                                                                                    |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Hooks             | Up to **10**, each with a name (≤120 chars) and URL (≤2048). URLs are encrypted and never sent back to the browser                        |
| URL check         | Same outbound SSRF guard as [outbound webhooks](https://docs.qiick.io/externa/webhooks) (private hosts need `WEBHOOKS_ALLOW_PRIVATE_HOSTS=true`)               |
| Manual trigger    | **Deploy** button in the admin header, for users with `deploy.trigger`. `POST /settings/project/integrations/deploy` (optional `hook_id`) |
| Deploy on publish | `deploy_on_publish` fires every hook after an item is published (manual or scheduled publish)                                             |
| Delivery          | Queued `DeliverDeployHookJob`: `POST`, 5 s timeout, no redirects, `User-Agent: Externa-Deploy/1.0`, 3 tries (10 / 30 / 60 s)              |
| Audit             | Activity log `log_name=deploy`, `event=deploy_triggered` (hook ids + source); Integrations shows the last run status                      |

A queue worker must be running. Editing hooks needs `can-manage-project-settings`; triggering them needs the new `deploy.trigger` permission, which `externa:upgrade` grants to the built-in `admin` role. MCP `publish_item` fires hooks only after its approval — [MCP server](https://docs.qiick.io/externa/mcp-server).

#### Webhook test

`POST …/integrations/webhook-test` (or the deprecated `/settings/project/webhook-test`) requires a configured URL; otherwise redirects with an error. Dispatches `OutboundWebhookDispatcher::dispatchPing()` onto the queue (needs a worker). Integrations settings also shows **last success / last error** and a recent delivery ring buffer after jobs finish.

### Appearance (branding) — `/settings/appearance`

Request: `UpdateAppearanceSettingsRequest`. Page: `resources/js/pages/settings/appearance.tsx`. Presets: `config/theme-presets.php`, `ThemePresetService`.

**Settings → Appearance** is **project branding**, not the personal light/dark toggle:

| Key                                    | Purpose                                                                              |
| -------------------------------------- | ------------------------------------------------------------------------------------ |
| `theme_preset`                         | Built-in theme preset (1.4.0): `teal` (default), `indigo`, `violet`, `rose`, `slate` |
| `project_color` / `project_color_dark` | Optional custom brand colors (`#RRGGBB`); override the preset                        |
| `project_logo` / `project_logo_dark`   | Logos (form fields `project_logo_id` / `project_logo_dark_id`)                       |
| `public_favicon`                       | Favicon (form field `public_favicon_id`)                                             |
| `default_appearance`                   | Default theme hint (`system` / light / dark)                                         |

Personal light/dark preference still uses the `appearance` cookie + `useAppearance` / `AppearanceTabs` in the user menu (`HandleAppearance` middleware). That path has **no** Spatie permission gate.

#### Theme presets (1.4.0)

From externa-core **1.4.0** ([#243](https://github.com/qiick-io/externa-core/issues/243)), **Settings → Appearance** offers five built-in presets with a live preview: **qiick Teal** (default), **Indigo**, **Violet**, **Rose**, and **Slate**. Each preset sets the full light and dark token set (`config/theme-presets.php`).

- **Precedence:** custom brand hex → selected preset → default teal. A custom colour is stored only when you set one, and saving Appearance keeps the selected preset ([#437](https://github.com/qiick-io/externa-core/issues/437)).
- **AI accent:** AI surfaces use violet. With the **Violet** preset, or a custom hex within about 30° of the violet hue, the AI tokens switch to teal so AI stays visually distinct.

#### Brand colour and focus-ring contrast

A custom brand colour is used for the focus ring and focused input border **only when it reaches 3:1** against the page background (WCAG 1.4.11 / 2.4.7), checked separately for light and dark. Otherwise Externa keeps the default teal ring ([#453](https://github.com/qiick-io/externa-core/issues/453)). The check uses the page background only; a brand that barely passes there may still be lower on card surfaces. Button text on the brand colour picks a contrasting foreground. See [Accessibility](https://docs.qiick.io/externa/accessibility#brand-colours-and-contrast).

#### Transactional email branding

Password reset, email verification, and other Markdown / `MailMessage` emails use the same project branding:

- **Logo** — light `project_logo` (emails use a light theme). If unset, the header shows the project name as text (never the default Laravel logo).
- **Primary color** — `project_color` for buttons and links (button text uses contrasting black/white).
- **Name** — Project settings name when set; otherwise `APP_NAME`.

The logo must be reachable by email clients as an **absolute public URL** (assets disk / `FILE_PUBLIC_URL_BASE` / CDN / S3). Localhost URLs work only for local mail testing; production mailboxes cannot load them.

### Sidebar — `/settings/appearance/sidebar`

Request: `UpdateAppearanceSidebarSettingsRequest`. Page: `resources/js/pages/settings/appearance/sidebar.tsx`.

| Key               | Notes                                                                  |
| ----------------- | ---------------------------------------------------------------------- |
| `sidebar_modules` | Ordered modules (`id`, `enabled`, `locked`). `ai` / `dashboard` locked |

Moved out of the former monolithic Project page in 1.3.0 so branding and navigation stay under Appearance.

## Source map

| Concern                | Location                                                                                               |
| ---------------------- | ------------------------------------------------------------------------------------------------------ |
| Routes                 | `routes/settings.php`                                                                                  |
| General                | `ProjectSettingsController.php` + `UpdateProjectSettingsRequest.php`                                   |
| Localization           | `ProjectLocalizationSettingsController.php` + `UpdateProjectLocalizationSettingsRequest.php`           |
| Auth & Security        | `ProjectSecuritySettingsController.php` + `UpdateProjectSecuritySettingsRequest.php`                   |
| Content                | `ProjectContentSettingsController.php` + `UpdateProjectContentSettingsRequest.php`                     |
| Files & Media          | `ProjectFilesSettingsController.php` + `UpdateProjectFilesSettingsRequest.php`                         |
| API                    | `ProjectApiSettingsController.php` + `UpdateProjectApiSettingsRequest.php`                             |
| Integrations           | `ProjectIntegrationsSettingsController.php` + `UpdateProjectIntegrationsSettingsRequest.php`           |
| Appearance             | `AppearanceSettingsController.php` + `UpdateAppearanceSettingsRequest.php`                             |
| Sidebar                | `AppearanceSidebarSettingsController.php` + `UpdateAppearanceSidebarSettingsRequest.php`               |
| Config keys / defaults | `config/settings.php`                                                                                  |
| Settings store         | `app/Services/Settings/SettingsRepository.php`, `ProjectSettings.php`, `ProjectAppearance.php`         |
| UI                     | `resources/js/pages/settings/project.tsx`, `project/*.tsx`, `appearance.tsx`, `appearance/sidebar.tsx` |
| Settings nav           | `resources/js/layouts/settings/layout.tsx`                                                             |
