Add super-admin Administration section and restructure global vs company settings

- Add Administration sidebar section (super-admin only) with Companies, Users, and Global Settings pages
- Add super-admin middleware, controllers, and API routes under /api/v1/super-admin/
- Allow super-admins to manage all companies and users across tenants
- Add user impersonation with short-lived tokens, audit logging, and UI banner
- Move system-level settings (Mail, PDF, Backup, Update, File Disk) from per-company to Administration > Global Settings
- Convert save_pdf_to_disk from CompanySetting to global Setting
- Add per-company mail configuration overrides (optional, falls back to global)
- Add CompanyMailConfigService to apply company mail config before sending emails
This commit is contained in:
Darko Gjorgjijoski
2026-04-03 10:35:40 +02:00
parent 25986b7bd5
commit 9432da467e
40 changed files with 2324 additions and 91 deletions

View File

@@ -58,6 +58,7 @@ class BootstrapController extends Controller
'login_page_description',
'admin_page_title',
'copyright_text',
'save_pdf_to_disk',
]);
return response()->json([

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\V1\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Mail\TestMail;
use App\Models\CompanySetting;
use App\Services\CompanyMailConfigService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Mail;
class CompanyMailConfigurationController extends Controller
{
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);
}
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]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\V1\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminCompanyUpdateRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use Illuminate\Http\Request;
class CompaniesController extends Controller
{
public function index(Request $request)
{
$companies = Company::query()
->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);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\V1\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminUserUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\ImpersonationLog;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
class UsersController extends Controller
{
public function index(Request $request)
{
$limit = $request->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);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Auth;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SuperAdminMiddleware
{
public function handle(Request $request, Closure $next, $guard = null): Response
{
if (Auth::guard($guard)->guest() || ! Auth::user()->isSuperAdmin()) {
return response()->json(['error' => 'unauthorized'], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminCompanyUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminUserUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}

View File

@@ -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),
];
}

View File

@@ -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 () {

View File

@@ -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();

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImpersonationLog extends Model
{
protected $guarded = ['id'];
protected $casts = [
'stopped_at' => 'datetime',
];
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -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']);

View File

@@ -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']);

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -0,0 +1,99 @@
<?php
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);
}
}

View File

@@ -19,6 +19,7 @@ trait GeneratesMenuTrait
'icon' => $data->data['icon'],
'name' => $data->data['name'],
'group' => $data->data['group'],
'group_label' => $data->data['group_label'] ?? '',
];
}
}

View File

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

View File

@@ -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([

View File

@@ -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' => '',
],
],
/*

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('impersonation_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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 <b>{COMPANY_NAME}</b>.</br> Please download using the button below:": "You have received a new invoice from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:"
"You have received a new invoice from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:": "You have received a new invoice from <b>{COMPANY_NAME}</b>.</br> 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"
}
}
}

View File

@@ -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 },

View File

@@ -0,0 +1,41 @@
<template>
<div
v-if="isImpersonating"
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
>
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
<span>{{ $t('administration.users.impersonating_banner') }}</span>
<button
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
:disabled="isStopping"
@click="stopImpersonating"
>
{{ $t('administration.users.stop_impersonating') }}
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import Ls from '@/scripts/services/ls.js'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
const administrationStore = useAdministrationStore()
let isStopping = ref(false)
const isImpersonating = computed(() => {
return Ls.get('admin.impersonating') === 'true'
})
async function stopImpersonating() {
isStopping.value = true
try {
await administrationStore.stopImpersonating()
} catch {
// Token already cleaned up in store action
}
window.location.href = '/admin/administration/users'
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`/admin/administration/companies/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
<script setup>
defineProps({
row: {
type: Object,
default: null,
},
table: {
type: Object,
default: null,
},
loadData: {
type: Function,
default: null,
},
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`/admin/administration/users/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<BaseDropdownItem
v-if="row.id !== userStore.currentUser?.id"
@click="onImpersonate"
>
<BaseIcon
name="ArrowRightEndOnRectangleIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('administration.users.impersonate') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { useDialogStore } from '@/scripts/stores/dialog'
const props = defineProps({
row: {
type: Object,
default: null,
},
table: {
type: Object,
default: null,
},
loadData: {
type: Function,
default: null,
},
})
const userStore = useUserStore()
const administrationStore = useAdministrationStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
function onImpersonate() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('administration.users.impersonate_confirm', {
name: props.row.name,
}),
yesLabel: t('administration.users.impersonate'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then((confirmed) => {
if (confirmed) {
administrationStore.impersonateUser(props.row.id).then(() => {
window.location.href = '/admin/dashboard'
})
}
})
}
</script>

View File

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

View File

@@ -2,6 +2,8 @@
<div v-if="isAppLoaded" class="h-full">
<NotificationRoot />
<ImpersonationBanner />
<SiteHeader />
<SiteSidebar />
@@ -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' })
}

View File

@@ -72,10 +72,16 @@
</div>
<nav
v-for="menu in globalStore.menuGroups"
:key="menu"
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="mt-5 space-y-1"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-4 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item.name"
@@ -126,10 +132,16 @@
"
>
<div
v-for="menu in globalStore.menuGroups"
:key="menu"
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="p-0 m-0 mt-6 list-none"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-6 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item"

View File

@@ -0,0 +1,162 @@
import http from '@/scripts/http'
import Ls from '@/scripts/services/ls.js'
import { defineStore } from 'pinia'
import { useNotificationStore } from '@/scripts/stores/notification'
import { handleError } from '@/scripts/helpers/error-handling'
export const useAdministrationStore = defineStore('administration', {
state: () => ({
companies: [],
totalCompanies: 0,
selectedCompany: null,
users: [],
totalUsers: 0,
}),
actions: {
fetchCompanies(params) {
return new Promise((resolve, reject) => {
http
.get('/api/v1/super-admin/companies', { params })
.then((response) => {
this.companies = response.data.data
this.totalCompanies = response.data.meta
? response.data.meta.total
: response.data.data.length
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchCompany(id) {
return new Promise((resolve, reject) => {
http
.get(`/api/v1/super-admin/companies/${id}`)
.then((response) => {
this.selectedCompany = response.data.data
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchUsers(params) {
return new Promise((resolve, reject) => {
http
.get('/api/v1/super-admin/users', { params })
.then((response) => {
this.users = response.data.data
this.totalUsers = response.data.meta
? response.data.meta.total
: response.data.data.length
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchUser(id) {
return new Promise((resolve, reject) => {
http
.get(`/api/v1/super-admin/users/${id}`)
.then((response) => {
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
updateUser(id, data) {
return new Promise((resolve, reject) => {
http
.put(`/api/v1/super-admin/users/${id}`, data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('administration.users.updated_message'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
impersonateUser(id) {
return new Promise((resolve, reject) => {
http
.post(`/api/v1/super-admin/users/${id}/impersonate`)
.then((response) => {
Ls.set('admin.impersonating', 'true')
Ls.set('auth.token', `Bearer ${response.data.token}`)
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
stopImpersonating() {
return new Promise((resolve, reject) => {
http
.post('/api/v1/super-admin/stop-impersonating')
.then((response) => {
Ls.remove('admin.impersonating')
Ls.remove('auth.token')
resolve(response)
})
.catch((err) => {
Ls.remove('admin.impersonating')
Ls.remove('auth.token')
handleError(err)
reject(err)
})
})
},
updateCompany(id, data) {
return new Promise((resolve, reject) => {
http
.put(`/api/v1/super-admin/companies/${id}`, data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t(
'administration.companies.updated_message'
),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
},
})

View File

@@ -0,0 +1,91 @@
import http from '@/scripts/http'
import { defineStore } from 'pinia'
import { useNotificationStore } from '@/scripts/stores/notification'
import { handleError } from '@/scripts/helpers/error-handling'
export const useCompanyMailStore = defineStore('company-mail', {
state: () => ({
mailConfigData: null,
mail_driver: '',
mail_drivers: [],
}),
actions: {
fetchMailDrivers() {
return new Promise((resolve, reject) => {
http
.get('/api/v1/mail/drivers')
.then((response) => {
if (response.data) {
this.mail_drivers = response.data
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchMailConfig() {
return new Promise((resolve, reject) => {
http
.get('/api/v1/company/mail/company-config')
.then((response) => {
if (response.data) {
this.mailConfigData = response.data
this.mail_driver = response.data.mail_driver || ''
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
updateMailConfig(data) {
return new Promise((resolve, reject) => {
http
.post('/api/v1/company/mail/company-config', data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('settings.mail.company_mail_config_updated'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
sendTestMail(data) {
return new Promise((resolve, reject) => {
http
.post('/api/v1/company/mail/company-test', data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
if (response.data.success) {
notificationStore.showNotification({
type: 'success',
message: global.t('general.send_mail_successfully'),
})
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
},
})

View File

@@ -0,0 +1,255 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.companies.edit_company')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="/admin/administration/companies"
/>
<BaseBreadcrumbItem
:title="$t('administration.companies.edit_company')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('administration.companies.owner')"
:error="v$.owner_id.$error && v$.owner_id.$errors[0].$message"
required
>
<BaseMultiselect
v-model="formData.owner_id"
label="name"
:invalid="v$.owner_id.$error"
:options="searchUsers"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
:filter-results="false"
:min-chars="0"
:resolve-on-load="true"
:delay="300"
object
track-by="name"
@select="onOwnerSelect"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model="formData.tax_id" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model="formData.vat_id" type="text" />
</BaseInputGroup>
</BaseInputGrid>
<BaseDivider class="my-6" />
<h3 class="text-lg font-medium text-gray-900 mb-4">
{{ $t('administration.companies.address') }}
</h3>
<BaseInputGrid>
<BaseInputGroup :label="$t('settings.company_info.phone')">
<BaseInput v-model="formData.address.phone" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.country')">
<BaseMultiselect
v-model="formData.address.country_id"
label="name"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.state')">
<BaseInput v-model="formData.address.state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.city')">
<BaseInput v-model="formData.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.zip')">
<BaseInput v-model="formData.address.zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.address')">
<BaseTextarea
v-model="formData.address.address_street_1"
rows="2"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { required, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import http from '@/scripts/http'
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let isLoading = ref(true)
let isSaving = ref(false)
const formData = reactive({
name: '',
owner_id: null,
vat_id: '',
tax_id: '',
address: {
address_street_1: '',
address_street_2: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
owner_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
await globalStore.fetchCountries()
const response = await administrationStore.fetchCompany(route.params.id)
const company = response.data.data
formData.name = company.name
formData.vat_id = company.vat_id || ''
formData.tax_id = company.tax_id || ''
if (company.owner) {
formData.owner_id = {
id: company.owner.id,
name: company.owner.name,
email: company.owner.email,
}
}
if (company.address) {
formData.address.address_street_1 = company.address.address_street_1 || ''
formData.address.address_street_2 = company.address.address_street_2 || ''
formData.address.country_id = company.address.country_id
formData.address.state = company.address.state || ''
formData.address.city = company.address.city || ''
formData.address.phone = company.address.phone || ''
formData.address.zip = company.address.zip || ''
}
isLoading.value = false
})
async function searchUsers(query) {
const response = await http.get('/api/v1/search', {
params: {
search: query,
type: 'User',
},
})
return response.data.users.map((user) => ({
id: user.id,
name: `${user.name} (${user.email})`,
email: user.email,
}))
}
function onOwnerSelect(option) {
formData.owner_id = option
}
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data = {
name: formData.name,
owner_id: formData.owner_id?.id || formData.owner_id,
vat_id: formData.vat_id,
tax_id: formData.tax_id,
address: formData.address,
}
await administrationStore.updateCompany(route.params.id, data)
isSaving.value = false
router.push({ name: 'admin.companies.index' })
}
</script>

View File

@@ -0,0 +1,188 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.companies.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
class="flex-1 mt-2"
>
<BaseInput
v-model="filters.search"
type="text"
name="search"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.companies.no_companies')"
:description="$t('administration.companies.list_description')"
>
<BaseIcon name="BuildingOfficeIcon" class="mt-5 mb-4 h-16 w-16 text-gray-300" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="companyTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.companies.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-owner="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.name }}</span>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-owner_email="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-actions="{ row }">
<AdminCompanyDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import AdminCompanyDropdown from '@/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue'
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
let table = ref(null)
let filters = reactive({
search: '',
})
const companyTableColumns = computed(() => {
return [
{
key: 'name',
label: t('administration.companies.company_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'owner',
label: t('administration.companies.owner'),
sortable: false,
},
{
key: 'owner_email',
label: t('general.email'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const showEmptyScreen = computed(() => {
return !administrationStore.totalCompanies && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
search: filters.search || '',
orderByField: sort.fieldName || 'name',
orderBy: sort.order || 'asc',
page,
}
isFetchingInitialData.value = true
let response = await administrationStore.fetchCompanies(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function clearFilter() {
filters.search = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.settings.title')" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.settings.title')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="menuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:modelValue="navigateToSetting"
/>
</div>
<div class="flex">
<div class="hidden mt-1 xl:block min-w-[240px]">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>
<script setup>
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseList from '@/scripts/components/list/BaseList.vue'
import BaseListItem from '@/scripts/components/list/BaseListItem.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
let currentSetting = ref({})
const menuItems = computed(() => [
{
title: t('settings.mail.mail_config'),
link: '/admin/administration/settings/mail-configuration',
icon: 'EnvelopeIcon',
},
{
title: t('settings.menu_title.pdf_generation'),
link: '/admin/administration/settings/pdf-generation',
icon: 'DocumentIcon',
},
{
title: t('settings.menu_title.backup'),
link: '/admin/administration/settings/backup',
icon: 'CircleStackIcon',
},
{
title: t('settings.menu_title.update_app'),
link: '/admin/administration/settings/update-app',
icon: 'ArrowPathIcon',
},
{
title: t('settings.menu_title.file_disk'),
link: '/admin/administration/settings/file-disk',
icon: 'FolderIcon',
},
])
watchEffect(() => {
if (route.path === '/admin/administration/settings') {
router.push('/admin/administration/settings/mail-configuration')
}
const item = menuItems.value.find((item) => {
return route.path.indexOf(item.link) > -1
})
currentSetting.value = item
})
function hasActiveUrl(url) {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting) {
return router.push(setting.link)
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.users.edit_user')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="/admin/administration/users"
/>
<BaseBreadcrumbItem
:title="$t('administration.users.edit_user')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('users.name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="formData.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')">
<BaseInput v-model="formData.phone" type="text" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('users.password')"
:error="v$.password.$error && v$.password.$errors[0].$message"
>
<BaseInput
v-model="formData.password"
type="password"
:invalid="v$.password.$error"
autocomplete="new-password"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div v-if="userData" class="mt-6 text-sm text-gray-500">
<p>
<strong>{{ $t('administration.users.role') }}:</strong>
{{ userData.role }}
</p>
<p v-if="userData.companies && userData.companies.length" class="mt-1">
<strong>{{ $t('navigation.companies') }}:</strong>
{{ userData.companies.map((c) => c.name).join(', ') }}
</p>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { required, email, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
const route = useRoute()
const router = useRouter()
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let isLoading = ref(true)
let isSaving = ref(false)
let userData = ref(null)
const formData = reactive({
name: '',
email: '',
phone: '',
password: '',
})
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
const response = await administrationStore.fetchUser(route.params.id)
const user = response.data.data
userData.value = user
formData.name = user.name
formData.email = user.email
formData.phone = user.phone || ''
isLoading.value = false
})
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data = {
name: formData.name,
email: formData.email,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
await administrationStore.updateUser(route.params.id, data)
isSaving.value = false
router.push({ name: 'admin.users.index' })
}
</script>

View File

@@ -0,0 +1,252 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.users.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('users.name')"
class="flex-1 mt-2 mr-4"
>
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.email')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.email"
type="text"
name="email"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')" class="flex-1 mt-2">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.users.no_users')"
:description="$t('administration.users.list_description')"
>
<BaseIcon
name="UsersIcon"
class="mt-5 mb-4 h-16 w-16 text-gray-300"
/>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="userTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.users.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-email="{ row }">
<span>{{ row.data.email }}</span>
</template>
<template #cell-role="{ row }">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getRoleBadgeClass(row.data.role)"
>
{{ row.data.role }}
</span>
</template>
<template #cell-companies="{ row }">
<div v-if="row.data.companies && row.data.companies.length" class="flex flex-wrap gap-1">
<span
v-for="company in row.data.companies.slice(0, 3)"
:key="company.id"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700"
>
{{ company.name }}
</span>
<span
v-if="row.data.companies.length > 3"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500"
>
+{{ row.data.companies.length - 3 }}
</span>
</div>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-actions="{ row }">
<AdminUserDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import AdminUserDropdown from '@/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue'
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
let table = ref(null)
let filters = reactive({
display_name: '',
email: '',
phone: '',
})
const userTableColumns = computed(() => {
return [
{
key: 'name',
label: t('users.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'email',
label: t('general.email'),
},
{
key: 'role',
label: t('administration.users.role'),
sortable: false,
},
{
key: 'companies',
label: t('navigation.companies'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const showEmptyScreen = computed(() => {
return !administrationStore.totalUsers && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
display_name: filters.display_name || '',
email: filters.email || '',
phone: filters.phone || '',
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await administrationStore.fetchUsers(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function getRoleBadgeClass(role) {
switch (role) {
case 'super admin':
return 'bg-purple-100 text-purple-800'
case 'admin':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
function clearFilter() {
filters.display_name = ''
filters.email = ''
filters.phone = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<MailTestModal :store-type="'company'" />
<BaseSettingCard
:title="$t('settings.mail.company_mail_config')"
:description="$t('settings.mail.company_mail_config_desc')"
>
<div class="mt-8">
<BaseSwitchSection
v-model="useCustomMailConfig"
:title="$t('settings.mail.use_custom_mail_config')"
:description="$t('settings.mail.use_custom_mail_config_desc')"
/>
</div>
<div v-if="useCustomMailConfig && companyMailStore.mailConfigData" class="mt-8">
<component
:is="mailDriver"
:config-data="companyMailStore.mailConfigData"
:is-saving="isSaving"
:mail-drivers="companyMailStore.mail_drivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val) => changeDriver(val)"
@submit-data="saveEmailConfig"
>
<BaseButton
variant="primary-outline"
type="button"
class="ml-2"
:content-loading="isFetchingInitialData"
@click="openMailTestModal"
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</div>
<div v-if="!useCustomMailConfig" class="mt-4 p-4 rounded-md bg-gray-50 text-sm text-gray-500">
{{ $t('settings.mail.using_global_mail_config') }}
</div>
</BaseSettingCard>
</template>
<script setup>
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
import { ref, computed, watch } from 'vue'
import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail'
import { useModalStore } from '@/scripts/stores/modal'
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
import { useI18n } from 'vue-i18n'
let isSaving = ref(false)
let isFetchingInitialData = ref(false)
const companyMailStore = useCompanyMailStore()
const modalStore = useModalStore()
const { t } = useI18n()
const useCustomMailConfig = ref(false)
loadData()
async function loadData() {
isFetchingInitialData.value = true
await companyMailStore.fetchMailDrivers()
await companyMailStore.fetchMailConfig()
useCustomMailConfig.value =
companyMailStore.mailConfigData?.use_custom_mail_config === 'YES'
isFetchingInitialData.value = false
}
function changeDriver(value) {
companyMailStore.mail_driver = value
companyMailStore.mailConfigData.mail_driver = value
}
const mailDriver = computed(() => {
if (companyMailStore.mail_driver === 'smtp') return Smtp
if (companyMailStore.mail_driver === 'mailgun') return Mailgun
if (companyMailStore.mail_driver === 'sendmail') return Basic
if (companyMailStore.mail_driver === 'ses') return Ses
if (companyMailStore.mail_driver === 'mail') return Basic
return Smtp
})
watch(useCustomMailConfig, async (newVal, oldVal) => {
if (oldVal === undefined) return
if (!newVal) {
isSaving.value = true
await companyMailStore.updateMailConfig({
use_custom_mail_config: 'NO',
mail_driver: '',
})
isSaving.value = false
}
})
async function saveEmailConfig(value) {
try {
isSaving.value = true
await companyMailStore.updateMailConfig({
...value,
use_custom_mail_config: 'YES',
})
isSaving.value = false
} catch (e) {
isSaving.value = false
}
}
function openMailTestModal() {
modalStore.openModal({
title: t('general.test_mail_conf'),
componentName: 'MailTestModal',
size: 'sm',
})
}
</script>

View File

@@ -84,7 +84,7 @@
<script setup>
import { useDiskStore } from '@/scripts/admin/stores/disk'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useModalStore } from '@/scripts/stores/modal'
import { ref, computed, reactive, onMounted, inject } from 'vue'
@@ -95,7 +95,7 @@ const utils = inject('utils')
const modalStore = useModalStore()
const diskStore = useDiskStore()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -139,7 +139,7 @@ const fileDiskColumns = computed(() => {
]
})
const savePdfToDisk = ref(companyStore.selectedCompanySettings.save_pdf_to_disk)
const savePdfToDisk = ref(globalStore.globalSettings?.save_pdf_to_disk || 'NO')
const savePdfToDiskField = computed({
get: () => {
@@ -156,7 +156,7 @@ const savePdfToDiskField = computed({
savePdfToDisk.value = value
await companyStore.updateCompanySettings({
await globalStore.updateGlobalSettings({
data,
message: 'general.setting_updated',
})

View File

@@ -27,6 +27,10 @@ router.beforeEach((to) => {
if (!userStore.hasAbilities(ability)) {
return { name: 'account.settings' }
}
} else if (to.meta.isSuperAdmin && isAppLoaded) {
if (!userStore.currentUser.is_super_admin) {
return { name: 'dashboard' }
}
} else if (to.meta.isOwner && isAppLoaded) {
if (!userStore.currentUser.is_owner) {
return { name: 'dashboard' }

View File

@@ -70,6 +70,7 @@ use App\Http\Controllers\V1\Admin\Role\AbilitiesController;
use App\Http\Controllers\V1\Admin\Role\RolesController;
use App\Http\Controllers\V1\Admin\Settings\CompanyController;
use App\Http\Controllers\V1\Admin\Settings\CompanyCurrencyCheckTransactionsController;
use App\Http\Controllers\V1\Admin\Settings\CompanyMailConfigurationController;
use App\Http\Controllers\V1\Admin\Settings\DiskController;
use App\Http\Controllers\V1\Admin\Settings\GetCompanyMailConfigurationController;
use App\Http\Controllers\V1\Admin\Settings\GetCompanySettingsController;
@@ -187,6 +188,25 @@ Route::prefix('/v1')->group(function () {
Route::post('/finish', FinishController::class);
});
// Super Admin
// ----------------------------------
Route::middleware(['auth:sanctum', 'super-admin'])->prefix('super-admin')->group(function () {
Route::get('companies', [App\Http\Controllers\V1\SuperAdmin\CompaniesController::class, 'index']);
Route::get('companies/{company}', [App\Http\Controllers\V1\SuperAdmin\CompaniesController::class, 'show']);
Route::put('companies/{company}', [App\Http\Controllers\V1\SuperAdmin\CompaniesController::class, 'update']);
Route::get('users', [App\Http\Controllers\V1\SuperAdmin\UsersController::class, 'index']);
Route::get('users/{user}', [App\Http\Controllers\V1\SuperAdmin\UsersController::class, 'show']);
Route::put('users/{user}', [App\Http\Controllers\V1\SuperAdmin\UsersController::class, 'update']);
Route::post('users/{user}/impersonate', [App\Http\Controllers\V1\SuperAdmin\UsersController::class, 'impersonate']);
});
// Stop impersonation - uses auth:sanctum only (the impersonated user's token, not super-admin)
Route::middleware(['auth:sanctum'])->prefix('super-admin')->group(function () {
Route::post('stop-impersonating', [App\Http\Controllers\V1\SuperAdmin\UsersController::class, 'stopImpersonating']);
});
Route::middleware(['auth:sanctum', 'company'])->group(function () {
Route::middleware(['bouncer'])->group(function () {
@@ -398,6 +418,10 @@ Route::prefix('/v1')->group(function () {
Route::get('/company/mail/config', GetCompanyMailConfigurationController::class);
Route::get('/company/mail/company-config', [CompanyMailConfigurationController::class, 'getMailConfig']);
Route::post('/company/mail/company-config', [CompanyMailConfigurationController::class, 'saveMailConfig']);
Route::post('/company/mail/company-test', [CompanyMailConfigurationController::class, 'testMailConfig']);
// PDF Generation
// ----------------------------------