mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-03 22:01:00 +00:00
feat(accounts): fresh accounts implementation
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Application;
|
||||
|
||||
use App\Domains\Accounts\Contracts\CompanyDataPurger;
|
||||
use App\Domains\Accounts\Contracts\CompanyDefaultsProvisioner;
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
use Silber\Bouncer\Database\Role;
|
||||
|
||||
/**
|
||||
* Everything that happens to a company either side of its working life: the
|
||||
* furniture a fresh one is handed, and the demolition of an old one.
|
||||
*
|
||||
* The reference data (payment methods, units) is provisioned through the
|
||||
* adapter; what stays here is the authorization scaffolding and the sheet of
|
||||
* default preferences a company starts out with.
|
||||
*/
|
||||
class CompanyService
|
||||
{
|
||||
/** Name and title of the role every company is created with. */
|
||||
private const OWNER_ROLE = 'owner';
|
||||
|
||||
private const OWNER_ROLE_TITLE = 'Owner';
|
||||
|
||||
/** Mail bodies quoted into the outgoing document mails. */
|
||||
private const INVOICE_MAIL_BODY = 'You have received a new invoice from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:';
|
||||
|
||||
private const ESTIMATE_MAIL_BODY = 'You have received a new estimate from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:';
|
||||
|
||||
private const PAYMENT_MAIL_BODY = 'Thank you for the payment.</b></br> Please download your payment receipt using the button below:';
|
||||
|
||||
/** Address blocks printed on the documents; placeholders filled at render. */
|
||||
private const BILLING_ADDRESS_FORMAT = '<h3>{BILLING_ADDRESS_NAME}</h3><p>{BILLING_ADDRESS_STREET_1}</p><p>{BILLING_ADDRESS_STREET_2}</p><p>{BILLING_CITY} {BILLING_STATE}</p><p>{BILLING_COUNTRY} {BILLING_ZIP_CODE}</p><p>{BILLING_PHONE}</p>';
|
||||
|
||||
private const SHIPPING_ADDRESS_FORMAT = '<h3>{SHIPPING_ADDRESS_NAME}</h3><p>{SHIPPING_ADDRESS_STREET_1}</p><p>{SHIPPING_ADDRESS_STREET_2}</p><p>{SHIPPING_CITY} {SHIPPING_STATE}</p><p>{SHIPPING_COUNTRY} {SHIPPING_ZIP_CODE}</p><p>{SHIPPING_PHONE}</p>';
|
||||
|
||||
private const COMPANY_ADDRESS_FORMAT = '<h3><strong>{COMPANY_NAME}</strong></h3><p>{COMPANY_ADDRESS_STREET_1}</p><p>{COMPANY_ADDRESS_STREET_2}</p><p>{COMPANY_CITY} {COMPANY_STATE}</p><p>{COMPANY_COUNTRY} {COMPANY_ZIP_CODE}</p><p>{COMPANY_PHONE}</p>';
|
||||
|
||||
/** The payer block on a receipt, spaced differently from the billing one. */
|
||||
private const PAYMENT_CUSTOMER_ADDRESS_FORMAT = '<h3>{BILLING_ADDRESS_NAME}</h3><p>{BILLING_ADDRESS_STREET_1}</p><p>{BILLING_ADDRESS_STREET_2}</p><p>{BILLING_CITY} {BILLING_STATE} {BILLING_ZIP_CODE}</p><p>{BILLING_COUNTRY}</p><p>{BILLING_PHONE}</p>';
|
||||
|
||||
public function __construct(
|
||||
private readonly CompanyDefaultsProvisioner $companyDefaultsProvisioner,
|
||||
private readonly CompanyDataPurger $companyDataPurger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Furnish a newly created company.
|
||||
*
|
||||
* Order is fixed: the owner role first, so the creator has something to be
|
||||
* assigned; then the reference data; then the preference sheet, which is
|
||||
* where the chosen currency lands.
|
||||
*/
|
||||
public function setupDefaults(Company $company, int $currencyId = 13): bool
|
||||
{
|
||||
$this->setupRoles($company);
|
||||
|
||||
$this->companyDefaultsProvisioner->provision($company);
|
||||
|
||||
$this->setupDefaultSettings($company, $currencyId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the company's `owner` role and grant it the whole ability
|
||||
* catalogue — every entry in the configuration, against the subject model
|
||||
* the entry names.
|
||||
*
|
||||
* Roles live inside a company's scope, so the scope is moved onto this
|
||||
* company first and left there for whatever the caller does next.
|
||||
*/
|
||||
public function setupRoles(Company $company): void
|
||||
{
|
||||
BouncerFacade::scope()->to($company->id);
|
||||
|
||||
$owner = BouncerFacade::role()->firstOrCreate([
|
||||
'name' => self::OWNER_ROLE,
|
||||
'title' => self::OWNER_ROLE_TITLE,
|
||||
'scope' => $company->id,
|
||||
]);
|
||||
|
||||
foreach (config('abilities.abilities') as $entry) {
|
||||
BouncerFacade::allow($owner)->to($entry['ability'], $entry['model']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wind a company up.
|
||||
*
|
||||
* The purger clears everything filed against the company first; what is
|
||||
* left here is the company's own furniture — its scoped roles, the
|
||||
* memberships pointing at it, its preferences, and the row itself. The
|
||||
* member accounts survive: only the link between them and the company is
|
||||
* cut.
|
||||
*/
|
||||
public function delete(Company $company): bool
|
||||
{
|
||||
$this->companyDataPurger->purge($company);
|
||||
|
||||
Role::query()
|
||||
->when($company->id, function ($query) use ($company) {
|
||||
$query->where('scope', $company->id);
|
||||
})
|
||||
->get()
|
||||
->each(function ($role) {
|
||||
$role->delete();
|
||||
});
|
||||
|
||||
$company->users()->detach();
|
||||
|
||||
$company->settings()->delete();
|
||||
|
||||
$company->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preference sheet a company starts out with.
|
||||
*
|
||||
* Two of these are historical rather than sensible and are kept on
|
||||
* purpose: the time zone defaults to `Asia/Kolkata`, and outgoing mail is
|
||||
* addressed from the project's own no-reply address until an owner changes
|
||||
* it. `bulk_exchange_rate_configured` starts out done, which keeps fresh
|
||||
* companies out of the exchange-rate backfill.
|
||||
*/
|
||||
private function setupDefaultSettings(Company $company, int $currencyId): void
|
||||
{
|
||||
CompanySetting::setSettings([
|
||||
'invoice_mail_body' => self::INVOICE_MAIL_BODY,
|
||||
'estimate_mail_body' => self::ESTIMATE_MAIL_BODY,
|
||||
'payment_mail_body' => self::PAYMENT_MAIL_BODY,
|
||||
'invoice_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
|
||||
'invoice_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
|
||||
'invoice_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
|
||||
'estimate_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
|
||||
'estimate_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
|
||||
'estimate_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
|
||||
'payment_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
|
||||
'payment_from_customer_address_format' => self::PAYMENT_CUSTOMER_ADDRESS_FORMAT,
|
||||
'currency' => $currencyId,
|
||||
'time_zone' => 'Asia/Kolkata',
|
||||
'language' => 'en',
|
||||
'fiscal_year' => '1-12',
|
||||
'carbon_date_format' => 'Y/m/d',
|
||||
'moment_date_format' => 'YYYY/MM/DD',
|
||||
'carbon_time_format' => 'H:i',
|
||||
'moment_time_format' => 'HH:mm',
|
||||
'invoice_use_time' => 'NO',
|
||||
'notification_email' => 'noreply@invoiceshelf.com',
|
||||
'notify_invoice_viewed' => 'NO',
|
||||
'notify_estimate_viewed' => 'NO',
|
||||
'tax_per_item' => 'NO',
|
||||
'discount_per_item' => 'NO',
|
||||
'invoice_email_attachment' => 'NO',
|
||||
'estimate_email_attachment' => 'NO',
|
||||
'payment_email_attachment' => 'NO',
|
||||
'retrospective_edits' => 'allow',
|
||||
'invoice_number_format' => '{{SERIES:INV}}{{DELIMITER:-}}{{SEQUENCE:6}}',
|
||||
'credit_note_number_format' => '{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}',
|
||||
'estimate_number_format' => '{{SERIES:EST}}{{DELIMITER:-}}{{SEQUENCE:6}}',
|
||||
'payment_number_format' => '{{SERIES:PAY}}{{DELIMITER:-}}{{SEQUENCE:6}}',
|
||||
'estimate_set_expiry_date_automatically' => 'YES',
|
||||
'estimate_expiry_date_days' => 7,
|
||||
'invoice_set_due_date_automatically' => 'YES',
|
||||
'invoice_due_date_days' => 7,
|
||||
'bulk_exchange_rate_configured' => 'YES',
|
||||
'estimate_convert_action' => 'no_action',
|
||||
'automatically_expire_public_links' => 'YES',
|
||||
'link_expiry_days' => 7,
|
||||
], $company->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Application;
|
||||
|
||||
use App\Domains\Accounts\Contracts\MemberReferencesCleaner;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* Every write behind the member endpoints: filing a staff account, pointing it
|
||||
* at a set of companies, and erasing one outright.
|
||||
*
|
||||
* A submitted membership list is authoritative rather than additive — a company
|
||||
* left off the list is detached — and each entry names the single role the
|
||||
* account is to hold inside that company, displacing whatever it held there
|
||||
* before.
|
||||
*
|
||||
* Handing out a role means moving Bouncer's scope onto the company first. The
|
||||
* scope is left wherever the last company in the list put it; nothing here puts
|
||||
* it back. Kept as it stands.
|
||||
*/
|
||||
class MemberService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MemberReferencesCleaner $memberReferencesCleaner,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* File a new account and place it in the listed companies.
|
||||
*
|
||||
* Its language preference is written as the sentinel `default`, so the new
|
||||
* member reads the app in whatever language their company is set to rather
|
||||
* than in a frozen copy of the language whoever added them was using.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param iterable<int, array{id: int, role: string}> $companies
|
||||
*/
|
||||
public function create(array $attributes, iterable $companies): User
|
||||
{
|
||||
$member = User::create($attributes);
|
||||
|
||||
$member->setSettings(['language' => 'default']);
|
||||
|
||||
$memberships = collect($companies);
|
||||
|
||||
$member->companies()->sync($memberships->pluck('id'));
|
||||
|
||||
$this->grantRoles($member, $memberships);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite an account and re-point it at the listed companies.
|
||||
*
|
||||
* Memberships are replaced wholesale, so an edit that omits a company both
|
||||
* detaches the account from it and leaves the roles it held there behind —
|
||||
* the role sync below only visits companies still on the list.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param iterable<int, array{id: int, role: string}> $companies
|
||||
*/
|
||||
public function update(User $user, array $attributes, iterable $companies): User
|
||||
{
|
||||
$user->update($attributes);
|
||||
|
||||
$memberships = collect($companies);
|
||||
|
||||
$user->companies()->sync($memberships->pluck('id'));
|
||||
|
||||
$this->grantRoles($user, $memberships);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase the named accounts, one after another.
|
||||
*
|
||||
* An id naming nobody is skipped rather than reported. Everything the
|
||||
* account authored outlives it: invoices, estimates, contacts, recurring
|
||||
* invoices, expenses, payments and catalog entries are left standing with
|
||||
* no author against them, and only the preferences rows and the account
|
||||
* itself actually go.
|
||||
*
|
||||
* @param array<int, int|string> $ids
|
||||
*/
|
||||
public function delete(array $ids): bool
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
$member = User::find($id);
|
||||
|
||||
if ($member === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->memberReferencesCleaner->clear($member);
|
||||
|
||||
if ($member->settings()->exists()) {
|
||||
$member->settings()->delete();
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the account exactly the one role each company named, discarding any
|
||||
* role it already held in that company.
|
||||
*
|
||||
* @param Collection<int, array{id: int, role: string}> $memberships
|
||||
*/
|
||||
private function grantRoles(User $member, Collection $memberships): void
|
||||
{
|
||||
foreach ($memberships as $membership) {
|
||||
BouncerFacade::scope()->to($membership['id']);
|
||||
|
||||
BouncerFacade::sync($member)->roles([$membership['role']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Auth;
|
||||
|
||||
use App\Domains\Accounts\Application\InvitationService;
|
||||
use App\Domains\Accounts\Http\Requests\LoginRequest;
|
||||
use App\Domains\Accounts\Models\CompanyInvitation;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Bearer-token endpoints for staff accounts.
|
||||
*
|
||||
* These sit on the public API prefix and serve the admin SPA as well as any
|
||||
* external client: trade a password for a personal access token, hand that
|
||||
* token back in, or ask whether the current request still carries one.
|
||||
*
|
||||
* Nothing here is tenant-scoped. A staff address is unique installation-wide,
|
||||
* so the company is resolved after sign-in from the request header, never
|
||||
* chosen at the door the way the customer portal does it.
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* One answer covers both an address nobody holds and a mistyped password,
|
||||
* so the two cannot be told apart from the reply.
|
||||
*/
|
||||
private const REJECTED = 'The provided credentials are incorrect.';
|
||||
|
||||
/**
|
||||
* Trade an email/password pair for a personal access token.
|
||||
*
|
||||
* The token is named after the calling device, which is what later makes
|
||||
* per-device revocation meaningful.
|
||||
*/
|
||||
public function login(LoginRequest $request)
|
||||
{
|
||||
$staff = $this->staffHolding($request->username);
|
||||
|
||||
if ($staff === null || ! Hash::check($request->password, $staff->password)) {
|
||||
throw ValidationException::withMessages(['email' => [self::REJECTED]]);
|
||||
}
|
||||
|
||||
// Deliberately reached only once the pair has been proven, so nobody
|
||||
// can spend an invitation by guessing at somebody else's password.
|
||||
$this->redeemPendingInvitation($request, $staff);
|
||||
|
||||
$minted = $staff->createToken($request->device_name);
|
||||
|
||||
return response()->json([
|
||||
'type' => 'Bearer',
|
||||
'token' => $minted->plainTextToken,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the token that carried this request.
|
||||
*
|
||||
* Quirk kept as is: exactly one token is revoked, never the account's
|
||||
* whole set, so the caller's other devices stay signed in. And a caller
|
||||
* authenticated by session cookie rather than a bearer token holds a
|
||||
* transient token that has nothing to delete, so that request errors out
|
||||
* instead of closing the session.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$carrier = $request->user()->currentAccessToken();
|
||||
$carrier->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether the caller is signed in.
|
||||
*
|
||||
* The bare boolean body is deliberate: the route already sits behind the
|
||||
* API guard, so the SPA reads this purely as a liveness ping.
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
return Auth::check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the staff account holding the submitted address.
|
||||
*
|
||||
* The comparison runs against the lower-cased column so that capitalising
|
||||
* an address differently from how it was stored still gets the account
|
||||
* in, on every database engine the app supports.
|
||||
*/
|
||||
private function staffHolding($submitted): ?User
|
||||
{
|
||||
return User::query()
|
||||
->whereRaw('LOWER(email) = ?', [strtolower($submitted)])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation carried alongside the credentials, when one is
|
||||
* still live.
|
||||
*
|
||||
* A token that is unknown, already spent or past its expiry is passed
|
||||
* over in silence. Sign-in itself is never held up by it.
|
||||
*/
|
||||
private function redeemPendingInvitation(LoginRequest $request, User $staff): void
|
||||
{
|
||||
$offered = $request->input('invitation_token');
|
||||
|
||||
if (! $offered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invitation = CompanyInvitation::query()
|
||||
->where('token', $offered)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
if ($invitation !== null) {
|
||||
app(InvitationService::class)->accept($invitation, $staff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Auth;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails as IssuesResetLinks;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Mails a staff account a link for choosing a new password.
|
||||
*
|
||||
* The imported trait drives the whole flow: validate the address, ask a
|
||||
* broker for a link, branch on what the broker says. Only the two replies are
|
||||
* swapped out below, because the admin SPA expects JSON rather than a redirect
|
||||
* back to a Blade form. The broker is left alone, so links are minted by the
|
||||
* default `users` broker against the staff table.
|
||||
*
|
||||
* Request volume is capped at the route, not here: the endpoint is registered
|
||||
* behind a ten-per-two-minutes throttle.
|
||||
*/
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
use IssuesResetLinks;
|
||||
|
||||
/**
|
||||
* Every reason a link might not go out collapses into this one sentence.
|
||||
*/
|
||||
private const UNDELIVERABLE = 'Email could not be sent to this email address.';
|
||||
|
||||
/**
|
||||
* Confirm that a link reached the mailer.
|
||||
*
|
||||
* The broker's own status key rides along in `data`, which is what the
|
||||
* SPA logs when a send is investigated after the fact.
|
||||
*
|
||||
* @param string $response
|
||||
*/
|
||||
protected function sendResetLinkResponse(Request $request, $response)
|
||||
{
|
||||
return response()->json(['message' => 'Password reset email sent.', 'data' => $response]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that no link went out.
|
||||
*
|
||||
* Quirk kept as is: this refusal is a probing oracle. An address nobody
|
||||
* holds fails here while a known address succeeds, so the difference
|
||||
* between 403 and 200 tells a caller which staff addresses exist. The
|
||||
* throttle on the route is the only thing narrowing that.
|
||||
*
|
||||
* @param string $response
|
||||
*/
|
||||
protected function sendResetLinkFailedResponse(Request $request, $response)
|
||||
{
|
||||
return response()->json(['error' => self::UNDELIVERABLE], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Auth;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers as OpensSessions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Cookie-session sign-in for the admin shell.
|
||||
*
|
||||
* This is the second, older way into the admin app, distinct from the bearer
|
||||
* flow next door: it opens a `web` session instead of minting a token. The
|
||||
* imported trait supplies the whole of it (field validation, the lockout
|
||||
* counter, the guard attempt and the success reply), so the only things
|
||||
* declared here are where a browser lands afterwards and how signing out is
|
||||
* handled.
|
||||
*
|
||||
* Quirk kept as is: the two sign-in doors disagree about their input. This one
|
||||
* validates `email` (the trait's default field name), while the token endpoint
|
||||
* takes `username`, so the same payload will not satisfy both.
|
||||
*/
|
||||
class LoginController extends Controller
|
||||
{
|
||||
use OpensSessions;
|
||||
|
||||
/**
|
||||
* Where a browser is sent once a session has been opened.
|
||||
*
|
||||
* Read by the trait through `redirectPath()`; only reached when the
|
||||
* caller did not ask for JSON, which the SPA always does.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = AppServiceProvider::HOME;
|
||||
|
||||
/**
|
||||
* Fence the sign-in action off from anyone already holding a session.
|
||||
*
|
||||
* Signing out is the one action exempted, for the obvious reason that it
|
||||
* is only ever useful while signed in. Note the guest guard redirects an
|
||||
* already-authenticated caller to the dashboard rather than refusing.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest', ['except' => 'logout']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the session opened by this controller.
|
||||
*
|
||||
* The trait's own version is replaced because it answers with a redirect
|
||||
* or a 204; this one returns nothing at all, which the framework renders
|
||||
* as an empty 200. Flushing the session and then rotating the CSRF token
|
||||
* is what stops the emptied session from being reused.
|
||||
*/
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$session = $request->session();
|
||||
$session->invalidate();
|
||||
$session->regenerateToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Auth;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords as ConsumesResetTokens;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Spends a reset token and stores the password chosen with it.
|
||||
*
|
||||
* Token lookup, expiry checking and the success/failure branch all come from
|
||||
* the imported trait, as do the input rules: a token, an address, and a
|
||||
* password that must be confirmed and satisfy the framework's default
|
||||
* strength rules. The broker is left at its default, so tokens are checked
|
||||
* against the staff table.
|
||||
*
|
||||
* Three things are narrowed below: the replies become JSON and plain text,
|
||||
* the write hands the password over unhashed, and the account is deliberately
|
||||
* left signed out afterwards.
|
||||
*/
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
use ConsumesResetTokens;
|
||||
|
||||
/**
|
||||
* Covers every way a token can fail: absent, expired, forged, or minted
|
||||
* for a different address than the one submitted with it.
|
||||
*/
|
||||
private const REFUSED = 'Failed, Invalid Token.';
|
||||
|
||||
/**
|
||||
* Where the trait would send a browser after a successful reset.
|
||||
*
|
||||
* Inert in practice, since both replies below are written by hand, but
|
||||
* the trait reads the property, so it stays declared.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = AppServiceProvider::HOME;
|
||||
|
||||
/**
|
||||
* Store the password chosen behind the token.
|
||||
*
|
||||
* Two deliberate departures from the trait. The value is handed over raw,
|
||||
* because the model hashes it on assignment and running it through the
|
||||
* hasher here would store a hash of a hash. And no session is opened
|
||||
* afterwards. The trait would sign the account straight in; here it comes
|
||||
* back through the login form instead.
|
||||
*
|
||||
* @param mixed $user the account the token was minted for
|
||||
* @param string $password
|
||||
* @return void
|
||||
*/
|
||||
protected function resetPassword($user, $password)
|
||||
{
|
||||
// Assigning the attribute is what runs the model's hashing mutator.
|
||||
$user->setAttribute('password', $password);
|
||||
|
||||
$rotated = Str::random(60);
|
||||
$user->setRememberToken($rotated);
|
||||
|
||||
$user->save();
|
||||
|
||||
Event::dispatch(new PasswordReset($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the token was spent and the password replaced.
|
||||
*
|
||||
* @param string $response
|
||||
*/
|
||||
protected function sendResetResponse(Request $request, $response)
|
||||
{
|
||||
return response()->json(['message' => 'Password reset successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a token that did not check out.
|
||||
*
|
||||
* Quirk kept as is: unlike the JSON everything else on this prefix
|
||||
* answers with, the refusal is a bare plain-text body carrying a 403, so
|
||||
* a client parsing the reply has to special-case this one path.
|
||||
*
|
||||
* @param string $response
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, $response)
|
||||
{
|
||||
return response(self::REFUSED, Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Company;
|
||||
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Publishes the catalog of abilities a role can be granted.
|
||||
*
|
||||
* The catalog is configuration rather than data: the same fixed list for every
|
||||
* company and every caller, which is why nothing here is scoped or filtered.
|
||||
* The role editor draws its checkboxes from it, so the entries travel exactly
|
||||
* as declared -- same order, subjects and dependencies included.
|
||||
*/
|
||||
class AbilitiesController extends Controller
|
||||
{
|
||||
/**
|
||||
* The whole catalog, straight out of configuration.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
return response()->json([
|
||||
'abilities' => config('abilities.abilities'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Accounts\Contracts\CompanyAddressWriter;
|
||||
use App\Domains\Accounts\Contracts\CompanyLogoManager;
|
||||
use App\Domains\Accounts\Http\Requests\CompanyLogoRequest;
|
||||
use App\Domains\Accounts\Http\Requests\CompanyRequest;
|
||||
use App\Domains\Accounts\Http\Resources\CompanyResource;
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* The profile of the company the request header points at.
|
||||
*
|
||||
* Neither endpoint takes a routed model: both resolve the company from the
|
||||
* `company` header and then ask the same gate whether the caller is the
|
||||
* account recorded against it as owner. Ability grants buy nothing here, and
|
||||
* neither does the platform-administrator flag.
|
||||
*/
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CompanyAddressWriter $companyAddressWriter,
|
||||
private readonly CompanyLogoManager $companyLogoManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rewrite the company record and upsert its postal address.
|
||||
*
|
||||
* The address block is read from the raw payload rather than the validated
|
||||
* set: only its country carries a rule, so the validated set would shrink
|
||||
* the address down to that single column. Kept as it stands.
|
||||
*
|
||||
* A payload carrying no address block at all still reaches the writer — as
|
||||
* an empty array, which is what the cast of a missing key produces.
|
||||
*/
|
||||
public function updateCompany(CompanyRequest $request)
|
||||
{
|
||||
$company = $this->companyFromHeader($request);
|
||||
|
||||
$this->authorize('manage company', $company);
|
||||
|
||||
$company->update($request->getCompanyPayload());
|
||||
|
||||
$address = (array) $request->input('address');
|
||||
|
||||
$this->companyAddressWriter->upsert($company, $address);
|
||||
|
||||
return new CompanyResource($company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace or drop the company logo.
|
||||
*
|
||||
* Two independent switches, in this order: the removal flag wipes whatever
|
||||
* is on file, and a submitted image is then stored — so a payload carrying
|
||||
* both ends up with the new image. The image arrives as a JSON envelope
|
||||
* holding a file name and a data URI, already checked by the form request,
|
||||
* and an envelope that decodes to nothing is simply ignored.
|
||||
*/
|
||||
public function uploadCompanyLogo(CompanyLogoRequest $request)
|
||||
{
|
||||
$company = $this->companyFromHeader($request);
|
||||
|
||||
$this->authorize('manage company', $company);
|
||||
|
||||
if ($this->removalRequested($request)) {
|
||||
$this->companyLogoManager->clear($company);
|
||||
}
|
||||
|
||||
$envelope = json_decode((string) $request->input('company_logo'));
|
||||
|
||||
if ($envelope) {
|
||||
$this->companyLogoManager->replaceBase64($company, $envelope->data, $envelope->name);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The company named by the request header, or null when the header names
|
||||
* nothing on file — the gate is then asked about a company that is not
|
||||
* there, exactly as before.
|
||||
*/
|
||||
private function companyFromHeader(Request $request): ?Company
|
||||
{
|
||||
return Company::query()->find($request->header('company'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller asked for the current logo to be dropped.
|
||||
*
|
||||
* Present-and-not-null, then cast to a boolean: `"0"` and the empty string
|
||||
* read as no, but the string `"false"` reads as yes. Kept as it stands.
|
||||
*/
|
||||
private function removalRequested(Request $request): bool
|
||||
{
|
||||
$flag = $request->input('is_company_logo_removed');
|
||||
|
||||
return $flag !== null && (bool) $flag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Accounts\Http\Requests\GetSettingsRequest;
|
||||
use App\Domains\Accounts\Http\Requests\UpdateSettingsRequest;
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* The preference store of the company named by the request header, plus the
|
||||
* two questions that hang off it: whether the books have been opened, and who
|
||||
* the company now belongs to.
|
||||
*
|
||||
* Reading preferences is open to any member; everything else is owner-only,
|
||||
* and ownership here is positional — the account recorded against the company,
|
||||
* not a role or an ability.
|
||||
*/
|
||||
class CompanySettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* The named preferences of the active company.
|
||||
*
|
||||
* Options with no row on file are absent from the reply rather than null,
|
||||
* so the map that comes back can be shorter than the one asked for — and
|
||||
* empty when none of them exist, which serialises as an empty list.
|
||||
*/
|
||||
public function show(GetSettingsRequest $request): JsonResponse
|
||||
{
|
||||
$wanted = (array) $request->input('settings');
|
||||
|
||||
return response()->json(
|
||||
CompanySetting::getSettings($wanted, $request->header('company'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch of preferences, upserting option by option.
|
||||
*
|
||||
* One of them is guarded: the trading currency is frozen as soon as the
|
||||
* company has anything on its books, and an attempt to move it is refused
|
||||
* with a plain 200 carrying `success: false` — no status code, no error
|
||||
* bag. The comparison against the stored value is strict, so submitting
|
||||
* the current currency as a number when the store holds it as a string
|
||||
* counts as a change and trips the guard.
|
||||
*/
|
||||
public function update(UpdateSettingsRequest $request): JsonResponse
|
||||
{
|
||||
$company = Company::query()->find($request->header('company'));
|
||||
|
||||
$this->authorize('manage company', $company);
|
||||
|
||||
$submitted = $request->input('settings');
|
||||
|
||||
if ($this->movesCurrency($submitted, $company) && $company->hasTransactions()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Cannot update company currency after transactions are created.',
|
||||
]);
|
||||
}
|
||||
|
||||
CompanySetting::setSettings($submitted, $request->header('company'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the company has anything on its books yet — the flag the SPA
|
||||
* uses to grey out the currency selector before the write is attempted.
|
||||
*/
|
||||
public function checkTransactions(Request $request): JsonResponse
|
||||
{
|
||||
$company = Company::query()->find($request->header('company'));
|
||||
|
||||
$this->authorize('manage company', $company);
|
||||
|
||||
return response()->json([
|
||||
'has_transactions' => $company->hasTransactions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the active company to one of its members.
|
||||
*
|
||||
* The target has to be a member already; a stranger is turned away with a
|
||||
* 200 carrying `success: false`, in the same shape as the currency guard.
|
||||
* On success the owner column moves and the target's roles in this company
|
||||
* are replaced by `owner` alone. Nothing is taken away from the outgoing
|
||||
* owner beyond the column itself — their role assignments stay, and with
|
||||
* them everything those roles allow.
|
||||
*/
|
||||
public function transferOwnership(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$company = Company::query()->find($request->header('company'));
|
||||
|
||||
$this->authorize('transfer company ownership', $company);
|
||||
|
||||
if (! $user->hasCompany($company->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this company.',
|
||||
]);
|
||||
}
|
||||
|
||||
$company->update(['owner_id' => $user->id]);
|
||||
|
||||
BouncerFacade::scope()->to($company->id);
|
||||
BouncerFacade::sync($user)->roles(['owner']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the submitted batch carries a currency different from the one on
|
||||
* file. A batch without a currency key never trips the guard, even when
|
||||
* the company is trading.
|
||||
*/
|
||||
private function movesCurrency(mixed $submitted, Company $company): bool
|
||||
{
|
||||
if (! Arr::exists($submitted, 'currency')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return CompanySetting::getSetting('currency', $company->id) !== $submitted['currency'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Accounts\Application\MemberService;
|
||||
use App\Domains\Accounts\Http\Requests\DeleteMemberRequest;
|
||||
use App\Domains\Accounts\Http\Requests\MemberRequest;
|
||||
use App\Domains\Accounts\Http\Resources\UserResource;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Owner-side administration of the staff accounts inside the active company.
|
||||
*
|
||||
* The controller settles who may act and hands everything else on: the form
|
||||
* request shapes the payload and the service owns every write. Ownership is
|
||||
* positional and re-read on each call, so the same person administers members
|
||||
* under one `company` header and is refused under the next.
|
||||
*
|
||||
* Note that the single-account routes bind their model installation-wide — an
|
||||
* id belonging to another tenant resolves perfectly well and is turned away by
|
||||
* the policy rather than by a missing row, which is a 403 where a 404 might be
|
||||
* expected. Kept as it stands.
|
||||
*/
|
||||
class MembersController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MemberService $memberService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A page of colleagues, newest first.
|
||||
*
|
||||
* The requester is struck from the rows but still counted in the envelope,
|
||||
* so a company of three people shows two members underneath the number
|
||||
* three. Kept as it stands.
|
||||
*
|
||||
* Page size defaults to ten, and unlike the other listings there is no
|
||||
* sentinel for "everything" — a limit of `all` reaches the paginator as-is.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$perPage = $request->has('limit') ? $request->limit : 10;
|
||||
$viewer = $request->user();
|
||||
|
||||
$members = User::whereCompany()
|
||||
->applyFilters($request->all())
|
||||
->where('id', '<>', $viewer->id)
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
|
||||
return UserResource::collection($members)->additional([
|
||||
'meta' => ['user_total_count' => User::whereCompany()->count()],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a staff account and place it in the companies the form listed.
|
||||
*
|
||||
* Note the gate: only the active company is weighed, so an owner may file
|
||||
* an account into any company whose id they care to submit.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function store(MemberRequest $request)
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$member = $this->memberService->create(
|
||||
$request->getUserPayload(),
|
||||
$request->validated('companies'),
|
||||
);
|
||||
|
||||
return new UserResource($member);
|
||||
}
|
||||
|
||||
/**
|
||||
* One colleague, provided they share the active company with the caller.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(User $member)
|
||||
{
|
||||
$this->authorize('view', $member);
|
||||
|
||||
return new UserResource($member);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a colleague's account and re-point their memberships.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(MemberRequest $request, User $member)
|
||||
{
|
||||
$this->authorize('update', $member);
|
||||
|
||||
$this->memberService->update(
|
||||
$member,
|
||||
$request->getUserPayload(),
|
||||
$request->validated('companies'),
|
||||
);
|
||||
|
||||
return new UserResource($member);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase a batch of accounts.
|
||||
*
|
||||
* The submitted ids were checked against the users table installation-wide,
|
||||
* then narrowed to members of the active company here, so an id belonging
|
||||
* to somebody else's tenant clears validation and is quietly dropped from
|
||||
* the batch — the call still answers success. Kept as it stands.
|
||||
*
|
||||
* The gate is the bulk ability rather than the per-account policy, so it
|
||||
* asks nothing about the individual targets; the narrowing above is what
|
||||
* keeps one company out of another's accounts.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function delete(DeleteMemberRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple users', User::class);
|
||||
|
||||
$submitted = $request->users;
|
||||
|
||||
if ($submitted) {
|
||||
$targets = User::whereCompany()
|
||||
->whereIn('id', $submitted)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if ($targets) {
|
||||
$this->memberService->delete($targets);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Accounts\Http\Requests\RoleRequest;
|
||||
use App\Domains\Accounts\Http\Resources\RoleResource;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
use Silber\Bouncer\Database\Role;
|
||||
|
||||
/**
|
||||
* The roles a company defines for its own members.
|
||||
*
|
||||
* Every action is owner-only by way of the role policy. None of the queries or
|
||||
* writes below name a company: roles sit inside the Bouncer scope that the
|
||||
* bouncer middleware derived from the `company` header, and reads and writes
|
||||
* alike inherit it. That is also why the listing's `company_id` filter can only
|
||||
* ever narrow the active company's roles -- pointing it at another company
|
||||
* intersects to nothing rather than crossing the tenant boundary.
|
||||
*/
|
||||
class RolesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Machine-readable key reported when a role is still held by someone.
|
||||
*/
|
||||
private const IN_USE_ERROR = 'role_attached_to_users';
|
||||
|
||||
/**
|
||||
* Human-readable counterpart of the in-use key.
|
||||
*/
|
||||
private const IN_USE_MESSAGE = 'Roles Attached to user';
|
||||
|
||||
/**
|
||||
* Every role visible in the active scope.
|
||||
*
|
||||
* Kept as is: `orderByField` is handed to the database untouched, unlike
|
||||
* the allow-listed sorts elsewhere in the app.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Role::class);
|
||||
|
||||
$query = Role::query();
|
||||
|
||||
if ($request->has('orderByField')) {
|
||||
$query->orderBy($request['orderByField'], $request['orderBy']);
|
||||
}
|
||||
|
||||
if ($request->company_id) {
|
||||
$query->where('scope', $request->company_id);
|
||||
}
|
||||
|
||||
return RoleResource::collection($query->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a role and settle its abilities in one go.
|
||||
*/
|
||||
public function store(RoleRequest $request)
|
||||
{
|
||||
$this->authorize('create', Role::class);
|
||||
|
||||
$role = Role::query()->create($request->getRolePayload());
|
||||
|
||||
$this->writeCatalogGrants($role, $request->abilities);
|
||||
|
||||
return RoleResource::make($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* One role with its current grants.
|
||||
*/
|
||||
public function show(Role $role)
|
||||
{
|
||||
$this->authorize('view', $role);
|
||||
|
||||
return RoleResource::make($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a role and rewrite its grants.
|
||||
*/
|
||||
public function update(RoleRequest $request, Role $role)
|
||||
{
|
||||
$this->authorize('update', $role);
|
||||
|
||||
$role->fill($request->getRolePayload())->save();
|
||||
|
||||
$this->writeCatalogGrants($role, $request->abilities);
|
||||
|
||||
return RoleResource::make($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a role, unless somebody in this company still holds it.
|
||||
*/
|
||||
public function destroy(Role $role)
|
||||
{
|
||||
$this->authorize('delete', $role);
|
||||
|
||||
if (User::whereIs($role->name)->exists()) {
|
||||
return respondJson(self::IN_USE_ERROR, self::IN_USE_MESSAGE);
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the whole ability catalog and make the role match the submission.
|
||||
*
|
||||
* The submission is read as a set of names: a catalog entry named in it is
|
||||
* granted, every other entry is revoked, so a role never keeps a grant the
|
||||
* caller left out. Names that match no catalog entry are simply never
|
||||
* looked at.
|
||||
*/
|
||||
private function writeCatalogGrants($role, $submitted): void
|
||||
{
|
||||
$wanted = array_column($submitted, 'ability');
|
||||
|
||||
foreach (config('abilities.abilities') as $entry) {
|
||||
if (in_array($entry['ability'], $wanted)) {
|
||||
BouncerFacade::allow($role)->to($entry['ability'], $entry['model']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
BouncerFacade::disallow($role)->to($entry['ability'], $entry['model']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Middleware;
|
||||
|
||||
use Illuminate\Auth\Middleware\Authenticate as FrameworkAuthenticate;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* The `auth` alias for staff routes.
|
||||
*
|
||||
* Everything about deciding who is signed in is inherited; the one thing
|
||||
* settled here is where a browser that is not signed in gets sent.
|
||||
*/
|
||||
class Authenticate extends FrameworkAuthenticate
|
||||
{
|
||||
/**
|
||||
* Name the page an unauthenticated caller should be bounced to.
|
||||
*
|
||||
* API clients get nothing back, which is what makes the framework raise a
|
||||
* 401 for them instead of a redirect. The parent already suppresses the
|
||||
* redirect for JSON callers, so the branch below is belt and braces.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return string|null
|
||||
*/
|
||||
protected function redirectTo($request)
|
||||
{
|
||||
return $request->expectsJson() ? null : route('login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Decides which company the rest of the request runs against.
|
||||
*
|
||||
* The caller names a workspace in the `company` header. A header that names
|
||||
* nothing the caller belongs to is not an error here: it is quietly overwritten
|
||||
* with the first company on the caller's membership list, so everything
|
||||
* downstream may read the header without vetting it again. Two situations skip
|
||||
* the rewrite -- an install whose membership table has not been created yet, and
|
||||
* the platform administrator arriving with no header at all (admin mode).
|
||||
*/
|
||||
class CompanyMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Schema::hasTable('user_company')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
|
||||
if ($actor === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$fallback = $actor->companies()->first();
|
||||
|
||||
if ($fallback === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$requested = $request->header('company');
|
||||
|
||||
if ($actor->isSuperAdmin() && ! $requested) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $requested || ! $actor->hasCompany($requested)) {
|
||||
$request->headers->set('company', $fallback->id);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Middleware;
|
||||
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* The `guest` alias: keeps signed-in staff off the sign-in pages.
|
||||
*
|
||||
* Rather than refusing, it forwards the caller to the dashboard, so hitting
|
||||
* the login page a second time in the same browser simply lands where they
|
||||
* were already headed.
|
||||
*/
|
||||
class RedirectIfAuthenticated
|
||||
{
|
||||
/**
|
||||
* Pass guests through; send anyone already holding a session home.
|
||||
*
|
||||
* With no guard named on the route the default one is consulted, which
|
||||
* means a customer-portal session does not count as being signed in here.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string|null $guard guard alias named on the route, if any
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $guard = null)
|
||||
{
|
||||
return Auth::guard($guard)->check()
|
||||
? redirect(RouteServiceProvider::HOME)
|
||||
: $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The mirror of the guest alias: keeps signed-out visitors off the admin shell.
|
||||
*
|
||||
* It guards the page that boots the SPA, so a visitor without a session is
|
||||
* shown the sign-in page instead of an app that would immediately fail every
|
||||
* request it makes.
|
||||
*
|
||||
* Quirk kept as is: the name says "unauthorized", but nothing about
|
||||
* permissions is examined. The only question asked is whether anyone is
|
||||
* signed in at all.
|
||||
*/
|
||||
class RedirectIfUnauthorized
|
||||
{
|
||||
/**
|
||||
* Hard-coded rather than resolved from the named route, so it stays put
|
||||
* even if the route's name changes.
|
||||
*/
|
||||
private const SIGN_IN_PATH = '/login';
|
||||
|
||||
/**
|
||||
* Let a signed-in caller through, and send everyone else to sign in.
|
||||
*
|
||||
* As with the guest alias, an unnamed guard means the default one, so
|
||||
* this asks about staff sessions only.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, $guard = null): Response
|
||||
{
|
||||
if (! Auth::guard($guard)->check()) {
|
||||
return redirect(self::SIGN_IN_PATH);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Silber\Bouncer\Bouncer;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Points Bouncer at the company the request is acting in.
|
||||
*
|
||||
* Role and ability rows carry the id of the company they were defined for, and
|
||||
* for the rest of this request Bouncer reads only the rows carrying the id set
|
||||
* here (plus the unscoped ones -- that tolerance lives in the scope class
|
||||
* registered by the app service provider, not here). The header is taken at
|
||||
* face value because the company middleware has already replaced anything the
|
||||
* caller has no claim to. A request with no header falls back to the caller's
|
||||
* first company; a caller who belongs to nothing is left unscoped.
|
||||
*/
|
||||
class ScopeBouncer
|
||||
{
|
||||
public function __construct(protected Bouncer $bouncer) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$scope = $request->header('company');
|
||||
|
||||
if (! $scope) {
|
||||
$fallback = $request->user()->companies()->first();
|
||||
|
||||
if ($fallback === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$scope = $fallback->id;
|
||||
}
|
||||
|
||||
$this->bouncer->scope()->to($scope);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use App\Rules\Base64Mime;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Validates a profile picture on its way in.
|
||||
*
|
||||
* The endpoint takes a picture by either of two routes and neither is demanded:
|
||||
* a multipart upload under `admin_avatar`, or a base64 JSON blob under
|
||||
* `avatar`. The same call also carries the removal flag, which needs no
|
||||
* picture at all — hence both fields being optional.
|
||||
*/
|
||||
class AvatarRequest extends FormRequest
|
||||
{
|
||||
/** Picture formats accepted, whichever route the picture arrives by. */
|
||||
private const ACCEPTED_FORMATS = ['gif', 'jpg', 'png'];
|
||||
|
||||
/** Ceiling on an uploaded file, in kilobytes. */
|
||||
private const MAX_KILOBYTES = 20000;
|
||||
|
||||
/**
|
||||
* The caller is editing their own profile, so there is nothing to weigh up.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'admin_avatar' => [
|
||||
'nullable',
|
||||
'file',
|
||||
'mimes:'.implode(',', self::ACCEPTED_FORMATS),
|
||||
'max:'.self::MAX_KILOBYTES,
|
||||
],
|
||||
'avatar' => [
|
||||
'nullable',
|
||||
new Base64Mime(self::ACCEPTED_FORMATS),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* The form behind opening a new company.
|
||||
*
|
||||
* Two things are actually demanded — a name nobody in the installation has
|
||||
* taken, and a currency — plus a country whenever an address block is in play.
|
||||
* Everything else about the address is waved through unchecked.
|
||||
*/
|
||||
class CompaniesRequest extends FormRequest
|
||||
{
|
||||
/** The address block, every field of it optional bar the country. */
|
||||
private const OPTIONAL_ADDRESS_FIELDS = [
|
||||
'name',
|
||||
'address_street_1',
|
||||
'address_street_2',
|
||||
'city',
|
||||
'state',
|
||||
];
|
||||
|
||||
private const TRAILING_ADDRESS_FIELDS = [
|
||||
'zip',
|
||||
'phone',
|
||||
'fax',
|
||||
];
|
||||
|
||||
/** Columns lifted off the validated payload onto the new company row. */
|
||||
private const COMPANY_FIELDS = [
|
||||
'name',
|
||||
'vat_id',
|
||||
'tax_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the caller may open a company at all is a gate question, asked
|
||||
* in the controller; nothing is decided at this layer.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembled in declaration order so the error bag keeps the order it has
|
||||
* always come back in: name, currency, then the address block with the
|
||||
* country sitting between the optional fields either side of it.
|
||||
*
|
||||
* The country rule is unconditional — a payload with no address block at
|
||||
* all is rejected for the missing country.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => [
|
||||
'required',
|
||||
Rule::unique('companies'),
|
||||
'string',
|
||||
],
|
||||
'currency' => [
|
||||
'required',
|
||||
],
|
||||
];
|
||||
|
||||
foreach (self::OPTIONAL_ADDRESS_FIELDS as $field) {
|
||||
$rules['address.'.$field] = ['nullable'];
|
||||
}
|
||||
|
||||
$rules['address.country_id'] = ['required'];
|
||||
|
||||
foreach (self::TRAILING_ADDRESS_FIELDS as $field) {
|
||||
$rules['address.'.$field] = ['nullable'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row to insert: the allow-listed columns, with the caller stamped on
|
||||
* as owner and a slug derived from the name.
|
||||
*
|
||||
* The two tax identifiers carry no rule of their own, so they are never
|
||||
* part of the validated payload and can never be written through here.
|
||||
* Listed all the same, as found.
|
||||
*/
|
||||
public function getCompanyPayload()
|
||||
{
|
||||
return array_merge(
|
||||
Arr::only($this->validated(), self::COMPANY_FIELDS),
|
||||
[
|
||||
'owner_id' => $this->user()->id,
|
||||
'slug' => Str::slug($this->name),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use App\Rules\Base64Mime;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* The envelope carrying a new company logo.
|
||||
*
|
||||
* One optional field: a JSON document holding a file name and a data URI. When
|
||||
* it is there it has to name a raster image the branding collection accepts,
|
||||
* and the bytes behind the data URI have to agree with that name.
|
||||
*
|
||||
* The removal flag travels in the same payload but is deliberately unchecked —
|
||||
* the controller reads it straight off the request.
|
||||
*/
|
||||
class CompanyLogoRequest extends FormRequest
|
||||
{
|
||||
/** Image types the logo may be. */
|
||||
private const ALLOWED_TYPES = ['gif', 'jpg', 'png'];
|
||||
|
||||
/**
|
||||
* Ownership of the company is settled by the gate in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_logo' => [
|
||||
'nullable',
|
||||
new Base64Mime(self::ALLOWED_TYPES),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
/**
|
||||
* The form behind editing the company the request header points at.
|
||||
*
|
||||
* The name has to stay unique across the whole installation, the company being
|
||||
* edited excepted — and it is excepted by the header value, not by a routed
|
||||
* model, because this endpoint has no route parameter to work from.
|
||||
*/
|
||||
class CompanyRequest extends FormRequest
|
||||
{
|
||||
/** Columns lifted off the validated payload onto the company row. */
|
||||
private const COMPANY_FIELDS = [
|
||||
'name',
|
||||
'vat_id',
|
||||
'tax_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Ownership is settled by the gate in the controller, so every caller that
|
||||
* got this far is let through.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two tax identifiers are declared but unchecked; the country of the
|
||||
* address block is required whether or not an address was submitted.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
$this->unclaimedName(),
|
||||
],
|
||||
'vat_id' => [
|
||||
'nullable',
|
||||
],
|
||||
'tax_id' => [
|
||||
'nullable',
|
||||
],
|
||||
'address.country_id' => [
|
||||
'required',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The name has to be free across the whole installation, with one company
|
||||
* excused: the one named by the request header.
|
||||
*
|
||||
* The exception is keyed on the header value rather than on a loaded model
|
||||
* — nothing here checks that the header names a company that exists, so a
|
||||
* header pointing at nothing simply excuses no row at all.
|
||||
*/
|
||||
private function unclaimedName(): Unique
|
||||
{
|
||||
return Rule::unique('companies')->ignore($this->header('company'), 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns to write, with a slug rebuilt from the submitted name.
|
||||
*
|
||||
* Both tax identifiers are declared as nullable rules here, so unlike the
|
||||
* creation form they do survive into the validated payload and are written
|
||||
* through. The slug is derived from the raw input rather than the
|
||||
* validated set, which is the same string either way.
|
||||
*/
|
||||
public function getCompanyPayload()
|
||||
{
|
||||
return array_merge(
|
||||
Arr::only($this->validated(), self::COMPANY_FIELDS),
|
||||
['slug' => Str::slug($this->name)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Guards the bulk removal of staff accounts.
|
||||
*
|
||||
* Every submitted id has to name an account, but the check reaches across the
|
||||
* whole installation rather than the active company: an id belonging to another
|
||||
* tenant clears validation here and is then dropped by the controller's
|
||||
* company-scoped lookup, so the batch skips it without complaint. Kept as it
|
||||
* stands.
|
||||
*/
|
||||
class DeleteMemberRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Nothing is settled at this layer — the controller asks Bouncer for the
|
||||
* `delete multiple users` ability before anything is touched.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-empty `users` list, each entry naming a row that exists.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'users' => ['required'],
|
||||
'users.*' => ['required', Rule::exists('users', 'id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* The query behind reading preferences: a list of option names to look up.
|
||||
*
|
||||
* The list itself has to be there, and each entry has to be a non-empty
|
||||
* string. Nothing checks that an option actually exists — unknown names are
|
||||
* simply absent from the reply.
|
||||
*/
|
||||
class GetSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Reading preferences is open to every member of the company, so nothing
|
||||
* is refused here.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => [
|
||||
'required',
|
||||
],
|
||||
'settings.*' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Shape check for the token sign-in payload.
|
||||
*
|
||||
* Three fields have to be present: the address being claimed, the password
|
||||
* offered for it, and a label for the device the token is being minted for.
|
||||
* Whether the pair is actually correct is settled by the controller, not here,
|
||||
* so nothing in this class can be used to probe for existing accounts.
|
||||
*
|
||||
* Quirk kept as is: presence is all that is asked for. The address is not
|
||||
* required to look like an email, nor even to be a string, so a payload that
|
||||
* sends the field as an array passes validation and only comes apart further
|
||||
* down when the value is lower-cased for lookup.
|
||||
*/
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The sign-in door is open to anyone standing in front of it.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* All three fields are mandatory and otherwise unconstrained.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required'],
|
||||
'password' => ['required'],
|
||||
'device_name' => ['required'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use App\Rules\IdnEmail;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Validates the staff-account form, which serves both filing a new member and
|
||||
* editing one that already exists.
|
||||
*
|
||||
* The two verbs part company in exactly two places: on an edit the uniqueness
|
||||
* check steps over the row being edited, and the password turns optional so a
|
||||
* form saved with the field left alone keeps the hash already on file.
|
||||
*
|
||||
* The address is unique across the whole installation rather than within the
|
||||
* company, so a collision tells one tenant that an address is already spoken
|
||||
* for somewhere else entirely. Kept as it stands.
|
||||
*/
|
||||
class MemberRequest extends FormRequest
|
||||
{
|
||||
/** The columns copied out of the payload onto the account row. */
|
||||
private const ACCOUNT_FIELDS = [
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* Nothing is decided at this layer; the member policy runs in the
|
||||
* controller, so every caller that got this far is let through.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The membership list is only checked for shape — each entry has to name a
|
||||
* company and a role, but neither is looked up here.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$editing = $this->getMethod() == 'PUT';
|
||||
|
||||
$address = Rule::unique('users');
|
||||
|
||||
if ($editing) {
|
||||
$address->ignore($this->member);
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => ['required'],
|
||||
'email' => ['required', new IdnEmail, $address],
|
||||
'phone' => ['nullable'],
|
||||
'password' => $editing ? ['nullable', 'min:8'] : ['required', 'min:8'],
|
||||
'companies' => ['required'],
|
||||
'companies.*.id' => ['required'],
|
||||
'companies.*.role' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The account row on its own, stamped with whoever is filing it.
|
||||
*
|
||||
* On an edit the stamp is written again, so the column records the last
|
||||
* person to save the form rather than the one who opened the account.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getUserPayload()
|
||||
{
|
||||
return collect($this->validated())
|
||||
->only(self::ACCOUNT_FIELDS)
|
||||
->merge(['creator_id' => $this->user()->id])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use App\Rules\IdnEmail;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Validates the signed-in account editing its own profile.
|
||||
*
|
||||
* A display name is the only thing genuinely demanded. The password field is
|
||||
* optional, so a form saved with it left alone keeps the hash already on file,
|
||||
* and the address has to stay unique installation-wide with the caller's own
|
||||
* row stepped over — otherwise re-saving an unchanged form would collide with
|
||||
* itself.
|
||||
*/
|
||||
class ProfileRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Everyone signed in may edit their own profile; there is no target to
|
||||
* weigh up, so the gate is open.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required'],
|
||||
'password' => ['nullable', 'min:8'],
|
||||
'email' => [
|
||||
'required',
|
||||
new IdnEmail,
|
||||
Rule::unique('users')->ignore(Auth::id(), 'id'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Payload rules for defining or renaming a company role.
|
||||
*
|
||||
* Role names are unique per company rather than per install, so the uniqueness
|
||||
* check is narrowed by hand to the scope named in the `company` header. A
|
||||
* rename excuses the role from its own name, but only when the verb is PUT --
|
||||
* kept as is, an otherwise identical PATCH collides with the stored name.
|
||||
*/
|
||||
class RoleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Access is settled by the role policy in the controller, not here.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a role submission has to satisfy before it is written.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$name = Rule::unique('roles')->where('scope', $this->header('company'));
|
||||
|
||||
if ($this->getMethod() === 'PUT') {
|
||||
$name->ignore($this->route('role')->id, 'id');
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', $name],
|
||||
'abilities' => ['required'],
|
||||
'abilities.*' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The submitted attributes, minus the abilities, stamped with the scope.
|
||||
*
|
||||
* Everything the caller sent survives the trip; the role model's own
|
||||
* fillable list decides what is actually written.
|
||||
*/
|
||||
public function getRolePayload()
|
||||
{
|
||||
$attributes = $this->except('abilities');
|
||||
|
||||
$attributes['scope'] = $this->header('company');
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* The payload behind writing preferences: one map of option names to values.
|
||||
*
|
||||
* Only its presence is checked. Neither the option names nor the values are
|
||||
* constrained in any way, so anything the caller sends is upserted as-is —
|
||||
* bar the currency, which the controller guards once the books are open.
|
||||
*/
|
||||
class UpdateSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Owner-only, but the gate that says so runs in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => [
|
||||
'required',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Resources;
|
||||
|
||||
use App\Domains\Contacts\Http\Resources\AddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* A company as the admin API publishes it.
|
||||
*
|
||||
* Identity, branding and the public handle, plus the postal address when one is
|
||||
* on file and the owning account when the caller has already loaded it. Two
|
||||
* further fields describe authorization inside this company: the roles defined
|
||||
* in its scope, and the title of the role the signed-in account holds there.
|
||||
*/
|
||||
class CompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$company = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $company->id,
|
||||
'name' => $company->name,
|
||||
'vat_id' => $company->vat_id,
|
||||
'tax_id' => $company->tax_id,
|
||||
'logo' => $company->logo,
|
||||
'logo_path' => $company->logo_path,
|
||||
'unique_hash' => $company->unique_hash,
|
||||
'owner_id' => $company->owner_id,
|
||||
'slug' => $company->slug,
|
||||
'created_at' => $company->created_at,
|
||||
'updated_at' => $company->updated_at,
|
||||
'address' => $this->when(
|
||||
$company->address()->exists(),
|
||||
fn () => new AddressResource($company->address)
|
||||
),
|
||||
'owner' => $this->when(
|
||||
$company->relationLoaded('owner'),
|
||||
fn () => new UserResource($company->owner)
|
||||
),
|
||||
'roles' => RoleResource::collection($company->roles),
|
||||
'user_role' => $this->assignedRoleTitle(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Title of the role the signed-in account holds inside this company.
|
||||
*
|
||||
* Read off the assignment table by company id, so it stays right for a
|
||||
* company other than the active one. Null when nobody is signed in, and
|
||||
* null when the account has no assignment here.
|
||||
*/
|
||||
private function assignedRoleTitle(): ?string
|
||||
{
|
||||
$viewer = Auth::user();
|
||||
|
||||
if ($viewer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::query()
|
||||
->from('assigned_roles')
|
||||
->join('roles', 'assigned_roles.role_id', '=', 'roles.id')
|
||||
->where([
|
||||
['assigned_roles.entity_type', '=', $viewer->getMorphClass()],
|
||||
['assigned_roles.entity_id', '=', $viewer->id],
|
||||
['assigned_roles.scope', '=', $this->id],
|
||||
])
|
||||
->value('roles.title');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Contacts\Http\Resources\CustomerPortal\AddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* The company behind the portal, as the customer portal publishes it.
|
||||
*
|
||||
* Only what the portal chrome renders: identity, the public handle, both forms
|
||||
* of the logo, the owning account's id, and the postal address when one is on
|
||||
* file. Nothing about roles or settings is exposed here.
|
||||
*/
|
||||
class CompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$company = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $company->id,
|
||||
'name' => $company->name,
|
||||
'slug' => $company->slug,
|
||||
'logo' => $company->logo,
|
||||
'logo_path' => $company->logo_path,
|
||||
'unique_hash' => $company->unique_hash,
|
||||
'owner_id' => $company->owner_id,
|
||||
'address' => $this->when(
|
||||
$company->address()->exists(),
|
||||
fn () => new AddressResource($company->address)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Money\Http\Resources\CustomerPortal\CurrencyResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* A staff account as the customer portal publishes it.
|
||||
*
|
||||
* The same profile fields as the admin view, minus the platform-administrator
|
||||
* flag: the portal only ever needs to know whether the account owns the company
|
||||
* it is looking at. Roles, the company-formatted creation date, and the currency
|
||||
* and companies when those exist, are carried along as well.
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$user = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'role' => $user->role,
|
||||
'contact_name' => $user->contact_name,
|
||||
'company_name' => $user->company_name,
|
||||
'website' => $user->website,
|
||||
'enable_portal' => $user->enable_portal,
|
||||
'currency_id' => $user->currency_id,
|
||||
'facebook_id' => $user->facebook_id,
|
||||
'google_id' => $user->google_id,
|
||||
'github_id' => $user->github_id,
|
||||
'created_at' => $user->created_at,
|
||||
'updated_at' => $user->updated_at,
|
||||
'avatar' => $user->avatar,
|
||||
'is_owner' => $user->isOwner(),
|
||||
'roles' => $user->roles,
|
||||
'formatted_created_at' => $user->formattedCreatedAt,
|
||||
'currency' => $this->when(
|
||||
$user->currency()->exists(),
|
||||
fn () => new CurrencyResource($user->currency)
|
||||
),
|
||||
'companies' => $this->when(
|
||||
$user->companies()->exists(),
|
||||
fn () => CompanyResource::collection($user->companies)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Resources;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* A single company role, shaped for the role editor.
|
||||
*
|
||||
* The abilities travel as the live grant set read back through Bouncer rather
|
||||
* than as whatever was last submitted, so the payload always reports what the
|
||||
* store actually holds.
|
||||
*/
|
||||
class RoleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$role = $this->resource;
|
||||
$createdAt = $this->getFormattedAt();
|
||||
|
||||
return [
|
||||
'id' => $role->id,
|
||||
'name' => $role->name,
|
||||
'title' => $role->title,
|
||||
'level' => $role->level,
|
||||
'formatted_created_at' => $createdAt,
|
||||
'abilities' => $role->getAbilities(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The creation date in the date format of the company owning the role.
|
||||
*
|
||||
* The format follows the role's own scope, not the company the reader is
|
||||
* looking in from.
|
||||
*/
|
||||
public function getFormattedAt()
|
||||
{
|
||||
$format = CompanySetting::getSetting('carbon_date_format', $this->scope);
|
||||
|
||||
return Carbon::parse($this->created_at)->translatedFormat($format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Http\Resources;
|
||||
|
||||
use App\Domains\Money\Http\Resources\CurrencyResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* A staff account as the admin API publishes it.
|
||||
*
|
||||
* Besides the stored columns it answers the two authorisation questions the SPA
|
||||
* asks about the caller (whether they own the active company, and whether they
|
||||
* are the platform administrator) and carries the roles held in the current
|
||||
* scope, the creation date already formatted for the active company, and the
|
||||
* currency and companies whenever those exist. The avatar is whatever the model
|
||||
* exposes: a media URL, or the literal number zero when none is on file.
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$user = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'role' => $user->role,
|
||||
'contact_name' => $user->contact_name,
|
||||
'company_name' => $user->company_name,
|
||||
'website' => $user->website,
|
||||
'enable_portal' => $user->enable_portal,
|
||||
'currency_id' => $user->currency_id,
|
||||
'facebook_id' => $user->facebook_id,
|
||||
'google_id' => $user->google_id,
|
||||
'github_id' => $user->github_id,
|
||||
'created_at' => $user->created_at,
|
||||
'updated_at' => $user->updated_at,
|
||||
'avatar' => $user->avatar,
|
||||
'is_owner' => $user->isOwner(),
|
||||
'is_super_admin' => $user->isSuperAdmin(),
|
||||
'roles' => $user->roles,
|
||||
'formatted_created_at' => $user->formattedCreatedAt,
|
||||
'currency' => $this->when(
|
||||
$user->currency()->exists(),
|
||||
fn () => new CurrencyResource($user->currency)
|
||||
),
|
||||
'companies' => $this->when(
|
||||
$user->companies()->exists(),
|
||||
fn () => CompanyResource::collection($user->companies)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Catalog\Models\Unit;
|
||||
use App\Domains\Contacts\Models\Address;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Metadata\Models\CustomField;
|
||||
use App\Domains\Metadata\Models\CustomFieldValue;
|
||||
use App\Domains\Money\Models\ExchangeRateLog;
|
||||
use App\Domains\Money\Models\ExchangeRateProvider;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Purchases\Models\ExpenseCategory;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Receivables\Models\PaymentMethod;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Domains\Taxation\Models\TaxType;
|
||||
use App\Platform\Storage\Models\FileDisk;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Silber\Bouncer\Database\Role;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* A tenant.
|
||||
*
|
||||
* Nearly every record in the application hangs off a company, and the roles
|
||||
* that grant access to those records are scoped to the company's id.
|
||||
*/
|
||||
class Company extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory;
|
||||
use InteractsWithMedia;
|
||||
|
||||
protected $table = 'companies';
|
||||
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
protected $appends = ['logo', 'logo_path'];
|
||||
|
||||
/**
|
||||
* A company keeps a single branding image on the public disk.
|
||||
*/
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('logo')->useDisk('public')->singleFile();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The account holding positional ownership of this company.
|
||||
*/
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account with a membership in this company.
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_company', 'company_id', 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The postal address printed on this company's documents.
|
||||
*/
|
||||
public function address(): HasOne
|
||||
{
|
||||
return $this->hasOne(Address::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-company preference rows, addressed by their `option` column.
|
||||
*/
|
||||
public function settings(): HasMany
|
||||
{
|
||||
return $this->hasMany(CompanySetting::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts filed under this company.
|
||||
*/
|
||||
public function customers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entries filed under this company.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(Item::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Units of measure available to this company's catalog.
|
||||
*/
|
||||
public function units(): HasMany
|
||||
{
|
||||
return $this->hasMany(Unit::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax rates this company can apply.
|
||||
*/
|
||||
public function taxTypes(): HasMany
|
||||
{
|
||||
return $this->hasMany(TaxType::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices issued by this company.
|
||||
*/
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates issued by this company.
|
||||
*/
|
||||
public function estimates(): HasMany
|
||||
{
|
||||
return $this->hasMany(Estimate::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring invoice schedules owned by this company.
|
||||
*/
|
||||
public function recurringInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(RecurringInvoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payments received by this company.
|
||||
*/
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ways this company accepts being paid.
|
||||
*/
|
||||
public function paymentMethods(): HasMany
|
||||
{
|
||||
return $this->hasMany(PaymentMethod::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expenses booked against this company.
|
||||
*/
|
||||
public function expenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Expense::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets this company sorts its expenses into.
|
||||
*/
|
||||
public function expenseCategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ExpenseCategory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field definitions declared by this company.
|
||||
*/
|
||||
public function customFields(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomField::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers recorded for this company's custom fields.
|
||||
*/
|
||||
public function customFieldValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomFieldValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded exchange rate lookups for this company.
|
||||
*/
|
||||
public function exchangeRateLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ExchangeRateLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured exchange rate sources for this company.
|
||||
*/
|
||||
public function exchangeRateProviders(): HasMany
|
||||
{
|
||||
return $this->hasMany(ExchangeRateProvider::class);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The roles defined inside this company's authorization scope.
|
||||
*/
|
||||
public function getRolesAttribute()
|
||||
{
|
||||
return Role::query()->where('scope', $this->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publicly reachable address of the branding image, null when none is
|
||||
* attached.
|
||||
*/
|
||||
public function getLogoAttribute()
|
||||
{
|
||||
$logo = $this->logoMedia();
|
||||
|
||||
return $logo ? $logo->getFullUrl() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the branding image lives.
|
||||
*
|
||||
* A local filesystem path while the default file disk is a system disk,
|
||||
* and a public address for every other kind of disk - the asymmetry is
|
||||
* deliberate, PDF rendering needs the path and the SPA needs the address.
|
||||
* The default disk is resolved whether or not an image is attached.
|
||||
*/
|
||||
public function getLogoPathAttribute()
|
||||
{
|
||||
$logo = $this->logoMedia();
|
||||
|
||||
$isSystem = FileDisk::query()->where('set_as_default', true)->first()->isSystem();
|
||||
|
||||
if (! $logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $isSystem ? $logo->getPath() : $logo->getFullUrl();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether any business record has been filed under this company yet.
|
||||
*
|
||||
* Contacts, catalog entries, invoices, estimates, expenses, payments and
|
||||
* recurring schedules all count; the first one found ends the search.
|
||||
*/
|
||||
public function hasTransactions(): bool
|
||||
{
|
||||
$ledgers = [
|
||||
'customers',
|
||||
'items',
|
||||
'invoices',
|
||||
'estimates',
|
||||
'expenses',
|
||||
'payments',
|
||||
'recurringInvoices',
|
||||
];
|
||||
|
||||
foreach ($ledgers as $ledger) {
|
||||
if ($this->{$ledger}()->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one media row behind the branding collection, if there is one.
|
||||
*/
|
||||
private function logoMedia()
|
||||
{
|
||||
return $this->getMedia('logo')->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* One preference belonging to a company.
|
||||
*
|
||||
* The store is a plain key/value table addressed by the `option` column. There
|
||||
* is no global layer underneath it: a read either finds a row for the company
|
||||
* asked about or comes back empty, and a write upserts on the option/company
|
||||
* pair.
|
||||
*/
|
||||
class CompanySetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'company_settings';
|
||||
|
||||
protected $fillable = ['company_id', 'option', 'value'];
|
||||
|
||||
/**
|
||||
* Company this preference belongs to.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a query to one company's rows.
|
||||
*/
|
||||
public function scopeWhereCompany($query, $company_id)
|
||||
{
|
||||
$query->where('company_id', $company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch of preferences for one company, replacing the value of any
|
||||
* option already on file and inserting the rest.
|
||||
*/
|
||||
public static function setSettings(array $settings, mixed $company_id): void
|
||||
{
|
||||
foreach ($settings as $option => $value) {
|
||||
self::updateOrCreate(
|
||||
['option' => $option, 'company_id' => $company_id],
|
||||
['option' => $option, 'company_id' => $company_id, 'value' => $value]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every preference on file for a company, keyed by option name.
|
||||
*/
|
||||
public static function getAllSettings(mixed $company_id): Collection
|
||||
{
|
||||
return self::flatten(
|
||||
static::whereCompany($company_id)->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The named preferences only; options with no row on file are left out.
|
||||
*/
|
||||
public static function getSettings(array $settings, mixed $company_id): Collection
|
||||
{
|
||||
return self::flatten(
|
||||
static::whereIn('option', $settings)->whereCompany($company_id)->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One preference value, or null when the company has no row for it.
|
||||
*/
|
||||
public static function getSetting(string $key, mixed $company_id): mixed
|
||||
{
|
||||
$setting = static::query()
|
||||
->where('option', $key)
|
||||
->whereCompany($company_id)
|
||||
->first();
|
||||
|
||||
if ($setting) {
|
||||
return $setting->value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce preference rows to an option => value collection.
|
||||
*/
|
||||
private static function flatten(Collection $rows): Collection
|
||||
{
|
||||
return $rows->mapWithKeys(function ($row) {
|
||||
return [$row['option'] => $row['value']];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Models;
|
||||
|
||||
use App\Domains\Accounts\Notifications\MailResetPasswordNotification;
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Contacts\Models\Address;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Money\Models\Currency;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Support\SafeOrderBy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Silber\Bouncer\Database\HasRolesAndAbilities;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* A staff account.
|
||||
*
|
||||
* Staff reach the application through one or more companies joined by the
|
||||
* `user_company` pivot, and what they may do inside each of them comes from
|
||||
* Bouncer roles scoped to that company. The pre-Bouncer `role` column has one
|
||||
* job left: flagging the platform administrator. Ownership is positional, read
|
||||
* from the active company's owner column rather than from any flag stored here.
|
||||
*/
|
||||
class User extends Authenticatable implements HasMedia
|
||||
{
|
||||
use HasApiTokens;
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
use HasRolesAndAbilities;
|
||||
use InteractsWithMedia;
|
||||
use Notifiable;
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
/**
|
||||
* Everything but the primary key may be mass assigned.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Secrets stripped from every serialized payload.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $with = [
|
||||
'currency',
|
||||
];
|
||||
|
||||
/**
|
||||
* Computed attributes, listed in the order they are serialized.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'formattedCreatedAt',
|
||||
'avatar',
|
||||
];
|
||||
|
||||
/**
|
||||
* A staff account keeps a single avatar image on the public disk.
|
||||
*/
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('admin_avatar')->useDisk('public')->singleFile();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Preferred currency, eager loaded on every query.
|
||||
*/
|
||||
public function currency(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Currency::class, 'currency_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoever created this account, when it was not self-registered.
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Companies this account is a member of.
|
||||
*/
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'user_company', 'user_id', 'company_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user preference rows, addressed by their `key` column.
|
||||
*/
|
||||
public function settings(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSetting::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts this account authored.
|
||||
*/
|
||||
public function customers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entries this account authored.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(Item::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates this account authored.
|
||||
*/
|
||||
public function estimates(): HasMany
|
||||
{
|
||||
return $this->hasMany(Estimate::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices this account authored.
|
||||
*/
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring invoice schedules this account authored.
|
||||
*/
|
||||
public function recurringInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(RecurringInvoice::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Payments this account recorded.
|
||||
*/
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payment::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expenses this account recorded.
|
||||
*/
|
||||
public function expenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Expense::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every postal address filed against this account.
|
||||
*/
|
||||
public function addresses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Address::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The address flagged for billing.
|
||||
*/
|
||||
public function billingAddress(): HasOne
|
||||
{
|
||||
return $this->hasOne(Address::class)->where('type', Address::BILLING_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The address flagged for shipping.
|
||||
*/
|
||||
public function shippingAddress(): HasOne
|
||||
{
|
||||
return $this->hasOne(Address::class)->where('type', Address::SHIPPING_TYPE);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors and mutators
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hash a password on assignment.
|
||||
*
|
||||
* A blank value is skipped so that saving a form which left the field empty
|
||||
* keeps the hash already on file.
|
||||
*/
|
||||
public function setPasswordAttribute(string $value): void
|
||||
{
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->attributes['password'] = bcrypt($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public URL of the avatar, or the number zero when none is attached.
|
||||
*/
|
||||
public function getAvatarAttribute()
|
||||
{
|
||||
$image = $this->getMedia('admin_avatar')->first();
|
||||
|
||||
return $image ? asset($image->getUrl()) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signup timestamp rendered with the date format of the company the
|
||||
* request is acting on.
|
||||
*/
|
||||
public function getFormattedCreatedAtAttribute($value)
|
||||
{
|
||||
return Carbon::parse($this->created_at)->format($this->contextDateFormat());
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Query scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sort by a caller-supplied column, sanitised before it reaches SQL.
|
||||
*/
|
||||
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
||||
{
|
||||
return SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only accounts matching every whitespace-separated term, a term
|
||||
* counting as matched when it turns up in the name, the email or the phone.
|
||||
*/
|
||||
public function scopeWhereSearch($query, $search)
|
||||
{
|
||||
$terms = explode(' ', $search);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$needle = self::wildcard($term);
|
||||
|
||||
$query->where(function ($match) use ($needle) {
|
||||
$match->where('name', 'LIKE', $needle)
|
||||
->orWhere('email', 'LIKE', $needle)
|
||||
->orWhere('phone', 'LIKE', $needle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the contact person.
|
||||
*/
|
||||
public function scopeWhereContactName($query, $contactName)
|
||||
{
|
||||
return $query->where('contact_name', 'LIKE', self::wildcard($contactName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the name the account is displayed under.
|
||||
*/
|
||||
public function scopeWhereDisplayName($query, $displayName)
|
||||
{
|
||||
return $query->where('name', 'LIKE', self::wildcard($displayName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the phone number.
|
||||
*/
|
||||
public function scopeWherePhone($query, $phone)
|
||||
{
|
||||
return $query->where('phone', 'LIKE', self::wildcard($phone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the email address.
|
||||
*/
|
||||
public function scopeWhereEmail($query, $email)
|
||||
{
|
||||
return $query->where('email', 'LIKE', self::wildcard($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only members of the company the request is acting on.
|
||||
*/
|
||||
public function scopeWhereCompany($query)
|
||||
{
|
||||
$company = request()->header('company');
|
||||
|
||||
return $query->whereHas('companies', function ($membership) use ($company) {
|
||||
$membership->where('company_id', $company);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Widen a listing to also take in the platform administrator.
|
||||
*/
|
||||
public function scopeWhereSuperAdmin($query)
|
||||
{
|
||||
$query->orWhere('role', 'super admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the whole result set for the sentinel limit "all", otherwise a
|
||||
* page of the requested size.
|
||||
*/
|
||||
public function scopePaginateData($query, $limit)
|
||||
{
|
||||
return $limit == 'all' ? $query->get() : $query->paginate($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every listed filter that carries a value.
|
||||
*/
|
||||
public function scopeApplyFilters($query, array $filters)
|
||||
{
|
||||
$scopes = [
|
||||
'search' => 'whereSearch',
|
||||
'display_name' => 'whereDisplayName',
|
||||
'email' => 'whereEmail',
|
||||
'phone' => 'wherePhone',
|
||||
];
|
||||
|
||||
foreach ($scopes as $filter => $scope) {
|
||||
$value = $filters[$filter] ?? null;
|
||||
|
||||
if ($value) {
|
||||
$query->{$scope}($value);
|
||||
}
|
||||
}
|
||||
|
||||
$role = $filters['role'] ?? null;
|
||||
|
||||
if ($role) {
|
||||
$query->whereHas('roles', function ($assigned) use ($role) {
|
||||
$assigned->where('roles.id', $role);
|
||||
});
|
||||
}
|
||||
|
||||
$sortField = $filters['orderByField'] ?? null;
|
||||
$sortDirection = $filters['orderBy'] ?? null;
|
||||
|
||||
if ($sortField || $sortDirection) {
|
||||
$query->whereOrder($sortField ?: 'name', $sortDirection ?: 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to accounts who authored an invoice inside a date range, when
|
||||
* the caller supplied both ends of it.
|
||||
*/
|
||||
public function scopeApplyInvoiceFilters($query, array $filters)
|
||||
{
|
||||
$from = $filters['from_date'] ?? null;
|
||||
$to = $filters['to_date'] ?? null;
|
||||
|
||||
if ($from && $to) {
|
||||
$query->invoicesBetween(
|
||||
Carbon::createFromFormat('Y-m-d', $from),
|
||||
Carbon::createFromFormat('Y-m-d', $to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to accounts holding at least one invoice dated inside the
|
||||
* inclusive range.
|
||||
*/
|
||||
public function scopeInvoicesBetween($query, $start, $end)
|
||||
{
|
||||
$range = [$start->format('Y-m-d'), $end->format('Y-m-d')];
|
||||
|
||||
$query->whereHas('invoices', function ($invoices) use ($range) {
|
||||
$invoices->whereBetween('invoice_date', $range);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Write a batch of preferences, replacing the value of any key already on
|
||||
* file and inserting the rest.
|
||||
*/
|
||||
public function setSettings(array $settings): void
|
||||
{
|
||||
foreach ($settings as $option => $value) {
|
||||
$this->settings()->updateOrCreate(['key' => $option], ['key' => $option, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every preference on file for this account, keyed by setting name.
|
||||
*/
|
||||
public function getAllSettings(): Collection
|
||||
{
|
||||
return $this->flattenSettings($this->settings()->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* The named preferences only; keys with no row on file are left out.
|
||||
*/
|
||||
public function getSettings(array $settings): Collection
|
||||
{
|
||||
return $this->flattenSettings($this->settings()->whereIn('key', $settings)->get());
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Identity and access
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve an account from the identifier a token grant was asked for.
|
||||
*/
|
||||
public function findForPassport(string $username): ?self
|
||||
{
|
||||
return $this->newQuery()->where('email', $username)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a session from a request-like object carrying the credentials.
|
||||
*/
|
||||
public static function login(object $request): bool
|
||||
{
|
||||
$credentials = [
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
];
|
||||
|
||||
return Auth::attempt($credentials, $request->remember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a password reset link pointing at the SPA reset screen.
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
$notification = new MailResetPasswordNotification($token);
|
||||
|
||||
$this->notify($notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is the platform administrator.
|
||||
*/
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return $this->role === 'super admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pre-Bouncer `role` column marks this account as privileged.
|
||||
*/
|
||||
public function isSuperAdminOrAdmin(): bool
|
||||
{
|
||||
return $this->hasLegacyAdminRole();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is a member of the given company.
|
||||
*/
|
||||
public function hasCompany(int $company_id): bool
|
||||
{
|
||||
return $this->companies()->pluck('company_id')->contains($company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account owns the company the request is acting on.
|
||||
*
|
||||
* Ownership is positional: it is read from the company's owner column, so
|
||||
* transferring ownership flips authorization immediately. Installs that
|
||||
* have not run the migration adding that column fall back to the old role
|
||||
* strings.
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
if (! Schema::hasColumn('companies', 'owner_id')) {
|
||||
return $this->hasLegacyAdminRole();
|
||||
}
|
||||
|
||||
$active = Company::find(request()->header('company'));
|
||||
|
||||
return $active && $this->id == $active->owner_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a navigation entry's requirements are met.
|
||||
*
|
||||
* Entries reserved for the platform administrator are settled by that gate
|
||||
* alone. Owners of the active company clear everything else. Everyone else
|
||||
* needs the named ability, checked against the entry's subject model first
|
||||
* and against the bare ability afterwards; an entry naming no ability at
|
||||
* all is open.
|
||||
*/
|
||||
public function checkAccess(object $data): bool
|
||||
{
|
||||
$meta = $data->data;
|
||||
|
||||
if (! empty($meta['super_admin_only'])) {
|
||||
return $this->isSuperAdmin();
|
||||
}
|
||||
|
||||
if ($this->isOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($meta['owner_only']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($meta['ability'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($meta['model']) && $this->can($meta['ability'], $meta['model'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->can($meta['ability']);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Internals
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Date format of the company the request is acting on, falling back to the
|
||||
* first company this account belongs to and to an ISO-style date when it
|
||||
* belongs to none.
|
||||
*/
|
||||
private function contextDateFormat(): mixed
|
||||
{
|
||||
$scope = request()->header('company');
|
||||
|
||||
$configured = $scope
|
||||
&& CompanySetting::query()->where('company_id', $scope)->exists();
|
||||
|
||||
if (! $configured) {
|
||||
$home = $this->companies()->first();
|
||||
|
||||
if (! $home) {
|
||||
return 'Y-m-d';
|
||||
}
|
||||
|
||||
$scope = $home->id;
|
||||
}
|
||||
|
||||
return CompanySetting::getSetting('carbon_date_format', $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-Bouncer administrator test, kept for installs whose companies
|
||||
* table has not gained its ownership column yet.
|
||||
*/
|
||||
private function hasLegacyAdminRole(): bool
|
||||
{
|
||||
return in_array($this->role, ['super admin', 'admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce preference rows to a name => value collection.
|
||||
*/
|
||||
private function flattenSettings(Collection $rows): Collection
|
||||
{
|
||||
return $rows->mapWithKeys(function ($row) {
|
||||
return [$row['key'] => $row['value']];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a term for a substring LIKE comparison.
|
||||
*/
|
||||
private static function wildcard($term): string
|
||||
{
|
||||
return '%'.$term.'%';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One preference belonging to a staff account.
|
||||
*
|
||||
* The store mirrors the company one, addressed by a `key` column instead of an
|
||||
* `option` column. The sentinel value "default" on the `language` key means
|
||||
* "follow the company", so promoting a member never freezes a copy of the
|
||||
* inviter's language.
|
||||
*/
|
||||
class UserSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'user_settings';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Account this preference belongs to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
/**
|
||||
* The password reset mail sent to staff accounts.
|
||||
*
|
||||
* The framework's own version links to a named route; this one points at the
|
||||
* SPA reset screen and quotes the configured token lifetime.
|
||||
*/
|
||||
class MailResetPasswordNotification extends ResetPassword
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Carry the reset token through to the parent notification.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($token)
|
||||
{
|
||||
parent::__construct($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* This notification goes out over mail only.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the reset mail, linking at the SPA screen that takes the token.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$resetUrl = url('/reset-password/'.$this->token);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Reset Password Notification')
|
||||
->line('Hello! You are receiving this email because we received a password reset request for your account.')
|
||||
->action('Reset Password', $resetUrl)
|
||||
->line('This password reset link will expire in '.config('auth.passwords.users.expire').' minutes')
|
||||
->line('If you did not request a password reset, no further action is required.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing is stored for the database channel.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Who may bring companies into being, hand them over, or wind them up.
|
||||
*
|
||||
* Two different questions are being asked here, and they do not agree with
|
||||
* each other. Creation looks at the company the request header points at:
|
||||
* whoever owns *that* company may open another one, which means an ordinary
|
||||
* member who owns nothing anywhere is refused. Transfer and deletion instead
|
||||
* look at the company being acted on and want the actor to be its recorded
|
||||
* owner, header or no header.
|
||||
*
|
||||
* Nothing in either question consults the platform administrator flag — that
|
||||
* account gets no shortcut past these.
|
||||
*/
|
||||
class CompanyPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Opening a new company.
|
||||
*
|
||||
* Judged against the active company, not the one about to exist.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Winding a company up.
|
||||
*/
|
||||
public function delete(User $user, Company $company): bool
|
||||
{
|
||||
return $this->ownsOutright($user, $company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handing a company to somebody else.
|
||||
*
|
||||
* Declared without a return type, as found.
|
||||
*/
|
||||
public function transferOwnership(User $user, Company $company)
|
||||
{
|
||||
return $this->ownsOutright($user, $company);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor is the company's recorded owner.
|
||||
*
|
||||
* Compared loosely, so an owner column holding a numeric string still
|
||||
* matches the id it names.
|
||||
*/
|
||||
private function ownsOutright(User $user, Company $company): bool
|
||||
{
|
||||
return $user->id == $company->owner_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* The bare owner check, reached as a gate that takes no subject.
|
||||
*
|
||||
* Callers that guard something belonging to the active company — but have no
|
||||
* row to hand over — ask this one. It resolves to ownership of the company in
|
||||
* the `company` header and nothing else: no ability is consulted, and the
|
||||
* platform administrator is not waved through.
|
||||
*/
|
||||
class OwnerPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Declared without a return type, as found.
|
||||
*/
|
||||
public function managedByOwner(User $user)
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Silber\Bouncer\Database\Role;
|
||||
|
||||
/**
|
||||
* Who may work with per-company roles.
|
||||
*
|
||||
* One question answers all seven entries: does the actor own the company named
|
||||
* in the `company` header? There is no second half here. Where a role is
|
||||
* handed in it is never looked at, so the role's own scope plays no part in
|
||||
* the decision — confining a role to its company is left to the scoping that
|
||||
* Bouncer applies while the query runs, not to this class.
|
||||
*/
|
||||
class RolePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Browsing the roles of the active company.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading one role. The role itself is not examined.
|
||||
*/
|
||||
public function view(User $user, Role $role): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defining a role.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renaming a role or resyncing its abilities.
|
||||
*/
|
||||
public function update(User $user, Role $role): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropping a role. Whether anybody still holds it is settled downstream,
|
||||
* not here.
|
||||
*/
|
||||
public function delete(User $user, Role $role): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringing a role back — unreachable, as roles are not soft-deleted.
|
||||
*/
|
||||
public function restore(User $user, Role $role): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erasing a role for good — unreachable for the same reason.
|
||||
*/
|
||||
public function forceDelete(User $user, Role $role): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Who may rewrite a company's own record and its settings.
|
||||
*
|
||||
* The question is positional and asked of the company being edited: is this
|
||||
* actor the account recorded against it as owner? Holding an ability, or being
|
||||
* the platform administrator, buys nothing here. The comparison is loose, so a
|
||||
* numeric string in the owner column still matches the id it names.
|
||||
*/
|
||||
class SettingsPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Editing the company profile, its settings, or its mail configuration.
|
||||
*
|
||||
* Declared without a return type, as found.
|
||||
*/
|
||||
public function manageCompany(User $user, Company $company)
|
||||
{
|
||||
return $user->id == $company->owner_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Accounts\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Who may administer staff accounts.
|
||||
*
|
||||
* Ownership here is positional and evaluated per request: the actor counts as
|
||||
* an owner only while the company named in the `company` header is the company
|
||||
* they own, so the same person is an owner on one call and nobody on the next.
|
||||
*
|
||||
* Decisions aimed at an existing account carry a second half — the account has
|
||||
* to sit inside that same header company — which keeps the owner of one tenant
|
||||
* from reading or overwriting an account that lives in another. Only view,
|
||||
* update and delete carry that half; the remaining entries stop after the
|
||||
* ownership question, which is noted where it happens.
|
||||
*/
|
||||
class UserPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Browsing the member list.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading one member.
|
||||
*/
|
||||
public function view(User $user, User $model): bool
|
||||
{
|
||||
return $this->mayActOn($user, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding a member.
|
||||
*
|
||||
* No target exists yet, so ownership of the header company is the whole
|
||||
* decision.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing one member.
|
||||
*/
|
||||
public function update(User $user, User $model): bool
|
||||
{
|
||||
return $this->mayActOn($user, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing one member.
|
||||
*
|
||||
* Nothing routes here today — member removal arrives through the bulk gate
|
||||
* below — but the tenant half is applied all the same.
|
||||
*/
|
||||
public function delete(User $user, User $model): bool
|
||||
{
|
||||
return $this->mayActOn($user, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringing back a removed member.
|
||||
*
|
||||
* Unreachable: accounts are erased outright rather than soft-deleted. Note
|
||||
* that the target is ignored, so ownership alone would answer this.
|
||||
*/
|
||||
public function restore(User $user, User $model): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erasing a member for good.
|
||||
*
|
||||
* Unreachable for the same reason, and likewise blind to the target.
|
||||
*/
|
||||
public function forceDelete(User $user, User $model): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inviting a member.
|
||||
*
|
||||
* Nothing calls this. The declaration is kept as found, return type
|
||||
* included — that is, without one — and the target goes unexamined.
|
||||
*/
|
||||
public function invite(User $user, User $model)
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing members in bulk.
|
||||
*
|
||||
* Reached as a gate rather than through a model, so there is no target to
|
||||
* confine: ownership of the header company opens the whole operation, and
|
||||
* the ids it is handed are resolved installation-wide.
|
||||
*/
|
||||
public function deleteMultiple(User $user)
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves: own the header company, and have the target inside it.
|
||||
*/
|
||||
private function mayActOn(User $user, User $target): bool
|
||||
{
|
||||
return $user->isOwner() && $this->isMemberOfActiveCompany($target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership of the company carried by the request header.
|
||||
*
|
||||
* Without this the target would be looked up by installation-wide id, and
|
||||
* one company's owner could reach another company's people.
|
||||
*/
|
||||
private function isMemberOfActiveCompany(User $target): bool
|
||||
{
|
||||
$activeCompanyId = request()->header('company');
|
||||
|
||||
if (! $activeCompanyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $target->companies()->whereKey($activeCompanyId)->exists();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user