diff --git a/app/Http/Controllers/V1/Admin/General/BootstrapController.php b/app/Http/Controllers/V1/Admin/General/BootstrapController.php index 9f56fbb5..38965275 100644 --- a/app/Http/Controllers/V1/Admin/General/BootstrapController.php +++ b/app/Http/Controllers/V1/Admin/General/BootstrapController.php @@ -58,6 +58,7 @@ class BootstrapController extends Controller 'login_page_description', 'admin_page_title', 'copyright_text', + 'save_pdf_to_disk', ]); return response()->json([ diff --git a/app/Http/Controllers/V1/Admin/Settings/CompanyMailConfigurationController.php b/app/Http/Controllers/V1/Admin/Settings/CompanyMailConfigurationController.php new file mode 100644 index 00000000..b03bee95 --- /dev/null +++ b/app/Http/Controllers/V1/Admin/Settings/CompanyMailConfigurationController.php @@ -0,0 +1,145 @@ +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); + } + + public function saveMailConfig(Request $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); + + return response()->json(['success' => true]); + } + + public function testMailConfig(Request $request): JsonResponse + { + $this->authorize('owner only'); + + $this->validate($request, [ + 'to' => 'required|email', + 'subject' => 'required', + 'message' => 'required', + ]); + + CompanyMailConfigService::apply($request->header('company')); + + Mail::to($request->to)->send(new TestMail($request->subject, $request->message)); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/V1/SuperAdmin/CompaniesController.php b/app/Http/Controllers/V1/SuperAdmin/CompaniesController.php new file mode 100644 index 00000000..fc301aab --- /dev/null +++ b/app/Http/Controllers/V1/SuperAdmin/CompaniesController.php @@ -0,0 +1,57 @@ +with(['owner', 'address']) + ->when($request->has('search'), function ($query) use ($request) { + $query->where('name', 'like', '%'.$request->search.'%'); + }) + ->when($request->has('orderByField') && $request->has('orderBy'), function ($query) use ($request) { + $query->orderBy($request->orderByField, $request->orderBy); + }, function ($query) { + $query->orderBy('name', 'asc'); + }) + ->paginate($request->input('limit', 10)); + + return CompanyResource::collection($companies); + } + + public function show(Company $company) + { + $company->load(['owner', 'address']); + + return new CompanyResource($company); + } + + public function update(AdminCompanyUpdateRequest $request, Company $company) + { + $company->update([ + 'name' => $request->name, + 'vat_id' => $request->vat_id, + 'tax_id' => $request->tax_id, + 'owner_id' => $request->owner_id, + ]); + + if ($request->has('address')) { + $company->address()->updateOrCreate( + ['company_id' => $company->id], + $request->address, + ); + } + + $company->load(['owner', 'address']); + + return new CompanyResource($company); + } +} diff --git a/app/Http/Controllers/V1/SuperAdmin/UsersController.php b/app/Http/Controllers/V1/SuperAdmin/UsersController.php new file mode 100644 index 00000000..57b122e6 --- /dev/null +++ b/app/Http/Controllers/V1/SuperAdmin/UsersController.php @@ -0,0 +1,101 @@ +has('limit') ? $request->limit : 10; + + $users = User::with('companies') + ->applyFilters($request->all()) + ->latest() + ->paginate($limit); + + return UserResource::collection($users); + } + + public function show(User $user) + { + $user->load('companies'); + + return new UserResource($user); + } + + public function update(AdminUserUpdateRequest $request, User $user) + { + $data = $request->only(['name', 'email', 'phone']); + + if ($request->filled('password')) { + $data['password'] = $request->password; + } + + $user->update($data); + + return new UserResource($user); + } + + public function impersonate(Request $request, User $user) + { + $admin = $request->user(); + + if ($admin->id === $user->id) { + return response()->json([ + 'error' => 'cannot_impersonate_self', + 'message' => 'You cannot impersonate yourself.', + ], 422); + } + + $token = $user->createToken( + 'impersonation-by-'.$admin->id, + ['*'], + now()->addHours(2), + ); + + $log = ImpersonationLog::create([ + 'admin_id' => $admin->id, + 'user_id' => $user->id, + 'ip_address' => $request->ip(), + 'token_id' => $token->accessToken->id, + ]); + + return response()->json([ + 'token' => $token->plainTextToken, + 'impersonation_log_id' => $log->id, + 'user' => new UserResource($user), + ]); + } + + public function stopImpersonating(Request $request) + { + $token = $request->user()->currentAccessToken(); + + if ($token instanceof PersonalAccessToken && str_starts_with($token->name, 'impersonation-by-')) { + $log = ImpersonationLog::where('token_id', $token->id) + ->whereNull('stopped_at') + ->first(); + + if ($log) { + $log->update(['stopped_at' => now()]); + } + + $token->delete(); + + return response()->json(['success' => true]); + } + + return response()->json([ + 'error' => 'not_impersonating', + 'message' => 'No active impersonation session.', + ], 422); + } +} diff --git a/app/Http/Middleware/SuperAdminMiddleware.php b/app/Http/Middleware/SuperAdminMiddleware.php new file mode 100644 index 00000000..765448e7 --- /dev/null +++ b/app/Http/Middleware/SuperAdminMiddleware.php @@ -0,0 +1,20 @@ +guest() || ! Auth::user()->isSuperAdmin()) { + return response()->json(['error' => 'unauthorized'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/AdminCompanyUpdateRequest.php b/app/Http/Requests/AdminCompanyUpdateRequest.php new file mode 100644 index 00000000..887e332e --- /dev/null +++ b/app/Http/Requests/AdminCompanyUpdateRequest.php @@ -0,0 +1,45 @@ +user()->isSuperAdmin(); + } + + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + Rule::unique('companies')->ignore($this->route('company')), + ], + 'owner_id' => [ + 'required', + 'exists:users,id', + ], + 'vat_id' => [ + 'nullable', + 'string', + ], + 'tax_id' => [ + 'nullable', + 'string', + ], + 'address.name' => ['nullable', 'string'], + 'address.address_street_1' => ['nullable', 'string'], + 'address.address_street_2' => ['nullable', 'string'], + 'address.city' => ['nullable', 'string'], + 'address.state' => ['nullable', 'string'], + 'address.country_id' => ['nullable', 'exists:countries,id'], + 'address.zip' => ['nullable', 'string'], + 'address.phone' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/AdminUserUpdateRequest.php b/app/Http/Requests/AdminUserUpdateRequest.php new file mode 100644 index 00000000..523c9f6a --- /dev/null +++ b/app/Http/Requests/AdminUserUpdateRequest.php @@ -0,0 +1,28 @@ +user()->isSuperAdmin(); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string'], + 'email' => [ + 'required', + 'email', + Rule::unique('users')->ignore($this->route('user')), + ], + 'phone' => ['nullable', 'string'], + 'password' => ['nullable', 'string', 'min:8'], + ]; + } +} diff --git a/app/Http/Resources/CompanyResource.php b/app/Http/Resources/CompanyResource.php index 7e518d38..22e442e6 100644 --- a/app/Http/Resources/CompanyResource.php +++ b/app/Http/Resources/CompanyResource.php @@ -24,9 +24,14 @@ class CompanyResource extends JsonResource 'unique_hash' => $this->unique_hash, 'owner_id' => $this->owner_id, 'slug' => $this->slug, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, 'address' => $this->when($this->address()->exists(), function () { return new AddressResource($this->address); }), + 'owner' => $this->when($this->relationLoaded('owner'), function () { + return new UserResource($this->owner); + }), 'roles' => RoleResource::collection($this->roles), ]; } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 691ea988..c12e5ff7 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -32,6 +32,7 @@ class UserResource extends JsonResource 'updated_at' => $this->updated_at, 'avatar' => $this->avatar, 'is_owner' => $this->isOwner(), + 'is_super_admin' => $this->isSuperAdmin(), 'roles' => $this->roles, 'formatted_created_at' => $this->formattedCreatedAt, 'currency' => $this->when($this->currency()->exists(), function () { diff --git a/app/Models/Estimate.php b/app/Models/Estimate.php index cafacfc1..847063bf 100644 --- a/app/Models/Estimate.php +++ b/app/Models/Estimate.php @@ -6,6 +6,7 @@ use App; use App\Facades\Hashids; use App\Facades\PDF; use App\Mail\SendEstimateMail; +use App\Services\CompanyMailConfigService; use App\Services\SerialNumberFormatter; use App\Space\PdfTemplateUtils; use App\Support\PdfHtmlSanitizer; @@ -370,6 +371,8 @@ class Estimate extends Model implements HasMedia { $data = $this->sendEstimateData($data); + CompanyMailConfigService::apply($this->company_id); + if ($this->status == Estimate::STATUS_DRAFT) { $this->status = Estimate::STATUS_SENT; $this->save(); diff --git a/app/Models/ImpersonationLog.php b/app/Models/ImpersonationLog.php new file mode 100644 index 00000000..2694e614 --- /dev/null +++ b/app/Models/ImpersonationLog.php @@ -0,0 +1,25 @@ + 'datetime', + ]; + + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index a56e035b..b8fa632f 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -6,6 +6,7 @@ use App; use App\Facades\Hashids; use App\Facades\PDF; use App\Mail\SendInvoiceMail; +use App\Services\CompanyMailConfigService; use App\Services\SerialNumberFormatter; use App\Space\PdfTemplateUtils; use App\Support\PdfHtmlSanitizer; @@ -477,6 +478,8 @@ class Invoice extends Model implements HasMedia { $data = $this->sendInvoiceData($data); + CompanyMailConfigService::apply($this->company_id); + $mail = \Mail::to($data['to']); if (! empty($data['cc'])) { $mail->cc($data['cc']); diff --git a/app/Models/Payment.php b/app/Models/Payment.php index e3b3193f..8b0f06a9 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Facades\Hashids; use App\Jobs\GeneratePaymentPdfJob; use App\Mail\SendPaymentMail; +use App\Services\CompanyMailConfigService; use App\Services\SerialNumberFormatter; use App\Support\PdfHtmlSanitizer; use App\Traits\GeneratesPdfTrait; @@ -145,6 +146,8 @@ class Payment extends Model implements HasMedia { $data = $this->sendPaymentData($data); + CompanyMailConfigService::apply($this->company_id); + $mail = \Mail::to($data['to']); if (! empty($data['cc'])) { $mail->cc($data['cc']); diff --git a/app/Models/User.php b/app/Models/User.php index b5c575cc..62484a2c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -75,6 +75,11 @@ class User extends Authenticatable implements HasMedia } } + public function isSuperAdmin(): bool + { + return $this->role === 'super admin'; + } + public function isSuperAdminOrAdmin() { return ($this->role == 'super admin') || ($this->role == 'admin'); @@ -375,6 +380,10 @@ class User extends Authenticatable implements HasMedia public function checkAccess($data) { + if (! empty($data->data['super_admin_only']) && $data->data['super_admin_only']) { + return $this->isSuperAdmin(); + } + if ($this->isOwner()) { return true; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4742eb48..487de0c3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -110,9 +110,11 @@ class AppServiceProvider extends ServiceProvider ->data('icon', $data['icon']) ->data('name', $data['name']) ->data('owner_only', $data['owner_only']) + ->data('super_admin_only', $data['super_admin_only'] ?? false) ->data('ability', $data['ability']) ->data('model', $data['model']) - ->data('group', $data['group']); + ->data('group', $data['group']) + ->data('group_label', $data['group_label'] ?? ''); } public function bootAuth() diff --git a/app/Services/CompanyMailConfigService.php b/app/Services/CompanyMailConfigService.php new file mode 100644 index 00000000..5831a3ee --- /dev/null +++ b/app/Services/CompanyMailConfigService.php @@ -0,0 +1,99 @@ + $data->data['icon'], 'name' => $data->data['name'], 'group' => $data->data['group'], + 'group_label' => $data->data['group_label'] ?? '', ]; } } diff --git a/app/Traits/GeneratesPdfTrait.php b/app/Traits/GeneratesPdfTrait.php index 623d33e8..980db0b5 100644 --- a/app/Traits/GeneratesPdfTrait.php +++ b/app/Traits/GeneratesPdfTrait.php @@ -5,6 +5,7 @@ namespace App\Traits; use App\Models\Address; use App\Models\CompanySetting; use App\Models\FileDisk; +use App\Models\Setting; use Carbon\Carbon; use Illuminate\Support\Facades\App; @@ -68,7 +69,7 @@ trait GeneratesPdfTrait public function generatePDF($collection_name, $file_name, $deleteExistingFile = false) { - $save_pdf_to_disk = CompanySetting::getSetting('save_pdf_to_disk', $this->company_id); + $save_pdf_to_disk = Setting::getSetting('save_pdf_to_disk') ?? 'NO'; if ($save_pdf_to_disk == 'NO') { return 0; diff --git a/bootstrap/app.php b/bootstrap/app.php index 36fced57..587cd651 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,6 +15,7 @@ use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfInstalled; use App\Http\Middleware\RedirectIfUnauthorized; use App\Http\Middleware\ScopeBouncer; +use App\Http\Middleware\SuperAdminMiddleware; use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrustProxies; use App\Providers\AppServiceProvider; @@ -81,6 +82,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'pdf-auth' => PdfMiddleware::class, 'redirect-if-installed' => RedirectIfInstalled::class, 'redirect-if-unauthenticated' => RedirectIfUnauthorized::class, + 'super-admin' => SuperAdminMiddleware::class, ]); $middleware->priority([ diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 0fe83bc2..74148ac4 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -170,16 +170,6 @@ return [ 'ability' => '', 'model' => '', ], - [ - 'title' => 'settings.menu_title.pdf_generation', - 'group' => '', - 'name' => 'PDF Generation', - 'link' => '/admin/settings/pdf-generation', - 'icon' => 'DocumentIcon', - 'owner_only' => true, - 'ability' => '', - 'model' => '', - ], [ 'title' => 'settings.roles.title', 'group' => '', @@ -261,7 +251,7 @@ return [ 'model' => Expense::class, ], [ - 'title' => 'settings.mail.mail_config', + 'title' => 'settings.mail.company_mail_config', 'group' => '', 'name' => 'Mail Configuration', 'link' => '/admin/settings/mail-configuration', @@ -270,36 +260,6 @@ return [ 'ability' => '', 'model' => '', ], - [ - 'title' => 'settings.menu_title.file_disk', - 'group' => '', - 'name' => 'File Disk', - 'link' => '/admin/settings/file-disk', - 'icon' => 'FolderIcon', - 'owner_only' => true, - 'ability' => '', - 'model' => '', - ], - [ - 'title' => 'settings.menu_title.backup', - 'group' => '', - 'name' => 'Backup', - 'link' => '/admin/settings/backup', - 'icon' => 'CircleStackIcon', - 'owner_only' => true, - 'ability' => '', - 'model' => '', - ], - [ - 'title' => 'settings.menu_title.update_app', - 'group' => '', - 'name' => 'Update App', - 'link' => '/admin/settings/update-app', - 'icon' => 'ArrowPathIcon', - 'owner_only' => true, - 'ability' => '', - 'model' => '', - ], ], /* @@ -431,6 +391,40 @@ return [ 'ability' => '', 'model' => '', ], + [ + 'title' => 'navigation.companies', + 'group' => 4, + 'group_label' => 'navigation.administration', + 'link' => '/admin/administration/companies', + 'icon' => 'BuildingOfficeIcon', + 'name' => 'AdminCompanies', + 'owner_only' => false, + 'super_admin_only' => true, + 'ability' => '', + 'model' => '', + ], + [ + 'title' => 'navigation.all_users', + 'group' => 4, + 'link' => '/admin/administration/users', + 'icon' => 'UsersIcon', + 'name' => 'AdminUsers', + 'owner_only' => false, + 'super_admin_only' => true, + 'ability' => '', + 'model' => '', + ], + [ + 'title' => 'navigation.settings', + 'group' => 4, + 'link' => '/admin/administration/settings/mail-configuration', + 'icon' => 'CogIcon', + 'name' => 'AdminSettings', + 'owner_only' => false, + 'super_admin_only' => true, + 'ability' => '', + 'model' => '', + ], ], /* diff --git a/database/migrations/2026_04_03_070131_create_impersonation_logs_table.php b/database/migrations/2026_04_03_070131_create_impersonation_logs_table.php new file mode 100644 index 00000000..613858e6 --- /dev/null +++ b/database/migrations/2026_04_03_070131_create_impersonation_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('admin_id'); + $table->unsignedBigInteger('user_id'); + $table->string('ip_address', 45)->nullable(); + $table->unsignedBigInteger('token_id')->nullable(); + $table->timestamp('stopped_at')->nullable(); + $table->timestamps(); + + $table->foreign('admin_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('impersonation_logs'); + } +}; diff --git a/lang/en.json b/lang/en.json index 66edc9d7..168a94fb 100644 --- a/lang/en.json +++ b/lang/en.json @@ -12,7 +12,10 @@ "settings": "Settings", "logout": "Logout", "users": "Users", - "modules": "Modules" + "modules": "Modules", + "administration": "Administration", + "companies": "Companies", + "all_users": "Users" }, "general": { "add_company": "Add Company", @@ -909,7 +912,13 @@ "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": "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.", + "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." }, "pdf": { "title": "PDF Setting", @@ -1665,5 +1674,32 @@ "mail_view_payment": "View Payment", "notification_view_estimate": "[Notification] Estimate viewed", "notification_view_invoice": "[Notification] Invoice viewed", - "You have received a new invoice from {COMPANY_NAME}.
Please download using the button below:": "You have received a new invoice from {COMPANY_NAME}.
Please download using the button below:" + "You have received a new invoice from {COMPANY_NAME}.
Please download using the button below:": "You have received a new invoice from {COMPANY_NAME}.
Please download using the button below:", + "administration": { + "companies": { + "title": "All Companies", + "edit_company": "Edit Company", + "company_name": "Company Name", + "owner": "Owner", + "address": "Address", + "updated_message": "Company updated successfully", + "no_companies": "No companies found", + "list_description": "Manage all companies across the system" + }, + "settings": { + "title": "Global Settings" + }, + "users": { + "title": "All Users", + "edit_user": "Edit User", + "role": "Role", + "no_users": "No users found", + "list_description": "View all users across the system", + "updated_message": "User updated successfully", + "impersonate": "Impersonate", + "impersonate_confirm": "You are about to impersonate {name}. You will be logged in as this user with a temporary session that expires in 2 hours. This action is logged.", + "impersonating_banner": "You are currently impersonating a user. All actions are logged.", + "stop_impersonating": "Stop Impersonating" + } + } } diff --git a/resources/scripts/admin/admin-router.js b/resources/scripts/admin/admin-router.js index 6a1c21d2..3f18fa2b 100644 --- a/resources/scripts/admin/admin-router.js +++ b/resources/scripts/admin/admin-router.js @@ -47,17 +47,10 @@ const ExpenseCategory = () => import('@/scripts/admin/views/settings/ExpenseCategorySetting.vue') const ExchangeRateSetting = () => import('@/scripts/admin/views/settings/ExchangeRateProviderSetting.vue') -const MailConfig = () => - import('@/scripts/admin/views/settings/MailConfigSetting.vue') -const FileDisk = () => - import('@/scripts/admin/views/settings/FileDiskSetting.vue') -const Backup = () => import('@/scripts/admin/views/settings/BackupSetting.vue') -const UpdateApp = () => - import('@/scripts/admin/views/settings/UpdateAppSetting.vue') const RolesSettings = () => import('@/scripts/admin/views/settings/RolesSettings.vue') -const PDFGenerationSettings = () => - import('@/scripts/admin/views/settings/PDFGenerationSetting.vue') +const CompanyMailConfig = () => + import('@/scripts/admin/views/settings/CompanyMailConfigSetting.vue') // Items const ItemsIndex = () => import('@/scripts/admin/views/items/Index.vue') @@ -115,6 +108,28 @@ const ModuleView = () => import('@/scripts/admin/views/modules/View.vue') const InvoicePublicPage = () => import('@/scripts/components/InvoicePublicPage.vue') +// Administration (Super Admin) +const AdminCompaniesIndex = () => + import('@/scripts/admin/views/administration/companies/Index.vue') +const AdminCompaniesEdit = () => + import('@/scripts/admin/views/administration/companies/Edit.vue') +const AdminUsersIndex = () => + import('@/scripts/admin/views/administration/users/Index.vue') +const AdminUsersEdit = () => + import('@/scripts/admin/views/administration/users/Edit.vue') +const AdminSettingsIndex = () => + import('@/scripts/admin/views/administration/settings/SettingsIndex.vue') +const AdminMailConfig = () => + import('@/scripts/admin/views/settings/MailConfigSetting.vue') +const AdminPDFGeneration = () => + import('@/scripts/admin/views/settings/PDFGenerationSetting.vue') +const AdminBackup = () => + import('@/scripts/admin/views/settings/BackupSetting.vue') +const AdminUpdateApp = () => + import('@/scripts/admin/views/settings/UpdateAppSetting.vue') +const AdminFileDisk = () => + import('@/scripts/admin/views/settings/FileDiskSetting.vue') + export default [ { path: '/installation', @@ -304,36 +319,11 @@ export default [ meta: { ability: abilities.VIEW_EXPENSE }, component: ExpenseCategory, }, - { path: 'mail-configuration', - name: 'mailconfig', + name: 'company.mailconfig', meta: { isOwner: true }, - component: MailConfig, - }, - { - path: 'file-disk', - name: 'file-disk', - meta: { isOwner: true }, - component: FileDisk, - }, - { - path: 'backup', - name: 'backup', - meta: { isOwner: true }, - component: Backup, - }, - { - path: 'update-app', - name: 'updateapp', - meta: { isOwner: true }, - component: UpdateApp, - }, - { - path: 'pdf-generation', - name: 'pdf.generation', - meta: { isOwner: true }, - component: PDFGenerationSettings, + component: CompanyMailConfig, }, ], }, @@ -495,6 +485,65 @@ export default [ meta: { ability: abilities.VIEW_FINANCIAL_REPORT }, component: ReportsIndex, }, + + // Administration (Super Admin) + { + path: 'administration/companies', + name: 'admin.companies.index', + meta: { isSuperAdmin: true }, + component: AdminCompaniesIndex, + }, + { + path: 'administration/companies/:id/edit', + name: 'admin.companies.edit', + meta: { isSuperAdmin: true }, + component: AdminCompaniesEdit, + }, + { + path: 'administration/users', + name: 'admin.users.index', + meta: { isSuperAdmin: true }, + component: AdminUsersIndex, + }, + { + path: 'administration/users/:id/edit', + name: 'admin.users.edit', + meta: { isSuperAdmin: true }, + component: AdminUsersEdit, + }, + { + path: 'administration/settings', + name: 'admin.settings', + meta: { isSuperAdmin: true }, + component: AdminSettingsIndex, + children: [ + { + path: 'mail-configuration', + name: 'admin.settings.mail', + component: AdminMailConfig, + }, + { + path: 'pdf-generation', + name: 'admin.settings.pdf', + component: AdminPDFGeneration, + }, + { + path: 'backup', + name: 'admin.settings.backup', + component: AdminBackup, + }, + { + path: 'update-app', + name: 'admin.settings.update', + component: AdminUpdateApp, + }, + { + path: 'file-disk', + name: 'admin.settings.filedisk', + component: AdminFileDisk, + }, + ], + }, ], }, { path: '/:catchAll(.*)', component: NotFoundPage }, diff --git a/resources/scripts/admin/components/ImpersonationBanner.vue b/resources/scripts/admin/components/ImpersonationBanner.vue new file mode 100644 index 00000000..3aaa7c5c --- /dev/null +++ b/resources/scripts/admin/components/ImpersonationBanner.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue b/resources/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue new file mode 100644 index 00000000..f5f72ede --- /dev/null +++ b/resources/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue @@ -0,0 +1,34 @@ + + + diff --git a/resources/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue b/resources/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue new file mode 100644 index 00000000..d212a176 --- /dev/null +++ b/resources/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue @@ -0,0 +1,77 @@ + + + diff --git a/resources/scripts/admin/components/modal-components/MailTestModal.vue b/resources/scripts/admin/components/modal-components/MailTestModal.vue index 73db9095..fcbe37ae 100644 --- a/resources/scripts/admin/components/modal-components/MailTestModal.vue +++ b/resources/scripts/admin/components/modal-components/MailTestModal.vue @@ -96,6 +96,14 @@ import { required, email, maxLength, helpers } from '@vuelidate/validators' import useVuelidate from '@vuelidate/core' import { useModalStore } from '@/scripts/stores/modal' import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver' +import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail' + +const props = defineProps({ + storeType: { + type: String, + default: 'global', + }, +}) let isSaving = ref(false) let formData = reactive({ @@ -106,6 +114,11 @@ let formData = reactive({ const modalStore = useModalStore() const mailDriverStore = useMailDriverStore() +const companyMailStore = useCompanyMailStore() + +const activeStore = computed(() => { + return props.storeType === 'company' ? companyMailStore : mailDriverStore +}) const { t } = useI18n() const modalActive = computed(() => { @@ -153,7 +166,7 @@ async function onTestMailSend() { } isSaving.value = true - let response = await mailDriverStore.sendTestMail(formData) + let response = await activeStore.value.sendTestMail(formData) if (response.data) { closeTestModal() isSaving.value = false diff --git a/resources/scripts/admin/layouts/LayoutBasic.vue b/resources/scripts/admin/layouts/LayoutBasic.vue index 5ed33910..807ea18a 100644 --- a/resources/scripts/admin/layouts/LayoutBasic.vue +++ b/resources/scripts/admin/layouts/LayoutBasic.vue @@ -2,6 +2,8 @@
+ + @@ -34,6 +36,7 @@ import SiteHeader from '@/scripts/admin/layouts/partials/TheSiteHeader.vue' import SiteSidebar from '@/scripts/admin/layouts/partials/TheSiteSidebar.vue' import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue' import ExchangeRateBulkUpdateModal from '@/scripts/admin/components/modal-components/ExchangeRateBulkUpdateModal.vue' +import ImpersonationBanner from '@/scripts/admin/components/ImpersonationBanner.vue' const globalStore = useGlobalStore() const route = useRoute() @@ -52,6 +55,8 @@ onMounted(() => { globalStore.bootstrap().then((res) => { if (route.meta.ability && !userStore.hasAbilities(route.meta.ability)) { router.push({ name: 'account.settings' }) + } else if (route.meta.isSuperAdmin && !userStore.currentUser.is_super_admin) { + router.push({ name: 'dashboard' }) } else if (route.meta.isOwner && !userStore.currentUser.is_owner) { router.push({ name: 'account.settings' }) } diff --git a/resources/scripts/admin/layouts/partials/TheSiteSidebar.vue b/resources/scripts/admin/layouts/partials/TheSiteSidebar.vue index 789a2719..ecb64c3e 100644 --- a/resources/scripts/admin/layouts/partials/TheSiteSidebar.vue +++ b/resources/scripts/admin/layouts/partials/TheSiteSidebar.vue @@ -72,10 +72,16 @@