From d0fbbe06726069ec5edb89a53405c53b6ebb4931 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 00:25:12 +0000 Subject: [PATCH] Switch team invites to email-only flow via identity API. Replace local member UUID invites with platform email invites, SSO post-auth redirect, and provisioner-backed pending access until accept. Co-authored-by: Cursor --- .../Controllers/Auth/SsoLoginController.php | 41 ++++++++-- app/Http/Controllers/Qms/MemberController.php | 30 ++++++-- app/Services/Identity/IdentityTeamClient.php | 74 +++++++++++++++++++ app/Services/Qms/TeamMemberProvisioner.php | 56 ++++++++++++++ .../views/qms/admin/members/create.blade.php | 38 ++++++++-- .../views/qms/admin/members/index.blade.php | 9 ++- 6 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 app/Services/Identity/IdentityTeamClient.php create mode 100644 app/Services/Qms/TeamMemberProvisioner.php diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index a8488fa..93c42fe 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\Identity\IdentityTeamClient; +use App\Services\Qms\TeamMemberProvisioner; use Illuminate\Http\Client\Response as HttpResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -26,12 +28,14 @@ class SsoLoginController extends Controller */ private const MAX_SSO_ATTEMPTS = 3; - public function connect(Request $request): RedirectResponse|View + public function connect(Request $request, IdentityTeamClient $identity): RedirectResponse|View { $intended = (string) $request->query('redirect', route('qms.dashboard')); if (Auth::check()) { - return $this->safeRedirect($intended, route('qms.dashboard')); + $redirect = $this->resolveLanding($identity, $request->user(), $intended); + + return $this->safeRedirect($redirect, route('qms.dashboard')); } if (! $request->boolean('fallback')) { @@ -39,7 +43,10 @@ class SsoLoginController extends Controller } if ($this->attemptSilentRefresh($request, $intended)) { - return $this->safeRedirect($intended, route('qms.dashboard')); + app(TeamMemberProvisioner::class)->sync(Auth::user()); + $redirect = $this->resolveLanding($identity, Auth::user(), $intended); + + return $this->safeRedirect($redirect, route('qms.dashboard')); } $verifier = Str::random(64); @@ -115,7 +122,9 @@ class SsoLoginController extends Controller $request->session()->regenerate(); $request->session()->forget('sso.attempts'); - return $this->finishCallback($request, $intended, null); + app(TeamMemberProvisioner::class)->sync($user); + + return $this->finishCallback($request, $intended, null, app(IdentityTeamClient::class)); } public function failed(Request $request): View @@ -241,8 +250,12 @@ class SsoLoginController extends Controller ); } - private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse - { + private function finishCallback( + Request $request, + string $intended, + ?string $error = null, + ?IdentityTeamClient $identity = null, + ): RedirectResponse { if ($error) { $attempts = (int) $request->session()->get('sso.attempts', 0) + 1; @@ -262,7 +275,21 @@ class SsoLoginController extends Controller ]); } - return $this->safeRedirect($intended, route('qms.dashboard')); + $user = Auth::user(); + $redirect = ($user instanceof User && $identity) + ? $this->resolveLanding($identity, $user, $intended) + : $intended; + + return $this->safeRedirect($redirect, route('qms.dashboard')); + } + + private function resolveLanding(IdentityTeamClient $identity, User $user, string $intended): string + { + try { + return $identity->postAuthRedirect($user->ownerRef(), $intended); + } catch (\Throwable) { + return $intended; + } } private function safeReturnUrl(string $url): string diff --git a/app/Http/Controllers/Qms/MemberController.php b/app/Http/Controllers/Qms/MemberController.php index a7b9eac..4e90077 100644 --- a/app/Http/Controllers/Qms/MemberController.php +++ b/app/Http/Controllers/Qms/MemberController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Qms\Concerns\ScopesToAccount; use App\Models\Branch; use App\Models\Member; +use App\Services\Identity\IdentityTeamClient; use App\Services\Qms\AuditLogger; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -51,22 +52,41 @@ class MemberController extends Controller ]); } - public function store(Request $request): RedirectResponse + public function store(Request $request, IdentityTeamClient $identity): RedirectResponse { $this->authorizeAbility($request, 'admin.members.manage'); $organization = $this->organization($request); $owner = $this->ownerRef($request); $validated = $request->validate([ - 'user_ref' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], 'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.roles')))], 'branch_id' => ['nullable', 'integer', 'exists:queue_branches,id'], ]); + $email = strtolower(trim($validated['email'])); + + if ($email === strtolower((string) $request->user()->email)) { + return back()->withErrors(['email' => 'You cannot invite yourself.']); + } + + $identity->inviteAppMember( + $owner, + $email, + ['queue'], + [ + 'queue' => [ + 'organization_id' => $organization->id, + 'role' => $validated['role'], + 'branch_id' => $validated['branch_id'] ?? null, + ], + ], + ); + $member = Member::updateOrCreate( [ 'organization_id' => $organization->id, - 'user_ref' => $validated['user_ref'], + 'user_ref' => $email, ], [ 'owner_ref' => $owner, @@ -75,9 +95,9 @@ class MemberController extends Controller ], ); - AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id); + AuditLogger::record($owner, 'member.invited', $organization->id, $owner, Member::class, $member->id); - return redirect()->route('qms.members.index')->with('success', 'Member saved.'); + return redirect()->route('qms.members.index')->with('success', 'Invitation sent to '.$email.'.'); } public function destroy(Request $request, Member $member): RedirectResponse diff --git a/app/Services/Identity/IdentityTeamClient.php b/app/Services/Identity/IdentityTeamClient.php new file mode 100644 index 0000000..74d3315 --- /dev/null +++ b/app/Services/Identity/IdentityTeamClient.php @@ -0,0 +1,74 @@ + $apps + * @param array $metadata + * + * @return array + */ + public function inviteAppMember( + string $ownerPublicId, + string $email, + array $apps, + array $metadata = [], + string $role = 'member', + ): array { + $response = $this->request('post', '/identity/team/invite', [ + 'owner' => $ownerPublicId, + 'email' => strtolower(trim($email)), + 'apps' => $apps, + 'role' => $role, + 'metadata' => $metadata, + ]); + + return (array) $response->json('data', []); + } + + public function postAuthRedirect(string $userPublicId, string $intendedUrl): string + { + $response = $this->request('get', '/identity/team/post-auth-redirect', [ + 'user' => $userPublicId, + 'redirect' => $intendedUrl, + ]); + + return (string) $response->json('data.url', $intendedUrl); + } + + /** @return list> */ + public function teamAccess(string $userPublicId): array + { + $response = $this->request('get', '/identity/team/access', [ + 'user' => $userPublicId, + ]); + + return (array) $response->json('data', []); + } + + private function request(string $method, string $path, array $query = []): Response + { + $url = rtrim((string) config('identity.api_url'), '/').$path; + $key = config('identity.api_key'); + + if ($url === '' || ! is_string($key) || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $response = Http::withToken($key) + ->timeout(10) + ->{$method}($url, $query); + + if (! $response->successful()) { + throw new RuntimeException($response->json('message') ?: 'Identity API request failed.'); + } + + return $response; + } +} diff --git a/app/Services/Qms/TeamMemberProvisioner.php b/app/Services/Qms/TeamMemberProvisioner.php new file mode 100644 index 0000000..5bab8ac --- /dev/null +++ b/app/Services/Qms/TeamMemberProvisioner.php @@ -0,0 +1,56 @@ +where('user_ref', strtolower((string) $user->email)) + ->update(['user_ref' => $user->ownerRef()]); + + try { + $access = $this->identity->teamAccess($user->ownerRef()); + } catch (\Throwable) { + return; + } + + foreach ($access as $grant) { + if (($grant['self'] ?? false) || ($grant['full_access'] ?? false)) { + continue; + } + + $apps = (array) ($grant['apps'] ?? []); + if (! in_array('queue', $apps, true)) { + continue; + } + + $meta = (array) data_get($grant, 'metadata.queue', []); + $organizationId = (int) ($meta['organization_id'] ?? 0); + if ($organizationId <= 0) { + continue; + } + + Member::updateOrCreate( + [ + 'organization_id' => $organizationId, + 'user_ref' => $user->ownerRef(), + ], + [ + 'owner_ref' => (string) ($grant['account'] ?? $user->ownerRef()), + 'role' => (string) ($meta['role'] ?? 'member'), + 'branch_id' => $meta['branch_id'] ?? null, + ], + ); + } + } +} diff --git a/resources/views/qms/admin/members/create.blade.php b/resources/views/qms/admin/members/create.blade.php index effaee1..7a21bd6 100644 --- a/resources/views/qms/admin/members/create.blade.php +++ b/resources/views/qms/admin/members/create.blade.php @@ -1,16 +1,38 @@
-

Add team member

-
+

Invite team member

+ + @csrf -
-
- + +
+ + +

They will receive an email to accept and join your Queue organization.

-
- + +
+ +
- + +
+ + +
+ +
diff --git a/resources/views/qms/admin/members/index.blade.php b/resources/views/qms/admin/members/index.blade.php index 06c8e74..054b95d 100644 --- a/resources/views/qms/admin/members/index.blade.php +++ b/resources/views/qms/admin/members/index.blade.php @@ -9,15 +9,20 @@
- + @foreach ($members as $member) + @php + $display = str_contains($member->user_ref, '@') + ? $member->user_ref + : (\App\Models\User::where('public_id', $member->user_ref)->value('email') ?? $member->user_ref); + @endphp - +
User refMember Role Branch
{{ $member->user_ref }}{{ $display }} {{ $roles[$member->role] ?? $member->role }} {{ $member->branch?->name ?? 'All' }}