mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-04 06:11:05 +00:00
feat(accounts): fresh accounts implementation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user