From 9174254165dca1b88c23a678191ff330f6f7f600 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 9 Apr 2026 10:06:27 +0200 Subject: [PATCH] Refactor install wizard and mail configuration --- .../Settings/MailConfigurationController.php | 171 +---- .../CompanyMailConfigurationController.php | 125 +--- .../Setup/DatabaseConfigurationController.php | 2 +- .../Controllers/Setup/LoginController.php | 41 +- .../Setup/SessionLoginController.php | 35 + app/Http/Middleware/PreventRequestForgery.php | 1 + .../Middleware/UseInstallWizardTokenAuth.php | 41 ++ .../CompanyMailConfigurationRequest.php | 36 + .../Requests/DatabaseEnvironmentRequest.php | 8 + app/Http/Requests/MailEnvironmentRequest.php | 157 +--- app/Providers/AppConfigProvider.php | 77 +- app/Providers/AppServiceProvider.php | 21 + app/Services/CompanyMailConfigService.php | 90 +-- app/Services/MailConfigurationService.php | 453 ++++++++++++ app/Services/Setup/EnvironmentManager.php | 49 +- app/Support/InstallWizardAuth.php | 21 + bootstrap/app.php | 3 + composer.json | 4 +- composer.lock | 335 ++++++++- config/database.php | 2 +- config/mail.php | 8 +- config/services.php | 5 + lang/en.json | 53 +- resources/scripts/InvoiceShelf.ts | 30 +- resources/scripts/api/endpoints.ts | 4 + resources/scripts/api/index.ts | 5 +- resources/scripts/api/install-client.ts | 34 + .../scripts/api/services/company.service.ts | 9 +- resources/scripts/api/services/index.ts | 2 +- .../scripts/api/services/mail.service.ts | 49 +- .../components/base/BaseWizardStep.vue | 19 +- resources/scripts/config/constants.ts | 2 + .../views/settings/AdminMailConfigView.vue | 56 +- .../components/MailConfigurationForm.vue | 697 ++++++++++++++++++ .../features/company/settings/store.ts | 10 +- .../company/settings/views/MailConfigView.vue | 110 +-- .../components/RequirementBadge.vue | 22 + .../features/installation/install-auth.ts | 21 + .../scripts/features/installation/routes.ts | 187 +++-- .../installation/use-installation-feedback.ts | 75 ++ .../installation/views/AccountView.vue | 22 +- .../installation/views/CompanyView.vue | 24 +- .../installation/views/DatabaseView.vue | 97 ++- .../installation/views/DomainView.vue | 52 +- .../installation/views/LanguageView.vue | 128 ++++ .../features/installation/views/MailView.vue | 200 ++--- .../installation/views/PermissionsView.vue | 77 +- .../installation/views/PreferencesView.vue | 49 +- .../installation/views/RequirementsView.vue | 96 ++- .../scripts/layouts/InstallationLayout.vue | 116 ++- resources/scripts/types/mail-config.ts | 42 ++ routes/web.php | 14 + tests/Feature/Admin/AdminSettingsTest.php | 56 ++ ...CompanyMailConfigurationControllerTest.php | 111 +++ .../Modules/HelloWorldIntegrationTest.php | 110 +++ 55 files changed, 3102 insertions(+), 1162 deletions(-) create mode 100644 app/Http/Controllers/Setup/SessionLoginController.php create mode 100644 app/Http/Middleware/UseInstallWizardTokenAuth.php create mode 100644 app/Http/Requests/CompanyMailConfigurationRequest.php create mode 100644 app/Services/MailConfigurationService.php create mode 100644 app/Support/InstallWizardAuth.php create mode 100644 resources/scripts/api/install-client.ts create mode 100644 resources/scripts/features/company/settings/components/MailConfigurationForm.vue create mode 100644 resources/scripts/features/installation/components/RequirementBadge.vue create mode 100644 resources/scripts/features/installation/install-auth.ts create mode 100644 resources/scripts/features/installation/use-installation-feedback.ts create mode 100644 resources/scripts/features/installation/views/LanguageView.vue create mode 100644 resources/scripts/types/mail-config.ts create mode 100644 tests/Feature/Company/CompanyMailConfigurationControllerTest.php create mode 100644 tests/Feature/Company/Modules/HelloWorldIntegrationTest.php diff --git a/app/Http/Controllers/Admin/Settings/MailConfigurationController.php b/app/Http/Controllers/Admin/Settings/MailConfigurationController.php index 47cbf2a4..7aca9f6a 100755 --- a/app/Http/Controllers/Admin/Settings/MailConfigurationController.php +++ b/app/Http/Controllers/Admin/Settings/MailConfigurationController.php @@ -6,29 +6,16 @@ use App\Http\Controllers\Controller; use App\Http\Requests\MailEnvironmentRequest; use App\Mail\TestMail; use App\Models\Setting; -use App\Services\Setup\EnvironmentManager; +use App\Services\MailConfigurationService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Mail; use Illuminate\Validation\ValidationException; -use Mail; class MailConfigurationController extends Controller { - /** - * The environment manager - * - * @var EnvironmentManager - */ - protected $environmentManager; - - /** - * The constructor - */ - public function __construct(EnvironmentManager $environmentManager) - { - $this->environmentManager = $environmentManager; - } + public function __construct(private readonly MailConfigurationService $mailConfigurationService) {} /** * Save the mail environment variables @@ -43,11 +30,7 @@ class MailConfigurationController extends Controller $setting = Setting::getSetting('profile_complete'); - // Prepare mail settings for database storage - $mailSettings = $this->prepareMailSettingsForDatabase($request); - - // Save mail settings to database - Setting::setSettings($mailSettings); + $this->mailConfigurationService->saveGlobalConfig($request->validated()); if ($setting !== 'COMPLETED') { Setting::setSetting('profile_complete', 4); @@ -58,63 +41,6 @@ class MailConfigurationController extends Controller ]); } - /** - * Prepare mail settings for database storage - */ - private function prepareMailSettingsForDatabase(MailEnvironmentRequest $request): array - { - $driver = $request->get('mail_driver'); - - // Base settings that are always saved - $settings = [ - 'mail_driver' => $driver, - 'from_name' => $request->get('from_name'), - 'from_mail' => $request->get('from_mail'), - ]; - - // Driver-specific settings - switch ($driver) { - case 'smtp': - $settings = array_merge($settings, [ - 'mail_host' => $request->get('mail_host'), - 'mail_port' => $request->get('mail_port'), - 'mail_username' => $request->get('mail_username'), - 'mail_password' => $request->get('mail_password'), - 'mail_encryption' => $request->get('mail_encryption', 'none'), - 'mail_scheme' => $request->get('mail_scheme'), - 'mail_url' => $request->get('mail_url'), - 'mail_timeout' => $request->get('mail_timeout'), - 'mail_local_domain' => $request->get('mail_local_domain'), - ]); - break; - - case 'mailgun': - $settings = array_merge($settings, [ - 'mail_mailgun_domain' => $request->get('mail_mailgun_domain'), - 'mail_mailgun_secret' => $request->get('mail_mailgun_secret'), - 'mail_mailgun_endpoint' => $request->get('mail_mailgun_endpoint', 'api.mailgun.net'), - 'mail_mailgun_scheme' => $request->get('mail_mailgun_scheme', 'https'), - ]); - break; - - case 'ses': - $settings = array_merge($settings, [ - 'mail_ses_key' => $request->get('mail_ses_key'), - 'mail_ses_secret' => $request->get('mail_ses_secret'), - 'mail_ses_region' => $request->get('mail_ses_region', 'us-east-1'), - ]); - break; - - case 'sendmail': - $settings = array_merge($settings, [ - 'mail_sendmail_path' => $request->get('mail_sendmail_path', '/usr/sbin/sendmail -bs -i'), - ]); - break; - } - - return $settings; - } - /** * Return the mail environment variables * @@ -125,84 +51,7 @@ class MailConfigurationController extends Controller { $this->authorize('manage email config'); - // Get mail settings from database - $mailSettings = Setting::getSettings([ - 'mail_driver', - 'mail_host', - 'mail_port', - 'mail_username', - 'mail_password', - 'mail_encryption', - 'mail_scheme', - 'mail_url', - 'mail_timeout', - 'mail_local_domain', - 'from_name', - 'from_mail', - 'mail_mailgun_domain', - 'mail_mailgun_secret', - 'mail_mailgun_endpoint', - 'mail_mailgun_scheme', - 'mail_ses_key', - 'mail_ses_secret', - 'mail_ses_region', - 'mail_sendmail_path', - ]); - - $driver = $mailSettings['mail_driver'] ?? config('mail.default'); - - // Base data that's always available - $MailData = [ - 'mail_driver' => $driver, - 'from_name' => $mailSettings['from_name'] ?? config('mail.from.name'), - 'from_mail' => $mailSettings['from_mail'] ?? config('mail.from.address'), - ]; - - // Driver-specific configuration - switch ($driver) { - case 'smtp': - $MailData = array_merge($MailData, [ - 'mail_host' => $mailSettings['mail_host'] ?? config('mail.mailers.smtp.host', ''), - 'mail_port' => $mailSettings['mail_port'] ?? config('mail.mailers.smtp.port', ''), - 'mail_username' => $mailSettings['mail_username'] ?? config('mail.mailers.smtp.username', ''), - 'mail_password' => $mailSettings['mail_password'] ?? config('mail.mailers.smtp.password', ''), - 'mail_encryption' => $mailSettings['mail_encryption'] ?? config('mail.mailers.smtp.encryption', 'none'), - 'mail_scheme' => $mailSettings['mail_scheme'] ?? '', - 'mail_url' => $mailSettings['mail_url'] ?? '', - 'mail_timeout' => $mailSettings['mail_timeout'] ?? '', - 'mail_local_domain' => $mailSettings['mail_local_domain'] ?? '', - ]); - break; - - case 'mailgun': - $MailData = array_merge($MailData, [ - 'mail_mailgun_domain' => $mailSettings['mail_mailgun_domain'] ?? '', - 'mail_mailgun_secret' => $mailSettings['mail_mailgun_secret'] ?? '', - 'mail_mailgun_endpoint' => $mailSettings['mail_mailgun_endpoint'] ?? 'api.mailgun.net', - 'mail_mailgun_scheme' => $mailSettings['mail_mailgun_scheme'] ?? 'https', - ]); - break; - - case 'ses': - $MailData = array_merge($MailData, [ - 'mail_ses_key' => $mailSettings['mail_ses_key'] ?? '', - 'mail_ses_secret' => $mailSettings['mail_ses_secret'] ?? '', - 'mail_ses_region' => $mailSettings['mail_ses_region'] ?? 'us-east-1', - ]); - break; - - case 'sendmail': - $MailData = array_merge($MailData, [ - 'mail_sendmail_path' => $mailSettings['mail_sendmail_path'] ?? '/usr/sbin/sendmail -bs -i', - ]); - break; - - default: - // For unknown drivers, return minimal configuration - break; - } - - return response()->json($MailData); + return response()->json($this->mailConfigurationService->getGlobalConfig()); } /** @@ -215,15 +64,7 @@ class MailConfigurationController extends Controller { $this->authorize('manage email config'); - $drivers = [ - 'smtp', - 'mail', - 'sendmail', - 'mailgun', - 'ses', - ]; - - return response()->json($drivers); + return response()->json($this->mailConfigurationService->getAvailableDrivers()); } /** diff --git a/app/Http/Controllers/Company/Settings/CompanyMailConfigurationController.php b/app/Http/Controllers/Company/Settings/CompanyMailConfigurationController.php index 34a426e5..69f3e49e 100644 --- a/app/Http/Controllers/Company/Settings/CompanyMailConfigurationController.php +++ b/app/Http/Controllers/Company/Settings/CompanyMailConfigurationController.php @@ -3,135 +3,38 @@ namespace App\Http\Controllers\Company\Settings; use App\Http\Controllers\Controller; +use App\Http\Requests\CompanyMailConfigurationRequest; use App\Mail\TestMail; -use App\Models\CompanySetting; use App\Services\CompanyMailConfigService; +use App\Services\MailConfigurationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Mail; +use Illuminate\Support\Facades\Mail; class CompanyMailConfigurationController extends Controller { + public function __construct(private readonly MailConfigurationService $mailConfigurationService) {} + public function getDefaultConfig(Request $request): JsonResponse { - $mailConfig = [ - 'from_name' => config('mail.from.name'), - 'from_mail' => config('mail.from.address'), - ]; - - return response()->json($mailConfig); + return response()->json($this->mailConfigurationService->getDefaultConfig()); } public function getMailConfig(Request $request): JsonResponse { - $companyId = $request->header('company'); - - $useCustom = CompanySetting::getSetting('use_custom_mail_config', $companyId) ?? 'NO'; - $driver = CompanySetting::getSetting('company_mail_driver', $companyId) ?? ''; - - $data = [ - 'use_custom_mail_config' => $useCustom, - 'mail_driver' => $driver, - 'from_name' => CompanySetting::getSetting('company_from_name', $companyId) ?? '', - 'from_mail' => CompanySetting::getSetting('company_from_mail', $companyId) ?? '', - ]; - - switch ($driver) { - case 'smtp': - $data = array_merge($data, [ - 'mail_host' => CompanySetting::getSetting('company_mail_host', $companyId) ?? '', - 'mail_port' => CompanySetting::getSetting('company_mail_port', $companyId) ?? '', - 'mail_username' => CompanySetting::getSetting('company_mail_username', $companyId) ?? '', - 'mail_password' => CompanySetting::getSetting('company_mail_password', $companyId) ?? '', - 'mail_encryption' => CompanySetting::getSetting('company_mail_encryption', $companyId) ?? 'none', - 'mail_scheme' => CompanySetting::getSetting('company_mail_scheme', $companyId) ?? '', - 'mail_url' => CompanySetting::getSetting('company_mail_url', $companyId) ?? '', - 'mail_timeout' => CompanySetting::getSetting('company_mail_timeout', $companyId) ?? '', - 'mail_local_domain' => CompanySetting::getSetting('company_mail_local_domain', $companyId) ?? '', - ]); - break; - - case 'mailgun': - $data = array_merge($data, [ - 'mail_mailgun_domain' => CompanySetting::getSetting('company_mail_mailgun_domain', $companyId) ?? '', - 'mail_mailgun_secret' => CompanySetting::getSetting('company_mail_mailgun_secret', $companyId) ?? '', - 'mail_mailgun_endpoint' => CompanySetting::getSetting('company_mail_mailgun_endpoint', $companyId) ?? 'api.mailgun.net', - 'mail_mailgun_scheme' => CompanySetting::getSetting('company_mail_mailgun_scheme', $companyId) ?? 'https', - ]); - break; - - case 'ses': - $data = array_merge($data, [ - 'mail_ses_key' => CompanySetting::getSetting('company_mail_ses_key', $companyId) ?? '', - 'mail_ses_secret' => CompanySetting::getSetting('company_mail_ses_secret', $companyId) ?? '', - 'mail_ses_region' => CompanySetting::getSetting('company_mail_ses_region', $companyId) ?? 'us-east-1', - ]); - break; - - case 'sendmail': - $data = array_merge($data, [ - 'mail_sendmail_path' => CompanySetting::getSetting('company_mail_sendmail_path', $companyId) ?? '/usr/sbin/sendmail -bs -i', - ]); - break; - } - - return response()->json($data); + return response()->json( + $this->mailConfigurationService->getCompanyConfig($request->header('company')) + ); } - public function saveMailConfig(Request $request): JsonResponse + public function saveMailConfig(CompanyMailConfigurationRequest $request): JsonResponse { $this->authorize('owner only'); - $companyId = $request->header('company'); - $driver = $request->get('mail_driver', ''); - - $settings = [ - 'use_custom_mail_config' => $request->get('use_custom_mail_config', 'NO'), - 'company_mail_driver' => $driver, - 'company_from_name' => $request->get('from_name', ''), - 'company_from_mail' => $request->get('from_mail', ''), - ]; - - switch ($driver) { - case 'smtp': - $settings = array_merge($settings, [ - 'company_mail_host' => $request->get('mail_host', ''), - 'company_mail_port' => $request->get('mail_port', ''), - 'company_mail_username' => $request->get('mail_username', ''), - 'company_mail_password' => $request->get('mail_password', ''), - 'company_mail_encryption' => $request->get('mail_encryption', 'none'), - 'company_mail_scheme' => $request->get('mail_scheme', ''), - 'company_mail_url' => $request->get('mail_url', ''), - 'company_mail_timeout' => $request->get('mail_timeout', ''), - 'company_mail_local_domain' => $request->get('mail_local_domain', ''), - ]); - break; - - case 'mailgun': - $settings = array_merge($settings, [ - 'company_mail_mailgun_domain' => $request->get('mail_mailgun_domain', ''), - 'company_mail_mailgun_secret' => $request->get('mail_mailgun_secret', ''), - 'company_mail_mailgun_endpoint' => $request->get('mail_mailgun_endpoint', 'api.mailgun.net'), - 'company_mail_mailgun_scheme' => $request->get('mail_mailgun_scheme', 'https'), - ]); - break; - - case 'ses': - $settings = array_merge($settings, [ - 'company_mail_ses_key' => $request->get('mail_ses_key', ''), - 'company_mail_ses_secret' => $request->get('mail_ses_secret', ''), - 'company_mail_ses_region' => $request->get('mail_ses_region', 'us-east-1'), - ]); - break; - - case 'sendmail': - $settings = array_merge($settings, [ - 'company_mail_sendmail_path' => $request->get('mail_sendmail_path', '/usr/sbin/sendmail -bs -i'), - ]); - break; - } - - CompanySetting::setSettings($settings, $companyId); + $this->mailConfigurationService->saveCompanyConfig( + $request->header('company'), + $request->validated() + ); return response()->json(['success' => true]); } diff --git a/app/Http/Controllers/Setup/DatabaseConfigurationController.php b/app/Http/Controllers/Setup/DatabaseConfigurationController.php index 9845e5f5..497237bc 100644 --- a/app/Http/Controllers/Setup/DatabaseConfigurationController.php +++ b/app/Http/Controllers/Setup/DatabaseConfigurationController.php @@ -54,7 +54,7 @@ class DatabaseConfigurationController extends Controller case 'sqlite': $databaseData = [ 'database_connection' => 'sqlite', - 'database_name' => config('database.connections.sqlite.database', storage_path('database.sqlite')), + 'database_name' => config('database.connections.sqlite.database') ?: 'storage/app/database.sqlite', ]; break; diff --git a/app/Http/Controllers/Setup/LoginController.php b/app/Http/Controllers/Setup/LoginController.php index f0612e87..474929f0 100644 --- a/app/Http/Controllers/Setup/LoginController.php +++ b/app/Http/Controllers/Setup/LoginController.php @@ -4,26 +4,47 @@ namespace App\Http\Controllers\Setup; use App\Http\Controllers\Controller; use App\Models\User; -use Auth; +use App\Support\InstallWizardAuth; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\Response; +use Illuminate\Support\Facades\Auth; class LoginController extends Controller { - /** - * Handle the incoming request. - * - * @return Response - */ - public function __invoke(Request $request) + public function __invoke(Request $request): JsonResponse { $user = User::where('role', 'super admin')->first(); - Auth::login($user); + if (! $user) { + return response()->json([ + 'message' => 'Super admin user not found.', + ], 404); + } + + $company = $user->companies()->first(); + if (! $company) { + return response()->json([ + 'message' => 'Super admin company not found.', + ], 422); + } + + Auth::guard('web')->logout(); + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + $user->tokens()->where('name', InstallWizardAuth::TOKEN_NAME)->delete(); + $token = $user->createToken( + InstallWizardAuth::TOKEN_NAME, + [InstallWizardAuth::TOKEN_ABILITY], + )->plainTextToken; return response()->json([ 'success' => true, + 'type' => 'Bearer', + 'token' => $token, 'user' => $user, - 'company' => $user->companies()->first(), + 'company' => $company, ]); } } diff --git a/app/Http/Controllers/Setup/SessionLoginController.php b/app/Http/Controllers/Setup/SessionLoginController.php new file mode 100644 index 00000000..3e02a596 --- /dev/null +++ b/app/Http/Controllers/Setup/SessionLoginController.php @@ -0,0 +1,35 @@ +user(); + + if (Auth::guard('web')->check()) { + Auth::guard('web')->logout(); + } + + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + Auth::guard('web')->login($user); + + if ($request->hasSession()) { + $request->session()->regenerate(); + } + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Http/Middleware/PreventRequestForgery.php b/app/Http/Middleware/PreventRequestForgery.php index e8db0489..046e01a6 100644 --- a/app/Http/Middleware/PreventRequestForgery.php +++ b/app/Http/Middleware/PreventRequestForgery.php @@ -20,5 +20,6 @@ class PreventRequestForgery extends Middleware */ protected $except = [ 'login', + 'installation/session-login', ]; } diff --git a/app/Http/Middleware/UseInstallWizardTokenAuth.php b/app/Http/Middleware/UseInstallWizardTokenAuth.php new file mode 100644 index 00000000..a700e95a --- /dev/null +++ b/app/Http/Middleware/UseInstallWizardTokenAuth.php @@ -0,0 +1,41 @@ +installationIsIncomplete()) { + return $next($request); + } + + config([ + 'sanctum.guard' => [], + 'sanctum.stateful' => [], + ]); + $request->attributes->set('install_wizard', true); + + return $next($request); + } + + private function installationIsIncomplete(): bool + { + if (! InstallUtils::isDbCreated()) { + return true; + } + + try { + return Setting::getSetting('profile_complete') !== 'COMPLETED'; + } catch (\Exception $e) { + return true; + } + } +} diff --git a/app/Http/Requests/CompanyMailConfigurationRequest.php b/app/Http/Requests/CompanyMailConfigurationRequest.php new file mode 100644 index 00000000..01d3ce81 --- /dev/null +++ b/app/Http/Requests/CompanyMailConfigurationRequest.php @@ -0,0 +1,36 @@ +string('use_custom_mail_config')->toString() !== 'YES') { + return [ + 'use_custom_mail_config' => ['required', 'string', Rule::in(['YES', 'NO'])], + 'mail_driver' => ['nullable', 'string'], + ]; + } + + return app(MailConfigurationService::class)->validationRules( + $this->string('mail_driver')->toString(), + true + ); + } +} diff --git a/app/Http/Requests/DatabaseEnvironmentRequest.php b/app/Http/Requests/DatabaseEnvironmentRequest.php index 5621a7d9..0850fa13 100644 --- a/app/Http/Requests/DatabaseEnvironmentRequest.php +++ b/app/Http/Requests/DatabaseEnvironmentRequest.php @@ -34,6 +34,10 @@ class DatabaseEnvironmentRequest extends FormRequest 'required', 'string', ], + 'database_overwrite' => [ + 'nullable', + 'boolean', + ], ]; break; @@ -63,6 +67,10 @@ class DatabaseEnvironmentRequest extends FormRequest 'required', 'string', ], + 'database_overwrite' => [ + 'nullable', + 'boolean', + ], ]; break; diff --git a/app/Http/Requests/MailEnvironmentRequest.php b/app/Http/Requests/MailEnvironmentRequest.php index 384fdd23..51194e93 100644 --- a/app/Http/Requests/MailEnvironmentRequest.php +++ b/app/Http/Requests/MailEnvironmentRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Services\MailConfigurationService; use Illuminate\Foundation\Http\FormRequest; class MailEnvironmentRequest extends FormRequest @@ -19,158 +20,8 @@ class MailEnvironmentRequest extends FormRequest */ public function rules(): array { - switch ($this->get('mail_driver')) { - case 'smtp': - return [ - 'mail_driver' => [ - 'required', - 'string', - ], - 'mail_host' => [ - 'required', - 'string', - ], - 'mail_port' => [ - 'required', - ], - 'mail_username' => [ - 'nullable', - 'string', - ], - 'mail_password' => [ - 'nullable', - 'string', - ], - 'mail_encryption' => [ - 'nullable', - 'string', - ], - 'mail_scheme' => [ - 'nullable', - 'string', - ], - 'mail_url' => [ - 'nullable', - 'string', - ], - 'mail_timeout' => [ - 'nullable', - 'integer', - ], - 'mail_local_domain' => [ - 'nullable', - 'string', - ], - 'from_name' => [ - 'required', - 'string', - ], - 'from_mail' => [ - 'required', - 'string', - 'email', - ], - ]; - - case 'mailgun': - return [ - 'mail_driver' => [ - 'required', - 'string', - ], - 'mail_mailgun_domain' => [ - 'required', - 'string', - ], - 'mail_mailgun_secret' => [ - 'required', - 'string', - ], - 'mail_mailgun_endpoint' => [ - 'nullable', - 'string', - ], - 'mail_mailgun_scheme' => [ - 'nullable', - 'string', - ], - 'from_name' => [ - 'required', - 'string', - ], - 'from_mail' => [ - 'required', - 'string', - 'email', - ], - ]; - - case 'ses': - return [ - 'mail_driver' => [ - 'required', - 'string', - ], - 'mail_ses_key' => [ - 'required', - 'string', - ], - 'mail_ses_secret' => [ - 'required', - 'string', - ], - 'mail_ses_region' => [ - 'nullable', - 'string', - ], - 'from_name' => [ - 'required', - 'string', - ], - 'from_mail' => [ - 'required', - 'string', - 'email', - ], - ]; - - case 'sendmail': - return [ - 'mail_driver' => [ - 'required', - 'string', - ], - 'mail_sendmail_path' => [ - 'nullable', - 'string', - ], - 'from_name' => [ - 'required', - 'string', - ], - 'from_mail' => [ - 'required', - 'string', - 'email', - ], - ]; - - default: - return [ - 'mail_driver' => [ - 'required', - 'string', - ], - 'from_name' => [ - 'required', - 'string', - ], - 'from_mail' => [ - 'required', - 'string', - 'email', - ], - ]; - } + return app(MailConfigurationService::class)->validationRules( + $this->string('mail_driver')->toString() + ); } } diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php index 8b8c8815..8a69dbdc 100644 --- a/app/Providers/AppConfigProvider.php +++ b/app/Providers/AppConfigProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Models\FileDisk; use App\Models\Setting; use App\Services\FileDiskService; +use App\Services\MailConfigurationService; use App\Services\Setup\InstallUtils; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -32,81 +33,7 @@ class AppConfigProvider extends ServiceProvider protected function configureMailFromDatabase(): void { try { - // Get mail settings from database - $mailSettings = Setting::getSettings([ - 'mail_driver', - 'mail_host', - 'mail_port', - 'mail_username', - 'mail_password', - 'mail_encryption', - 'mail_scheme', - 'mail_url', - 'mail_timeout', - 'mail_local_domain', - 'from_name', - 'from_mail', - 'mail_mailgun_domain', - 'mail_mailgun_secret', - 'mail_mailgun_endpoint', - 'mail_mailgun_scheme', - 'mail_ses_key', - 'mail_ses_secret', - 'mail_ses_region', - 'mail_sendmail_path', - ]); - - if (! empty($mailSettings['mail_driver'])) { - $driver = $mailSettings['mail_driver']; - - // Set default mailer - Config::set('mail.default', $driver); - - // Configure based on driver - switch ($driver) { - case 'smtp': - Config::set('mail.mailers.smtp.host', $mailSettings['mail_host'] ?? '127.0.0.1'); - Config::set('mail.mailers.smtp.port', $mailSettings['mail_port'] ?? 2525); - Config::set('mail.mailers.smtp.username', $mailSettings['mail_username'] ?? null); - Config::set('mail.mailers.smtp.password', $mailSettings['mail_password'] ?? null); - Config::set('mail.mailers.smtp.encryption', $mailSettings['mail_encryption'] ?? 'none'); - Config::set('mail.mailers.smtp.scheme', $mailSettings['mail_scheme'] ?? null); - Config::set('mail.mailers.smtp.url', $mailSettings['mail_url'] ?? null); - Config::set('mail.mailers.smtp.timeout', $mailSettings['mail_timeout'] ?? null); - Config::set('mail.mailers.smtp.local_domain', $mailSettings['mail_local_domain'] ?? null); - break; - - case 'mailgun': - Config::set('mail.mailers.mailgun.domain', $mailSettings['mail_mailgun_domain'] ?? null); - Config::set('mail.mailers.mailgun.secret', $mailSettings['mail_mailgun_secret'] ?? null); - Config::set('mail.mailers.mailgun.endpoint', $mailSettings['mail_mailgun_endpoint'] ?? 'api.mailgun.net'); - Config::set('mail.mailers.mailgun.scheme', $mailSettings['mail_mailgun_scheme'] ?? 'https'); - - // Also set services config for mailgun - Config::set('services.mailgun.domain', $mailSettings['mail_mailgun_domain'] ?? null); - Config::set('services.mailgun.secret', $mailSettings['mail_mailgun_secret'] ?? null); - Config::set('services.mailgun.endpoint', $mailSettings['mail_mailgun_endpoint'] ?? 'api.mailgun.net'); - break; - - case 'ses': - Config::set('services.ses.key', $mailSettings['mail_ses_key'] ?? null); - Config::set('services.ses.secret', $mailSettings['mail_ses_secret'] ?? null); - Config::set('services.ses.region', $mailSettings['mail_ses_region'] ?? 'us-east-1'); - break; - - case 'sendmail': - Config::set('mail.mailers.sendmail.path', $mailSettings['mail_sendmail_path'] ?? '/usr/sbin/sendmail -bs -i'); - break; - } - - // Set global from address and name - if (! empty($mailSettings['from_mail'])) { - Config::set('mail.from.address', $mailSettings['from_mail']); - } - if (! empty($mailSettings['from_name'])) { - Config::set('mail.from.name', $mailSettings['from_name']); - } - } + app(MailConfigurationService::class)->applyGlobalConfig(); } catch (\Exception $e) { // Silently fail if database is not available (during installation, migrations, etc.) // This prevents the application from breaking during setup diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index acf9eda6..2b8622e1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,11 +20,14 @@ use App\Policies\SettingsPolicy; use App\Policies\UserPolicy; use App\Services\Setup\InstallUtils; use App\Support\BouncerDefaultScope; +use App\Support\InstallWizardAuth; use Gate; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Illuminate\Support\ServiceProvider; +use Laravel\Sanctum\Sanctum; use Silber\Bouncer\Database\Models as BouncerModels; use Silber\Bouncer\Database\Role; use View; @@ -54,6 +57,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureInstallWizardTokenAuth(); if (InstallUtils::isDbCreated()) { $this->addMenus(); @@ -166,4 +170,21 @@ class AppServiceProvider extends ServiceProvider { Broadcast::routes(['middleware' => 'api.auth']); } + + private function configureInstallWizardTokenAuth(): void + { + Sanctum::authenticateAccessTokensUsing(function ($accessToken, bool $isValid): bool { + if (! $isValid) { + return false; + } + + $request = request(); + + if (! $request instanceof Request || ! $request->attributes->get('install_wizard', false)) { + return $isValid; + } + + return $accessToken->can(InstallWizardAuth::TOKEN_ABILITY); + }); + } } diff --git a/app/Services/CompanyMailConfigService.php b/app/Services/CompanyMailConfigService.php index 5831a3ee..e62e8474 100644 --- a/app/Services/CompanyMailConfigService.php +++ b/app/Services/CompanyMailConfigService.php @@ -2,98 +2,10 @@ namespace App\Services; -use App\Models\CompanySetting; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Mail; - class CompanyMailConfigService { - private static array $mailSettingKeys = [ - 'company_mail_driver', - 'company_mail_host', - 'company_mail_port', - 'company_mail_username', - 'company_mail_password', - 'company_mail_encryption', - 'company_mail_scheme', - 'company_mail_url', - 'company_mail_timeout', - 'company_mail_local_domain', - 'company_from_name', - 'company_from_mail', - 'company_mail_mailgun_domain', - 'company_mail_mailgun_secret', - 'company_mail_mailgun_endpoint', - 'company_mail_mailgun_scheme', - 'company_mail_ses_key', - 'company_mail_ses_secret', - 'company_mail_ses_region', - 'company_mail_sendmail_path', - ]; - public static function apply(int $companyId): void { - $useCustom = CompanySetting::getSetting('use_custom_mail_config', $companyId); - - if ($useCustom !== 'YES') { - return; - } - - $settings = []; - foreach (self::$mailSettingKeys as $key) { - $value = CompanySetting::getSetting($key, $companyId); - if ($value !== null) { - $settings[$key] = $value; - } - } - - $driver = $settings['company_mail_driver'] ?? null; - - if (empty($driver)) { - return; - } - - Config::set('mail.default', $driver); - - switch ($driver) { - case 'smtp': - Config::set('mail.mailers.smtp.host', $settings['company_mail_host'] ?? '127.0.0.1'); - Config::set('mail.mailers.smtp.port', $settings['company_mail_port'] ?? 2525); - Config::set('mail.mailers.smtp.username', $settings['company_mail_username'] ?? null); - Config::set('mail.mailers.smtp.password', $settings['company_mail_password'] ?? null); - Config::set('mail.mailers.smtp.encryption', $settings['company_mail_encryption'] ?? 'none'); - Config::set('mail.mailers.smtp.scheme', $settings['company_mail_scheme'] ?? null); - Config::set('mail.mailers.smtp.url', $settings['company_mail_url'] ?? null); - Config::set('mail.mailers.smtp.timeout', $settings['company_mail_timeout'] ?? null); - Config::set('mail.mailers.smtp.local_domain', $settings['company_mail_local_domain'] ?? null); - break; - - case 'mailgun': - Config::set('services.mailgun.domain', $settings['company_mail_mailgun_domain'] ?? null); - Config::set('services.mailgun.secret', $settings['company_mail_mailgun_secret'] ?? null); - Config::set('services.mailgun.endpoint', $settings['company_mail_mailgun_endpoint'] ?? 'api.mailgun.net'); - Config::set('services.mailgun.scheme', $settings['company_mail_mailgun_scheme'] ?? 'https'); - break; - - case 'ses': - Config::set('services.ses.key', $settings['company_mail_ses_key'] ?? null); - Config::set('services.ses.secret', $settings['company_mail_ses_secret'] ?? null); - Config::set('services.ses.region', $settings['company_mail_ses_region'] ?? 'us-east-1'); - break; - - case 'sendmail': - Config::set('mail.mailers.sendmail.path', $settings['company_mail_sendmail_path'] ?? '/usr/sbin/sendmail -bs -i'); - break; - } - - if (! empty($settings['company_from_mail'])) { - Config::set('mail.from.address', $settings['company_from_mail']); - } - if (! empty($settings['company_from_name'])) { - Config::set('mail.from.name', $settings['company_from_name']); - } - - // Purge the cached mailer so Laravel creates a new one with updated config - Mail::purge($driver); + app(MailConfigurationService::class)->applyCompanyConfig($companyId); } } diff --git a/app/Services/MailConfigurationService.php b/app/Services/MailConfigurationService.php new file mode 100644 index 00000000..b39780f5 --- /dev/null +++ b/app/Services/MailConfigurationService.php @@ -0,0 +1,453 @@ + [ + 'mail_host', + 'mail_port', + 'mail_username', + 'mail_password', + 'mail_encryption', + 'mail_scheme', + 'mail_url', + 'mail_timeout', + 'mail_local_domain', + ], + 'mail' => [], + 'sendmail' => [ + 'mail_sendmail_path', + ], + 'ses' => [ + 'mail_ses_key', + 'mail_ses_secret', + 'mail_ses_region', + ], + 'mailgun' => [ + 'mail_mailgun_domain', + 'mail_mailgun_secret', + 'mail_mailgun_endpoint', + 'mail_mailgun_scheme', + ], + 'postmark' => [ + 'mail_postmark_token', + 'mail_postmark_message_stream_id', + ], + ]; + + private const BASE_FIELDS = [ + 'mail_driver', + 'from_name', + 'from_mail', + ]; + + public function getAvailableDrivers(): array + { + return array_values(array_filter(self::DRIVER_ORDER, fn (string $driver) => $this->isDriverAvailable($driver))); + } + + public function getGlobalConfig(): array + { + return $this->buildConfigPayload( + Setting::getSettings($this->getGlobalSettingKeys())->all(), + self::GLOBAL_SCOPE + ); + } + + public function getCompanyConfig(int|string $companyId): array + { + $settings = CompanySetting::getSettings($this->getCompanySettingKeys(), $companyId)->all(); + + return array_merge( + [ + 'use_custom_mail_config' => $settings['use_custom_mail_config'] ?? 'NO', + ], + $this->buildConfigPayload($settings, self::COMPANY_SCOPE) + ); + } + + public function getDefaultConfig(): array + { + return [ + 'from_name' => $this->getDefaultValue('from_name'), + 'from_mail' => $this->getDefaultValue('from_mail'), + ]; + } + + public function saveGlobalConfig(array $payload): void + { + Setting::setSettings($this->prepareSettingsForStorage($payload, self::GLOBAL_SCOPE)); + } + + public function saveCompanyConfig(int|string $companyId, array $payload): void + { + if (($payload['use_custom_mail_config'] ?? 'YES') !== 'YES') { + CompanySetting::setSettings([ + 'use_custom_mail_config' => 'NO', + ], $companyId); + + return; + } + + CompanySetting::setSettings( + $this->prepareSettingsForStorage($payload, self::COMPANY_SCOPE) + [ + 'use_custom_mail_config' => 'YES', + ], + $companyId + ); + } + + public function applyGlobalConfig(): void + { + $settings = Setting::getSettings($this->getGlobalSettingKeys())->all(); + + $this->applyStoredSettings($settings, self::GLOBAL_SCOPE); + } + + public function applyCompanyConfig(int|string $companyId): void + { + $settings = CompanySetting::getSettings($this->getCompanySettingKeys(), $companyId)->all(); + + if (($settings['use_custom_mail_config'] ?? 'NO') !== 'YES') { + return; + } + + $this->applyStoredSettings($settings, self::COMPANY_SCOPE); + } + + public function validationRules(?string $driver, bool $allowDisabledCustomConfig = false): array + { + $availableDrivers = $this->getAvailableDrivers(); + $driver = $this->normalizeRequestedDriver($driver, $availableDrivers); + + $rules = [ + 'mail_driver' => [ + 'required', + 'string', + Rule::in($availableDrivers), + ], + 'from_name' => [ + 'required', + 'string', + ], + 'from_mail' => [ + 'required', + 'string', + 'email', + ], + ]; + + if ($allowDisabledCustomConfig) { + $rules['use_custom_mail_config'] = [ + 'required', + 'string', + Rule::in(['YES', 'NO']), + ]; + } + + return array_merge($rules, match ($driver) { + 'smtp' => [ + 'mail_host' => ['required', 'string'], + 'mail_port' => ['required', 'integer'], + 'mail_username' => ['nullable', 'string'], + 'mail_password' => ['nullable', 'string'], + 'mail_encryption' => ['nullable', 'string', Rule::in(['none', 'tls', 'ssl'])], + 'mail_scheme' => ['nullable', 'string', Rule::in(['smtp', 'smtps'])], + 'mail_url' => ['nullable', 'string'], + 'mail_timeout' => ['nullable', 'integer'], + 'mail_local_domain' => ['nullable', 'string'], + ], + 'sendmail' => [ + 'mail_sendmail_path' => ['nullable', 'string'], + ], + 'ses' => [ + 'mail_ses_key' => ['required', 'string'], + 'mail_ses_secret' => ['required', 'string'], + 'mail_ses_region' => ['nullable', 'string'], + ], + 'mailgun' => [ + 'mail_mailgun_domain' => ['required', 'string'], + 'mail_mailgun_secret' => ['required', 'string'], + 'mail_mailgun_endpoint' => ['required', 'string'], + 'mail_mailgun_scheme' => ['nullable', 'string', Rule::in(['https', 'api'])], + ], + 'postmark' => [ + 'mail_postmark_token' => ['required', 'string'], + 'mail_postmark_message_stream_id' => ['nullable', 'string'], + ], + default => [], + }); + } + + public function getGlobalSettingKeys(): array + { + return $this->buildSettingKeys(self::GLOBAL_SCOPE, true); + } + + public function getCompanySettingKeys(): array + { + return array_merge( + $this->buildSettingKeys(self::COMPANY_SCOPE, true), + ['use_custom_mail_config'] + ); + } + + private function buildSettingKeys(string $scope, bool $includeAllDrivers): array + { + $fields = self::BASE_FIELDS; + + if ($includeAllDrivers) { + foreach (self::DRIVER_FIELDS as $driverFields) { + $fields = array_merge($fields, $driverFields); + } + } + + return array_values(array_unique(array_map( + fn (string $field) => $this->storedKey($scope, $field), + $fields + ))); + } + + private function buildConfigPayload(array $settings, string $scope): array + { + $driver = $this->normalizeStoredDriver( + $this->resolveStoredValue($settings, $scope, 'mail_driver') + ); + + $payload = [ + 'mail_driver' => $driver, + 'from_name' => $this->resolveStoredValue($settings, $scope, 'from_name'), + 'from_mail' => $this->resolveStoredValue($settings, $scope, 'from_mail'), + ]; + + foreach (self::DRIVER_FIELDS[$driver] as $field) { + $payload[$field] = $this->resolveStoredValue($settings, $scope, $field); + } + + return $payload; + } + + private function prepareSettingsForStorage(array $payload, string $scope): array + { + $driver = $this->normalizeRequestedDriver($payload['mail_driver'] ?? null, $this->getAvailableDrivers()); + + $settings = [ + $this->storedKey($scope, 'mail_driver') => $driver, + $this->storedKey($scope, 'from_name') => $payload['from_name'] ?? $this->getDefaultValue('from_name'), + $this->storedKey($scope, 'from_mail') => $payload['from_mail'] ?? $this->getDefaultValue('from_mail'), + ]; + + foreach (self::DRIVER_FIELDS[$driver] as $field) { + $settings[$this->storedKey($scope, $field)] = $this->normalizeStoredValue( + $field, + $payload[$field] ?? $this->getDefaultValue($field) + ); + } + + return $settings; + } + + private function applyStoredSettings(array $settings, string $scope): void + { + $driver = $settings[$this->storedKey($scope, 'mail_driver')] ?? null; + + if (! $driver || ! in_array($driver, self::DRIVER_ORDER, true)) { + return; + } + + Config::set('mail.default', $driver); + + match ($driver) { + 'smtp' => $this->applySmtpSettings($settings, $scope), + 'sendmail' => $this->applySendmailSettings($settings, $scope), + 'ses' => $this->applySesSettings($settings, $scope), + 'mailgun' => $this->applyMailgunSettings($settings, $scope), + 'postmark' => $this->applyPostmarkSettings($settings, $scope), + default => null, + }; + + Config::set('mail.from.address', $this->resolveStoredValue($settings, $scope, 'from_mail')); + Config::set('mail.from.name', $this->resolveStoredValue($settings, $scope, 'from_name')); + + Mail::purge($driver); + } + + private function applySmtpSettings(array $settings, string $scope): void + { + Config::set('mail.mailers.smtp.host', $this->resolveStoredValue($settings, $scope, 'mail_host')); + Config::set('mail.mailers.smtp.port', $this->resolveStoredValue($settings, $scope, 'mail_port')); + Config::set('mail.mailers.smtp.username', $this->resolveStoredValue($settings, $scope, 'mail_username')); + Config::set('mail.mailers.smtp.password', $this->resolveStoredValue($settings, $scope, 'mail_password')); + Config::set('mail.mailers.smtp.encryption', $this->resolveStoredValue($settings, $scope, 'mail_encryption')); + Config::set('mail.mailers.smtp.scheme', $this->nullIfBlank($this->resolveStoredValue($settings, $scope, 'mail_scheme'))); + Config::set('mail.mailers.smtp.url', $this->nullIfBlank($this->resolveStoredValue($settings, $scope, 'mail_url'))); + Config::set('mail.mailers.smtp.timeout', $this->nullIfBlank($this->resolveStoredValue($settings, $scope, 'mail_timeout'))); + Config::set('mail.mailers.smtp.local_domain', $this->nullIfBlank($this->resolveStoredValue($settings, $scope, 'mail_local_domain'))); + } + + private function applySendmailSettings(array $settings, string $scope): void + { + Config::set('mail.mailers.sendmail.path', $this->resolveStoredValue($settings, $scope, 'mail_sendmail_path')); + } + + private function applySesSettings(array $settings, string $scope): void + { + Config::set('services.ses.key', $this->resolveStoredValue($settings, $scope, 'mail_ses_key')); + Config::set('services.ses.secret', $this->resolveStoredValue($settings, $scope, 'mail_ses_secret')); + Config::set('services.ses.region', $this->resolveStoredValue($settings, $scope, 'mail_ses_region')); + } + + private function applyMailgunSettings(array $settings, string $scope): void + { + $domain = $this->resolveStoredValue($settings, $scope, 'mail_mailgun_domain'); + $secret = $this->resolveStoredValue($settings, $scope, 'mail_mailgun_secret'); + $endpoint = $this->resolveStoredValue($settings, $scope, 'mail_mailgun_endpoint'); + $scheme = $this->resolveStoredValue($settings, $scope, 'mail_mailgun_scheme'); + + Config::set('mail.mailers.mailgun.domain', $domain); + Config::set('mail.mailers.mailgun.secret', $secret); + Config::set('mail.mailers.mailgun.endpoint', $endpoint); + Config::set('mail.mailers.mailgun.scheme', $scheme); + + Config::set('services.mailgun.domain', $domain); + Config::set('services.mailgun.secret', $secret); + Config::set('services.mailgun.endpoint', $endpoint); + Config::set('services.mailgun.scheme', $scheme); + } + + private function applyPostmarkSettings(array $settings, string $scope): void + { + $token = $this->resolveStoredValue($settings, $scope, 'mail_postmark_token'); + $messageStreamId = $this->nullIfBlank($this->resolveStoredValue($settings, $scope, 'mail_postmark_message_stream_id')); + + Config::set('services.postmark.token', $token); + Config::set('mail.mailers.postmark.token', $token); + Config::set('mail.mailers.postmark.message_stream_id', $messageStreamId); + } + + private function resolveStoredValue(array $settings, string $scope, string $field): mixed + { + $key = $this->storedKey($scope, $field); + + if (array_key_exists($key, $settings)) { + return $settings[$key]; + } + + return $this->getDefaultValue($field); + } + + private function storedKey(string $scope, string $field): string + { + return $scope === self::COMPANY_SCOPE ? "company_{$field}" : $field; + } + + private function getDefaultValue(string $field): mixed + { + return match ($field) { + 'mail_driver' => $this->normalizeStoredDriver(config('mail.default')), + 'from_name' => config('mail.from.name'), + 'from_mail' => config('mail.from.address'), + 'mail_host' => config('mail.mailers.smtp.host', '127.0.0.1'), + 'mail_port' => config('mail.mailers.smtp.port', 587), + 'mail_username', 'mail_password', 'mail_scheme', 'mail_url', 'mail_timeout', 'mail_local_domain' => '', + 'mail_encryption' => config('mail.mailers.smtp.encryption', 'none'), + 'mail_sendmail_path' => config('mail.mailers.sendmail.path', '/usr/sbin/sendmail -bs -i'), + 'mail_ses_key' => config('services.ses.key', ''), + 'mail_ses_secret' => config('services.ses.secret', ''), + 'mail_ses_region' => config('services.ses.region', 'us-east-1'), + 'mail_mailgun_domain' => config('services.mailgun.domain', ''), + 'mail_mailgun_secret' => config('services.mailgun.secret', ''), + 'mail_mailgun_endpoint' => config('services.mailgun.endpoint', 'api.mailgun.net'), + 'mail_mailgun_scheme' => config('mail.mailers.mailgun.scheme', config('services.mailgun.scheme', 'https')), + 'mail_postmark_token' => config('services.postmark.token', ''), + 'mail_postmark_message_stream_id' => config('mail.mailers.postmark.message_stream_id', ''), + default => '', + }; + } + + private function normalizeRequestedDriver(?string $driver, array $availableDrivers): string + { + if ($driver && in_array($driver, $availableDrivers, true)) { + return $driver; + } + + return $availableDrivers[0] ?? self::DEFAULT_DRIVER; + } + + private function normalizeStoredDriver(?string $driver): string + { + $availableDrivers = $this->getAvailableDrivers(); + + if ($driver && in_array($driver, $availableDrivers, true)) { + return $driver; + } + + return $availableDrivers[0] ?? self::DEFAULT_DRIVER; + } + + private function normalizeStoredValue(string $field, mixed $value): mixed + { + if (is_string($value)) { + $value = trim($value); + } + + return match ($field) { + 'mail_port', 'mail_timeout' => $value === '' ? null : $value, + 'mail_scheme', + 'mail_url', + 'mail_local_domain', + 'mail_postmark_message_stream_id' => $value === '' ? '' : $value, + 'mail_mailgun_endpoint' => $value === '' ? 'api.mailgun.net' : $value, + 'mail_mailgun_scheme' => $value === '' ? 'https' : $value, + 'mail_sendmail_path' => $value === '' ? '/usr/sbin/sendmail -bs -i' : $value, + 'mail_ses_region' => $value === '' ? 'us-east-1' : $value, + 'mail_encryption' => $value === '' ? 'none' : $value, + default => $value, + }; + } + + private function nullIfBlank(mixed $value): mixed + { + return $value === '' ? null : $value; + } + + private function isDriverAvailable(string $driver): bool + { + return match ($driver) { + 'smtp', 'mail', 'sendmail' => true, + 'ses' => class_exists(Sdk::class), + 'mailgun' => class_exists(MailgunTransportFactory::class) + && class_exists(HttpClient::class), + 'postmark' => class_exists(PostmarkTransportFactory::class) + && class_exists(HttpClient::class), + default => false, + }; + } +} diff --git a/app/Services/Setup/EnvironmentManager.php b/app/Services/Setup/EnvironmentManager.php index be565511..167a6f31 100755 --- a/app/Services/Setup/EnvironmentManager.php +++ b/app/Services/Setup/EnvironmentManager.php @@ -159,15 +159,17 @@ class EnvironmentManager ]; } $dbEnv['DB_DATABASE'] = $request->get('database_name'); - if (! empty($dbEnv['DB_DATABASE'])) { - $sqlite_path = $dbEnv['DB_DATABASE']; - } else { - $sqlite_path = database_path('database.sqlite'); - } - // Create empty SQLite database if it doesn't exist. - if (! file_exists($sqlite_path)) { - copy(database_path('stubs/sqlite.empty.db'), $sqlite_path); - $dbEnv['DB_DATABASE'] = $sqlite_path; + $sqlitePath = $this->resolveSqliteDatabasePath($dbEnv['DB_DATABASE']); + // Create empty SQLite database if it doesn't exist. Ensure the + // parent directory exists first so user-supplied absolute paths + // (e.g. /var/data/foo.sqlite) work even when the directory hasn't + // been pre-created. + if (! file_exists($sqlitePath)) { + $parentDir = dirname($sqlitePath); + if (! is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + copy(database_path('stubs/sqlite.empty.db'), $sqlitePath); } } @@ -213,7 +215,9 @@ class EnvironmentManager $connectionArray = array_merge($settings, [ 'driver' => $connection, - 'database' => $request->get('database_name'), + 'database' => $connection === 'sqlite' + ? $this->resolveSqliteDatabasePath($request->get('database_name')) + : $request->get('database_name'), ]); if ($connection !== 'sqlite' && $request->has('database_username') && $request->has('database_password')) { @@ -233,7 +237,30 @@ class EnvironmentManager ], ]); - return DB::connection()->getPdo(); + DB::purge($connection); + + return DB::connection($connection)->getPdo(); + } + + private function resolveSqliteDatabasePath(?string $databasePath): string + { + $databasePath = trim((string) $databasePath); + + if ($databasePath === '') { + return storage_path('app/database.sqlite'); + } + + if ($this->isAbsolutePath($databasePath)) { + return $databasePath; + } + + return base_path($databasePath); + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, DIRECTORY_SEPARATOR) + || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1; } /** diff --git a/app/Support/InstallWizardAuth.php b/app/Support/InstallWizardAuth.php new file mode 100644 index 00000000..973c23fb --- /dev/null +++ b/app/Support/InstallWizardAuth.php @@ -0,0 +1,21 @@ +headers->get(self::HEADER) === self::HEADER_VALUE; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 587cd651..84b0e881 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,7 @@ use App\Http\Middleware\ScopeBouncer; use App\Http\Middleware\SuperAdminMiddleware; use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrustProxies; +use App\Http\Middleware\UseInstallWizardTokenAuth; use App\Providers\AppServiceProvider; use Illuminate\Auth\Middleware\Authorize; use Illuminate\Foundation\Application; @@ -47,12 +48,14 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->preventRequestForgery(except: [ 'login', + 'installation/session-login', ]); $middleware->append([ CheckForMaintenanceMode::class, TrimStrings::class, TrustProxies::class, + UseInstallWizardTokenAuth::class, ConfigMiddleware::class, ]); diff --git a/composer.json b/composer.json index f4bdced5..c9c04cd6 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,10 @@ "spatie/flysystem-dropbox": "^3.0", "spatie/laravel-backup": "^10.0", "spatie/laravel-medialibrary": "^11.11", + "symfony/http-client": "^7.3", "symfony/mailer": "^7.3", - "symfony/mailgun-mailer": "^7.3" + "symfony/mailgun-mailer": "^7.3", + "symfony/postmark-mailer": "^7.3" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.5", diff --git a/composer.lock b/composer.lock index cf630817..a9970c3c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "22bfbe20025a4775402886e590527c90", + "content-hash": "df856db1b42a7e244e298e6c129c2cba", "packages": [ { "name": "aws/aws-crt-php", @@ -6338,6 +6338,185 @@ ], "time": "2026-01-29T09:41:02+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T12:55:43+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.0.7", @@ -7271,6 +7450,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.33.0", @@ -7514,6 +7773,80 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/postmark-mailer", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/postmark-mailer.git", + "reference": "8b573474e89368f1ddb25b43fd86a6dd51343e9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/postmark-mailer/zipball/8b573474e89368f1ddb25b43fd86a6dd51343e9b", + "reference": "8b573474e89368f1ddb25b43fd86a6dd51343e9b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "symfony/mailer": "^7.2|^8.0" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/webhook": "^6.4|^7.0|^8.0" + }, + "type": "symfony-mailer-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\Bridge\\Postmark\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Postmark Mailer Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/postmark-mailer/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-08T08:25:11+00:00" + }, { "name": "symfony/process", "version": "v8.0.8", diff --git a/config/database.php b/config/database.php index 53dcae02..cd3cb538 100644 --- a/config/database.php +++ b/config/database.php @@ -34,7 +34,7 @@ return [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'database' => env('DB_DATABASE') ?: storage_path('app/database.sqlite'), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'busy_timeout' => null, diff --git a/config/mail.php b/config/mail.php index 5e683700..6b2da284 100644 --- a/config/mail.php +++ b/config/mail.php @@ -50,11 +50,15 @@ return [ 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), ], + 'mail' => [ + 'transport' => 'mail', + ], + 'mailgun' => [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), - 'scheme' => 'https', + 'scheme' => env('MAILGUN_SCHEME', 'https'), ], 'ses' => [ @@ -63,7 +67,7 @@ return [ 'postmark' => [ 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), // 'client' => [ // 'timeout' => 5, // ], diff --git a/config/services.php b/config/services.php index 51db1fe8..c64c38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -20,6 +20,11 @@ return [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), + 'scheme' => env('MAILGUN_SCHEME', 'https'), + ], + + 'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), ], 'sparkpost' => [ diff --git a/lang/en.json b/lang/en.json index cc37329d..88c09fdd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -947,25 +947,48 @@ "port": "Mail Port", "driver": "Mail Driver", "secret": "Secret", + "scheme": "Scheme", + "url": "URL", + "timeout": "Timeout", + "local_domain": "EHLO Domain", "mailgun_secret": "Mailgun Secret", "mailgun_domain": "Domain", "mailgun_endpoint": "Mailgun Endpoint", + "mailgun_scheme": "Mailgun Scheme", "ses_secret": "SES Secret", "ses_key": "SES Key", "ses_region": "AWS Region", + "postmark_token": "Postmark Token", + "postmark_message_stream_id": "Message Stream ID", + "sendmail_path": "Sendmail Path", "password": "Mail Password", "username": "Mail Username", "mail_config": "Mail Configuration", + "mail_config_updated": "Mail configuration updated successfully", "from_name": "From Mail Name", "from_mail": "From Mail Address", "encryption": "Mail Encryption", - "mail_config_desc": "Below is the form for Configuring Email driver for sending emails from the app. You can also configure third party providers like Sendgrid, SES etc.", + "mail_config_desc": "Configure the Laravel mail driver used to send emails from the application, including provider-specific credentials when needed.", "company_mail_config": "Mail Configuration", "company_mail_config_desc": "Configure a custom mail driver for this company. When enabled, this overrides the global mail configuration.", "company_mail_config_updated": "Company mail configuration updated successfully", "use_custom_mail_config": "Use Custom Mail Configuration", "use_custom_mail_config_desc": "Enable this to use a company-specific mail configuration instead of the global settings.", - "using_global_mail_config": "This company is using the global mail configuration. Enable the toggle above to configure a custom mail driver." + "using_global_mail_config": "This company is using the global mail configuration. Enable the toggle above to configure a custom mail driver.", + "basic_settings": "Basic Settings", + "advanced_settings": "Advanced Settings", + "show_advanced_settings": "Show Advanced Settings", + "hide_advanced_settings": "Hide Advanced Settings", + "native_mail_desc": "Use PHP's native mail transport. Delivery is handled by the server's PHP mail configuration.", + "sendmail_desc": "Use the system sendmail binary for delivery. You can override the command path in Advanced Settings.", + "drivers": { + "smtp": "SMTP", + "mail": "PHP Mail", + "sendmail": "Sendmail", + "ses": "Amazon SES", + "mailgun": "Mailgun", + "postmark": "Postmark" + } }, "pdf": { "title": "PDF Setting", @@ -1569,6 +1592,7 @@ "db_name": "Database Name", "db_path": "Database Path", "overwrite": "Overwrite existing database and proceed", + "overwrite_confirm_desc": "This will permanently wipe the selected database before installation continues. Make sure you do not need any existing data.", "desc": "Create a database on your server and set the credentials using the form below." }, "permissions": { @@ -1598,18 +1622,41 @@ "port": "Mail Port", "driver": "Mail Driver", "secret": "Secret", + "scheme": "Scheme", + "url": "URL", + "timeout": "Timeout", + "local_domain": "EHLO Domain", "mailgun_secret": "Mailgun Secret", "mailgun_domain": "Domain", "mailgun_endpoint": "Mailgun Endpoint", + "mailgun_scheme": "Mailgun Scheme", "ses_secret": "SES Secret", + "ses_region": "AWS Region", "ses_key": "SES Key", + "postmark_token": "Postmark Token", + "postmark_message_stream_id": "Message Stream ID", + "sendmail_path": "Sendmail Path", "password": "Mail Password", "username": "Mail Username", "mail_config": "Mail Configuration", "from_name": "From Mail Name", "from_mail": "From Mail Address", "encryption": "Mail Encryption", - "mail_config_desc": "Below is the form for Configuring Email driver for sending emails from the app. You can also configure third party providers like Sendgrid, SES etc." + "mail_config_desc": "Configure the Laravel mail driver used to send emails from the application, including provider-specific credentials when needed.", + "basic_settings": "Basic Settings", + "advanced_settings": "Advanced Settings", + "show_advanced_settings": "Show Advanced Settings", + "hide_advanced_settings": "Hide Advanced Settings", + "native_mail_desc": "Use PHP's native mail transport. Delivery is handled by the server's PHP mail configuration.", + "sendmail_desc": "Use the system sendmail binary for delivery. You can override the command path in Advanced Settings.", + "drivers": { + "smtp": "SMTP", + "mail": "PHP Mail", + "sendmail": "Sendmail", + "ses": "Amazon SES", + "mailgun": "Mailgun", + "postmark": "Postmark" + } }, "req": { "system_req": "System Requirements", diff --git a/resources/scripts/InvoiceShelf.ts b/resources/scripts/InvoiceShelf.ts index fc324fdc..884c05a6 100644 --- a/resources/scripts/InvoiceShelf.ts +++ b/resources/scripts/InvoiceShelf.ts @@ -67,8 +67,12 @@ export default class InvoiceShelf { /** * Execute all registered boot callbacks, install plugins, * and mount the app to `document.body`. + * + * Async so the install wizard's pre-DB language choice can be loaded + * before the first render — see the `install_language` localStorage key + * set by features/installation/views/LanguageView.vue. */ - start(): void { + async start(): Promise { // Execute boot callbacks so modules can register routes / components this.executeCallbacks() @@ -78,6 +82,18 @@ export default class InvoiceShelf { // i18n this.i18n = createAppI18n(this.messages) + // If the install wizard's Language step set a locale before the DB + // existed, honor it now so the rest of the wizard renders in the right + // language. Falls through to 'en' silently on any failure. + const installLanguage = this.readInstallLanguage() + if (installLanguage && installLanguage !== 'en') { + try { + await setI18nLanguage(this.i18n, installLanguage) + } catch { + // Locale file missing or load failed — fall back to en, no-op. + } + } + // Install plugins this.app.use(router) this.app.use(this.i18n) @@ -97,4 +113,16 @@ export default class InvoiceShelf { callback(this.app, router) } } + + /** + * Read the install-wizard language choice from localStorage. Wrapped in + * try/catch because localStorage can throw in private-browsing edge cases. + */ + private readInstallLanguage(): string | null { + try { + return localStorage.getItem('install_language') + } catch { + return null + } + } } diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index 17cbdb37..e64de8e3 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -7,6 +7,10 @@ export const API = { AUTH_CHECK: '/api/v1/auth/check', CSRF_COOKIE: '/sanctum/csrf-cookie', REGISTER_WITH_INVITATION: '/api/v1/auth/register-with-invitation', + INSTALLATION_LOGIN: '/api/v1/installation/login', + INSTALLATION_SET_DOMAIN: '/api/v1/installation/set-domain', + INSTALLATION_WIZARD_STEP: '/api/v1/installation/wizard-step', + INSTALLATION_SESSION_LOGIN: '/installation/session-login', // Invitation Registration (public) INVITATION_DETAILS: '/api/v1/invitations', // append /{token}/details diff --git a/resources/scripts/api/index.ts b/resources/scripts/api/index.ts index 7d5a4abc..4ed8b76d 100644 --- a/resources/scripts/api/index.ts +++ b/resources/scripts/api/index.ts @@ -110,11 +110,8 @@ export type { CreateBackupPayload, DeleteBackupParams, MailConfig, - MailConfigResponse, + CompanyMailConfig, MailDriver, - SmtpConfig, - MailgunConfig, - SesConfig, TestMailPayload, PdfConfig, PdfConfigResponse, diff --git a/resources/scripts/api/install-client.ts b/resources/scripts/api/install-client.ts new file mode 100644 index 00000000..ee0ebf3f --- /dev/null +++ b/resources/scripts/api/install-client.ts @@ -0,0 +1,34 @@ +import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios' +import { LS_KEYS } from '@/scripts/config/constants' +import * as localStore from '@/scripts/utils/local-storage' + +export const INSTALL_WIZARD_HEADER = 'X-Install-Wizard' + +const installClient: AxiosInstance = axios.create({ + withCredentials: true, + headers: { + common: { + 'X-Requested-With': 'XMLHttpRequest', + [INSTALL_WIZARD_HEADER]: '1', + }, + }, +}) + +installClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const authToken = localStore.get(LS_KEYS.INSTALL_AUTH_TOKEN) + const companyId = localStore.get(LS_KEYS.INSTALL_SELECTED_COMPANY) + + config.headers[INSTALL_WIZARD_HEADER] = '1' + + if (authToken) { + config.headers.Authorization = authToken + } + + if (companyId !== null && companyId !== undefined && String(companyId) !== '') { + config.headers.company = String(companyId) + } + + return config +}) + +export { installClient } diff --git a/resources/scripts/api/services/company.service.ts b/resources/scripts/api/services/company.service.ts index 89f18f12..48944a98 100644 --- a/resources/scripts/api/services/company.service.ts +++ b/resources/scripts/api/services/company.service.ts @@ -2,6 +2,7 @@ import { client } from '../client' import { API } from '../endpoints' import type { Company } from '@/scripts/types/domain/company' import type { ApiResponse } from '@/scripts/types/api' +import type { CompanyMailConfig, MailConfig, TestMailPayload } from '@/scripts/types/mail-config' export interface UpdateCompanyPayload { name: string @@ -78,22 +79,22 @@ export const companyService = { }, // Company Mail Configuration - async getMailDefaultConfig(): Promise> { + async getMailDefaultConfig(): Promise> { const { data } = await client.get(API.COMPANY_MAIL_DEFAULT_CONFIG) return data }, - async getMailConfig(): Promise> { + async getMailConfig(): Promise { const { data } = await client.get(API.COMPANY_MAIL_CONFIG) return data }, - async saveMailConfig(payload: Record): Promise<{ success: boolean }> { + async saveMailConfig(payload: Partial): Promise<{ success: boolean }> { const { data } = await client.post(API.COMPANY_MAIL_CONFIG, payload) return data }, - async testMailConfig(payload: Record): Promise<{ success: boolean }> { + async testMailConfig(payload: TestMailPayload): Promise<{ success: boolean }> { const { data } = await client.post(API.COMPANY_MAIL_TEST, payload) return data }, diff --git a/resources/scripts/api/services/index.ts b/resources/scripts/api/services/index.ts index f0338e37..27379f0b 100644 --- a/resources/scripts/api/services/index.ts +++ b/resources/scripts/api/services/index.ts @@ -54,7 +54,7 @@ export type { CreateNotePayload } from './note.service' export type { CreateExchangeRateProviderPayload, BulkUpdatePayload, ExchangeRateResponse, ActiveProviderResponse } from './exchange-rate.service' export type { Module, ModuleInstallPayload, ModuleCheckResponse } from './module.service' export type { Backup, BackupListResponse, CreateBackupPayload, DeleteBackupParams } from './backup.service' -export type { MailConfig, MailConfigResponse, MailDriver, SmtpConfig, MailgunConfig, SesConfig, TestMailPayload } from './mail.service' +export type { MailConfig, CompanyMailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config' export type { PdfConfig, PdfConfigResponse, PdfDriver, DomPdfConfig, GotenbergConfig } from './pdf.service' export type { Disk, DiskDriversResponse, DiskDriverValue, CreateDiskPayload } from './disk.service' export type { CheckUpdateResponse, UpdateRelease, UpdateDownloadResponse, UpdateStepResponse, FinishUpdatePayload } from './update.service' diff --git a/resources/scripts/api/services/mail.service.ts b/resources/scripts/api/services/mail.service.ts index 7f90f0ff..3569b80d 100644 --- a/resources/scripts/api/services/mail.service.ts +++ b/resources/scripts/api/services/mail.service.ts @@ -1,51 +1,6 @@ import { client } from '../client' import { API } from '../endpoints' - -export type MailDriver = string - -export interface SmtpConfig { - mail_driver: string - mail_host: string - mail_port: number | null - mail_username: string - mail_password: string - mail_encryption: string - from_mail: string - from_name: string -} - -export interface MailgunConfig { - mail_driver: string - mail_mailgun_domain: string - mail_mailgun_secret: string - mail_mailgun_endpoint: string - from_mail: string - from_name: string -} - -export interface SesConfig { - mail_driver: string - mail_host: string - mail_port: number | null - mail_ses_key: string - mail_ses_secret: string - mail_ses_region: string - from_mail: string - from_name: string -} - -export type MailConfig = SmtpConfig | MailgunConfig | SesConfig - -export interface MailConfigResponse { - mail_driver: string - [key: string]: unknown -} - -export interface TestMailPayload { - to: string - subject: string - message: string -} +import type { MailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config' export const mailService = { async getDrivers(): Promise { @@ -53,7 +8,7 @@ export const mailService = { return data }, - async getConfig(): Promise { + async getConfig(): Promise { const { data } = await client.get(API.MAIL_CONFIG) return data }, diff --git a/resources/scripts/components/base/BaseWizardStep.vue b/resources/scripts/components/base/BaseWizardStep.vue index 278c0945..100ca019 100644 --- a/resources/scripts/components/base/BaseWizardStep.vue +++ b/resources/scripts/components/base/BaseWizardStep.vue @@ -8,27 +8,32 @@ interface Props { stepDescriptionClass?: string } +/** + * The wizard step lives inside InstallationLayout's card chrome, so the + * container itself is just a content wrapper — no extra background, border, + * or rounding. Earlier defaults included those, which created a visible + * card-inside-a-card when used inside the layout. + */ withDefaults(defineProps(), { title: null, description: null, - stepContainerClass: - 'w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded', + stepContainerClass: 'w-full', stepTitleClass: 'text-2xl not-italic font-semibold leading-7 text-heading', stepDescriptionClass: - 'w-full mt-2.5 mb-8 text-sm not-italic leading-snug text-muted lg:w-7/12 md:w-7/12 sm:w-7/12', + 'mt-2 mb-6 text-sm not-italic leading-relaxed text-muted', }) diff --git a/resources/scripts/config/constants.ts b/resources/scripts/config/constants.ts index f1a4c123..47fec0c8 100644 --- a/resources/scripts/config/constants.ts +++ b/resources/scripts/config/constants.ts @@ -53,6 +53,8 @@ export type Theme = typeof THEME[keyof typeof THEME] /** Local storage keys used throughout the app */ export const LS_KEYS = { AUTH_TOKEN: 'auth.token', + INSTALL_AUTH_TOKEN: 'install.auth.token', + INSTALL_SELECTED_COMPANY: 'install.selectedCompany', SELECTED_COMPANY: 'selectedCompany', IS_ADMIN_MODE: 'isAdminMode', SIDEBAR_COLLAPSED: 'sidebarCollapsed', diff --git a/resources/scripts/features/admin/views/settings/AdminMailConfigView.vue b/resources/scripts/features/admin/views/settings/AdminMailConfigView.vue index a3644448..50cf7454 100644 --- a/resources/scripts/features/admin/views/settings/AdminMailConfigView.vue +++ b/resources/scripts/features/admin/views/settings/AdminMailConfigView.vue @@ -1,14 +1,12 @@ + + diff --git a/resources/scripts/features/company/settings/store.ts b/resources/scripts/features/company/settings/store.ts index 342cdf6d..b46e483a 100644 --- a/resources/scripts/features/company/settings/store.ts +++ b/resources/scripts/features/company/settings/store.ts @@ -3,7 +3,7 @@ import { ref } from 'vue' import { companyService } from '../../../api/services/company.service' import type { CompanySettingsPayload } from '../../../api/services/company.service' import { mailService } from '../../../api/services/mail.service' -import type { MailDriver, MailConfigResponse } from '../../../api/services/mail.service' +import type { CompanyMailConfig, MailDriver } from '../../../types/mail-config' import { useNotificationStore } from '../../../stores/notification.store' import { handleApiError } from '../../../utils/error-handling' @@ -15,7 +15,7 @@ import { handleApiError } from '../../../utils/error-handling' export const useSettingsStore = defineStore('settings', () => { // Company Mail state const mailDrivers = ref([]) - const mailConfigData = ref(null) + const mailConfigData = ref(null) const currentMailDriver = ref('smtp') async function fetchMailDrivers(): Promise { @@ -29,9 +29,9 @@ export const useSettingsStore = defineStore('settings', () => { } } - async function fetchMailConfig(): Promise { + async function fetchMailConfig(): Promise { try { - const response = await companyService.getMailConfig() as unknown as MailConfigResponse + const response = await companyService.getMailConfig() mailConfigData.value = response currentMailDriver.value = response.mail_driver ?? 'smtp' return response @@ -48,7 +48,7 @@ export const useSettingsStore = defineStore('settings', () => { const notificationStore = useNotificationStore() notificationStore.showNotification({ type: 'success', - message: 'settings.mail.config_updated', + message: 'settings.mail.company_mail_config_updated', }) } catch (err: unknown) { handleApiError(err) diff --git a/resources/scripts/features/company/settings/views/MailConfigView.vue b/resources/scripts/features/company/settings/views/MailConfigView.vue index c11ba4f0..1cd24820 100644 --- a/resources/scripts/features/company/settings/views/MailConfigView.vue +++ b/resources/scripts/features/company/settings/views/MailConfigView.vue @@ -1,79 +1,107 @@ diff --git a/resources/scripts/features/installation/install-auth.ts b/resources/scripts/features/installation/install-auth.ts new file mode 100644 index 00000000..2fbc8793 --- /dev/null +++ b/resources/scripts/features/installation/install-auth.ts @@ -0,0 +1,21 @@ +import { LS_KEYS } from '@/scripts/config/constants' +import * as localStore from '@/scripts/utils/local-storage' + +export function setInstallWizardAuth(token: string, companyId?: number | string | null): void { + localStore.set(LS_KEYS.INSTALL_AUTH_TOKEN, token) + setInstallWizardCompany(companyId) +} + +export function setInstallWizardCompany(companyId?: number | string | null): void { + if (companyId === null || companyId === undefined || companyId === '') { + localStore.remove(LS_KEYS.INSTALL_SELECTED_COMPANY) + return + } + + localStore.set(LS_KEYS.INSTALL_SELECTED_COMPANY, String(companyId)) +} + +export function clearInstallWizardAuth(): void { + localStore.remove(LS_KEYS.INSTALL_AUTH_TOKEN) + localStore.remove(LS_KEYS.INSTALL_SELECTED_COMPANY) +} diff --git a/resources/scripts/features/installation/routes.ts b/resources/scripts/features/installation/routes.ts index f4de50f6..b1f73e51 100644 --- a/resources/scripts/features/installation/routes.ts +++ b/resources/scripts/features/installation/routes.ts @@ -1,93 +1,122 @@ import type { RouteRecordRaw } from 'vue-router' +import InstallationLayout from '@/scripts/layouts/InstallationLayout.vue' /** - * The installation wizard is a multi-step flow rendered inside a single - * parent view. Individual step views are not routed independently -- they - * are controlled by the parent Installation component via dynamic - * components. This route simply mounts the wizard entry point. + * The installation wizard is a multi-step flow. Every step is a child of the + * /installation parent route, which renders InstallationLayout (logo, card + * chrome, step progress dots) once and a inside the card. * - * The individual step views are: - * 1. RequirementsView - * 2. PermissionsView - * 3. DatabaseView - * 4. DomainView - * 5. MailView - * 6. AccountView - * 7. CompanyView - * 8. PreferencesView + * Step order — Language is intentionally first so the rest of the wizard + * renders in the user's chosen locale: + * + * 1. LanguageView (/installation/language) + * 2. RequirementsView (/installation/requirements) + * 3. PermissionsView (/installation/permissions) + * 4. DatabaseView (/installation/database) + * 5. DomainView (/installation/domain) + * 6. MailView (/installation/mail) + * 7. AccountView (/installation/account) + * 8. CompanyView (/installation/company) + * 9. PreferencesView (/installation/preferences) + * + * Each child view owns its own next() function and calls router.push() to + * the next step by route name. There is no event-based step coordination — + * the router IS the state machine. */ export const installationRoutes: RouteRecordRaw[] = [ { path: '/installation', - name: 'installation', - component: () => import('./views/RequirementsView.vue'), + component: InstallationLayout, meta: { - title: 'wizard.req.system_req', - isInstallation: true, - }, - }, - { - path: '/installation/permissions', - name: 'installation.permissions', - component: () => import('./views/PermissionsView.vue'), - meta: { - title: 'wizard.permissions.permissions', - isInstallation: true, - }, - }, - { - path: '/installation/database', - name: 'installation.database', - component: () => import('./views/DatabaseView.vue'), - meta: { - title: 'wizard.database.database', - isInstallation: true, - }, - }, - { - path: '/installation/domain', - name: 'installation.domain', - component: () => import('./views/DomainView.vue'), - meta: { - title: 'wizard.verify_domain.title', - isInstallation: true, - }, - }, - { - path: '/installation/mail', - name: 'installation.mail', - component: () => import('./views/MailView.vue'), - meta: { - title: 'wizard.mail.mail_config', - isInstallation: true, - }, - }, - { - path: '/installation/account', - name: 'installation.account', - component: () => import('./views/AccountView.vue'), - meta: { - title: 'wizard.account_info', - isInstallation: true, - }, - }, - { - path: '/installation/company', - name: 'installation.company', - component: () => import('./views/CompanyView.vue'), - meta: { - title: 'wizard.company_info', - isInstallation: true, - }, - }, - { - path: '/installation/preferences', - name: 'installation.preferences', - component: () => import('./views/PreferencesView.vue'), - meta: { - title: 'wizard.preferences', isInstallation: true, }, + children: [ + { + path: '', + redirect: { name: 'installation.language' }, + }, + { + path: 'language', + name: 'installation.language', + component: () => import('./views/LanguageView.vue'), + meta: { + title: 'wizard.install_language.title', + isInstallation: true, + }, + }, + { + path: 'requirements', + name: 'installation.requirements', + component: () => import('./views/RequirementsView.vue'), + meta: { + title: 'wizard.req.system_req', + isInstallation: true, + }, + }, + { + path: 'permissions', + name: 'installation.permissions', + component: () => import('./views/PermissionsView.vue'), + meta: { + title: 'wizard.permissions.permissions', + isInstallation: true, + }, + }, + { + path: 'database', + name: 'installation.database', + component: () => import('./views/DatabaseView.vue'), + meta: { + title: 'wizard.database.database', + isInstallation: true, + }, + }, + { + path: 'domain', + name: 'installation.domain', + component: () => import('./views/DomainView.vue'), + meta: { + title: 'wizard.verify_domain.title', + isInstallation: true, + }, + }, + { + path: 'mail', + name: 'installation.mail', + component: () => import('./views/MailView.vue'), + meta: { + title: 'wizard.mail.mail_config', + isInstallation: true, + }, + }, + { + path: 'account', + name: 'installation.account', + component: () => import('./views/AccountView.vue'), + meta: { + title: 'wizard.account_info', + isInstallation: true, + }, + }, + { + path: 'company', + name: 'installation.company', + component: () => import('./views/CompanyView.vue'), + meta: { + title: 'wizard.company_info', + isInstallation: true, + }, + }, + { + path: 'preferences', + name: 'installation.preferences', + component: () => import('./views/PreferencesView.vue'), + meta: { + title: 'wizard.preferences', + isInstallation: true, + }, + }, + ], }, ] diff --git a/resources/scripts/features/installation/use-installation-feedback.ts b/resources/scripts/features/installation/use-installation-feedback.ts new file mode 100644 index 00000000..f29265f8 --- /dev/null +++ b/resources/scripts/features/installation/use-installation-feedback.ts @@ -0,0 +1,75 @@ +import { useI18n } from 'vue-i18n' +import { useNotificationStore } from '@/scripts/stores/notification.store' +import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling' + +interface InstallationResponse { + success?: boolean | string + error?: string | boolean + error_message?: string + message?: string +} + +export function useInstallationFeedback() { + const { t } = useI18n() + const notificationStore = useNotificationStore() + + function isSuccessfulResponse(response: InstallationResponse | null | undefined): boolean { + return Boolean(response?.success) && !response?.error && !response?.error_message + } + + function showResponseError(response: InstallationResponse | null | undefined): void { + const candidate = + typeof response?.error_message === 'string' && response.error_message.trim() + ? response.error_message + : typeof response?.error === 'string' && response.error.trim() + ? response.error + : typeof response?.message === 'string' && response.message.trim() + ? response.message + : '' + + notificationStore.showNotification({ + type: 'error', + message: resolveMessage(candidate), + }) + } + + function showRequestError(error: unknown): void { + if (error instanceof Error && !('response' in error) && error.message.trim()) { + notificationStore.showNotification({ + type: 'error', + message: resolveMessage(error.message), + }) + + return + } + + const normalizedError = handleApiError(error) + + notificationStore.showNotification({ + type: 'error', + message: resolveMessage(normalizedError.message), + }) + } + + function resolveMessage(message: string): string { + const normalizedMessage = message.trim() + + if (!normalizedMessage) { + return 'validation.something_went_wrong' + } + + const wizardErrorKey = `wizard.errors.${normalizedMessage}` + + if (t(wizardErrorKey) !== wizardErrorKey) { + return wizardErrorKey + } + + return getErrorTranslationKey(normalizedMessage) ?? normalizedMessage + } + + return { + isSuccessfulResponse, + showRequestError, + showResponseError, + } +} diff --git a/resources/scripts/features/installation/views/AccountView.vue b/resources/scripts/features/installation/views/AccountView.vue index 61d8f30b..e021b660 100644 --- a/resources/scripts/features/installation/views/AccountView.vue +++ b/resources/scripts/features/installation/views/AccountView.vue @@ -104,6 +104,7 @@ diff --git a/resources/scripts/features/installation/views/MailView.vue b/resources/scripts/features/installation/views/MailView.vue index 269e8fb4..975eee9c 100644 --- a/resources/scripts/features/installation/views/MailView.vue +++ b/resources/scripts/features/installation/views/MailView.vue @@ -1,135 +1,18 @@ - - + + diff --git a/resources/scripts/features/installation/views/PermissionsView.vue b/resources/scripts/features/installation/views/PermissionsView.vue index a364e9f0..b5b0b9c6 100644 --- a/resources/scripts/features/installation/views/PermissionsView.vue +++ b/resources/scripts/features/installation/views/PermissionsView.vue @@ -4,54 +4,50 @@ :description="$t('wizard.permissions.permission_desc')" > - +
- + + + +
- - +
-
+
-
-
{{ permission.folder }}
-
- - - {{ permission.permission }} -
-
+ {{ permission.folder }} + + {{ permission.permission }} + +
+
+
-