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 @@
+
+