mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-02 13:21:02 +00:00
Full rewrite of the installer (incl. the finish step), self-update pipeline, settings store, cron webhook, dashboard/bootstrap surface, environment manager, wizard login, config endpoint and shared helpers. Byte-compatible public interfaces and observable behavior, including documented legacy quirks; verified by the pilot behavioral suite and the full test suite.
216 lines
7.3 KiB
PHP
216 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Platform\Operations\Http\Company;
|
|
|
|
use App\Domains\Accounts\Http\Resources\CompanyInvitationResource;
|
|
use App\Domains\Accounts\Http\Resources\CompanyResource;
|
|
use App\Domains\Accounts\Http\Resources\UserResource;
|
|
use App\Domains\Accounts\Models\Company;
|
|
use App\Domains\Accounts\Models\CompanyInvitation;
|
|
use App\Domains\Accounts\Models\CompanySetting;
|
|
use App\Domains\Money\Models\Currency;
|
|
use App\Platform\Http\Controller;
|
|
use App\Platform\Modules\Models\Module;
|
|
use App\Platform\Operations\Http\Concerns\GeneratesMenu;
|
|
use App\Platform\Operations\Models\Setting;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use InvoiceShelf\Modules\Registry as ModuleRegistry;
|
|
use Silber\Bouncer\BouncerFacade;
|
|
|
|
/**
|
|
* The single round trip that hydrates the admin SPA on load: who is signed in,
|
|
* which workspace they are looking at, and what navigation they may see.
|
|
*
|
|
* Three shapes come out of here -- platform administration, a user who belongs
|
|
* to no company yet, and the ordinary company view -- built from one common
|
|
* envelope so the shared keys cannot drift apart.
|
|
*/
|
|
class BootstrapController extends Controller
|
|
{
|
|
use GeneratesMenu;
|
|
|
|
/**
|
|
* Instance-wide settings the shell needs before it can paint anything.
|
|
*
|
|
* Deliberately an allow-list rather than a dump of the settings table:
|
|
* credentials and tokens stored alongside these must not reach a browser.
|
|
*/
|
|
private const SHELL_SETTINGS = [
|
|
'admin_portal_theme',
|
|
'admin_portal_logo',
|
|
'login_page_logo',
|
|
'login_page_heading',
|
|
'login_page_description',
|
|
'admin_page_title',
|
|
'copyright_text',
|
|
'save_pdf_to_disk',
|
|
'show_sidebar_group_labels',
|
|
];
|
|
|
|
/**
|
|
* Handle the incoming request.
|
|
*
|
|
* @return JsonResponse
|
|
*/
|
|
public function __invoke(Request $request)
|
|
{
|
|
$user = $request->user();
|
|
$memberships = $user->companies;
|
|
|
|
if ($user->isSuperAdmin() && $request->has('admin_mode')) {
|
|
return response()->json($this->administrationView($user, $memberships));
|
|
}
|
|
|
|
if ($memberships->isEmpty()) {
|
|
return response()->json($this->envelope($user, Currency::first()));
|
|
}
|
|
|
|
return response()->json($this->companyView($request, $user, $memberships));
|
|
}
|
|
|
|
/**
|
|
* Re-read the workspace named by the company header. No membership check
|
|
* here; the SPA calls it right after it has switched companies.
|
|
*/
|
|
public function currentCompany(Request $request)
|
|
{
|
|
return new CompanyResource(Company::find($request->header('company')));
|
|
}
|
|
|
|
/**
|
|
* Everything every variant carries, with the company-scoped slots empty.
|
|
* Each variant fills in the ones that apply to it.
|
|
*/
|
|
private function envelope($user, ?Currency $currency): array
|
|
{
|
|
return [
|
|
'current_user' => new UserResource($user),
|
|
'current_user_settings' => $user->getAllSettings(),
|
|
'current_user_abilities' => [],
|
|
'companies' => [],
|
|
'current_company' => null,
|
|
'current_company_settings' => [],
|
|
'current_company_currency' => $currency,
|
|
'config' => config('invoiceshelf'),
|
|
'global_settings' => Setting::getSettings(self::SHELL_SETTINGS),
|
|
'main_menu' => [],
|
|
'setting_menu' => [],
|
|
'modules' => [],
|
|
'pending_invitations' => CompanyInvitationResource::collection(
|
|
$this->openInvitations($user)
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Platform administration: no workspace is selected, so the payload swaps
|
|
* in the admin navigation and lists every company the admin belongs to.
|
|
*/
|
|
private function administrationView($user, $memberships): array
|
|
{
|
|
return array_merge($this->envelope($user, Currency::first()), [
|
|
'companies' => CompanyResource::collection($memberships),
|
|
'main_menu' => $this->generateMenu('admin_menu', $user),
|
|
'admin_mode' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The ordinary view, scoped to one company.
|
|
*/
|
|
private function companyView(Request $request, $user, $memberships): array
|
|
{
|
|
// Both menus are resolved against the abilities Bouncer has cached so
|
|
// far, i.e. before the refresh further down. Keep that order.
|
|
$mainMenu = $this->mainMenuWithModules($user);
|
|
$settingMenu = $this->generateMenu('setting_menu', $user);
|
|
|
|
$company = $this->activeCompany($request, $user);
|
|
$companySettings = CompanySetting::getAllSettings($company->id);
|
|
|
|
$currency = $companySettings->has('currency')
|
|
? Currency::find($companySettings->get('currency'))
|
|
: Currency::first();
|
|
|
|
BouncerFacade::refreshFor($user);
|
|
|
|
return array_merge($this->envelope($user, $currency), [
|
|
'current_user_abilities' => $user->getAbilities(),
|
|
'companies' => CompanyResource::collection($memberships),
|
|
'current_company' => new CompanyResource($company),
|
|
'current_company_settings' => $companySettings,
|
|
'main_menu' => $mainMenu,
|
|
'setting_menu' => $settingMenu,
|
|
'modules' => Module::where('enabled', true)->pluck('name'),
|
|
'user_menu' => $this->moduleUserMenu(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The workspace this request talks about: the one named by the company
|
|
* header when the user is actually a member of it, their first otherwise.
|
|
*/
|
|
private function activeCompany(Request $request, $user): ?Company
|
|
{
|
|
$requested = Company::find($request->header('company'));
|
|
|
|
if ($requested && $user->hasCompany($requested->id)) {
|
|
return $requested;
|
|
}
|
|
|
|
return $user->companies()->first();
|
|
}
|
|
|
|
/**
|
|
* Main navigation with module-registered entries appended. They join the
|
|
* same list so the frontend orders core and module items in one pass.
|
|
*/
|
|
private function mainMenuWithModules($user): array
|
|
{
|
|
$menu = $this->generateMenu('main_menu', $user);
|
|
|
|
foreach (ModuleRegistry::allMenu() as $slug => $entry) {
|
|
$menu[] = [
|
|
'title' => __($entry['title']),
|
|
'link' => $entry['link'],
|
|
'icon' => $entry['icon'],
|
|
'name' => 'module-'.$slug,
|
|
'group' => $entry['group'] ?? 'modules',
|
|
'group_label' => $entry['group_label'] ?? 'navigation.modules',
|
|
'priority' => $entry['priority'] ?? 100,
|
|
];
|
|
}
|
|
|
|
return $menu;
|
|
}
|
|
|
|
/**
|
|
* Module entries for the account dropdown, lightest priority first.
|
|
*/
|
|
private function moduleUserMenu(): array
|
|
{
|
|
return collect(ModuleRegistry::allUserMenu())
|
|
->map(fn (array $entry, string $slug): array => [
|
|
...$entry,
|
|
'title' => __($entry['title']),
|
|
'name' => 'module-'.$slug,
|
|
])
|
|
->sortBy('priority')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Invitations still awaiting this user's answer, with everything the
|
|
* frontend needs to describe them eager-loaded.
|
|
*/
|
|
private function openInvitations($user)
|
|
{
|
|
return CompanyInvitation::forUser($user)
|
|
->pending()
|
|
->with(['company', 'role', 'invitedBy'])
|
|
->get();
|
|
}
|
|
}
|