From 1d0f964ef4865e3aa6ba9c47750e456c9afd542e Mon Sep 17 00:00:00 2001 From: Sambhav Aggarwal <4591834+sambhav-aggarwal@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:38:23 +0530 Subject: [PATCH] RFC 002 phase A: headless core + Blade starter kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package is now headless by default, Fortify-style. Page views are no longer auto-loaded; the UI ships as a starter kit that is ejected into the app once and becomes app-owned code. - New 'ui' config (NEEV_UI: 'blade' | null). null (default) registers no Blade page routes — API, OAuth/SSO, and email flows are fully functional standalone. 'blade' registers the page routes, rendered from the app-owned views - Page templates moved to stubs/blade/views/; new neev:ui {kit} [--force] command ejects them to resources/views/vendor/neev (the existing publish path, so already-published consumers keep working). It never overwrites app files without --force - Email templates are ejected by the installer regardless of kit — app-owned and editable from day one — while the package keeps fallback copies so headless installs send mail with zero setup. The per-template variable contract (RFC 002 §5.5) is documented and treated as API - neev:install gains the kit prompt (blade/none, default blade) as a third argument and delegates ejection to neev:ui - Machine-facing web routes (OAuth redirect/callback) stay always-on: they are IdP contracts, not kit UI - Headless email links target the app frontend instead of the now unregistered Blade routes: verification links carry the signed query for the API 'mail.verify' endpoint ({app.url}/verify-email?...), new-user team invitations link to {app.url}/register?invitation_id=… - Publish tags: neev-views replaced by neev-blade-kit + neev-mail - Blade component/layout paths register only when the kit is ejected Tests: suite exercises the Blade flows against the stubs (simulating an ejected app); new HeadlessModeTest (routes absent, API works, frontend email links) and InstallUiCommandTest (ejection, --force semantics, unknown kit). 1004 green; Pint and PHPStan clean. RFC 002 §8 questions resolved: single Blade kit incl. app-shell views; ejection via the dedicated neev:ui command. --- CHANGELOG.md | 7 + README.md | 9 +- UPGRADING.md | 23 ++ config/neev.php | 16 + docs/README.md | 17 +- docs/cli-commands.md | 26 +- docs/configuration.md | 19 ++ docs/installation.md | 33 +- docs/rfcs/002-starter-kits.md | 3 +- docs/web-routes.md | 10 +- phpstan-baseline.neon | 2 +- routes/neev.php | 309 +++++++++--------- src/Commands/InstallNeev.php | 15 +- src/Commands/InstallUi.php | 94 ++++++ src/Http/Controllers/TeamApiController.php | 19 +- src/NeevServiceProvider.php | 36 +- src/Services/AuthService.php | 25 +- .../views/account/left-section.blade.php | 0 .../views/account/login-attempt.blade.php | 0 .../blade}/views/account/profile.blade.php | 0 .../views/account/recovery-codes.blade.php | 0 .../blade}/views/account/security.blade.php | 0 .../blade}/views/account/sessions.blade.php | 0 .../blade}/views/account/teams.blade.php | 0 .../blade}/views/account/tokens.blade.php | 0 .../blade}/views/auth/change-email.blade.php | 0 .../views/auth/forgot-password.blade.php | 0 .../views/auth/login-password.blade.php | 0 .../blade}/views/auth/login.blade.php | 0 .../blade}/views/auth/otp-mfa.blade.php | 0 .../blade}/views/auth/register.blade.php | 0 .../views/auth/reset-password.blade.php | 0 .../blade}/views/auth/verify-email.blade.php | 0 .../views/components/action-message.blade.php | 0 .../views/components/action-section.blade.php | 0 .../components/application-logo.blade.php | 0 .../components/application-mark.blade.php | 0 .../authentication-card-logo.blade.php | 0 .../components/authentication-card.blade.php | 0 .../blade}/views/components/banner.blade.php | 0 .../blade}/views/components/button.blade.php | 0 .../blade}/views/components/card.blade.php | 0 .../views/components/checkbox.blade.php | 0 .../components/confirmation-modal.blade.php | 0 .../components/confirms-password.blade.php | 0 .../views/components/danger-button.blade.php | 0 .../views/components/dialog-modal.blade.php | 0 .../views/components/dropdown-link.blade.php | 0 .../views/components/dropdown.blade.php | 0 .../views/components/form-section.blade.php | 0 .../views/components/input-error.blade.php | 0 .../blade}/views/components/input.blade.php | 0 .../blade}/views/components/label.blade.php | 0 .../blade}/views/components/modal.blade.php | 0 .../views/components/nav-link.blade.php | 0 .../views/components/refresh-button.blade.php | 0 .../components/responsive-nav-link.blade.php | 0 .../components/secondary-button.blade.php | 0 .../views/components/section-border.blade.php | 0 .../views/components/section-title.blade.php | 0 .../components/switchable-team.blade.php | 0 .../views/components/table-body-tr.blade.php | 0 .../blade}/views/components/table.blade.php | 0 .../components/validation-errors.blade.php | 0 .../components/validation-status.blade.php | 0 .../blade}/views/layouts/app.blade.php | 0 .../blade}/views/layouts/guest.blade.php | 0 .../blade}/views/navigation-menu.blade.php | 0 .../blade}/views/team/create.blade.php | 0 .../views/team/domain-federation.blade.php | 0 .../blade}/views/team/left-section.blade.php | 0 .../blade}/views/team/members.blade.php | 0 .../blade}/views/team/profile.blade.php | 0 .../blade}/views/team/settings.blade.php | 0 .../blade}/views/welcome.blade.php | 0 tests/Feature/HeadlessModeTest.php | 106 ++++++ tests/Feature/InstallUiCommandTest.php | 52 +++ tests/TestCase.php | 18 + 78 files changed, 642 insertions(+), 197 deletions(-) create mode 100644 src/Commands/InstallUi.php rename {resources => stubs/blade}/views/account/left-section.blade.php (100%) rename {resources => stubs/blade}/views/account/login-attempt.blade.php (100%) rename {resources => stubs/blade}/views/account/profile.blade.php (100%) rename {resources => stubs/blade}/views/account/recovery-codes.blade.php (100%) rename {resources => stubs/blade}/views/account/security.blade.php (100%) rename {resources => stubs/blade}/views/account/sessions.blade.php (100%) rename {resources => stubs/blade}/views/account/teams.blade.php (100%) rename {resources => stubs/blade}/views/account/tokens.blade.php (100%) rename {resources => stubs/blade}/views/auth/change-email.blade.php (100%) rename {resources => stubs/blade}/views/auth/forgot-password.blade.php (100%) rename {resources => stubs/blade}/views/auth/login-password.blade.php (100%) rename {resources => stubs/blade}/views/auth/login.blade.php (100%) rename {resources => stubs/blade}/views/auth/otp-mfa.blade.php (100%) rename {resources => stubs/blade}/views/auth/register.blade.php (100%) rename {resources => stubs/blade}/views/auth/reset-password.blade.php (100%) rename {resources => stubs/blade}/views/auth/verify-email.blade.php (100%) rename {resources => stubs/blade}/views/components/action-message.blade.php (100%) rename {resources => stubs/blade}/views/components/action-section.blade.php (100%) rename {resources => stubs/blade}/views/components/application-logo.blade.php (100%) rename {resources => stubs/blade}/views/components/application-mark.blade.php (100%) rename {resources => stubs/blade}/views/components/authentication-card-logo.blade.php (100%) rename {resources => stubs/blade}/views/components/authentication-card.blade.php (100%) rename {resources => stubs/blade}/views/components/banner.blade.php (100%) rename {resources => stubs/blade}/views/components/button.blade.php (100%) rename {resources => stubs/blade}/views/components/card.blade.php (100%) rename {resources => stubs/blade}/views/components/checkbox.blade.php (100%) rename {resources => stubs/blade}/views/components/confirmation-modal.blade.php (100%) rename {resources => stubs/blade}/views/components/confirms-password.blade.php (100%) rename {resources => stubs/blade}/views/components/danger-button.blade.php (100%) rename {resources => stubs/blade}/views/components/dialog-modal.blade.php (100%) rename {resources => stubs/blade}/views/components/dropdown-link.blade.php (100%) rename {resources => stubs/blade}/views/components/dropdown.blade.php (100%) rename {resources => stubs/blade}/views/components/form-section.blade.php (100%) rename {resources => stubs/blade}/views/components/input-error.blade.php (100%) rename {resources => stubs/blade}/views/components/input.blade.php (100%) rename {resources => stubs/blade}/views/components/label.blade.php (100%) rename {resources => stubs/blade}/views/components/modal.blade.php (100%) rename {resources => stubs/blade}/views/components/nav-link.blade.php (100%) rename {resources => stubs/blade}/views/components/refresh-button.blade.php (100%) rename {resources => stubs/blade}/views/components/responsive-nav-link.blade.php (100%) rename {resources => stubs/blade}/views/components/secondary-button.blade.php (100%) rename {resources => stubs/blade}/views/components/section-border.blade.php (100%) rename {resources => stubs/blade}/views/components/section-title.blade.php (100%) rename {resources => stubs/blade}/views/components/switchable-team.blade.php (100%) rename {resources => stubs/blade}/views/components/table-body-tr.blade.php (100%) rename {resources => stubs/blade}/views/components/table.blade.php (100%) rename {resources => stubs/blade}/views/components/validation-errors.blade.php (100%) rename {resources => stubs/blade}/views/components/validation-status.blade.php (100%) rename {resources => stubs/blade}/views/layouts/app.blade.php (100%) rename {resources => stubs/blade}/views/layouts/guest.blade.php (100%) rename {resources => stubs/blade}/views/navigation-menu.blade.php (100%) rename {resources => stubs/blade}/views/team/create.blade.php (100%) rename {resources => stubs/blade}/views/team/domain-federation.blade.php (100%) rename {resources => stubs/blade}/views/team/left-section.blade.php (100%) rename {resources => stubs/blade}/views/team/members.blade.php (100%) rename {resources => stubs/blade}/views/team/profile.blade.php (100%) rename {resources => stubs/blade}/views/team/settings.blade.php (100%) rename {resources => stubs/blade}/views/welcome.blade.php (100%) create mode 100644 tests/Feature/HeadlessModeTest.php create mode 100644 tests/Feature/InstallUiCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6877731..1ff0088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Headless core + Blade starter kit (RFC 002, phase A)** — the package is now fully headless by default, Fortify-style: + - New `ui` config value (`NEEV_UI`: `'blade'` | `null`). `null` (default) registers no Blade page routes — API, OAuth/SSO, and email flows work standalone. `'blade'` registers the page routes, rendered from **app-owned** views + - The Blade page templates moved from package-loaded views to `stubs/blade/views/`; `php artisan neev:ui blade` ejects them to `resources/views/vendor/neev` where they belong to the app (existing published views keep working — same path) + - **Email templates are ejected to the app by the installer** (always, regardless of kit) so they're editable from day one; the package keeps fallback copies so headless installs still send mail. The per-template variable contract is documented in `docs/rfcs/002-starter-kits.md` §5.5 and treated as API + - `neev:install` gains a starter-kit prompt (`blade`/`none`) and third argument; new `neev:ui {kit} [--force]` command for kit ejection on existing apps (never overwrites app files without `--force`) + - Headless email links point at the app's frontend (`{app.url}/verify-email?...`, `/register?invitation_id=...`) instead of the unregistered Blade routes + - New publish tags: `neev-blade-kit`, `neev-mail` (replacing `neev-views`) - **Configurable route prefix** — new `route_prefix` config key (`NEEV_ROUTE_PREFIX`, default `neev`) namespaces every machine-facing route the package registers: the API namespace, OAuth redirect/callback, tenant SSO, and `/csrf-cookie`. Blade UI pages (`/login`, `/account/...`) stay at the root. Route names are unchanged. The MFA-token route gate in `NeevAPIMiddleware` now follows the prefix (previously hardcoded — customised route files silently broke MFA step-up) ### Changed diff --git a/README.md b/README.md index f191926..b1b4a6c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Neev - Enterprise User Management for Laravel -Neev is a comprehensive Laravel package that provides enterprise-grade user authentication, team management, and security features. It's designed as a complete starter kit for SaaS applications, eliminating the need to build complex user management systems from scratch. +Neev is a comprehensive Laravel package that provides enterprise-grade user authentication, team management, and security features for SaaS applications, eliminating the need to build complex user management systems from scratch. The package is headless by default — the API, OAuth/SSO, and email flows work standalone for SPA/API frontends — with an optional Blade starter kit whose pages are ejected into your app at install and are yours to edit. [![Latest Version](https://img.shields.io/packagist/v/ssntpl/neev.svg?style=flat-square)](https://packagist.org/packages/ssntpl/neev) [![License](https://img.shields.io/packagist/l/ssntpl/neev.svg?style=flat-square)](https://packagist.org/packages/ssntpl/neev) @@ -82,6 +82,8 @@ composer require ssntpl/neev php artisan neev:install ``` +The wizard asks three questions: multi-tenant isolation (yes/no), team support (yes/no), and which frontend starter kit you want — `blade` ejects ready-made pages into `resources/views/vendor/neev/` (app-owned), `none` keeps the package headless. Email templates are ejected to your app either way. You can eject a kit later with `php artisan neev:ui blade`. + ### 3. Configure Environment ```env @@ -332,6 +334,8 @@ All API routes are prefixed with `/neev` — the prefix is configurable via `rou ## Web Routes +The Blade page routes below (everything except the OAuth/SSO endpoints) register only when the Blade starter kit is installed (`'ui' => 'blade'` in `config/neev.php`) — headless installs use the API instead. + ### Public Routes | Method | Route | Name | Description | @@ -676,7 +680,8 @@ Email verification is enforced by applying the opt-in `neev:verified-email` midd ### Setup ```bash -php artisan neev:install # Interactive setup (asks: tenants? teams?) +php artisan neev:install # Interactive setup (asks: tenants? teams? starter kit?) +php artisan neev:ui blade # Eject the Blade starter kit (or `none` for headless) php artisan neev:download-geoip # Download GeoIP database ``` diff --git a/UPGRADING.md b/UPGRADING.md index 7982b82..ecc0d2f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -13,6 +13,29 @@ changes see [CHANGELOG.md](./CHANGELOG.md). ## 0.4.5 → Unreleased +**The package is now headless by default (RFC 002, action required for +Blade UI users).** +Page views are no longer auto-loaded from the package, and the Blade +page routes (`/login`, `/account/...`) only register when +`config('neev.ui') === 'blade'`. + +- **Using the shipped Blade UI?** Run `php artisan neev:ui blade` — + it ejects the views to `resources/views/vendor/neev` (app-owned from + then on) and sets `'ui' => 'blade'`. If you had already published + the views, your files are untouched (same path); just set the `ui` + config. +- **Headless / SPA / API-only?** Nothing to do — the Blade page routes + disappear (they were dead weight), and verification/invitation email + links now point at your frontend (`{app.url}/verify-email?...`, + `{app.url}/register?invitation_id=...&hash=...`) carrying the + signed query for the API endpoints. +- Email templates: the installer copies them to + `resources/views/vendor/neev/emails` so they're yours to edit; the + package retains fallbacks. The available variables per template are + documented and stable (see RFC 002 §5.5). +- The `neev-views` publish tag is replaced by `neev-blade-kit` and + `neev-mail`. + **OAuth/SSO routes moved under the route prefix (action required for identity providers).** All machine-facing routes now live under the configurable diff --git a/config/neev.php b/config/neev.php index c94185b..43997b4 100644 --- a/config/neev.php +++ b/config/neev.php @@ -35,6 +35,22 @@ */ 'route_prefix' => env('NEEV_ROUTE_PREFIX', 'neev'), + /* + |-------------------------------------------------------------------------- + | Frontend UI + |-------------------------------------------------------------------------- + | + | Which starter kit drives the frontend. 'blade' registers the Blade + | page routes (/login, /account/..., rendered from the app-owned views + | the installer ejected). null runs the package headless: only the API, + | OAuth/SSO, and email flows are active, and you build the frontend + | yourself (see docs/rfcs/002-starter-kits.md). + | + | Set by `php artisan neev:install` / `php artisan neev:ui`. + | + */ + 'ui' => env('NEEV_UI'), + /* |-------------------------------------------------------------------------- | Authentication diff --git a/docs/README.md b/docs/README.md index 1cd6054..a30eb98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Neev Documentation -Neev is an enterprise-grade Laravel package for user authentication, team management, and multi-tenancy in SaaS applications. See the [main README](../README.md) for a quick overview and getting started guide. +Neev is an enterprise-grade Laravel package for user authentication, team management, and multi-tenancy in SaaS applications. The package is headless by default — API, OAuth/SSO, and email flows work standalone — with an optional Blade starter kit whose pages are ejected into your app at install ([RFC 002](./rfcs/002-starter-kits.md)). See the [main README](../README.md) for a quick overview and getting started guide. --- @@ -26,7 +26,7 @@ Endpoint and route details for building against Neev. | Reference | Description | |-----------|-------------| | [API Reference](./api-reference.md) | Every REST endpoint with request/response examples | -| [Web Routes](./web-routes.md) | All Blade-rendered web routes and view files | +| [Web Routes](./web-routes.md) | All Blade-rendered web routes and view files (require the Blade starter kit, `'ui' => 'blade'`) | ## Architecture @@ -40,12 +40,12 @@ Design decisions and internal patterns — useful when extending Neev or contrib ## Proposals -Design proposals under review, not yet implemented. +Design proposals and their implementation status. | Document | Status | Description | |----------|--------|-------------| | [SPA Cookie Mode](./spa-cookie-mode.md) | Phases 1–3 implemented; phase 4 (consumer guide) pending | HttpOnly-cookie auth + signed double-submit CSRF for same-origin SPAs. Additive to the existing bearer-token API. Driven by the TAILLOG web rebuild and otper. | -| [RFC 002 — Headless Core + Starter Kits](./rfcs/002-starter-kits.md) | Proposed (design agreed 2026-07-02) | Package becomes fully headless (Fortify-style); Blade UI ejects into the app as a starter kit at install; email templates app-owned with a documented variable contract; React kit reserved as a future kit. | +| [RFC 002 — Headless Core + Starter Kits](./rfcs/002-starter-kits.md) | Phase A implemented | Package is fully headless (Fortify-style); Blade UI ejects into the app as a starter kit at install; email templates app-owned with a documented variable contract; React kit reserved as a future kit. | --- @@ -59,10 +59,14 @@ neev/ │ ├── factories/ # Model factories (testing) │ └── migrations/ # Database migrations ├── resources/ -│ └── views/ # Blade templates (64 files) +│ └── views/ +│ └── emails/ # Email templates (headless fallbacks; ejected to the app at install) ├── routes/ │ ├── neev.php # Web and API routes │ └── sso.php # Tenant SSO routes +├── stubs/ +│ └── blade/ +│ └── views/ # Blade starter kit page views (ejected via neev:ui blade) └── src/ ├── Commands/ # Artisan commands ├── Contracts/ # Interfaces (ContextContainer, HasMembers, etc.) @@ -204,7 +208,8 @@ See [CLI Commands](./cli-commands.md) for full reference with options and exampl | Command | Description | |---------|-------------| -| `neev:install` | Interactive setup wizard | +| `neev:install` | Interactive setup wizard (tenant, teams, starter kit) | +| `neev:ui` | Eject a frontend starter kit (`blade`/`none`) and the email templates | | `neev:download-geoip` | Download MaxMind GeoLite2 database | | `neev:clean-login-attempts` | Remove old login attempt records | | `neev:tenant:create` | Create a tenant (isolated) or team (shared) | diff --git a/docs/cli-commands.md b/docs/cli-commands.md index 893cdeb..7e5eabc 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -16,9 +16,33 @@ Interactive setup wizard for new installations. ```bash php artisan neev:install + +# Non-interactive: {tenant} {teams} {kit} +php artisan neev:install yes no blade ``` -Prompts for: multi-tenant isolation and team support. Publishes the config file and sets the `tenant` and `team` options accordingly. Only runs on a fresh installation (fails if the `users` table has records). +Prompts for: multi-tenant isolation (`yes`/`no`), team support (`yes`/`no`), and the frontend starter kit (`blade`/`none`, default `blade`). Publishes the config file, sets the `tenant` and `team` options accordingly, and calls `neev:ui` to eject the chosen kit (and, always, the email templates). Only runs on a fresh installation (fails if the `users` table has records). + +### `neev:ui` + +Eject a frontend starter kit into the application. Also the way to switch an existing install between Blade and headless. + +```bash +php artisan neev:ui blade # eject the Blade kit +php artisan neev:ui blade --force # overwrite files that already exist in the app +php artisan neev:ui none # headless — no page views, page routes disabled +``` + +| Argument / Option | Description | +|-------------------|-------------| +| `kit` | Starter kit to eject: `blade` or `none` | +| `--force` | Overwrite files that already exist in the app (without it, existing files are kept) | + +What it does: + +- **`blade`**: copies the package's Blade page views (auth, account, team pages, components, layouts) from `stubs/blade/views/` to `resources/views/vendor/neev/` — app-owned from then on — and sets `'ui' => 'blade'` in `config/neev.php`, which registers the Blade page routes. +- **`none`**: sets `'ui' => env('NEEV_UI')` (headless — no Blade page routes); no page views are copied. +- **Always** (both kits): ejects the email templates to `resources/views/vendor/neev/emails/` so they're the app's to edit. The package keeps fallback copies, so headless installs send mail with zero setup. The variables available in each template are documented in [RFC 002 §5.5](./rfcs/002-starter-kits.md#55-email-templates--app-owned-with-a-variable-contract) and treated as API. ### `neev:download-geoip` diff --git a/docs/configuration.md b/docs/configuration.md index e7758ea..747e59e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -60,6 +60,21 @@ Blade UI pages (`/login`, `/register`, `/account/...`) stay at the root — they --- +## Frontend UI + +```php +'ui' => env('NEEV_UI'), +``` + +Which starter kit drives the frontend. Valid values: + +- **`'blade'`** — registers the Blade page routes (`/login`, `/register`, `/account/...`), rendered from the app-owned views the installer ejected to `resources/views/vendor/neev/`. Setting `'blade'` without ejecting the kit gives a clear "view not found" error — run `php artisan neev:ui blade` first. +- **`null`** (default) — headless: no Blade page routes are registered. The API routes, OAuth/SSO endpoints, and email flows are unaffected; you build the frontend yourself. Verification and new-user invitation email links point at your frontend (`{app.url}/verify-email?...`, `{app.url}/register?invitation_id=...&hash=...`) carrying the signed query for the API endpoints. + +This value is normally set for you by `php artisan neev:install` / `php artisan neev:ui` — see [CLI Commands](./cli-commands.md#neevui) and [RFC 002](./rfcs/002-starter-kits.md). + +--- + ## Authentication ### Username Support @@ -371,6 +386,9 @@ return [ // Routes 'route_prefix' => env('NEEV_ROUTE_PREFIX', 'neev'), + // Frontend UI ('blade' = Blade page routes from app-owned views; null = headless) + 'ui' => env('NEEV_UI'), + // Authentication 'support_username' => false, 'oauth' => [ @@ -456,6 +474,7 @@ return [ | Variable | Description | Default | |----------|-------------|---------| | `NEEV_ROUTE_PREFIX` | Prefix for machine-facing routes (API, OAuth, SSO, csrf-cookie) | `neev` | +| `NEEV_UI` | Frontend starter kit: `blade` or unset (headless) | unset (headless) | | `NEEV_JWT_SECRET` | Secret for signing MFA JWTs | Falls back to `APP_KEY` | | `MAXMIND_EDITION` | GeoIP database edition | `GeoLite2-City` | | `MAXMIND_LICENSE_KEY` | MaxMind license key | - | diff --git a/docs/installation.md b/docs/installation.md index 4f06456..9e8d66c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -36,22 +36,33 @@ php artisan neev:install This command must be run on a fresh installation — it aborts if the `users` table already contains records. It will: 1. Publish the configuration file to `config/neev.php` (overwriting any existing copy) 2. Set the `tenant` and `team` config options based on your answers +3. Eject your chosen frontend starter kit — and, always, the email templates — via `neev:ui` ### Installation Options -The command takes two arguments and interactively prompts for any that are missing: +The command takes three arguments and interactively prompts for any that are missing: | Prompt | Config key | Description | |--------|------------|-------------| | Would you like to enable multi-tenant isolation? | `tenant` | Users scoped to a tenant; the same email can exist in different tenants | | Would you like to install team support? | `team` | Enable team/organization features | +| Which frontend starter kit would you like? | `ui` | `blade` — ready-made pages ejected into your app; `none` — headless, you build the frontend yourself | + +**Starter kit choices:** + +- **`blade`** (default): copies the Blade page views (login, account, team pages, components, layouts) to `resources/views/vendor/neev/` — they are app-owned from then on — and sets `'ui' => 'blade'` so the Blade page routes (`/login`, `/account/...`) are registered. +- **`none`**: headless. No page views are copied and no Blade page routes are registered — the API, OAuth/SSO, and email flows are fully functional, and you build the frontend yourself. + +Either way, the email templates are copied to `resources/views/vendor/neev/emails/` so they're yours to edit from day one. You can also pass the answers directly: ```bash -php artisan neev:install yes no # tenant: yes, teams: no +php artisan neev:install yes no blade # tenant: yes, teams: no, kit: blade ``` +If you start headless and want the Blade kit later, run `php artisan neev:ui blade` at any time — see [CLI Commands](./cli-commands.md#neevui). + All other features (username support, OAuth, MFA, password policies, etc.) are configured by editing `config/neev.php` — see the [Configuration Reference](./configuration.md). --- @@ -181,13 +192,25 @@ php artisan vendor:publish --tag=neev-migrations This copies migrations to `database/migrations/` for customization. Publishing is optional — migrations load automatically from the package. -### Publish Views +### Eject the Blade Starter Kit + +Neev is headless by default — the Blade pages only exist once the starter kit is ejected into your app (normally done by the installer). To eject it later, or re-eject: + +```bash +php artisan neev:ui blade # never overwrites your files +php artisan neev:ui blade --force # overwrites existing files +``` + +This copies the page views to `resources/views/vendor/neev/` (app-owned) and sets `'ui' => 'blade'` in your config, which registers the Blade page routes. See [CLI Commands](./cli-commands.md#neevui). + +The raw publish tags also exist if you prefer `vendor:publish` (note these do not set the `ui` config): ```bash -php artisan vendor:publish --tag=neev-views +php artisan vendor:publish --tag=neev-blade-kit # page views, components, layouts +php artisan vendor:publish --tag=neev-mail # email templates only ``` -This copies Blade templates to `resources/views/vendor/neev/` for customization. +> The old `neev-views` tag no longer exists — it was replaced by `neev-blade-kit` and `neev-mail`. ### Publish Routes diff --git a/docs/rfcs/002-starter-kits.md b/docs/rfcs/002-starter-kits.md index a773c5f..694f34a 100644 --- a/docs/rfcs/002-starter-kits.md +++ b/docs/rfcs/002-starter-kits.md @@ -1,6 +1,7 @@ # RFC 002 — Headless Core + Starter Kits -> **Status:** Proposed (design agreed with maintainer 2026-07-02; Phase A pending implementation) +> **Status:** Phase A implemented (headless core, `ui` config, Blade kit in `stubs/`, `neev:ui` command, mail ejection); Phase B (React kit) future; Phase C (docs polish) pending +> **§8 resolutions:** one Blade kit including the app-shell views (Q1, Q2); kit ejection on existing apps via the dedicated `neev:ui` command, keeping the installer's fresh-install guard (Q3). Kit ejection sources email templates from the package's own `resources/views/emails` rather than a duplicate `stubs/mail/` copy. > **Drivers:** neev as a Fortify-class drop-in for fresh Laravel installs; TAILLOG/otper React rebuilds > **Depends on:** SPA cookie mode (`docs/spa-cookie-mode.md`), configurable `route_prefix` diff --git a/docs/web-routes.md b/docs/web-routes.md index ed66759..ab51f4e 100644 --- a/docs/web-routes.md +++ b/docs/web-routes.md @@ -2,6 +2,8 @@ Complete reference for all Neev web routes. These routes render Blade views for browser-based authentication. +> **Blade kit required:** all Blade page routes in this document register only when `'ui' => 'blade'` in `config/neev.php` — i.e. after the Blade starter kit is ejected (`php artisan neev:ui blade` or the installer's kit prompt). On a headless install (`ui` = `null`, the default) none of these page routes exist. The [OAuth / Social Login](#oauth--social-login) and [Tenant SSO](#tenant-sso) routes below, and everything in the [API Reference](./api-reference.md), are always registered regardless. + --- ## Public Routes @@ -79,7 +81,7 @@ These routes are accessible without authentication. ### OAuth / Social Login -These routes live under the configurable route prefix (`route_prefix` in `config/neev.php`, env `NEEV_ROUTE_PREFIX`, default `neev`). The paths below use the default prefix. +These routes live under the configurable route prefix (`route_prefix` in `config/neev.php`, env `NEEV_ROUTE_PREFIX`, default `neev`). The paths below use the default prefix. Unlike the Blade page routes, they are **always registered** — headless installs use them too. | Method | Route | Name | Description | |--------|-------|------|-------------| @@ -93,7 +95,7 @@ These routes live under the configurable route prefix (`route_prefix` in `config ### Tenant SSO -These routes also live under the configurable route prefix. +These routes also live under the configurable route prefix and are always registered. | Method | Route | Name | Description | |--------|-------|------|-------------| @@ -297,10 +299,10 @@ For multi-tenant routes. Requires a tenant to be resolved (from the X-Tenant hea ## View Files -Views are stored in `resources/views/` and can be published: +The page views are part of the Blade starter kit — they live in your application at `resources/views/vendor/neev/` (app-owned) once the kit is ejected: ```bash -php artisan vendor:publish --tag=neev-views +php artisan neev:ui blade ``` | View | Description | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 32f92e0..e2a74e9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -141,7 +141,7 @@ parameters: - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' identifier: property.notFound - count: 1 + count: 2 path: src/Http/Controllers/TeamApiController.php - diff --git a/routes/neev.php b/routes/neev.php index 33d40c6..db9191e 100644 --- a/routes/neev.php +++ b/routes/neev.php @@ -21,179 +21,186 @@ Route::bind('team', fn ($value) => Team::model()->findOrFail($value)); -//web routes -Route::middleware(['web', TenantMiddleware::class])->group(function () { - Route::get('/register', [UserAuthController::class, 'registerCreate']) - ->name('register'); - - Route::get('/login', [UserAuthController::class, 'loginCreate']) - ->name('login'); - - Route::get('/login/{id}', [UserAuthController::class, 'loginUsingLink']) - ->name('login.link'); - - Route::get('/otp/mfa/{method}', [UserAuthController::class, 'verifyMFAOTPCreate']) - ->name('otp.mfa.create'); - - Route::get('/forgot-password', [UserAuthController::class, 'forgotPasswordCreate']) - ->name('password.request'); - - Route::get('/update-password/{id}/{hash}', [UserAuthController::class, 'updatePasswordCreate']) - ->name('reset.request'); - - //OAuth (web) — under the machine-facing route prefix; these URLs are - // registered as redirect URIs at the identity providers. - Route::prefix(config('neev.route_prefix', 'neev'))->group(function () { +// Machine-facing web routes — always registered, kit or not. The OAuth +// redirect/callback need the web middleware (Socialite session state) and +// their URLs are registered as redirect URIs at the identity providers. +Route::middleware(['web', TenantMiddleware::class]) + ->prefix(config('neev.route_prefix', 'neev')) + ->group(function () { Route::get('/oauth/{service}', [OAuthController::class, 'redirect']) ->name('oauth.redirect'); Route::get('/oauth/{service}/callback', [OAuthController::class, 'callback']) ->name('oauth.callback'); }); - Route::get('/email/verify/{id}/{hash}', [UserAuthController::class, 'emailVerifyStore']) - ->name('verification.verify'); +// Blade starter kit page routes — registered only when the kit is +// installed (config('neev.ui') === 'blade'). Headless installs use the +// API flows below and build their own frontend. +if (config('neev.ui') === 'blade') { + Route::middleware(['web', TenantMiddleware::class])->group(function () { + Route::get('/register', [UserAuthController::class, 'registerCreate']) + ->name('register'); - Route::get('/email/change/verify/{id}', [UserAuthController::class, 'emailChangeVerify']) - ->name('email.change.verify'); + Route::get('/login', [UserAuthController::class, 'loginCreate']) + ->name('login'); - // Rate-limited auth submission routes - Route::middleware('throttle:10,1')->group(function () { - Route::post('/register', [UserAuthController::class, 'registerStore']); + Route::get('/login/{id}', [UserAuthController::class, 'loginUsingLink']) + ->name('login.link'); - Route::put('/login', [UserAuthController::class, 'loginPassword']) - ->name('login.password'); + Route::get('/otp/mfa/{method}', [UserAuthController::class, 'verifyMFAOTPCreate']) + ->name('otp.mfa.create'); - Route::post('/login/link', [UserAuthController::class, 'sendLoginLink']) - ->name('login.link.send'); + Route::get('/forgot-password', [UserAuthController::class, 'forgotPasswordCreate']) + ->name('password.request'); - Route::post('/otp/mfa', [UserAuthController::class, 'verifyMFAOTPStore']) - ->name('otp.mfa.store'); + Route::get('/update-password/{id}/{hash}', [UserAuthController::class, 'updatePasswordCreate']) + ->name('reset.request'); - Route::post('/otp/mfa/send', [UserAuthController::class, 'emailOTPSend']) - ->name('otp.mfa.send'); + Route::get('/email/verify/{id}/{hash}', [UserAuthController::class, 'emailVerifyStore']) + ->name('verification.verify'); - Route::post('/login', [UserAuthController::class, 'loginStore']); + Route::get('/email/change/verify/{id}', [UserAuthController::class, 'emailChangeVerify']) + ->name('email.change.verify'); - Route::post('/forgot-password', [UserAuthController::class, 'forgotPasswordLink']) - ->name('password.email'); + // Rate-limited auth submission routes + Route::middleware('throttle:10,1')->group(function () { + Route::post('/register', [UserAuthController::class, 'registerStore']); - Route::post('/update-password', [UserAuthController::class, 'updatePasswordStore']) - ->name('user-password.update'); + Route::put('/login', [UserAuthController::class, 'loginPassword']) + ->name('login.password'); - Route::post('/passkeys/login/options', [PasskeyController::class,'generateLoginOptions']) - ->name('passkeys.login.options'); + Route::post('/login/link', [UserAuthController::class, 'sendLoginLink']) + ->name('login.link.send'); - Route::post('/passkeys/login', [PasskeyController::class,'loginViaWeb']) - ->name('passkeys.login'); - }); + Route::post('/otp/mfa', [UserAuthController::class, 'verifyMFAOTPStore']) + ->name('otp.mfa.store'); + + Route::post('/otp/mfa/send', [UserAuthController::class, 'emailOTPSend']) + ->name('otp.mfa.send'); + + Route::post('/login', [UserAuthController::class, 'loginStore']); + + Route::post('/forgot-password', [UserAuthController::class, 'forgotPasswordLink']) + ->name('password.email'); - Route::middleware(['neev:web'])->group(function () { - Route::get('/email/verify', [UserAuthController::class, 'emailVerifyCreate']) - ->name('verification.notice'); - - Route::get('/email/send', [UserAuthController::class, 'emailVerifySend']) - ->name('email.verification.send'); - - Route::get('/email/change', [UserAuthController::class, 'emailChangeCreate']) - ->name('email.change'); - Route::put('/email/change', [UserAuthController::class, 'emailChangeStore']) - ->name('email.update'); - - Route::post('/logout', [UserAuthController::class, 'destroy']) - ->name('logout'); - - Route::prefix('account')->group(function () { - Route::get('/profile', [UserController::class, 'profile']) - ->name('account.profile'); - Route::get('/security', [UserController::class, 'security']) - ->name('account.security'); - Route::get('/tokens', [UserController::class, 'tokens']) - ->name('account.tokens'); - Route::get('/teams', [UserController::class, 'teams']) - ->name('account.teams'); - Route::get('/sessions', [UserController::class, 'sessions']) - ->name('account.sessions'); - Route::get('/loginAttempts', [UserController::class, 'loginAttempts']) - ->name('account.loginAttempts'); - - Route::post('/multiFactorAuth', [UserController::class, 'addMultiFactorAuth']) - ->name('multi.auth'); - Route::put('/multiFactorAuth', [UserController::class, 'preferredMultiFactorAuth']) - ->name('multi.preferred'); - Route::get('/recovery/codes', [UserController::class, 'recoveryCodes']) - ->name('recovery.codes'); - Route::post('/recovery/codes', [UserController::class, 'generateRecoveryCodes']) - ->name('recovery.generate'); - - Route::post('/passkeys/register/options', [PasskeyController::class,'generateRegistrationOptions']) - ->name('passkeys.register.options'); - Route::post('/passkeys/register', [PasskeyController::class,'registerViaWeb']) - ->name('passkeys.register'); - Route::delete('/passkeys', [PasskeyController::class,'deletePasskey']) - ->name('passkeys.delete'); - - Route::put('/profileUpdate', [UserController::class, 'profileUpdate']) - ->name('profile.update'); - Route::post('/change-password', [UserController::class, 'changePassword']) - ->name('password.change'); - Route::delete('/accountDelete', [UserController::class, 'accountDelete']) - ->name('account.delete'); - Route::post('/logoutSessions', [UserAuthController::class, 'destroyAll']) - ->name('logout.sessions'); - Route::post('/tokens/store', [UserController::class, 'tokenStore']) - ->name('tokens.store'); - Route::delete('/tokens/delete', [UserController::class, 'tokenDelete']) - ->name('tokens.delete'); - Route::delete('/tokens/deleteAll', [UserController::class, 'tokenDeleteAll']) - ->name('tokens.deleteAll'); - Route::put('/tokens/update', [UserController::class, 'tokenUpdate']) - ->name('tokens.update'); + Route::post('/update-password', [UserAuthController::class, 'updatePasswordStore']) + ->name('user-password.update'); + + Route::post('/passkeys/login/options', [PasskeyController::class,'generateLoginOptions']) + ->name('passkeys.login.options'); + + Route::post('/passkeys/login', [PasskeyController::class,'loginViaWeb']) + ->name('passkeys.login'); }); - Route::prefix('teams')->group(function () { - Route::get('/{team}/profile', [TeamController::class, 'profile']) - ->name('teams.profile'); - Route::put('/switch', [TeamController::class, 'switch']) - ->name('teams.switch'); - Route::get('/create', [TeamController::class, 'create']) - ->name('teams.create'); - Route::get('/{team}/members', [TeamController::class, 'members']) - ->name('teams.members'); - Route::get('/{team}/domain', [TeamController::class, 'domain']) - ->name('teams.domain'); - Route::get('/{team}/settings', [TeamController::class, 'settings']) - ->name('teams.settings'); - - Route::post('/create', [TeamController::class, 'store']) - ->name('teams.store'); - Route::put('/update', [TeamController::class, 'update']) - ->name('teams.update'); - Route::delete('/delete', [TeamController::class, 'delete']) - ->name('teams.delete'); - Route::put('/members/invite', [TeamController::class, 'inviteMember']) - ->name('teams.invite'); - Route::delete('/members/leave', [TeamController::class, 'leave']) - ->name('teams.leave'); - Route::put('/roles/change', [RoleController::class, 'roleChange']) - ->name('teams.roles.change'); - Route::put('/members/invite/action', [TeamController::class, 'inviteAction']) - ->name('teams.invite.action'); - Route::post('/members/request', [TeamController::class, 'request']) - ->name('teams.request'); - Route::put('/members/request/action', [TeamController::class, 'requestAction']) - ->name('teams.request.action'); - Route::put('/owner/change', [TeamController::class, 'ownerChange']) - ->name('teams.owner.change'); - Route::post('/{team}/domain', [TeamController::class, 'federateDomain']); - Route::put('/{domain}/domain', [TeamController::class, 'updateDomain']); - Route::delete('/{domain}/domain', [TeamController::class, 'deleteDomain']); - Route::put('/{domain}/domain/rules', [TeamController::class, 'updateDomainRule']) - ->name('domain.rules'); - Route::put('/domain/primary', [TeamController::class, 'primaryDomain']) - ->name('domain.primary'); + Route::middleware(['neev:web'])->group(function () { + Route::get('/email/verify', [UserAuthController::class, 'emailVerifyCreate']) + ->name('verification.notice'); + + Route::get('/email/send', [UserAuthController::class, 'emailVerifySend']) + ->name('email.verification.send'); + + Route::get('/email/change', [UserAuthController::class, 'emailChangeCreate']) + ->name('email.change'); + Route::put('/email/change', [UserAuthController::class, 'emailChangeStore']) + ->name('email.update'); + + Route::post('/logout', [UserAuthController::class, 'destroy']) + ->name('logout'); + + Route::prefix('account')->group(function () { + Route::get('/profile', [UserController::class, 'profile']) + ->name('account.profile'); + Route::get('/security', [UserController::class, 'security']) + ->name('account.security'); + Route::get('/tokens', [UserController::class, 'tokens']) + ->name('account.tokens'); + Route::get('/teams', [UserController::class, 'teams']) + ->name('account.teams'); + Route::get('/sessions', [UserController::class, 'sessions']) + ->name('account.sessions'); + Route::get('/loginAttempts', [UserController::class, 'loginAttempts']) + ->name('account.loginAttempts'); + + Route::post('/multiFactorAuth', [UserController::class, 'addMultiFactorAuth']) + ->name('multi.auth'); + Route::put('/multiFactorAuth', [UserController::class, 'preferredMultiFactorAuth']) + ->name('multi.preferred'); + Route::get('/recovery/codes', [UserController::class, 'recoveryCodes']) + ->name('recovery.codes'); + Route::post('/recovery/codes', [UserController::class, 'generateRecoveryCodes']) + ->name('recovery.generate'); + + Route::post('/passkeys/register/options', [PasskeyController::class,'generateRegistrationOptions']) + ->name('passkeys.register.options'); + Route::post('/passkeys/register', [PasskeyController::class,'registerViaWeb']) + ->name('passkeys.register'); + Route::delete('/passkeys', [PasskeyController::class,'deletePasskey']) + ->name('passkeys.delete'); + + Route::put('/profileUpdate', [UserController::class, 'profileUpdate']) + ->name('profile.update'); + Route::post('/change-password', [UserController::class, 'changePassword']) + ->name('password.change'); + Route::delete('/accountDelete', [UserController::class, 'accountDelete']) + ->name('account.delete'); + Route::post('/logoutSessions', [UserAuthController::class, 'destroyAll']) + ->name('logout.sessions'); + Route::post('/tokens/store', [UserController::class, 'tokenStore']) + ->name('tokens.store'); + Route::delete('/tokens/delete', [UserController::class, 'tokenDelete']) + ->name('tokens.delete'); + Route::delete('/tokens/deleteAll', [UserController::class, 'tokenDeleteAll']) + ->name('tokens.deleteAll'); + Route::put('/tokens/update', [UserController::class, 'tokenUpdate']) + ->name('tokens.update'); + }); + + Route::prefix('teams')->group(function () { + Route::get('/{team}/profile', [TeamController::class, 'profile']) + ->name('teams.profile'); + Route::put('/switch', [TeamController::class, 'switch']) + ->name('teams.switch'); + Route::get('/create', [TeamController::class, 'create']) + ->name('teams.create'); + Route::get('/{team}/members', [TeamController::class, 'members']) + ->name('teams.members'); + Route::get('/{team}/domain', [TeamController::class, 'domain']) + ->name('teams.domain'); + Route::get('/{team}/settings', [TeamController::class, 'settings']) + ->name('teams.settings'); + + Route::post('/create', [TeamController::class, 'store']) + ->name('teams.store'); + Route::put('/update', [TeamController::class, 'update']) + ->name('teams.update'); + Route::delete('/delete', [TeamController::class, 'delete']) + ->name('teams.delete'); + Route::put('/members/invite', [TeamController::class, 'inviteMember']) + ->name('teams.invite'); + Route::delete('/members/leave', [TeamController::class, 'leave']) + ->name('teams.leave'); + Route::put('/roles/change', [RoleController::class, 'roleChange']) + ->name('teams.roles.change'); + Route::put('/members/invite/action', [TeamController::class, 'inviteAction']) + ->name('teams.invite.action'); + Route::post('/members/request', [TeamController::class, 'request']) + ->name('teams.request'); + Route::put('/members/request/action', [TeamController::class, 'requestAction']) + ->name('teams.request.action'); + Route::put('/owner/change', [TeamController::class, 'ownerChange']) + ->name('teams.owner.change'); + Route::post('/{team}/domain', [TeamController::class, 'federateDomain']); + Route::put('/{domain}/domain', [TeamController::class, 'updateDomain']); + Route::delete('/{domain}/domain', [TeamController::class, 'deleteDomain']); + Route::put('/{domain}/domain/rules', [TeamController::class, 'updateDomainRule']) + ->name('domain.rules'); + Route::put('/domain/primary', [TeamController::class, 'primaryDomain']) + ->name('domain.primary'); + }); }); }); -}); +} //APIs Route::prefix(config('neev.route_prefix', 'neev'))->middleware(TenantMiddleware::class)->group(function () { diff --git a/src/Commands/InstallNeev.php b/src/Commands/InstallNeev.php index 5310b80..b6642bb 100644 --- a/src/Commands/InstallNeev.php +++ b/src/Commands/InstallNeev.php @@ -17,7 +17,8 @@ class InstallNeev extends Command implements PromptsForMissingInput * @var string */ protected $signature = 'neev:install {tenant : Enable multi-tenant isolation (yes/no)} - {teams : Enable team support (yes/no)}'; + {teams : Enable team support (yes/no)} + {kit : Frontend starter kit (blade/none)}'; /** * The console command description. @@ -51,6 +52,9 @@ public function handle() $this->replaceInFile("'team' => false,", "'team' => true,", $file); } + // Eject the chosen starter kit (and, always, the email templates). + $this->call('neev:ui', ['kit' => $this->argument('kit')]); + $this->info('Neev installed successfully!'); } @@ -89,6 +93,15 @@ protected function promptForMissingArgumentsUsing() ], default: 'yes' ), + + 'kit' => fn () => select( + label: 'Which frontend starter kit would you like?', + options: [ + 'blade' => 'Blade — ready-made pages ejected into your app', + 'none' => 'None — headless, I will build the frontend myself', + ], + default: 'blade' + ), ]; } } diff --git a/src/Commands/InstallUi.php b/src/Commands/InstallUi.php new file mode 100644 index 0000000..41bb8d3 --- /dev/null +++ b/src/Commands/InstallUi.php @@ -0,0 +1,94 @@ +argument('kit'); + + if (!in_array($kit, ['blade', 'none'], true)) { + $this->error("Unknown starter kit [{$kit}]. Available: blade, none."); + return self::FAILURE; + } + + // Email templates are always ejected — they are the app's to edit + // from day one. The package copies remain only as the headless + // fallback. See docs/rfcs/002-starter-kits.md §5.5. + $this->copyDirectory( + $files, + dirname(__DIR__, 2) . '/resources/views/emails', + resource_path('views/vendor/neev/emails'), + ); + + if ($kit === 'blade') { + $this->copyDirectory( + $files, + dirname(__DIR__, 2) . '/stubs/blade/views', + resource_path('views/vendor/neev'), + ); + + $this->setUiConfig('blade'); + $this->info("Blade starter kit ejected to resources/views/vendor/neev — it's yours now."); + } else { + $this->setUiConfig(null); + $this->info('Running headless: no page views ejected, Blade page routes disabled.'); + } + + return self::SUCCESS; + } + + /** + * Copy a directory file-by-file, skipping files the app already owns + * unless --force is given. + */ + protected function copyDirectory(Filesystem $files, string $from, string $to): void + { + $copied = 0; + $skipped = 0; + + foreach ($files->allFiles($from) as $file) { + $target = $to . '/' . $file->getRelativePathname(); + + if ($files->exists($target) && !$this->option('force')) { + $skipped++; + continue; + } + + $files->ensureDirectoryExists(dirname($target)); + $files->copy($file->getPathname(), $target); + $copied++; + } + + $label = str_replace(base_path() . '/', '', $to); + $this->line(" {$label}: {$copied} file(s) copied" . ($skipped ? ", {$skipped} existing file(s) kept (use --force to overwrite)" : '')); + } + + protected function setUiConfig(?string $value): void + { + $file = config_path('neev.php'); + if (!file_exists($file)) { + $this->warn("config/neev.php is not published; set 'ui' => " . var_export($value, true) . ' after publishing.'); + return; + } + + $contents = file_get_contents($file); + $replacement = "'ui' => " . ($value === null ? "env('NEEV_UI')" : var_export($value, true)) . ','; + $updated = preg_replace("/'ui' => [^,]+,/", $replacement, $contents, 1, $count); + + if ($count === 1) { + file_put_contents($file, $updated); + } else { + $this->warn("Could not update 'ui' in config/neev.php — set it to " . var_export($value, true) . ' manually.'); + } + } +} diff --git a/src/Http/Controllers/TeamApiController.php b/src/Http/Controllers/TeamApiController.php index 977d0d1..50ea5de 100644 --- a/src/Http/Controllers/TeamApiController.php +++ b/src/Http/Controllers/TeamApiController.php @@ -255,11 +255,20 @@ public function inviteMember(Request $request) $invitation->role = $request->role; $invitation->save(); - $signedUrl = URL::temporarySignedRoute( - 'register', - $expiry, - ['id' => $invitation->id, 'hash' => sha1($request->email)] - ); + if (config('neev.ui') === 'blade') { + $signedUrl = URL::temporarySignedRoute( + 'register', + $expiry, + ['id' => $invitation->id, 'hash' => sha1($request->email)] + ); + } else { + // Headless: link to the app's register page; the SPA + // forwards invitation_id + hash to POST register. + $signedUrl = config('app.url') . '/register?' . http_build_query([ + 'invitation_id' => $invitation->id, + 'hash' => sha1($request->email), + ]); + } Mail::to($request->email)->send(new TeamInvitation($team->name, 'there', $signedUrl, $expiry, false)); return response()->json([ diff --git a/src/NeevServiceProvider.php b/src/NeevServiceProvider.php index b73549d..93cd662 100644 --- a/src/NeevServiceProvider.php +++ b/src/NeevServiceProvider.php @@ -16,6 +16,7 @@ use Ssntpl\Neev\Commands\Domain\VerifyDomainCommand; use Ssntpl\Neev\Commands\DownloadGeoLiteDb; use Ssntpl\Neev\Commands\InstallNeev; +use Ssntpl\Neev\Commands\InstallUi; use Ssntpl\Neev\Commands\Member\AddMemberCommand; use Ssntpl\Neev\Commands\Member\ListMembersCommand; use Ssntpl\Neev\Commands\Member\RemoveMemberCommand; @@ -123,9 +124,16 @@ public function boot(): void __DIR__.'/../database/migrations/2025_01_01_000012_create_tenant_auth_settings_table.php' => database_path('migrations/2025_01_01_000012_create_tenant_auth_settings_table.php'), ], 'neev-migrations'); + // Blade starter kit: ejected into the app (app-owned from then on). $this->publishes([ - __DIR__.'/../resources/views' => resource_path('views/vendor/neev'), - ], 'neev-views'); + __DIR__.'/../stubs/blade/views' => resource_path('views/vendor/neev'), + ], 'neev-blade-kit'); + + // Email templates: ejected by the installer so the app owns them; + // the package copies below remain as the headless fallback. + $this->publishes([ + __DIR__.'/../resources/views/emails' => resource_path('views/vendor/neev/emails'), + ], 'neev-mail'); $this->publishes([ __DIR__.'/../routes/neev.php' => base_path('/routes/neev.php'), @@ -141,23 +149,22 @@ public function boot(): void $this->loadRoutesFrom(__DIR__ . '/../routes/sso.php'); + // The package view namespace holds only the email fallbacks; page + // views live exclusively in the app once a kit is ejected + // (resources/views/vendor/neev, which Laravel resolves first). $this->loadViewsFrom(__DIR__.'/../resources/views', 'neev'); $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'neev'); - Blade::anonymousComponentPath( - file_exists(resource_path('views/vendor/neev/components')) - ? resource_path('views/vendor/neev/components') - : __DIR__.'/../resources/views/components', - 'neev-component' - ); + // Kit components/layouts are app-owned; only register the paths + // when the kit has been ejected. + if (is_dir(resource_path('views/vendor/neev/components'))) { + Blade::anonymousComponentPath(resource_path('views/vendor/neev/components'), 'neev-component'); + } - Blade::anonymousComponentPath( - file_exists(resource_path('views/vendor/neev/layouts')) - ? resource_path('views/vendor/neev/layouts') - : __DIR__.'/../resources/views/layouts', - 'neev-layout' - ); + if (is_dir(resource_path('views/vendor/neev/layouts'))) { + Blade::anonymousComponentPath(resource_path('views/vendor/neev/layouts'), 'neev-layout'); + } } public function register(): void @@ -170,6 +177,7 @@ public function register(): void $this->commands([ InstallNeev::class, + InstallUi::class, DownloadGeoLiteDb::class, CleanOldLoginAttempts::class, CleanPendingMfaSetups::class, diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php index 881c600..95d3fda 100644 --- a/src/Services/AuthService.php +++ b/src/Services/AuthService.php @@ -105,13 +105,26 @@ public function createApiToken(Request $request, GeoIP $geoIP, $user, string $me public function sendEmailVerification(User $user): void { $expiryMinutes = config('neev.url_expiry_time', 60); - $signedUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes($expiryMinutes), - ['id' => $user->id, 'hash' => hash('sha256', $user->email)] - ); - Mail::to($user->email)->send(new VerifyUserEmail($signedUrl, $user->name, 'Verify Email', $expiryMinutes)); + if (config('neev.ui') === 'blade') { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes($expiryMinutes), + ['id' => $user->id, 'hash' => hash('sha256', $user->email)] + ); + } else { + // Headless: the Blade page routes are not registered. Link to + // the app's frontend, carrying the signature for the API + // verification endpoint (route 'mail.verify'). + $signedUrl = URL::temporarySignedRoute( + 'mail.verify', + now()->addMinutes($expiryMinutes), + ['id' => $user->id, 'hash' => hash('sha256', $user->email)] + ); + $url = config('app.url') . '/verify-email?' . parse_url($signedUrl, PHP_URL_QUERY); + } + + Mail::to($user->email)->send(new VerifyUserEmail($url, $user->name, 'Verify Email', $expiryMinutes)); } /** diff --git a/resources/views/account/left-section.blade.php b/stubs/blade/views/account/left-section.blade.php similarity index 100% rename from resources/views/account/left-section.blade.php rename to stubs/blade/views/account/left-section.blade.php diff --git a/resources/views/account/login-attempt.blade.php b/stubs/blade/views/account/login-attempt.blade.php similarity index 100% rename from resources/views/account/login-attempt.blade.php rename to stubs/blade/views/account/login-attempt.blade.php diff --git a/resources/views/account/profile.blade.php b/stubs/blade/views/account/profile.blade.php similarity index 100% rename from resources/views/account/profile.blade.php rename to stubs/blade/views/account/profile.blade.php diff --git a/resources/views/account/recovery-codes.blade.php b/stubs/blade/views/account/recovery-codes.blade.php similarity index 100% rename from resources/views/account/recovery-codes.blade.php rename to stubs/blade/views/account/recovery-codes.blade.php diff --git a/resources/views/account/security.blade.php b/stubs/blade/views/account/security.blade.php similarity index 100% rename from resources/views/account/security.blade.php rename to stubs/blade/views/account/security.blade.php diff --git a/resources/views/account/sessions.blade.php b/stubs/blade/views/account/sessions.blade.php similarity index 100% rename from resources/views/account/sessions.blade.php rename to stubs/blade/views/account/sessions.blade.php diff --git a/resources/views/account/teams.blade.php b/stubs/blade/views/account/teams.blade.php similarity index 100% rename from resources/views/account/teams.blade.php rename to stubs/blade/views/account/teams.blade.php diff --git a/resources/views/account/tokens.blade.php b/stubs/blade/views/account/tokens.blade.php similarity index 100% rename from resources/views/account/tokens.blade.php rename to stubs/blade/views/account/tokens.blade.php diff --git a/resources/views/auth/change-email.blade.php b/stubs/blade/views/auth/change-email.blade.php similarity index 100% rename from resources/views/auth/change-email.blade.php rename to stubs/blade/views/auth/change-email.blade.php diff --git a/resources/views/auth/forgot-password.blade.php b/stubs/blade/views/auth/forgot-password.blade.php similarity index 100% rename from resources/views/auth/forgot-password.blade.php rename to stubs/blade/views/auth/forgot-password.blade.php diff --git a/resources/views/auth/login-password.blade.php b/stubs/blade/views/auth/login-password.blade.php similarity index 100% rename from resources/views/auth/login-password.blade.php rename to stubs/blade/views/auth/login-password.blade.php diff --git a/resources/views/auth/login.blade.php b/stubs/blade/views/auth/login.blade.php similarity index 100% rename from resources/views/auth/login.blade.php rename to stubs/blade/views/auth/login.blade.php diff --git a/resources/views/auth/otp-mfa.blade.php b/stubs/blade/views/auth/otp-mfa.blade.php similarity index 100% rename from resources/views/auth/otp-mfa.blade.php rename to stubs/blade/views/auth/otp-mfa.blade.php diff --git a/resources/views/auth/register.blade.php b/stubs/blade/views/auth/register.blade.php similarity index 100% rename from resources/views/auth/register.blade.php rename to stubs/blade/views/auth/register.blade.php diff --git a/resources/views/auth/reset-password.blade.php b/stubs/blade/views/auth/reset-password.blade.php similarity index 100% rename from resources/views/auth/reset-password.blade.php rename to stubs/blade/views/auth/reset-password.blade.php diff --git a/resources/views/auth/verify-email.blade.php b/stubs/blade/views/auth/verify-email.blade.php similarity index 100% rename from resources/views/auth/verify-email.blade.php rename to stubs/blade/views/auth/verify-email.blade.php diff --git a/resources/views/components/action-message.blade.php b/stubs/blade/views/components/action-message.blade.php similarity index 100% rename from resources/views/components/action-message.blade.php rename to stubs/blade/views/components/action-message.blade.php diff --git a/resources/views/components/action-section.blade.php b/stubs/blade/views/components/action-section.blade.php similarity index 100% rename from resources/views/components/action-section.blade.php rename to stubs/blade/views/components/action-section.blade.php diff --git a/resources/views/components/application-logo.blade.php b/stubs/blade/views/components/application-logo.blade.php similarity index 100% rename from resources/views/components/application-logo.blade.php rename to stubs/blade/views/components/application-logo.blade.php diff --git a/resources/views/components/application-mark.blade.php b/stubs/blade/views/components/application-mark.blade.php similarity index 100% rename from resources/views/components/application-mark.blade.php rename to stubs/blade/views/components/application-mark.blade.php diff --git a/resources/views/components/authentication-card-logo.blade.php b/stubs/blade/views/components/authentication-card-logo.blade.php similarity index 100% rename from resources/views/components/authentication-card-logo.blade.php rename to stubs/blade/views/components/authentication-card-logo.blade.php diff --git a/resources/views/components/authentication-card.blade.php b/stubs/blade/views/components/authentication-card.blade.php similarity index 100% rename from resources/views/components/authentication-card.blade.php rename to stubs/blade/views/components/authentication-card.blade.php diff --git a/resources/views/components/banner.blade.php b/stubs/blade/views/components/banner.blade.php similarity index 100% rename from resources/views/components/banner.blade.php rename to stubs/blade/views/components/banner.blade.php diff --git a/resources/views/components/button.blade.php b/stubs/blade/views/components/button.blade.php similarity index 100% rename from resources/views/components/button.blade.php rename to stubs/blade/views/components/button.blade.php diff --git a/resources/views/components/card.blade.php b/stubs/blade/views/components/card.blade.php similarity index 100% rename from resources/views/components/card.blade.php rename to stubs/blade/views/components/card.blade.php diff --git a/resources/views/components/checkbox.blade.php b/stubs/blade/views/components/checkbox.blade.php similarity index 100% rename from resources/views/components/checkbox.blade.php rename to stubs/blade/views/components/checkbox.blade.php diff --git a/resources/views/components/confirmation-modal.blade.php b/stubs/blade/views/components/confirmation-modal.blade.php similarity index 100% rename from resources/views/components/confirmation-modal.blade.php rename to stubs/blade/views/components/confirmation-modal.blade.php diff --git a/resources/views/components/confirms-password.blade.php b/stubs/blade/views/components/confirms-password.blade.php similarity index 100% rename from resources/views/components/confirms-password.blade.php rename to stubs/blade/views/components/confirms-password.blade.php diff --git a/resources/views/components/danger-button.blade.php b/stubs/blade/views/components/danger-button.blade.php similarity index 100% rename from resources/views/components/danger-button.blade.php rename to stubs/blade/views/components/danger-button.blade.php diff --git a/resources/views/components/dialog-modal.blade.php b/stubs/blade/views/components/dialog-modal.blade.php similarity index 100% rename from resources/views/components/dialog-modal.blade.php rename to stubs/blade/views/components/dialog-modal.blade.php diff --git a/resources/views/components/dropdown-link.blade.php b/stubs/blade/views/components/dropdown-link.blade.php similarity index 100% rename from resources/views/components/dropdown-link.blade.php rename to stubs/blade/views/components/dropdown-link.blade.php diff --git a/resources/views/components/dropdown.blade.php b/stubs/blade/views/components/dropdown.blade.php similarity index 100% rename from resources/views/components/dropdown.blade.php rename to stubs/blade/views/components/dropdown.blade.php diff --git a/resources/views/components/form-section.blade.php b/stubs/blade/views/components/form-section.blade.php similarity index 100% rename from resources/views/components/form-section.blade.php rename to stubs/blade/views/components/form-section.blade.php diff --git a/resources/views/components/input-error.blade.php b/stubs/blade/views/components/input-error.blade.php similarity index 100% rename from resources/views/components/input-error.blade.php rename to stubs/blade/views/components/input-error.blade.php diff --git a/resources/views/components/input.blade.php b/stubs/blade/views/components/input.blade.php similarity index 100% rename from resources/views/components/input.blade.php rename to stubs/blade/views/components/input.blade.php diff --git a/resources/views/components/label.blade.php b/stubs/blade/views/components/label.blade.php similarity index 100% rename from resources/views/components/label.blade.php rename to stubs/blade/views/components/label.blade.php diff --git a/resources/views/components/modal.blade.php b/stubs/blade/views/components/modal.blade.php similarity index 100% rename from resources/views/components/modal.blade.php rename to stubs/blade/views/components/modal.blade.php diff --git a/resources/views/components/nav-link.blade.php b/stubs/blade/views/components/nav-link.blade.php similarity index 100% rename from resources/views/components/nav-link.blade.php rename to stubs/blade/views/components/nav-link.blade.php diff --git a/resources/views/components/refresh-button.blade.php b/stubs/blade/views/components/refresh-button.blade.php similarity index 100% rename from resources/views/components/refresh-button.blade.php rename to stubs/blade/views/components/refresh-button.blade.php diff --git a/resources/views/components/responsive-nav-link.blade.php b/stubs/blade/views/components/responsive-nav-link.blade.php similarity index 100% rename from resources/views/components/responsive-nav-link.blade.php rename to stubs/blade/views/components/responsive-nav-link.blade.php diff --git a/resources/views/components/secondary-button.blade.php b/stubs/blade/views/components/secondary-button.blade.php similarity index 100% rename from resources/views/components/secondary-button.blade.php rename to stubs/blade/views/components/secondary-button.blade.php diff --git a/resources/views/components/section-border.blade.php b/stubs/blade/views/components/section-border.blade.php similarity index 100% rename from resources/views/components/section-border.blade.php rename to stubs/blade/views/components/section-border.blade.php diff --git a/resources/views/components/section-title.blade.php b/stubs/blade/views/components/section-title.blade.php similarity index 100% rename from resources/views/components/section-title.blade.php rename to stubs/blade/views/components/section-title.blade.php diff --git a/resources/views/components/switchable-team.blade.php b/stubs/blade/views/components/switchable-team.blade.php similarity index 100% rename from resources/views/components/switchable-team.blade.php rename to stubs/blade/views/components/switchable-team.blade.php diff --git a/resources/views/components/table-body-tr.blade.php b/stubs/blade/views/components/table-body-tr.blade.php similarity index 100% rename from resources/views/components/table-body-tr.blade.php rename to stubs/blade/views/components/table-body-tr.blade.php diff --git a/resources/views/components/table.blade.php b/stubs/blade/views/components/table.blade.php similarity index 100% rename from resources/views/components/table.blade.php rename to stubs/blade/views/components/table.blade.php diff --git a/resources/views/components/validation-errors.blade.php b/stubs/blade/views/components/validation-errors.blade.php similarity index 100% rename from resources/views/components/validation-errors.blade.php rename to stubs/blade/views/components/validation-errors.blade.php diff --git a/resources/views/components/validation-status.blade.php b/stubs/blade/views/components/validation-status.blade.php similarity index 100% rename from resources/views/components/validation-status.blade.php rename to stubs/blade/views/components/validation-status.blade.php diff --git a/resources/views/layouts/app.blade.php b/stubs/blade/views/layouts/app.blade.php similarity index 100% rename from resources/views/layouts/app.blade.php rename to stubs/blade/views/layouts/app.blade.php diff --git a/resources/views/layouts/guest.blade.php b/stubs/blade/views/layouts/guest.blade.php similarity index 100% rename from resources/views/layouts/guest.blade.php rename to stubs/blade/views/layouts/guest.blade.php diff --git a/resources/views/navigation-menu.blade.php b/stubs/blade/views/navigation-menu.blade.php similarity index 100% rename from resources/views/navigation-menu.blade.php rename to stubs/blade/views/navigation-menu.blade.php diff --git a/resources/views/team/create.blade.php b/stubs/blade/views/team/create.blade.php similarity index 100% rename from resources/views/team/create.blade.php rename to stubs/blade/views/team/create.blade.php diff --git a/resources/views/team/domain-federation.blade.php b/stubs/blade/views/team/domain-federation.blade.php similarity index 100% rename from resources/views/team/domain-federation.blade.php rename to stubs/blade/views/team/domain-federation.blade.php diff --git a/resources/views/team/left-section.blade.php b/stubs/blade/views/team/left-section.blade.php similarity index 100% rename from resources/views/team/left-section.blade.php rename to stubs/blade/views/team/left-section.blade.php diff --git a/resources/views/team/members.blade.php b/stubs/blade/views/team/members.blade.php similarity index 100% rename from resources/views/team/members.blade.php rename to stubs/blade/views/team/members.blade.php diff --git a/resources/views/team/profile.blade.php b/stubs/blade/views/team/profile.blade.php similarity index 100% rename from resources/views/team/profile.blade.php rename to stubs/blade/views/team/profile.blade.php diff --git a/resources/views/team/settings.blade.php b/stubs/blade/views/team/settings.blade.php similarity index 100% rename from resources/views/team/settings.blade.php rename to stubs/blade/views/team/settings.blade.php diff --git a/resources/views/welcome.blade.php b/stubs/blade/views/welcome.blade.php similarity index 100% rename from resources/views/welcome.blade.php rename to stubs/blade/views/welcome.blade.php diff --git a/tests/Feature/HeadlessModeTest.php b/tests/Feature/HeadlessModeTest.php new file mode 100644 index 0000000..4018552 --- /dev/null +++ b/tests/Feature/HeadlessModeTest.php @@ -0,0 +1,106 @@ +set('neev.ui', null); + $app['config']->set('neev.team', true); + } + + // ----------------------------------------------------------------- + // Route registration + // ----------------------------------------------------------------- + + public function test_blade_page_routes_are_not_registered(): void + { + $this->get('/login')->assertNotFound(); + $this->get('/register')->assertNotFound(); + $this->assertFalse(Route::has('login')); + $this->assertFalse(Route::has('account.security')); + } + + public function test_machine_facing_routes_remain_registered(): void + { + // API namespace + $this->assertTrue(Route::has('neev.csrf-cookie')); + $this->assertTrue(Route::has('mail.verify')); + // OAuth web redirect/callback (IdP contracts) + $this->assertTrue(Route::has('oauth.redirect')); + $this->assertTrue(Route::has('oauth.callback')); + // Tenant SSO + $this->assertTrue(Route::has('sso.callback')); + } + + public function test_api_login_works_headless(): void + { + config(['neev.password' => ['required']]); + $user = User::factory()->create(['password' => 'password123']); + + $this->postJson('/neev/login', ['email' => $user->email, 'password' => 'password123']) + ->assertOk() + ->assertJsonPath('auth_state', 'authenticated'); + } + + // ----------------------------------------------------------------- + // Email links point at the frontend, not Blade routes + // ----------------------------------------------------------------- + + public function test_registration_verification_email_links_to_frontend(): void + { + Mail::fake(); + config(['neev.password' => ['required', 'confirmed'], 'neev.team' => false]); + + $this->postJson('/neev/register', [ + 'name' => 'Headless User', + 'email' => 'headless@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ])->assertOk(); + + Mail::assertSent(VerifyUserEmail::class, function (VerifyUserEmail $mail) { + return str_starts_with($mail->url, config('app.url') . '/verify-email?') + && str_contains($mail->url, 'signature='); + }); + } + + public function test_new_user_team_invitation_links_to_frontend_register(): void + { + Mail::fake(); + + $owner = User::factory()->create(); + $team = \Ssntpl\Neev\Models\Team::model()->forceCreate([ + 'name' => 'Headless Team', + 'user_id' => $owner->id, + 'is_public' => false, + 'activated_at' => now(), + ]); + $team->addMember($owner); + $token = $owner->createLoginToken(1440)->plainTextToken; + + $this->withHeader('Authorization', 'Bearer ' . $token) + ->postJson('/neev/teams/inviteUser', [ + 'team_id' => $team->id, + 'email' => 'newcomer@example.com', + ])->assertOk(); + + Mail::assertSent(TeamInvitation::class, function (TeamInvitation $mail) { + return str_starts_with($mail->url, config('app.url') . '/register?') + && str_contains($mail->url, 'invitation_id='); + }); + } +} diff --git a/tests/Feature/InstallUiCommandTest.php b/tests/Feature/InstallUiCommandTest.php new file mode 100644 index 0000000..ea96866 --- /dev/null +++ b/tests/Feature/InstallUiCommandTest.php @@ -0,0 +1,52 @@ +artisan('neev:ui', ['kit' => 'blade'])->assertSuccessful(); + + $this->assertFileExists(resource_path('views/vendor/neev/auth/login.blade.php')); + $this->assertFileExists(resource_path('views/vendor/neev/account/security.blade.php')); + $this->assertFileExists(resource_path('views/vendor/neev/components/button.blade.php')); + $this->assertFileExists(resource_path('views/vendor/neev/emails/email-verify.blade.php')); + } + + public function test_none_kit_ejects_only_email_templates(): void + { + $this->artisan('neev:ui', ['kit' => 'none'])->assertSuccessful(); + + $this->assertFileExists(resource_path('views/vendor/neev/emails/email-verify.blade.php')); + $this->assertFileDoesNotExist(resource_path('views/vendor/neev/auth/login.blade.php')); + } + + public function test_existing_app_files_are_kept_without_force(): void + { + $target = resource_path('views/vendor/neev/emails/email-verify.blade.php'); + File::ensureDirectoryExists(dirname($target)); + File::put($target, 'app-owned customisation'); + + $this->artisan('neev:ui', ['kit' => 'none'])->assertSuccessful(); + $this->assertSame('app-owned customisation', File::get($target)); + + $this->artisan('neev:ui', ['kit' => 'none', '--force' => true])->assertSuccessful(); + $this->assertNotSame('app-owned customisation', File::get($target)); + } + + public function test_unknown_kit_fails(): void + { + $this->artisan('neev:ui', ['kit' => 'angular'])->assertFailed(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 869e6fd..f580838 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Ssntpl\Neev\Tests; +use Illuminate\Support\Facades\Blade; use Orchestra\Testbench\TestCase as BaseTestCase; use Ssntpl\Neev\NeevServiceProvider; @@ -24,6 +25,23 @@ protected function defineEnvironment($app): void ]); $app['config']->set('app.key', 'base64:YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE='); + + // Most tests exercise the Blade web flows; simulate an app that + // ejected the Blade starter kit. Headless-specific tests override + // this back to null in their own defineEnvironment(). + $app['config']->set('neev.ui', 'blade'); + } + + protected function setUp(): void + { + parent::setUp(); + + // Resolve kit views straight from the stubs, as if they had been + // ejected to resources/views/vendor/neev. + $stubViews = dirname(__DIR__) . '/stubs/blade/views'; + $this->app['view']->prependNamespace('neev', $stubViews); + Blade::anonymousComponentPath($stubViews . '/components', 'neev-component'); + Blade::anonymousComponentPath($stubViews . '/layouts', 'neev-layout'); } protected function defineDatabaseMigrations(): void