diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 1ebb282..d31af5a 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\Frontdesk\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('frontdesk.dashboard')); if (Auth::check()) { - return $this->safeRedirect($intended, route('frontdesk.dashboard')); + $redirect = $this->resolveLanding($identity, $request->user(), $intended); + + return $this->safeRedirect($redirect, route('frontdesk.dashboard')); } if (! $request->boolean('fallback')) { @@ -39,7 +43,10 @@ class SsoLoginController extends Controller } if ($this->attemptSilentRefresh($request, $intended)) { - return $this->safeRedirect($intended, route('frontdesk.dashboard')); + app(TeamMemberProvisioner::class)->sync(Auth::user()); + $redirect = $this->resolveLanding($identity, Auth::user(), $intended); + + return $this->safeRedirect($redirect, route('frontdesk.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('frontdesk.dashboard')); + $user = Auth::user(); + $redirect = ($user instanceof User && $identity) + ? $this->resolveLanding($identity, $user, $intended) + : $intended; + + return $this->safeRedirect($redirect, route('frontdesk.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/Frontdesk/MemberController.php b/app/Http/Controllers/Frontdesk/MemberController.php index b837862..a5c7eae 100644 --- a/app/Http/Controllers/Frontdesk/MemberController.php +++ b/app/Http/Controllers/Frontdesk/MemberController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Models\Branch; use App\Models\Member; +use App\Services\Identity\IdentityTeamClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -50,30 +51,50 @@ 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('frontdesk.roles')))], 'branch_id' => ['nullable', 'integer', 'exists:frontdesk_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, + ['frontdesk'], + [ + 'frontdesk' => [ + 'organization_id' => $organization->id, + 'role' => $validated['role'], + 'branch_id' => $validated['branch_id'] ?? null, + ], + ], + ); + Member::updateOrCreate( [ 'organization_id' => $organization->id, - 'user_ref' => $validated['user_ref'], + 'user_ref' => $email, ], [ - 'owner_ref' => $this->ownerRef($request), + 'owner_ref' => $owner, 'role' => $validated['role'], 'branch_id' => $validated['branch_id'] ?? null, ], ); - return redirect()->route('frontdesk.members.index')->with('success', 'Member saved.'); + return redirect()->route('frontdesk.members.index')->with('success', 'Invitation sent to '.$email.'.'); } public function destroy(Request $request, Member $member): RedirectResponse diff --git a/app/Services/Frontdesk/TeamMemberProvisioner.php b/app/Services/Frontdesk/TeamMemberProvisioner.php new file mode 100644 index 0000000..ff6a664 --- /dev/null +++ b/app/Services/Frontdesk/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('frontdesk', $apps, true)) { + continue; + } + + $meta = (array) data_get($grant, 'metadata.frontdesk', []); + $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/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/resources/views/frontdesk/admin/members/create.blade.php b/resources/views/frontdesk/admin/members/create.blade.php index b7c11ca..22ca2ba 100644 --- a/resources/views/frontdesk/admin/members/create.blade.php +++ b/resources/views/frontdesk/admin/members/create.blade.php @@ -1,28 +1,38 @@ - +
-

Add team member

-

Enter the Ladill user public ID (OIDC sub) of the person to invite.

+

Invite team member

+
@csrf -
+
- + + +

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

+
+ +
+
+
- +
- + +
diff --git a/resources/views/frontdesk/admin/members/index.blade.php b/resources/views/frontdesk/admin/members/index.blade.php index 3a37d8e..273b786 100644 --- a/resources/views/frontdesk/admin/members/index.blade.php +++ b/resources/views/frontdesk/admin/members/index.blade.php @@ -7,12 +7,17 @@
- + @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 IDRoleBranch
MemberRoleBranch
{{ $member->user_ref }}{{ $display }} {{ $roles[$member->role] ?? $member->role }} {{ $member->branch?->name ?? 'All branches' }}