Refactor install wizard and mail configuration
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/Setup/SessionLoginController.php
Normal file
35
app/Http/Controllers/Setup/SessionLoginController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Setup;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class SessionLoginController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,5 +20,6 @@ class PreventRequestForgery extends Middleware
|
||||
*/
|
||||
protected $except = [
|
||||
'login',
|
||||
'installation/session-login',
|
||||
];
|
||||
}
|
||||
|
||||
41
app/Http/Middleware/UseInstallWizardTokenAuth.php
Normal file
41
app/Http/Middleware/UseInstallWizardTokenAuth.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\Setup\InstallUtils;
|
||||
use App\Support\InstallWizardAuth;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UseInstallWizardTokenAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! InstallWizardAuth::isRequest($request) || ! $this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/CompanyMailConfigurationRequest.php
Normal file
36
app/Http/Requests/CompanyMailConfigurationRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Services\MailConfigurationService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CompanyMailConfigurationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
453
app/Services/MailConfigurationService.php
Normal file
453
app/Services/MailConfigurationService.php
Normal file
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Setting;
|
||||
use Aws\Sdk;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory;
|
||||
use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory;
|
||||
|
||||
class MailConfigurationService
|
||||
{
|
||||
public const DEFAULT_DRIVER = 'smtp';
|
||||
|
||||
private const GLOBAL_SCOPE = 'global';
|
||||
|
||||
private const COMPANY_SCOPE = 'company';
|
||||
|
||||
private const DRIVER_ORDER = [
|
||||
'smtp',
|
||||
'mail',
|
||||
'sendmail',
|
||||
'ses',
|
||||
'mailgun',
|
||||
'postmark',
|
||||
];
|
||||
|
||||
private const DRIVER_FIELDS = [
|
||||
'smtp' => [
|
||||
'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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
21
app/Support/InstallWizardAuth.php
Normal file
21
app/Support/InstallWizardAuth.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InstallWizardAuth
|
||||
{
|
||||
public const HEADER = 'X-Install-Wizard';
|
||||
|
||||
public const HEADER_VALUE = '1';
|
||||
|
||||
public const TOKEN_NAME = 'installation-wizard';
|
||||
|
||||
public const TOKEN_ABILITY = 'installation:wizard';
|
||||
|
||||
public static function isRequest(Request $request): bool
|
||||
{
|
||||
return $request->headers->get(self::HEADER) === self::HEADER_VALUE;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
335
composer.lock
generated
335
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
// ],
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
53
lang/en.json
53
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",
|
||||
|
||||
@@ -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<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,11 +110,8 @@ export type {
|
||||
CreateBackupPayload,
|
||||
DeleteBackupParams,
|
||||
MailConfig,
|
||||
MailConfigResponse,
|
||||
CompanyMailConfig,
|
||||
MailDriver,
|
||||
SmtpConfig,
|
||||
MailgunConfig,
|
||||
SesConfig,
|
||||
TestMailPayload,
|
||||
PdfConfig,
|
||||
PdfConfigResponse,
|
||||
|
||||
34
resources/scripts/api/install-client.ts
Normal file
34
resources/scripts/api/install-client.ts
Normal file
@@ -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<string>(LS_KEYS.INSTALL_AUTH_TOKEN)
|
||||
const companyId = localStore.get<string | number>(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 }
|
||||
@@ -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<Record<string, unknown>> {
|
||||
async getMailDefaultConfig(): Promise<Pick<MailConfig, 'from_name' | 'from_mail'>> {
|
||||
const { data } = await client.get(API.COMPANY_MAIL_DEFAULT_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async getMailConfig(): Promise<Record<string, unknown>> {
|
||||
async getMailConfig(): Promise<CompanyMailConfig> {
|
||||
const { data } = await client.get(API.COMPANY_MAIL_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async saveMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
|
||||
async saveMailConfig(payload: Partial<CompanyMailConfig>): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.COMPANY_MAIL_CONFIG, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async testMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
|
||||
async testMailConfig(payload: TestMailPayload): Promise<{ success: boolean }> {
|
||||
const { data } = await client.post(API.COMPANY_MAIL_TEST, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<MailDriver[]> {
|
||||
@@ -53,7 +8,7 @@ export const mailService = {
|
||||
return data
|
||||
},
|
||||
|
||||
async getConfig(): Promise<MailConfigResponse> {
|
||||
async getConfig(): Promise<MailConfig> {
|
||||
const { data } = await client.get(API.MAIL_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
@@ -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<Props>(), {
|
||||
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',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="stepContainerClass">
|
||||
<div v-if="title || description">
|
||||
<p v-if="title" :class="stepTitleClass">
|
||||
<header v-if="title || description" class="mb-6">
|
||||
<h2 v-if="title" :class="stepTitleClass">
|
||||
{{ title }}
|
||||
</p>
|
||||
</h2>
|
||||
<p v-if="description" :class="stepDescriptionClass">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { mailService } from '@/scripts/api/services/mail.service'
|
||||
import type { MailConfig, MailDriver } from '@/scripts/api/services/mail.service'
|
||||
import SmtpMailDriver from '@/scripts/features/company/settings/components/SmtpMailDriver.vue'
|
||||
import MailgunMailDriver from '@/scripts/features/company/settings/components/MailgunMailDriver.vue'
|
||||
import SesMailDriver from '@/scripts/features/company/settings/components/SesMailDriver.vue'
|
||||
import BasicMailDriver from '@/scripts/features/company/settings/components/BasicMailDriver.vue'
|
||||
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
|
||||
import MailTestModal from '@/scripts/features/company/settings/components/MailTestModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -17,9 +15,8 @@ const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const mailConfigData = ref<Record<string, unknown> | null>(null)
|
||||
const mailConfigData = ref<MailConfig | null>(null)
|
||||
const mailDrivers = ref<MailDriver[]>([])
|
||||
const currentMailDriver = ref('smtp')
|
||||
|
||||
loadData()
|
||||
|
||||
@@ -34,34 +31,17 @@ async function loadData(): Promise<void> {
|
||||
|
||||
mailDrivers.value = driversResponse
|
||||
mailConfigData.value = configResponse
|
||||
currentMailDriver.value = configResponse.mail_driver ?? 'smtp'
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const mailDriver = computed(() => {
|
||||
switch (currentMailDriver.value) {
|
||||
case 'mailgun':
|
||||
return MailgunMailDriver
|
||||
case 'ses':
|
||||
return SesMailDriver
|
||||
case 'sendmail':
|
||||
case 'mail':
|
||||
return BasicMailDriver
|
||||
default:
|
||||
return SmtpMailDriver
|
||||
}
|
||||
})
|
||||
|
||||
function changeDriver(value: string): void {
|
||||
currentMailDriver.value = value
|
||||
|
||||
if (mailConfigData.value) {
|
||||
mailConfigData.value.mail_driver = value
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmailConfig(value: MailConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
@@ -71,7 +51,7 @@ async function saveEmailConfig(value: MailConfig): Promise<void> {
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: t(`settings.success.${response.success}`),
|
||||
message: 'settings.mail.mail_config_updated',
|
||||
})
|
||||
|
||||
if (mailConfigData.value) {
|
||||
@@ -81,6 +61,12 @@ async function saveEmailConfig(value: MailConfig): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -103,13 +89,11 @@ function openMailTestModal(): void {
|
||||
:description="$t('settings.mail.mail_config_desc')"
|
||||
>
|
||||
<div v-if="mailConfigData" class="mt-14">
|
||||
<component
|
||||
:is="mailDriver"
|
||||
<MailConfigurationForm
|
||||
:config-data="mailConfigData"
|
||||
:is-saving="isSaving"
|
||||
:mail-drivers="mailDrivers"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@on-change-driver="changeDriver"
|
||||
@submit-data="saveEmailConfig"
|
||||
>
|
||||
<BaseButton
|
||||
@@ -121,7 +105,7 @@ function openMailTestModal(): void {
|
||||
>
|
||||
{{ $t('general.test_mail_conf') }}
|
||||
</BaseButton>
|
||||
</component>
|
||||
</MailConfigurationForm>
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { email, helpers, numeric, required } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
|
||||
|
||||
interface SelectOption<TValue extends string = string> {
|
||||
label: string
|
||||
value: TValue
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Partial<MailConfig>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
mailDrivers?: MailDriver[]
|
||||
translationScope?: string
|
||||
submitLabel?: string
|
||||
submitIcon?: string
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
mailDrivers: () => [],
|
||||
translationScope: 'settings.mail',
|
||||
submitLabel: 'general.save',
|
||||
submitIcon: 'ArrowDownOnSquareIcon',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: MailConfig]
|
||||
'on-change-driver': [driver: MailDriver]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visibleSecrets = reactive<Record<string, boolean>>({})
|
||||
const showAdvancedFields = ref(false)
|
||||
|
||||
const mailConfig = reactive<MailConfig>(createDefaultMailConfig())
|
||||
|
||||
const fallbackDrivers: MailDriver[] = ['smtp', 'mail', 'sendmail']
|
||||
const encryptionOptions: SelectOption[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'TLS', value: 'tls' },
|
||||
{ label: 'SSL', value: 'ssl' },
|
||||
]
|
||||
const smtpSchemeOptions: SelectOption[] = [
|
||||
{ label: 'SMTP', value: 'smtp' },
|
||||
{ label: 'SMTPS', value: 'smtps' },
|
||||
]
|
||||
const mailgunSchemeOptions: SelectOption[] = [
|
||||
{ label: 'HTTPS', value: 'https' },
|
||||
{ label: 'API', value: 'api' },
|
||||
]
|
||||
|
||||
const availableDrivers = computed<MailDriver[]>(() => {
|
||||
return props.mailDrivers.length ? props.mailDrivers : fallbackDrivers
|
||||
})
|
||||
|
||||
const driverOptions = computed<SelectOption<MailDriver>[]>(() => {
|
||||
return availableDrivers.value.map((driver) => ({
|
||||
label: t(`${props.translationScope}.drivers.${driver}`),
|
||||
value: driver,
|
||||
}))
|
||||
})
|
||||
|
||||
const currentDriver = computed<MailDriver>({
|
||||
get: () => normalizeDriver(mailConfig.mail_driver, availableDrivers.value),
|
||||
set: (driver) => {
|
||||
mailConfig.mail_driver = driver
|
||||
},
|
||||
})
|
||||
|
||||
const hasAdvancedFields = computed<boolean>(() => {
|
||||
return getAdvancedFields(currentDriver.value).length > 0
|
||||
})
|
||||
|
||||
const rules = computed(() => {
|
||||
const commonRules = {
|
||||
mail_driver: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
from_mail: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
from_name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}
|
||||
|
||||
switch (currentDriver.value) {
|
||||
case 'smtp':
|
||||
return {
|
||||
...commonRules,
|
||||
mail_host: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_port: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
|
||||
},
|
||||
mail_timeout: {
|
||||
numeric: helpers.withMessage(t('validation.numbers_only'), (value: string) => {
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return /^\d+$/.test(value)
|
||||
}),
|
||||
},
|
||||
}
|
||||
case 'ses':
|
||||
return {
|
||||
...commonRules,
|
||||
mail_ses_key: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_ses_secret: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_ses_region: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}
|
||||
case 'mailgun':
|
||||
return {
|
||||
...commonRules,
|
||||
mail_mailgun_domain: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_mailgun_secret: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
mail_mailgun_endpoint: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}
|
||||
case 'postmark':
|
||||
return {
|
||||
...commonRules,
|
||||
mail_postmark_token: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}
|
||||
default:
|
||||
return commonRules
|
||||
}
|
||||
})
|
||||
|
||||
const v$ = useVuelidate(rules, mailConfig)
|
||||
|
||||
watch(
|
||||
() => [props.configData, props.mailDrivers] as const,
|
||||
() => {
|
||||
syncMailConfig()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function createDefaultMailConfig(): MailConfig {
|
||||
return {
|
||||
mail_driver: 'smtp',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
mail_host: '',
|
||||
mail_port: '587',
|
||||
mail_username: '',
|
||||
mail_password: '',
|
||||
mail_encryption: 'none',
|
||||
mail_scheme: '',
|
||||
mail_url: '',
|
||||
mail_timeout: '',
|
||||
mail_local_domain: '',
|
||||
mail_sendmail_path: '/usr/sbin/sendmail -bs -i',
|
||||
mail_ses_key: '',
|
||||
mail_ses_secret: '',
|
||||
mail_ses_region: 'us-east-1',
|
||||
mail_mailgun_domain: '',
|
||||
mail_mailgun_secret: '',
|
||||
mail_mailgun_endpoint: 'api.mailgun.net',
|
||||
mail_mailgun_scheme: 'https',
|
||||
mail_postmark_token: '',
|
||||
mail_postmark_message_stream_id: '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDriver(driver: MailConfig['mail_driver'], drivers: MailDriver[]): MailDriver {
|
||||
if (driver && drivers.includes(driver as MailDriver)) {
|
||||
return driver as MailDriver
|
||||
}
|
||||
|
||||
return drivers[0] ?? 'smtp'
|
||||
}
|
||||
|
||||
function syncMailConfig(): void {
|
||||
Object.assign(mailConfig, createDefaultMailConfig(), props.configData)
|
||||
mailConfig.mail_driver = normalizeDriver(mailConfig.mail_driver, availableDrivers.value)
|
||||
showAdvancedFields.value = hasAdvancedValues(currentDriver.value)
|
||||
v$.value.$reset()
|
||||
}
|
||||
|
||||
function hasAdvancedValues(driver: MailDriver): boolean {
|
||||
const defaultMailConfig = createDefaultMailConfig()
|
||||
|
||||
return getAdvancedFields(driver).some((field) => {
|
||||
const value = mailConfig[field]
|
||||
const defaultValue = defaultMailConfig[field]
|
||||
|
||||
return value !== '' && value !== null && value !== undefined && value !== defaultValue
|
||||
})
|
||||
}
|
||||
|
||||
function getAdvancedFields(driver: MailDriver): Array<keyof MailConfig> {
|
||||
switch (driver) {
|
||||
case 'smtp':
|
||||
return ['mail_scheme', 'mail_url', 'mail_timeout', 'mail_local_domain']
|
||||
case 'sendmail':
|
||||
return ['mail_sendmail_path']
|
||||
case 'mailgun':
|
||||
return ['mail_mailgun_scheme']
|
||||
case 'postmark':
|
||||
return ['mail_postmark_message_stream_id']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldError(field: string): string | undefined {
|
||||
const validationField = v$.value[field as keyof typeof v$.value]
|
||||
|
||||
if (!validationField || !('$error' in validationField) || !validationField.$error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return validationField.$errors[0]?.$message as string | undefined
|
||||
}
|
||||
|
||||
function toggleSecret(field: string): void {
|
||||
visibleSecrets[field] = !visibleSecrets[field]
|
||||
}
|
||||
|
||||
function getSecretInputType(field: string): string {
|
||||
return visibleSecrets[field] ? 'text' : 'password'
|
||||
}
|
||||
|
||||
function translationKey(key: string): string {
|
||||
return `${props.translationScope}.${key}`
|
||||
}
|
||||
|
||||
function changeDriver(value: MailDriver): void {
|
||||
currentDriver.value = value
|
||||
showAdvancedFields.value = false
|
||||
v$.value.$reset()
|
||||
emit('on-change-driver', value)
|
||||
}
|
||||
|
||||
async function saveEmailConfig(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit-data', { ...mailConfig, mail_driver: currentDriver.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="saveEmailConfig">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('driver'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_driver')"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="currentDriver"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="driverOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
:can-deselect="false"
|
||||
:can-clear="false"
|
||||
:invalid="v$.mail_driver.$error"
|
||||
@update:model-value="changeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h3 class="text-sm font-semibold text-heading">
|
||||
{{ $t(translationKey('basic_settings')) }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="currentDriver === 'mail' || currentDriver === 'sendmail'"
|
||||
class="mt-2 text-sm text-muted"
|
||||
>
|
||||
{{
|
||||
currentDriver === 'mail'
|
||||
? $t(translationKey('native_mail_desc'))
|
||||
: $t(translationKey('sendmail_desc'))
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<template v-if="currentDriver === 'smtp'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('host'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_host')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_host"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_host?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_host?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('port'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_port')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_port"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_port?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_port?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('username'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_username"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('password'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_password"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="getSecretInputType('mail_password')"
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="visibleSecrets.mail_password ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="toggleSecret('mail_password')"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('encryption'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="mailConfig.mail_encryption"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="encryptionOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
:can-clear="false"
|
||||
:can-deselect="false"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'ses'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('ses_key'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_ses_key')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_ses_key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_ses_key?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_ses_key?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('ses_secret'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_ses_secret')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_ses_secret"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_ses_secret?.$error"
|
||||
:type="getSecretInputType('mail_ses_secret')"
|
||||
autocomplete="off"
|
||||
@input="v$.mail_ses_secret?.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="visibleSecrets.mail_ses_secret ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="toggleSecret('mail_ses_secret')"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('ses_region'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_ses_region')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_ses_region"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_ses_region?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_ses_region?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'mailgun'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('mailgun_domain'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_mailgun_domain')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_mailgun_domain"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_mailgun_domain?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_mailgun_domain?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('mailgun_secret'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_mailgun_secret')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_mailgun_secret"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_mailgun_secret?.$error"
|
||||
:type="getSecretInputType('mail_mailgun_secret')"
|
||||
autocomplete="off"
|
||||
@input="v$.mail_mailgun_secret?.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="visibleSecrets.mail_mailgun_secret ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="toggleSecret('mail_mailgun_secret')"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('mailgun_endpoint'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_mailgun_endpoint')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_mailgun_endpoint"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_mailgun_endpoint?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_mailgun_endpoint?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'postmark'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('postmark_token'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_postmark_token')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_postmark_token"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_postmark_token?.$error"
|
||||
:type="getSecretInputType('mail_postmark_token')"
|
||||
autocomplete="off"
|
||||
@input="v$.mail_postmark_token?.$touch()"
|
||||
>
|
||||
<template #right>
|
||||
<BaseIcon
|
||||
:name="visibleSecrets.mail_postmark_token ? 'EyeIcon' : 'EyeSlashIcon'"
|
||||
class="mr-1 text-muted cursor-pointer"
|
||||
@click="toggleSecret('mail_postmark_token')"
|
||||
/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('from_mail'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('from_mail')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.from_mail"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.from_mail.$error"
|
||||
type="text"
|
||||
@input="v$.from_mail.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('from_name'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('from_name')"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.from_name"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.from_name.$error"
|
||||
type="text"
|
||||
@input="v$.from_name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAdvancedFields" class="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-primary-600"
|
||||
@click="showAdvancedFields = !showAdvancedFields"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="showAdvancedFields ? 'ChevronUpIcon' : 'ChevronDownIcon'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{
|
||||
showAdvancedFields
|
||||
? $t(translationKey('hide_advanced_settings'))
|
||||
: $t(translationKey('show_advanced_settings'))
|
||||
}}
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvancedFields" class="mt-4 rounded-lg border border-line-default p-4">
|
||||
<h3 class="text-sm font-semibold text-heading">
|
||||
{{ $t(translationKey('advanced_settings')) }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<template v-if="currentDriver === 'smtp'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('scheme'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="mailConfig.mail_scheme"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="smtpSchemeOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('url'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_url"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('timeout'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:error="getFieldError('mail_timeout')"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_timeout"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:invalid="v$.mail_timeout?.$error"
|
||||
type="text"
|
||||
@input="v$.mail_timeout?.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('local_domain'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_local_domain"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'sendmail'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('sendmail_path'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_sendmail_path"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'mailgun'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('mailgun_scheme'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="mailConfig.mail_mailgun_scheme"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="mailgunSchemeOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
:can-clear="false"
|
||||
:can-deselect="false"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDriver === 'postmark'">
|
||||
<BaseInputGroup
|
||||
:label="$t(translationKey('postmark_message_stream_id'))"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-model.trim="mailConfig.mail_postmark_message_stream_id"
|
||||
:content-loading="isFetchingInitialData"
|
||||
type="text"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex">
|
||||
<BaseButton
|
||||
:disabled="isSaving"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:loading="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon v-if="!isSaving" :name="submitIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t(submitLabel) }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -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<MailDriver[]>([])
|
||||
const mailConfigData = ref<MailConfigResponse | null>(null)
|
||||
const mailConfigData = ref<CompanyMailConfig | null>(null)
|
||||
const currentMailDriver = ref<string>('smtp')
|
||||
|
||||
async function fetchMailDrivers(): Promise<MailDriver[]> {
|
||||
@@ -29,9 +29,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMailConfig(): Promise<MailConfigResponse> {
|
||||
async function fetchMailConfig(): Promise<CompanyMailConfig> {
|
||||
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)
|
||||
|
||||
@@ -1,79 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '../../../../stores/modal.store'
|
||||
import { useNotificationStore } from '../../../../stores/notification.store'
|
||||
import { companyService } from '../../../../api/services/company.service'
|
||||
import { mailService } from '../../../../api/services/mail.service'
|
||||
import type { MailDriver } from '../../../../api/services/mail.service'
|
||||
import Smtp from '@/scripts/features/company/settings/components/SmtpMailDriver.vue'
|
||||
import Mailgun from '@/scripts/features/company/settings/components/MailgunMailDriver.vue'
|
||||
import Ses from '@/scripts/features/company/settings/components/SesMailDriver.vue'
|
||||
import Basic from '@/scripts/features/company/settings/components/BasicMailDriver.vue'
|
||||
import type { CompanyMailConfig, MailConfig, MailDriver } from '@/scripts/types/mail-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
|
||||
import MailTestModal from '@/scripts/features/company/settings/components/MailTestModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const useCustomMailConfig = ref<boolean>(false)
|
||||
|
||||
const mailConfigData = ref<Record<string, unknown> | null>(null)
|
||||
const mailConfigData = ref<CompanyMailConfig | null>(null)
|
||||
const mailDrivers = ref<MailDriver[]>([])
|
||||
const currentMailDriver = ref<string>('smtp')
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
mailService.getDrivers(),
|
||||
companyService.getMailConfig(),
|
||||
])
|
||||
mailDrivers.value = driversResponse
|
||||
mailConfigData.value = configResponse
|
||||
currentMailDriver.value = (configResponse.mail_driver as string) ?? 'smtp'
|
||||
useCustomMailConfig.value =
|
||||
(configResponse.use_custom_mail_config as string) === 'YES'
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
try {
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
mailService.getDrivers(),
|
||||
companyService.getMailConfig(),
|
||||
])
|
||||
|
||||
function changeDriver(value: string): void {
|
||||
currentMailDriver.value = value
|
||||
if (mailConfigData.value) {
|
||||
mailConfigData.value.mail_driver = value
|
||||
mailDrivers.value = driversResponse
|
||||
mailConfigData.value = configResponse
|
||||
useCustomMailConfig.value = configResponse.use_custom_mail_config === 'YES'
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const mailDriver = computed(() => {
|
||||
if (currentMailDriver.value === 'smtp') return Smtp
|
||||
if (currentMailDriver.value === 'mailgun') return Mailgun
|
||||
if (currentMailDriver.value === 'sendmail') return Basic
|
||||
if (currentMailDriver.value === 'ses') return Ses
|
||||
if (currentMailDriver.value === 'mail') return Basic
|
||||
return Smtp
|
||||
})
|
||||
|
||||
watch(useCustomMailConfig, async (newVal, oldVal) => {
|
||||
if (oldVal === undefined) return
|
||||
|
||||
if (!newVal) {
|
||||
isSaving.value = true
|
||||
await companyService.saveMailConfig({
|
||||
use_custom_mail_config: 'NO',
|
||||
mail_driver: '',
|
||||
})
|
||||
isSaving.value = false
|
||||
try {
|
||||
await companyService.saveMailConfig({
|
||||
use_custom_mail_config: 'NO',
|
||||
})
|
||||
|
||||
if (mailConfigData.value) {
|
||||
mailConfigData.value.use_custom_mail_config = 'NO'
|
||||
}
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.mail.company_mail_config_updated',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
useCustomMailConfig.value = true
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(value: Record<string, unknown>): Promise<void> {
|
||||
async function saveEmailConfig(value: MailConfig): Promise<void> {
|
||||
try {
|
||||
isSaving.value = true
|
||||
await companyService.saveMailConfig({
|
||||
...value,
|
||||
use_custom_mail_config: 'YES',
|
||||
})
|
||||
|
||||
if (mailConfigData.value) {
|
||||
mailConfigData.value = {
|
||||
...mailConfigData.value,
|
||||
...value,
|
||||
use_custom_mail_config: 'YES',
|
||||
}
|
||||
}
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.mail.company_mail_config_updated',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -104,13 +132,11 @@ function openMailTestModal(): void {
|
||||
</div>
|
||||
|
||||
<div v-if="useCustomMailConfig && mailConfigData" class="mt-8">
|
||||
<component
|
||||
:is="mailDriver"
|
||||
<MailConfigurationForm
|
||||
:config-data="mailConfigData"
|
||||
:is-saving="isSaving"
|
||||
:mail-drivers="mailDrivers"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@on-change-driver="(val: string) => changeDriver(val)"
|
||||
@submit-data="saveEmailConfig"
|
||||
>
|
||||
<BaseButton
|
||||
@@ -122,7 +148,7 @@ function openMailTestModal(): void {
|
||||
>
|
||||
{{ $t('general.test_mail_conf') }}
|
||||
</BaseButton>
|
||||
</component>
|
||||
</MailConfigurationForm>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center justify-center h-6 w-6 rounded-full',
|
||||
ok
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-red-100 text-red-600',
|
||||
]"
|
||||
:aria-label="ok ? 'OK' : 'Missing'"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="ok ? 'CheckIcon' : 'XMarkIcon'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
ok: boolean
|
||||
}>()
|
||||
</script>
|
||||
21
resources/scripts/features/installation/install-auth.ts
Normal file
21
resources/scripts/features/installation/install-auth.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 <router-view /> 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
helpers,
|
||||
required,
|
||||
@@ -113,7 +114,9 @@ import {
|
||||
email,
|
||||
} from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { setInstallWizardCompany } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface UserForm {
|
||||
name: string
|
||||
@@ -122,12 +125,9 @@ interface UserForm {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isShowPassword = ref<boolean>(false)
|
||||
@@ -186,22 +186,24 @@ async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const { data: res } = await client.put('/api/v1/me', userForm)
|
||||
const { data: res } = await installClient.put('/api/v1/me', userForm)
|
||||
|
||||
if (res.data) {
|
||||
if (avatarFileBlob.value) {
|
||||
const avatarData = new FormData()
|
||||
avatarData.append('admin_avatar', avatarFileBlob.value)
|
||||
await client.post('/api/v1/me/upload-avatar', avatarData)
|
||||
await installClient.post('/api/v1/me/upload-avatar', avatarData)
|
||||
}
|
||||
|
||||
const company = res.data.companies?.[0]
|
||||
if (company) {
|
||||
localStorage.setItem('selectedCompany', String(company.id))
|
||||
setInstallWizardCompany(company.id)
|
||||
}
|
||||
|
||||
emit('next', 6)
|
||||
await router.push({ name: 'installation.company' })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.company_info')"
|
||||
:description="$t('wizard.company_info_desc')"
|
||||
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -121,11 +120,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, maxLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import type { Country } from '../../../types/domain/customer'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface CompanyAddress {
|
||||
address_street_1: string
|
||||
@@ -145,12 +146,9 @@ interface CompanyFormData {
|
||||
address: CompanyAddress
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
@@ -201,11 +199,13 @@ const v$ = useVuelidate(rules, validationState)
|
||||
onMounted(async () => {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await client.get(API.COUNTRIES)
|
||||
const { data } = await installClient.get(API.COUNTRIES)
|
||||
countries.value = data.data ?? data
|
||||
// Default to US
|
||||
const us = countries.value.find((c) => c.code === 'US')
|
||||
if (us) companyForm.address.country_id = us.id
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
@@ -232,7 +232,7 @@ async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await client.put(API.COMPANY, companyForm)
|
||||
await installClient.put(API.COMPANY, companyForm)
|
||||
|
||||
if (logoFileBlob.value) {
|
||||
const logoData = new FormData()
|
||||
@@ -243,10 +243,12 @@ async function next(): Promise<void> {
|
||||
data: logoFileBlob.value,
|
||||
}),
|
||||
)
|
||||
await client.post(API.COMPANY_UPLOAD_LOGO, logoData)
|
||||
await installClient.post(API.COMPANY_UPLOAD_LOGO, logoData)
|
||||
}
|
||||
|
||||
emit('next', 7)
|
||||
await router.push({ name: 'installation.preferences' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.database.database')"
|
||||
:description="$t('wizard.database.desc')"
|
||||
step-container="w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded md:w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -53,13 +52,24 @@
|
||||
|
||||
<!-- SQLite fields -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
||||
<BaseInputGroup :label="$t('wizard.database.db_name')">
|
||||
<BaseInput v-model="databaseData.database_name" type="text" disabled />
|
||||
<div class="mb-6">
|
||||
<BaseInputGroup
|
||||
:label="$t('wizard.database.db_path')"
|
||||
help-text="Absolute path or path relative to the project root. Defaults to storage/app/database.sqlite."
|
||||
required
|
||||
>
|
||||
<BaseInput v-model="databaseData.database_name" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-6">
|
||||
<BaseCheckbox
|
||||
v-model="databaseData.database_overwrite"
|
||||
:label="$t('wizard.database.overwrite')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
@@ -73,7 +83,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { useDialogStore } from '../../../stores/dialog.store'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface DatabaseConfig {
|
||||
database_connection: string
|
||||
@@ -87,17 +100,15 @@ interface DatabaseConfig {
|
||||
app_locale: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
interface DatabaseDriverOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
|
||||
@@ -127,22 +138,29 @@ async function getDatabaseConfig(connection?: string): Promise<void> {
|
||||
const params: Record<string, string> = {}
|
||||
if (connection) params.connection = connection
|
||||
|
||||
const { data } = await client.get('/api/v1/installation/database/config', { params })
|
||||
try {
|
||||
const { data } = await installClient.get('/api/v1/installation/database/config', { params })
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
databaseData.database_connection = data.config.database_connection
|
||||
}
|
||||
|
||||
if (data.config.database_connection === 'sqlite') {
|
||||
databaseData.database_name = data.config.database_name
|
||||
} else {
|
||||
databaseData.database_name = null
|
||||
if (data.config.database_host) {
|
||||
databaseData.database_hostname = data.config.database_host
|
||||
}
|
||||
if (data.config.database_port) {
|
||||
databaseData.database_port = data.config.database_port
|
||||
if (data.config.database_connection === 'sqlite') {
|
||||
databaseData.database_name = data.config.database_name
|
||||
} else {
|
||||
databaseData.database_name = null
|
||||
if (data.config.database_host) {
|
||||
databaseData.database_hostname = data.config.database_host
|
||||
}
|
||||
if (data.config.database_port) {
|
||||
databaseData.database_port = data.config.database_port
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,18 +169,45 @@ function onChangeDriver(connection: string): void {
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
if (databaseData.database_overwrite) {
|
||||
const confirmed = await dialogStore.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('wizard.database.overwrite_confirm_desc'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const { data: res } = await client.post(
|
||||
const { data: res } = await installClient.post(
|
||||
'/api/v1/installation/database/config',
|
||||
databaseData,
|
||||
)
|
||||
|
||||
if (res.success) {
|
||||
await client.post('/api/v1/installation/finish')
|
||||
emit('next', 3)
|
||||
if (!isSuccessfulResponse(res)) {
|
||||
showResponseError(res)
|
||||
return
|
||||
}
|
||||
|
||||
const { data: finishResponse } = await installClient.post('/api/v1/installation/finish')
|
||||
|
||||
if (!isSuccessfulResponse(finishResponse)) {
|
||||
showResponseError(finishResponse)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.domain' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -49,16 +49,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import { clearInstallWizardAuth, setInstallWizardAuth } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
interface InstallationLoginResponse {
|
||||
success: boolean
|
||||
type: string
|
||||
token: string
|
||||
company: {
|
||||
id: number | string
|
||||
} | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
const isSaving = ref<boolean>(false)
|
||||
|
||||
const formData = reactive<{ app_domain: string }>({
|
||||
@@ -87,14 +97,36 @@ async function verifyDomain(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await client.put('/api/v1/installation/set-domain', formData)
|
||||
await client.get('/sanctum/csrf-cookie')
|
||||
await client.post('/api/v1/installation/login')
|
||||
const { data } = await client.get('/api/v1/auth/check')
|
||||
clearInstallWizardAuth()
|
||||
|
||||
if (data) {
|
||||
emit('next', 4)
|
||||
const { data: domainResponse } = await installClient.put(
|
||||
API.INSTALLATION_SET_DOMAIN,
|
||||
formData,
|
||||
)
|
||||
|
||||
if (!isSuccessfulResponse(domainResponse)) {
|
||||
showResponseError(domainResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await installClient.post<InstallationLoginResponse>(
|
||||
API.INSTALLATION_LOGIN,
|
||||
)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token || !data.company?.id) {
|
||||
throw new Error('Installer login response was incomplete.')
|
||||
}
|
||||
|
||||
setInstallWizardAuth(`${data.type} ${data.token}`, data.company.id)
|
||||
|
||||
await router.push({ name: 'installation.mail' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
128
resources/scripts/features/installation/views/LanguageView.vue
Normal file
128
resources/scripts/features/installation/views/LanguageView.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.install_language.title')"
|
||||
:description="$t('wizard.install_language.description')"
|
||||
step-container-class="w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<BaseInputGroup
|
||||
:label="$t('wizard.language')"
|
||||
:error="v$.language.$error ? String(v$.language.$errors[0]?.$message) : undefined"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="formData.language"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="languages"
|
||||
label="name"
|
||||
value-prop="code"
|
||||
:placeholder="$t('settings.preferences.select_language')"
|
||||
track-by="name"
|
||||
:searchable="true"
|
||||
:invalid="v$.language.$error"
|
||||
class="w-full"
|
||||
@change="onLanguageChange"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving || isFetchingInitialData"
|
||||
class="mt-8"
|
||||
>
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</form>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface LanguageOption {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
/**
|
||||
* The Language step runs BEFORE the Database step, so we can't persist the
|
||||
* choice to the `settings` table — there is no database yet. Instead we
|
||||
* store it in localStorage. InvoiceShelf::start() reads this on app boot
|
||||
* (see resources/scripts/InvoiceShelf.ts) and pre-loads the matching locale,
|
||||
* so the language survives page reloads through the rest of the wizard.
|
||||
*
|
||||
* The user-facing language preference is persisted to the DB later by
|
||||
* PreferencesView (the final step) via the existing /api/v1/me/settings
|
||||
* and /api/v1/company/settings endpoints — no separate backend call here.
|
||||
*/
|
||||
const STORAGE_KEY = 'install_language'
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(true)
|
||||
const isSaving = ref<boolean>(false)
|
||||
const languages = ref<LanguageOption[]>([])
|
||||
|
||||
const formData = reactive<{ language: string }>({
|
||||
language: localStorage.getItem(STORAGE_KEY) ?? 'en',
|
||||
})
|
||||
|
||||
const rules = computed(() => ({
|
||||
language: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, formData)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await installClient.get('/api/v1/installation/languages')
|
||||
languages.value = data?.languages ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch the UI language as soon as the user picks one — they shouldn't have
|
||||
* to click Continue to see the wizard re-render in their preferred locale.
|
||||
* Falls back silently if the locale loader hasn't been registered yet.
|
||||
*/
|
||||
async function onLanguageChange(code: string): Promise<void> {
|
||||
if (!code) return
|
||||
if (typeof window.loadLanguage === 'function') {
|
||||
await window.loadLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
// Persist the choice client-side so a page reload mid-wizard doesn't
|
||||
// lose the language. The DB doesn't exist yet at this step.
|
||||
localStorage.setItem(STORAGE_KEY, formData.language)
|
||||
await router.push({ name: 'installation.requirements' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,135 +1,18 @@
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.mail.mail_config')"
|
||||
:description="$t('wizard.mail.mail_config_desc')"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.driver')" required>
|
||||
<BaseMultiselect
|
||||
v-model="mailDriver"
|
||||
:options="mailDriverOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
:can-deselect="false"
|
||||
:can-clear="false"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Fields -->
|
||||
<template v-if="mailDriver === 'smtp'">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.host')" required>
|
||||
<BaseInput v-model="mailConfig.mail_host" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.port')" required>
|
||||
<BaseInput v-model="mailConfig.mail_port" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.username')">
|
||||
<BaseInput v-model="mailConfig.mail_username" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.password')">
|
||||
<BaseInput v-model="mailConfig.mail_password" type="password" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.encryption')">
|
||||
<BaseMultiselect
|
||||
v-model="mailConfig.mail_encryption"
|
||||
:options="encryptionOptions"
|
||||
:can-deselect="true"
|
||||
:placeholder="$t('wizard.mail.none')"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.from_mail')">
|
||||
<BaseInput v-model="mailConfig.from_mail" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
||||
<BaseInputGroup :label="$t('wizard.mail.from_name')">
|
||||
<BaseInput v-model="mailConfig.from_name" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Basic driver info -->
|
||||
<template v-if="mailDriver === 'sendmail' || mailDriver === 'mail'">
|
||||
<p class="text-sm text-muted mb-6">
|
||||
{{ $t('wizard.mail.basic_mail_desc') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('wizard.save_cont') }}
|
||||
</BaseButton>
|
||||
</form>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '@/scripts/api/install-client'
|
||||
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
|
||||
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface MailConfig {
|
||||
mail_driver: string
|
||||
mail_host: string
|
||||
mail_port: string
|
||||
mail_username: string
|
||||
mail_password: string
|
||||
mail_encryption: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
[key: string]: string
|
||||
}
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
interface DriverOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const mailDriver = ref<string>('smtp')
|
||||
|
||||
const mailDriverOptions = ref<DriverOption[]>([
|
||||
{ label: 'SMTP', value: 'smtp' },
|
||||
{ label: 'Mailgun', value: 'mailgun' },
|
||||
{ label: 'SES', value: 'ses' },
|
||||
{ label: 'Sendmail', value: 'sendmail' },
|
||||
{ label: 'Mail', value: 'mail' },
|
||||
])
|
||||
|
||||
const encryptionOptions = ref<string[]>(['tls', 'ssl'])
|
||||
|
||||
const mailConfig = reactive<MailConfig>({
|
||||
mail_driver: 'smtp',
|
||||
mail_host: '',
|
||||
mail_port: '587',
|
||||
mail_username: '',
|
||||
mail_password: '',
|
||||
mail_encryption: 'tls',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const mailConfigData = ref<MailConfig | null>(null)
|
||||
const mailDrivers = ref<MailDriver[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
@@ -137,32 +20,61 @@ onMounted(async () => {
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
try {
|
||||
const { data: configData } = await client.get('/api/v1/mail/config')
|
||||
if (configData) {
|
||||
Object.assign(mailConfig, configData)
|
||||
mailDriver.value = configData.mail_driver ?? 'smtp'
|
||||
}
|
||||
const [{ data: driversData }, { data: configData }] = await Promise.all([
|
||||
installClient.get<MailDriver[]>('/api/v1/mail/drivers'),
|
||||
installClient.get<MailConfig>('/api/v1/mail/config'),
|
||||
])
|
||||
|
||||
mailDrivers.value = driversData
|
||||
mailConfigData.value = configData
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(value: string): void {
|
||||
mailDriver.value = value
|
||||
mailConfig.mail_driver = value
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
async function saveMailConfig(value: MailConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
mailConfig.mail_driver = mailDriver.value
|
||||
const { data } = await client.post('/api/v1/mail/config', mailConfig)
|
||||
if (data.success) {
|
||||
emit('next', 5)
|
||||
const { data } = await installClient.post('/api/v1/mail/config', value)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
mailConfigData.value = {
|
||||
...value,
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.account' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.mail.mail_config')"
|
||||
:description="$t('wizard.mail.mail_config_desc')"
|
||||
>
|
||||
<MailConfigurationForm
|
||||
v-if="mailConfigData"
|
||||
:config-data="mailConfigData"
|
||||
:is-saving="isSaving"
|
||||
:mail-drivers="mailDrivers"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
translation-scope="wizard.mail"
|
||||
submit-label="wizard.save_cont"
|
||||
submit-icon="ArrowRightIcon"
|
||||
@submit-data="saveMailConfig"
|
||||
/>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
@@ -4,54 +4,50 @@
|
||||
:description="$t('wizard.permissions.permission_desc')"
|
||||
>
|
||||
<!-- Placeholders -->
|
||||
<BaseContentPlaceholders v-if="isFetchingInitialData">
|
||||
<div
|
||||
v-if="isFetchingInitialData"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4 border border-line-default"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<BaseContentPlaceholdersText :lines="1" class="col-span-4 p-3" />
|
||||
<BaseContentPlaceholders>
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-32" />
|
||||
</BaseContentPlaceholders>
|
||||
<div class="h-6 w-6 rounded-full bg-surface-tertiary animate-pulse" />
|
||||
</div>
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="mt-10"
|
||||
style="width: 96px; height: 42px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<div
|
||||
v-else-if="permissions.length"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<div
|
||||
v-for="(permission, index) in permissions"
|
||||
:key="index"
|
||||
class="border border-line-default"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<div class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4">
|
||||
<div class="col-span-2 p-3">{{ permission.folder }}</div>
|
||||
<div class="p-3 text-right">
|
||||
<span
|
||||
v-if="permission.isSet"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-green-500"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-red-500"
|
||||
/>
|
||||
<span>{{ permission.permission }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-body font-mono">{{ permission.folder }}</span>
|
||||
<span class="flex items-center gap-2 text-sm text-muted">
|
||||
<span class="font-medium">{{ permission.permission }}</span>
|
||||
<RequirementBadge :ok="permission.isSet" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<BaseButton
|
||||
v-show="!isFetchingInitialData"
|
||||
class="mt-10"
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="next"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('wizard.continue') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseWizardStep>
|
||||
@@ -59,7 +55,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import RequirementBadge from '../components/RequirementBadge.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface Permission {
|
||||
folder: string
|
||||
@@ -67,11 +66,8 @@ interface Permission {
|
||||
isSet: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
@@ -84,16 +80,21 @@ onMounted(() => {
|
||||
async function getPermissions(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await client.get('/api/v1/installation/permissions')
|
||||
const { data } = await installClient.get('/api/v1/installation/permissions')
|
||||
permissions.value = data.permissions?.permissions ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
emit('next')
|
||||
isSaving.value = false
|
||||
try {
|
||||
await router.push({ name: 'installation.database' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.preferences')"
|
||||
:description="$t('wizard.preferences_desc')"
|
||||
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -131,9 +130,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import { useDialogStore } from '../../../stores/dialog.store'
|
||||
import { clearInstallWizardAuth } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface PreferencesData {
|
||||
currency: number
|
||||
@@ -163,14 +164,10 @@ interface LanguageOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
@@ -220,17 +217,19 @@ onMounted(async () => {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [currRes, dateRes, tzRes, fyRes, langRes] = await Promise.all([
|
||||
client.get(API.CURRENCIES),
|
||||
client.get(API.DATE_FORMATS),
|
||||
client.get(API.TIMEZONES),
|
||||
client.get(`${API.CONFIG}?key=fiscal_years`),
|
||||
client.get(`${API.CONFIG}?key=languages`),
|
||||
installClient.get(API.CURRENCIES),
|
||||
installClient.get(API.DATE_FORMATS),
|
||||
installClient.get(API.TIMEZONES),
|
||||
installClient.get(`${API.CONFIG}?key=fiscal_years`),
|
||||
installClient.get(`${API.CONFIG}?key=languages`),
|
||||
])
|
||||
currencies.value = currRes.data.data ?? currRes.data
|
||||
dateFormats.value = dateRes.data.data ?? dateRes.data
|
||||
timeZones.value = tzRes.data.data ?? tzRes.data
|
||||
fiscalYears.value = fyRes.data.data ?? fyRes.data ?? []
|
||||
languages.value = langRes.data.data ?? langRes.data ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
@@ -259,21 +258,35 @@ function next(): void {
|
||||
settings: { ...currentPreferences },
|
||||
}
|
||||
|
||||
const { data: response } = await client.post(API.COMPANY_SETTINGS, settingsPayload)
|
||||
const { data: response } = await installClient.post(API.COMPANY_SETTINGS, settingsPayload)
|
||||
|
||||
if (response) {
|
||||
const userSettings = {
|
||||
settings: { language: currentPreferences.language },
|
||||
}
|
||||
await client.put(API.ME_SETTINGS, userSettings)
|
||||
await installClient.put(API.ME_SETTINGS, userSettings)
|
||||
const { data: sessionLoginResponse } = await installClient.post(
|
||||
API.INSTALLATION_SESSION_LOGIN,
|
||||
)
|
||||
|
||||
if (response.token) {
|
||||
localStorage.setItem('auth.token', response.token)
|
||||
if (!isSuccessfulResponse(sessionLoginResponse)) {
|
||||
showResponseError(sessionLoginResponse)
|
||||
return
|
||||
}
|
||||
|
||||
emit('next', 'COMPLETED')
|
||||
router.push('/admin/dashboard')
|
||||
// Mark the install as complete on the backend so the
|
||||
// InstallationMiddleware stops redirecting to /installation. The
|
||||
// OnboardingWizardController persists this to
|
||||
// Setting::profile_complete.
|
||||
await installClient.post(API.INSTALLATION_WIZARD_STEP, {
|
||||
profile_complete: 'COMPLETED',
|
||||
})
|
||||
|
||||
clearInstallWizardAuth()
|
||||
await router.push('/admin/dashboard')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -3,52 +3,39 @@
|
||||
:title="$t('wizard.req.system_req')"
|
||||
:description="$t('wizard.req.system_req_desc')"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="mb-6">
|
||||
<div
|
||||
v-if="phpSupportInfo"
|
||||
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
|
||||
>
|
||||
<div class="col-span-2 text-sm">
|
||||
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{{ phpSupportInfo.current }}
|
||||
<span
|
||||
v-if="phpSupportInfo.supported"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="requirements">
|
||||
<div
|
||||
v-for="(fulfilled, name) in requirements"
|
||||
:key="name"
|
||||
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
|
||||
>
|
||||
<div class="col-span-2 text-sm">{{ name }}</div>
|
||||
<div class="text-right">
|
||||
<span
|
||||
v-if="fulfilled"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="phpSupportInfo || requirements"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<!-- PHP version row — first so it's visually grouped with the extension list -->
|
||||
<div
|
||||
v-if="phpSupportInfo"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<span class="text-sm text-body">
|
||||
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-body">
|
||||
<span class="text-muted">{{ phpSupportInfo.current }}</span>
|
||||
<RequirementBadge :ok="phpSupportInfo.supported" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Extension rows -->
|
||||
<div
|
||||
v-for="(fulfilled, name) in requirements"
|
||||
:key="name"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<span class="text-sm text-body font-mono">{{ name }}</span>
|
||||
<RequirementBadge :ok="fulfilled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<BaseButton v-if="hasNext" @click="next">
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #left="slotProps">
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
@@ -67,7 +54,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import RequirementBadge from '../components/RequirementBadge.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface PhpSupportInfo {
|
||||
minimum: string
|
||||
@@ -75,11 +65,8 @@ interface PhpSupportInfo {
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const requirements = ref<Record<string, boolean> | null>(null)
|
||||
const phpSupportInfo = ref<PhpSupportInfo | null>(null)
|
||||
@@ -98,17 +85,22 @@ onMounted(() => {
|
||||
async function getRequirements(): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const { data } = await client.get('/api/v1/installation/requirements')
|
||||
const { data } = await installClient.get('/api/v1/installation/requirements')
|
||||
requirements.value = data?.requirements?.requirements?.php ?? null
|
||||
phpSupportInfo.value = data?.phpSupportInfo ?? null
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
emit('next')
|
||||
isSaving.value = false
|
||||
try {
|
||||
await router.push({ name: 'installation.permissions' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,121 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-y-auto text-base">
|
||||
<div class="bg-glass-gradient relative min-h-screen w-full overflow-hidden">
|
||||
<NotificationRoot />
|
||||
|
||||
<div class="container mx-auto px-4">
|
||||
<router-view />
|
||||
</div>
|
||||
<main
|
||||
class="
|
||||
relative flex min-h-screen flex-col items-center justify-center
|
||||
px-4 py-12 sm:px-6
|
||||
"
|
||||
>
|
||||
<!-- Logo above the card -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<MainLogo
|
||||
v-if="!loginPageLogo"
|
||||
class="h-12 w-auto text-primary-500"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="loginPageLogo"
|
||||
alt="InvoiceShelf"
|
||||
class="h-12 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Wizard card — same visual language as AuthLayout / BaseCard -->
|
||||
<article
|
||||
class="
|
||||
w-full max-w-3xl
|
||||
bg-surface
|
||||
rounded-xl
|
||||
border border-line-default
|
||||
shadow-sm
|
||||
backdrop-blur-sm
|
||||
px-8 py-10 sm:px-10 sm:py-12
|
||||
"
|
||||
>
|
||||
<!-- Step progress indicator -->
|
||||
<div
|
||||
v-if="totalSteps > 0"
|
||||
class="mb-8 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-for="step in totalSteps"
|
||||
:key="step"
|
||||
:class="[
|
||||
'h-2 rounded-full transition-all duration-300',
|
||||
step === currentStep
|
||||
? 'w-8 bg-primary-500'
|
||||
: step < currentStep
|
||||
? 'w-2 bg-primary-500'
|
||||
: 'w-2 bg-line-default',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<router-view />
|
||||
</article>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-8 text-center text-xs text-subtle">
|
||||
<span v-if="copyrightText">{{ copyrightText }}</span>
|
||||
<span v-else>
|
||||
Powered by
|
||||
<a
|
||||
href="https://invoiceshelf.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 hover:text-primary-600 font-medium transition-colors"
|
||||
>InvoiceShelf</a>
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
|
||||
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
login_page_logo?: string
|
||||
copyright_text?: string
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Step ordinal lookup. Each child route's `meta.installStep` is its 1-based
|
||||
* position in the wizard. The progress dots in the layout react to this.
|
||||
*/
|
||||
const STEP_ORDER = [
|
||||
'installation.language',
|
||||
'installation.requirements',
|
||||
'installation.permissions',
|
||||
'installation.database',
|
||||
'installation.domain',
|
||||
'installation.mail',
|
||||
'installation.account',
|
||||
'installation.company',
|
||||
'installation.preferences',
|
||||
]
|
||||
|
||||
const totalSteps = computed<number>(() => STEP_ORDER.length)
|
||||
|
||||
const currentStep = computed<number>(() => {
|
||||
const name = route.name?.toString() ?? ''
|
||||
const idx = STEP_ORDER.indexOf(name)
|
||||
return idx >= 0 ? idx + 1 : 1
|
||||
})
|
||||
|
||||
const copyrightText = computed<string | null>(() => window.copyright_text ?? null)
|
||||
|
||||
const loginPageLogo = computed<string | false>(() => {
|
||||
if (window.login_page_logo) return window.login_page_logo
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
42
resources/scripts/types/mail-config.ts
Normal file
42
resources/scripts/types/mail-config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type MailDriver =
|
||||
| 'smtp'
|
||||
| 'mail'
|
||||
| 'sendmail'
|
||||
| 'ses'
|
||||
| 'mailgun'
|
||||
| 'postmark'
|
||||
|
||||
export interface MailConfig {
|
||||
mail_driver: MailDriver | ''
|
||||
from_mail: string
|
||||
from_name: string
|
||||
mail_host: string
|
||||
mail_port: string
|
||||
mail_username: string
|
||||
mail_password: string
|
||||
mail_encryption: string
|
||||
mail_scheme: string
|
||||
mail_url: string
|
||||
mail_timeout: string
|
||||
mail_local_domain: string
|
||||
mail_sendmail_path: string
|
||||
mail_ses_key: string
|
||||
mail_ses_secret: string
|
||||
mail_ses_region: string
|
||||
mail_mailgun_domain: string
|
||||
mail_mailgun_secret: string
|
||||
mail_mailgun_endpoint: string
|
||||
mail_mailgun_scheme: string
|
||||
mail_postmark_token: string
|
||||
mail_postmark_message_stream_id: string
|
||||
}
|
||||
|
||||
export interface CompanyMailConfig extends MailConfig {
|
||||
use_custom_mail_config: 'YES' | 'NO'
|
||||
}
|
||||
|
||||
export interface TestMailPayload {
|
||||
to: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Http\Controllers\CustomerPortal\PaymentPdfController as CustomerPaymentP
|
||||
use App\Http\Controllers\Modules\ScriptController;
|
||||
use App\Http\Controllers\Modules\StyleController;
|
||||
use App\Http\Controllers\Pdf\DocumentPdfController;
|
||||
use App\Http\Controllers\Setup\SessionLoginController;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyInvitation;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -126,6 +127,19 @@ Route::get('/installation', function () {
|
||||
})->name('install')
|
||||
->middleware(['redirect-if-installed']);
|
||||
|
||||
// Catch-all for installation wizard sub-routes (language, requirements,
|
||||
// permissions, database, domain, mail, account, company, preferences).
|
||||
// The Vue Router handles the actual step rendering on the SPA side; this
|
||||
// just makes sure deep links and hard refreshes inside the wizard hit the
|
||||
// SPA shell instead of 404ing.
|
||||
Route::get('/installation/{vue?}', function () {
|
||||
return view('app');
|
||||
})->where('vue', '.*')
|
||||
->middleware(['redirect-if-installed']);
|
||||
|
||||
Route::post('/installation/session-login', SessionLoginController::class)
|
||||
->middleware(['redirect-if-installed', 'auth:sanctum']);
|
||||
|
||||
// Registration via invitation (serves SPA)
|
||||
// -------------------------------------------------
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\Mailer\Bridge\Mailgun\Transport\MailgunTransportFactory;
|
||||
use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkTransportFactory;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
@@ -73,6 +76,26 @@ test('get global mail configuration', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('get global mail drivers returns capability-backed drivers', function () {
|
||||
$drivers = getJson('/api/v1/mail/drivers')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
expect($drivers)->toContain('smtp', 'mail', 'sendmail', 'ses');
|
||||
|
||||
if (class_exists(HttpClient::class) && class_exists(MailgunTransportFactory::class)) {
|
||||
expect($drivers)->toContain('mailgun');
|
||||
} else {
|
||||
expect($drivers)->not->toContain('mailgun');
|
||||
}
|
||||
|
||||
if (class_exists(HttpClient::class) && class_exists(PostmarkTransportFactory::class)) {
|
||||
expect($drivers)->toContain('postmark');
|
||||
} else {
|
||||
expect($drivers)->not->toContain('postmark');
|
||||
}
|
||||
});
|
||||
|
||||
test('save global mail configuration', function () {
|
||||
postJson('/api/v1/mail/config', [
|
||||
'mail_driver' => 'smtp',
|
||||
@@ -81,6 +104,10 @@ test('save global mail configuration', function () {
|
||||
'mail_username' => 'demo-user',
|
||||
'mail_password' => 'secret',
|
||||
'mail_encryption' => 'tls',
|
||||
'mail_scheme' => 'smtp',
|
||||
'mail_url' => 'smtp://smtp.example.com',
|
||||
'mail_timeout' => 30,
|
||||
'mail_local_domain' => 'invoiceshelf.test',
|
||||
'from_name' => 'InvoiceShelf',
|
||||
'from_mail' => 'hello@example.com',
|
||||
])
|
||||
@@ -98,6 +125,35 @@ test('save global mail configuration', function () {
|
||||
'option' => 'from_mail',
|
||||
'value' => 'hello@example.com',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('settings', [
|
||||
'option' => 'mail_timeout',
|
||||
'value' => '30',
|
||||
]);
|
||||
});
|
||||
|
||||
test('save global postmark configuration', function () {
|
||||
postJson('/api/v1/mail/config', [
|
||||
'mail_driver' => 'postmark',
|
||||
'mail_postmark_token' => 'postmark-token',
|
||||
'mail_postmark_message_stream_id' => 'outbound',
|
||||
'from_name' => 'InvoiceShelf',
|
||||
'from_mail' => 'billing@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'success' => 'mail_variables_save_successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('settings', [
|
||||
'option' => 'mail_driver',
|
||||
'value' => 'postmark',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('settings', [
|
||||
'option' => 'mail_postmark_token',
|
||||
'value' => 'postmark-token',
|
||||
]);
|
||||
});
|
||||
|
||||
test('get pdf configuration', function () {
|
||||
|
||||
111
tests/Feature/Company/CompanyMailConfigurationControllerTest.php
Normal file
111
tests/Feature/Company/CompanyMailConfigurationControllerTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\CompanyMailConfigService;
|
||||
use App\Services\MailConfigurationService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$user = User::find(1);
|
||||
$this->companyId = $user->companies()->first()->id;
|
||||
|
||||
$this->withHeaders([
|
||||
'company' => $this->companyId,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['*']);
|
||||
});
|
||||
|
||||
test('get company mail configuration falls back to global config defaults', function () {
|
||||
Setting::setSettings([
|
||||
'mail_driver' => 'mail',
|
||||
'from_name' => 'Global Mailer',
|
||||
'from_mail' => 'global@example.com',
|
||||
]);
|
||||
|
||||
app(MailConfigurationService::class)->applyGlobalConfig();
|
||||
|
||||
getJson('/api/v1/company/mail/company-config')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'use_custom_mail_config' => 'NO',
|
||||
'mail_driver' => 'mail',
|
||||
'from_name' => 'Global Mailer',
|
||||
'from_mail' => 'global@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('save company mail configuration persists postmark settings', function () {
|
||||
postJson('/api/v1/company/mail/company-config', [
|
||||
'use_custom_mail_config' => 'YES',
|
||||
'mail_driver' => 'postmark',
|
||||
'mail_postmark_token' => 'company-postmark-token',
|
||||
'mail_postmark_message_stream_id' => 'broadcasts',
|
||||
'from_name' => 'Company Mailer',
|
||||
'from_mail' => 'company@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('company_settings', [
|
||||
'company_id' => $this->companyId,
|
||||
'option' => 'use_custom_mail_config',
|
||||
'value' => 'YES',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('company_settings', [
|
||||
'company_id' => $this->companyId,
|
||||
'option' => 'company_mail_postmark_token',
|
||||
'value' => 'company-postmark-token',
|
||||
]);
|
||||
});
|
||||
|
||||
test('disabling company mail configuration only flips the custom-config toggle', function () {
|
||||
CompanySetting::setSettings([
|
||||
'use_custom_mail_config' => 'YES',
|
||||
'company_mail_driver' => 'postmark',
|
||||
'company_mail_postmark_token' => 'existing-company-token',
|
||||
], $this->companyId);
|
||||
|
||||
postJson('/api/v1/company/mail/company-config', [
|
||||
'use_custom_mail_config' => 'NO',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
expect(CompanySetting::getSetting('use_custom_mail_config', $this->companyId))->toBe('NO');
|
||||
expect(CompanySetting::getSetting('company_mail_postmark_token', $this->companyId))
|
||||
->toBe('existing-company-token');
|
||||
});
|
||||
|
||||
test('company mail runtime apply maps postmark settings into laravel config', function () {
|
||||
CompanySetting::setSettings([
|
||||
'use_custom_mail_config' => 'YES',
|
||||
'company_mail_driver' => 'postmark',
|
||||
'company_mail_postmark_token' => 'runtime-postmark-token',
|
||||
'company_mail_postmark_message_stream_id' => 'outbound',
|
||||
'company_from_name' => 'Runtime Mailer',
|
||||
'company_from_mail' => 'runtime@example.com',
|
||||
], $this->companyId);
|
||||
|
||||
CompanyMailConfigService::apply($this->companyId);
|
||||
|
||||
expect(config('mail.default'))->toBe('postmark');
|
||||
expect(config('services.postmark.token'))->toBe('runtime-postmark-token');
|
||||
expect(config('mail.mailers.postmark.message_stream_id'))->toBe('outbound');
|
||||
expect(config('mail.from.name'))->toBe('Runtime Mailer');
|
||||
expect(config('mail.from.address'))->toBe('runtime@example.com');
|
||||
});
|
||||
110
tests/Feature/Company/Modules/HelloWorldIntegrationTest.php
Normal file
110
tests/Feature/Company/Modules/HelloWorldIntegrationTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Module;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\putJson;
|
||||
|
||||
/**
|
||||
* Integration test that exercises the real Modules/HelloWorld module end-to-end
|
||||
* — no Registry mocking. Proves that when an active module's ServiceProvider
|
||||
* registers menu + settings via InvoiceShelf\Modules\Registry, the host app's
|
||||
* bootstrap, company-modules index, and settings controllers all surface it.
|
||||
*
|
||||
* The HelloWorld module's provider boots automatically because nwidart sees
|
||||
* it in `storage/app/modules_statuses.json` (set to enabled when the module
|
||||
* was generated via `php artisan module:make HelloWorld`).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$user = User::find(1);
|
||||
$this->companyId = $user->companies()->first()->id;
|
||||
$this->withHeaders([
|
||||
'company' => $this->companyId,
|
||||
]);
|
||||
Sanctum::actingAs($user, ['*']);
|
||||
|
||||
// Mark the module as activated at the InvoiceShelf instance level so it
|
||||
// shows up in the company-context Active Modules index.
|
||||
Module::query()->updateOrCreate(
|
||||
['name' => 'HelloWorld'],
|
||||
['version' => '1.0.0', 'installed' => true, 'enabled' => true],
|
||||
);
|
||||
});
|
||||
|
||||
test('HelloWorld registers a menu entry visible in bootstrap', function () {
|
||||
$response = getJson('api/v1/bootstrap')->assertOk();
|
||||
|
||||
$menu = collect($response->json('module_menu'));
|
||||
$entry = $menu->firstWhere('link', '/admin/modules/hello-world/settings');
|
||||
|
||||
expect($entry)->not->toBeNull();
|
||||
expect($entry['title'])->toBe('helloworld::menu.title');
|
||||
expect($entry['icon'])->toBe('HandRaisedIcon');
|
||||
});
|
||||
|
||||
test('HelloWorld appears in the company Active Modules index with has_settings flag', function () {
|
||||
$response = getJson('api/v1/company-modules')->assertOk();
|
||||
|
||||
// The DB row stores PascalCase but the controller normalizes to kebab-case
|
||||
// for the URL/registry slug.
|
||||
$row = collect($response->json('data'))->firstWhere('slug', 'hello-world');
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row['name'])->toBe('HelloWorld');
|
||||
expect($row['has_settings'])->toBeTrue();
|
||||
expect($row['menu']['icon'])->toBe('HandRaisedIcon');
|
||||
});
|
||||
|
||||
test('GET module settings returns the HelloWorld schema with defaults', function () {
|
||||
$response = getJson('api/v1/modules/hello-world/settings')->assertOk();
|
||||
|
||||
$sections = $response->json('schema.sections');
|
||||
expect($sections)->toHaveCount(2);
|
||||
expect($sections[0]['title'])->toBe('helloworld::settings.greeting_section');
|
||||
|
||||
$fields = collect($sections[0]['fields'])->keyBy('key');
|
||||
expect($fields)->toHaveKeys(['greeting', 'recipient', 'show_emoji']);
|
||||
expect($fields['greeting']['type'])->toBe('text');
|
||||
expect($fields['greeting']['rules'])->toContain('required');
|
||||
|
||||
// Defaults flow through when nothing has been saved yet
|
||||
$values = $response->json('values');
|
||||
expect($values['greeting'])->toBe('Hello, world!');
|
||||
expect($values['show_emoji'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('PUT module settings persists values per company', function () {
|
||||
putJson('api/v1/modules/hello-world/settings', [
|
||||
'greeting' => 'Bonjour!',
|
||||
'recipient' => 'Marie',
|
||||
'show_emoji' => false,
|
||||
'tone' => 'formal',
|
||||
'note' => 'A custom welcome.',
|
||||
])->assertOk();
|
||||
|
||||
expect(CompanySetting::getSetting('module.hello-world.greeting', $this->companyId))
|
||||
->toBe('Bonjour!');
|
||||
expect(CompanySetting::getSetting('module.hello-world.show_emoji', $this->companyId))
|
||||
->toBe('0');
|
||||
expect(CompanySetting::getSetting('module.hello-world.tone', $this->companyId))
|
||||
->toBe('formal');
|
||||
|
||||
// Re-fetch and confirm the values round-trip through the show endpoint
|
||||
$response = getJson('api/v1/modules/hello-world/settings')->assertOk();
|
||||
expect($response->json('values.greeting'))->toBe('Bonjour!');
|
||||
expect($response->json('values.tone'))->toBe('formal');
|
||||
});
|
||||
|
||||
test('PUT rejects when required fields are missing', function () {
|
||||
putJson('api/v1/modules/hello-world/settings', [
|
||||
'recipient' => 'No greeting given',
|
||||
])->assertStatus(422)
|
||||
->assertJsonValidationErrors(['greeting', 'tone']);
|
||||
});
|
||||
Reference in New Issue
Block a user