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:
@@ -58,6 +58,7 @@ class BootstrapController extends Controller
|
||||
'login_page_description',
|
||||
'admin_page_title',
|
||||
'copyright_text',
|
||||
'save_pdf_to_disk',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/V1/SuperAdmin/CompaniesController.php
Normal file
57
app/Http/Controllers/V1/SuperAdmin/CompaniesController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/V1/SuperAdmin/UsersController.php
Normal file
101
app/Http/Controllers/V1/SuperAdmin/UsersController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
20
app/Http/Middleware/SuperAdminMiddleware.php
Normal file
20
app/Http/Middleware/SuperAdminMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/AdminCompanyUpdateRequest.php
Normal file
45
app/Http/Requests/AdminCompanyUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/AdminUserUpdateRequest.php
Normal file
28
app/Http/Requests/AdminUserUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
25
app/Models/ImpersonationLog.php
Normal file
25
app/Models/ImpersonationLog.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
99
app/Services/CompanyMailConfigService.php
Normal file
99
app/Services/CompanyMailConfigService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ trait GeneratesMenuTrait
|
||||
'icon' => $data->data['icon'],
|
||||
'name' => $data->data['name'],
|
||||
'group' => $data->data['group'],
|
||||
'group_label' => $data->data['group_label'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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' => '',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
42
lang/en.json
42
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 <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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
resources/scripts/admin/admin-router.js
vendored
121
resources/scripts/admin/admin-router.js
vendored
@@ -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 },
|
||||
|
||||
41
resources/scripts/admin/components/ImpersonationBanner.vue
Normal file
41
resources/scripts/admin/components/ImpersonationBanner.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
162
resources/scripts/admin/stores/administration.js
vendored
Normal file
162
resources/scripts/admin/stores/administration.js
vendored
Normal 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)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
91
resources/scripts/admin/stores/company-mail.js
vendored
Normal file
91
resources/scripts/admin/stores/company-mail.js
vendored
Normal 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)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
255
resources/scripts/admin/views/administration/companies/Edit.vue
Normal file
255
resources/scripts/admin/views/administration/companies/Edit.vue
Normal 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>
|
||||
188
resources/scripts/admin/views/administration/companies/Index.vue
Normal file
188
resources/scripts/admin/views/administration/companies/Index.vue
Normal 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>
|
||||
@@ -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>
|
||||
181
resources/scripts/admin/views/administration/users/Edit.vue
Normal file
181
resources/scripts/admin/views/administration/users/Edit.vue
Normal 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>
|
||||
252
resources/scripts/admin/views/administration/users/Index.vue
Normal file
252
resources/scripts/admin/views/administration/users/Index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
4
resources/scripts/router/index.js
vendored
4
resources/scripts/router/index.js
vendored
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user