diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index d31af5a..e9179c7 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -286,7 +286,10 @@ class SsoLoginController extends Controller private function resolveLanding(IdentityTeamClient $identity, User $user, string $intended): string { try { - return $identity->postAuthRedirect($user->ownerRef(), $intended); + $access = $identity->appAccess($user->ownerRef(), $intended); + \App\Support\StaffUx::remember($access); + + return $access['url'] !== '' ? $access['url'] : $intended; } catch (\Throwable) { return $intended; } diff --git a/app/Http/Controllers/Frontdesk/MemberController.php b/app/Http/Controllers/Frontdesk/MemberController.php index 0fe8dde..1faa82c 100644 --- a/app/Http/Controllers/Frontdesk/MemberController.php +++ b/app/Http/Controllers/Frontdesk/MemberController.php @@ -42,7 +42,7 @@ class MemberController extends Controller ]); } - public function create(Request $request): View + public function create(Request $request, IdentityTeamClient $identity): View { $this->authorizeAbility($request, 'admin.members.manage'); $organization = $this->organization($request); @@ -53,10 +53,18 @@ class MemberController extends Controller ->orderBy('name') ->get(); + $mailboxOptions = []; + try { + $mailboxOptions = $identity->mailboxOptions($this->ownerRef($request)); + } catch (\Throwable) { + // Identity optional for form rendering. + } + return view('frontdesk.admin.members.create', [ 'organization' => $organization, 'branches' => $branches, 'roles' => config('frontdesk.roles'), + 'mailboxOptions' => $mailboxOptions, ]); } diff --git a/app/Services/Identity/IdentityTeamClient.php b/app/Services/Identity/IdentityTeamClient.php index 74d3315..fbb3e2a 100644 --- a/app/Services/Identity/IdentityTeamClient.php +++ b/app/Services/Identity/IdentityTeamClient.php @@ -32,14 +32,40 @@ class IdentityTeamClient return (array) $response->json('data', []); } + /** + * @return array{url: string, apps: list, full_access: bool, show_hub: bool, show_billing: bool} + */ + public function appAccess(string $userPublicId, string $intendedUrl = ''): array + { + $response = $this->request('get', '/identity/team/post-auth-redirect', array_filter([ + 'user' => $userPublicId, + 'redirect' => $intendedUrl !== '' ? $intendedUrl : null, + ])); + + $data = (array) $response->json('data', []); + + return [ + 'url' => (string) ($data['url'] ?? $intendedUrl), + 'apps' => array_values(array_map('strval', (array) ($data['apps'] ?? []))), + 'full_access' => (bool) ($data['full_access'] ?? false), + 'show_hub' => (bool) ($data['show_hub'] ?? ($data['full_access'] ?? false)), + 'show_billing' => (bool) ($data['show_billing'] ?? ($data['full_access'] ?? false)), + ]; + } + public function postAuthRedirect(string $userPublicId, string $intendedUrl): string { - $response = $this->request('get', '/identity/team/post-auth-redirect', [ - 'user' => $userPublicId, - 'redirect' => $intendedUrl, + return $this->appAccess($userPublicId, $intendedUrl)['url']; + } + + /** @return list */ + public function mailboxOptions(string $ownerPublicId): array + { + $response = $this->request('get', '/identity/team/mailbox-options', [ + 'owner' => $ownerPublicId, ]); - return (string) $response->json('data.url', $intendedUrl); + return array_values(array_map('strval', (array) $response->json('data', []))); } /** @return list> */ diff --git a/app/Support/StaffUx.php b/app/Support/StaffUx.php new file mode 100644 index 0000000..65e38af --- /dev/null +++ b/app/Support/StaffUx.php @@ -0,0 +1,119 @@ +user(); + if (! $user) { + return false; + } + + if ($resolved = self::fromTeamAccess($user)) { + return $resolved['show_hub']; + } + + $cached = session(self::SESSION_KEY); + if (is_array($cached) && array_key_exists('show_hub', $cached)) { + return (bool) $cached['show_hub']; + } + + // Unknown in a silo before SSO cache — assume owner (fail open for hub). + return true; + } + + public static function showBilling(?Authenticatable $user = null): bool + { + $user ??= auth()->user(); + if (! $user) { + return false; + } + + if ($resolved = self::fromTeamAccess($user)) { + return $resolved['show_billing']; + } + + $cached = session(self::SESSION_KEY); + if (is_array($cached) && array_key_exists('show_billing', $cached)) { + return (bool) $cached['show_billing']; + } + + return true; + } + + /** @return list|null Null means unrestricted / unknown. */ + public static function allowedAppSlugs(?Authenticatable $user = null): ?array + { + $user ??= auth()->user(); + if (! $user) { + return []; + } + + if ($resolved = self::fromTeamAccess($user)) { + return $resolved['full_access'] ? null : $resolved['apps']; + } + + $cached = session(self::SESSION_KEY); + if (is_array($cached)) { + if (! empty($cached['full_access'])) { + return null; + } + if (array_key_exists('apps', $cached) && is_array($cached['apps'])) { + return array_values(array_map('strval', $cached['apps'])); + } + } + + return null; + } + + /** + * @param array{full_access?: bool, apps?: list, show_hub?: bool, show_billing?: bool} $payload + */ + public static function remember(array $payload): void + { + session([self::SESSION_KEY => [ + 'full_access' => (bool) ($payload['full_access'] ?? false), + 'apps' => array_values(array_map('strval', (array) ($payload['apps'] ?? []))), + 'show_hub' => (bool) ($payload['show_hub'] ?? false), + 'show_billing' => (bool) ($payload['show_billing'] ?? false), + ]]); + } + + /** @return array{full_access: bool, apps: list, show_hub: bool, show_billing: bool}|null */ + private static function fromTeamAccess(Authenticatable $user): ?array + { + $service = 'App\\Services\\Team\\TeamAccessService'; + $userClass = 'App\\Models\\User'; + + if (! class_exists($service) || ! is_a($user, $userClass)) { + return null; + } + + try { + $access = app($service); + + return [ + 'full_access' => $access->hasFullProductAccess($user), + 'apps' => $access->accessibleAppSlugs($user), + 'show_hub' => $access->showProductHub($user), + 'show_billing' => $access->showBilling($user), + ]; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Support/UserProfileMenu.php b/app/Support/UserProfileMenu.php index 9461caf..95d6ae3 100644 --- a/app/Support/UserProfileMenu.php +++ b/app/Support/UserProfileMenu.php @@ -21,15 +21,25 @@ class UserProfileMenu return []; } + $showHub = StaffUx::showProductHub($user); + $showBilling = StaffUx::showBilling($user); $items = []; foreach ([ - ['label' => 'Home', 'path' => '', 'host' => 'home'], + ['label' => 'Home', 'path' => '', 'host' => 'home', 'requires_hub' => true], ['label' => 'Profile', 'path' => 'profile'], ['label' => 'Account Settings', 'path' => 'account-settings'], ['label' => 'Dashboard', 'path' => 'dashboard'], - ['label' => 'Billing', 'path' => 'billing'], + ['label' => 'Billing', 'path' => 'billing', 'requires_billing' => true], ] as $link) { + if (! empty($link['requires_hub']) && ! $showHub) { + continue; + } + + if (! empty($link['requires_billing']) && ! $showBilling) { + continue; + } + $href = self::platformUrl($link['host'] ?? 'account', $link['path']); if (self::isCurrentMenuLink($link, $href)) { @@ -43,7 +53,7 @@ class UserProfileMenu ]; } - if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) { + if ($showBilling && Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) { $items[] = ['type' => 'wallet']; } diff --git a/resources/views/frontdesk/admin/members/create.blade.php b/resources/views/frontdesk/admin/members/create.blade.php index 22ca2ba..b1c8da5 100644 --- a/resources/views/frontdesk/admin/members/create.blade.php +++ b/resources/views/frontdesk/admin/members/create.blade.php @@ -2,12 +2,26 @@

Invite team member

-
+ @csrf + @if (! empty($mailboxOptions)) +
+ + +

Or type any email below.

+
+ @endif +
-

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

diff --git a/resources/views/partials/launcher.blade.php b/resources/views/partials/launcher.blade.php index de21e62..8c0ffa6 100644 --- a/resources/views/partials/launcher.blade.php +++ b/resources/views/partials/launcher.blade.php @@ -2,17 +2,59 @@ // Shared Ladill app launcher — IDENTICAL across every app/service. // Driven by config/ladill_launcher.php (fully-extracted apps only) + icons // from public/images/launcher-icons/. Omits the current app (matched by - // APP_URL host). To replicate elsewhere, copy this file + the config + - // the launcher-icons folder verbatim. + // APP_URL host). Scoped team members only see apps they can access; the + // launcher is hidden entirely when they only have one app (or none). $sidebar = (bool) ($sidebar ?? false); $selfHost = parse_url((string) config('app.url'), PHP_URL_HOST); + $allowedSlugs = \App\Support\StaffUx::allowedAppSlugs(auth()->user()); $launcherApps = array_values(array_filter( config('ladill_launcher.apps', []), - fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost + function (array $app) use ($selfHost, $allowedSlugs) { + if (parse_url($app['url'], PHP_URL_HOST) === $selfHost) { + return false; + } + + if ($allowedSlugs === null) { + return true; + } + + $host = (string) parse_url($app['url'], PHP_URL_HOST); + $root = (string) config('app.platform_domain', 'ladill.com'); + $suffix = '.'.$root; + if (! str_ends_with($host, $suffix)) { + return false; + } + $sub = substr($host, 0, -strlen($suffix)); + + foreach ($allowedSlugs as $slug) { + if ($slug === $sub || str_starts_with($sub, $slug)) { + return true; + } + } + + // Launcher URLs may use marketing hostnames — match by known slug keys. + $slug = match (true) { + str_starts_with($host, 'care.') => 'care', + str_starts_with($host, 'meet.') => 'meet', + str_starts_with($host, 'queue.') => 'queue', + str_starts_with($host, 'frontdesk.') => 'frontdesk', + str_starts_with($host, 'mail.') => 'mail', + str_starts_with($host, 'email.') => 'email', + default => explode('.', $host)[0] ?? null, + }; + + return $slug && in_array($slug, $allowedSlugs, true); + } )); + + // Single-app staff: no launcher (they already are in their only app). + if ($allowedSlugs !== null && count($allowedSlugs) <= 1) { + $launcherApps = []; + } + $launcherOverflows = count($launcherApps) > 15; @endphp -@if (! empty($launcherApps)) +@if (! empty($launcherApps) && \App\Support\StaffUx::showProductHub(auth()->user()))