diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 0530385..32b42c2 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -3,9 +3,14 @@ namespace App\Http\Controllers; use App\Features\Desktop\MvpShell; +use App\Models\InstanceConnection; +use App\Models\User; use App\Models\Workspace; use App\Services\Surreal\SurrealRuntimeManager; +use App\Support\Connections\InstanceConnectionManager; use App\Support\Features\DesktopUi; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; use RuntimeException; @@ -14,7 +19,7 @@ class HomeController extends Controller { /** - * @return array, * decisions: array, * messages: array - * }> + * } */ - private function workspaces(bool $localReady): array + private function activeWorkspaceState(InstanceConnection $activeConnection, bool $localReady): array { - return [ - 'katra-local' => [ - 'slug' => 'katra-local', - 'label' => 'Katra Local', - 'meta' => 'Local', - 'prefix' => 'K', - 'summary' => $localReady - ? 'A local-first workspace on this device with the embedded Surreal runtime already available.' - : 'A local-first workspace on this device for conversations, tasks, artifacts, and decisions.', - 'room' => '# design-room', - 'roomStatus' => $localReady ? 'local' : 'draft', - 'participants' => [ - ['label' => 'You', 'meta' => 'Human'], - ['label' => 'Planner Agent', 'meta' => 'Worker'], - ['label' => 'Research Model', 'meta' => 'Model'], - ['label' => 'Context Agent', 'meta' => 'Worker'], - ], - 'notes' => [ - 'The core room should feel durable enough that work keeps accumulating here over time.', - 'Tasks, artifacts, and decisions should stay linked without overtaking the room itself.', - 'The first desktop shell should stay generic enough to seed other local-first products later on.', - ], - 'tasks' => [ - [ - 'label' => 'Shape the MVP shell', - 'status' => 'In review', - 'summary' => 'Tighten the room layout, spacing, and navigation so the shell feels like an app instead of a staged page.', - ], - [ - 'label' => 'Introduce connection switching', - 'status' => 'Queued', - 'summary' => 'Make room for local and remote instances without changing the top-level layout.', - ], - [ - 'label' => 'Refine linked work', - 'status' => 'Draft', - 'summary' => 'Keep tasks, artifacts, and decisions close to the room without turning the whole shell into a dashboard.', - ], - ], - 'artifacts' => [ - [ - 'label' => 'Room layout', - 'kind' => 'Note', - 'summary' => 'Current draft for the center room and right-side context split.', - ], - [ - 'label' => 'Brand guide', - 'kind' => 'Guide', - 'summary' => 'Nord palette, quieter type, and the current Katra identity rules.', - ], - ], - 'decisions' => [ - [ - 'label' => 'Persistent rooms', - 'owner' => 'Product', - 'summary' => 'One room per participant set should replace disposable transcript threads.', - ], - [ - 'label' => 'Diagnostics stay hidden', - 'owner' => 'Desktop', - 'summary' => 'Connection and runtime details belong in logs or a developer view, not in the main shell.', - ], - ], - 'messages' => [ - [ - 'speaker' => 'You', - 'role' => 'Human', - 'tone' => 'plain', - 'meta' => 'Just now', - 'body' => 'This room should feel like the place where work already lives, not like a landing page that still needs to explain itself.', - ], - [ - 'speaker' => 'Planner Agent', - 'role' => 'Agent', - 'tone' => 'accent', - 'meta' => 'Note', - 'body' => 'Keep the center focused on the room. Tasks, artifacts, and decisions can stay visible in the context rail without taking over the main flow.', - ], - [ - 'speaker' => 'Research Model', - 'role' => 'Model', - 'tone' => 'subtle', - 'meta' => 'Draft', - 'body' => 'The layout should stay generic enough to support other local-first products later: rooms on the left, active work in the center, linked context on the right.', - ], - ], - ], - 'design-lab' => [ - 'slug' => 'design-lab', - 'label' => 'Design Lab', - 'meta' => 'Local', - 'prefix' => 'D', - 'summary' => 'A quieter workspace for shell structure, navigation studies, and UI direction.', - 'room' => '# shell-studies', - 'roomStatus' => 'draft', - 'participants' => [ - ['label' => 'You', 'meta' => 'Human'], - ['label' => 'Visual Agent', 'meta' => 'Worker'], - ['label' => 'Layout Model', 'meta' => 'Model'], - ['label' => 'Critique Agent', 'meta' => 'Worker'], - ], - 'notes' => [ - 'Reduce surface noise until the room can carry the experience on its own.', - 'Treat navigation as durable structure, not as temporary marketing copy.', - 'Let component work stay atomic so the shell can evolve without rewrites.', - ], - 'tasks' => [ - [ - 'label' => 'Tighten sidebar density', - 'status' => 'Active', - 'summary' => 'Remove truncation pressure and make the workspace selector feel native to the app frame.', - ], - [ - 'label' => 'Calm the center room', - 'status' => 'Queued', - 'summary' => 'Strip away explanatory blocks until the room reads clearly without helping text.', - ], - ], - 'artifacts' => [ - [ - 'label' => 'Sidebar studies', - 'kind' => 'Mock', - 'summary' => 'Current workspace selector and room list direction.', - ], - [ - 'label' => 'Type rhythm', - 'kind' => 'Spec', - 'summary' => 'Reduced tracking and line-height targets for the desktop shell.', - ], - ], - 'decisions' => [ - [ - 'label' => 'No marketing copy in-app', - 'owner' => 'Design', - 'summary' => 'The desktop shell should assume the user already chose to be here.', - ], - ], - 'messages' => [ - [ - 'speaker' => 'You', - 'role' => 'Human', - 'tone' => 'plain', - 'meta' => 'Now', - 'body' => 'Let the workspace selector behave like a selector, not like a section accordion.', - ], - [ - 'speaker' => 'Visual Agent', - 'role' => 'Agent', - 'tone' => 'accent', - 'meta' => 'Draft', - 'body' => 'Wider rails and calmer spacing are helping, but the shell still needs less explanation and more structure.', - ], - ], - ], - 'relay-cloud' => [ - 'slug' => 'relay-cloud', - 'label' => 'Relay Cloud', - 'meta' => 'Remote', - 'prefix' => 'R', - 'summary' => 'A remote instance view for shared orchestration, worker presence, and linked team context.', + if ($activeConnection->kind === InstanceConnection::KIND_SERVER) { + return [ + 'slug' => 'remote-instance', + 'label' => $activeConnection->name, + 'meta' => $this->connectionMeta($activeConnection), + 'prefix' => $this->connectionPrefix($activeConnection), + 'summary' => sprintf( + 'A connected server workspace for shared orchestration, worker presence, and linked team context on %s.', + $activeConnection->name, + ), 'room' => '# relay-ops', 'roomStatus' => 'remote', 'participants' => [ @@ -247,50 +102,179 @@ private function workspaces(bool $localReady): array 'body' => 'Desktop workers can stay available to authenticated instances while the main queue remains server-owned.', ], ], + ]; + } + + return [ + 'slug' => 'current-instance', + 'label' => $activeConnection->name, + 'meta' => $this->connectionMeta($activeConnection), + 'prefix' => $this->connectionPrefix($activeConnection), + 'summary' => $localReady + ? 'The embedded Surreal runtime is available and the primary workspace is ready.' + : 'A primary workspace on this instance for conversations, tasks, artifacts, and decisions.', + 'room' => '# design-room', + 'roomStatus' => $localReady ? 'ready' : 'draft', + 'participants' => [ + ['label' => 'You', 'meta' => 'Human'], + ['label' => 'Planner Agent', 'meta' => 'Worker'], + ['label' => 'Research Model', 'meta' => 'Model'], + ['label' => 'Context Agent', 'meta' => 'Worker'], + ], + 'notes' => [ + 'The core room should feel durable enough that work keeps accumulating here over time.', + 'Tasks, artifacts, and decisions should stay linked without overtaking the room itself.', + 'The first desktop shell should stay generic enough to seed other local-first products later on.', + ], + 'tasks' => [ + [ + 'label' => 'Shape the MVP shell', + 'status' => 'In review', + 'summary' => 'Tighten the room layout, spacing, and navigation so the shell feels like an app instead of a staged page.', + ], + [ + 'label' => 'Introduce connection switching', + 'status' => 'Queued', + 'summary' => 'Make room for local and remote instances without changing the top-level layout.', + ], + [ + 'label' => 'Refine linked work', + 'status' => 'Draft', + 'summary' => 'Keep tasks, artifacts, and decisions close to the room without turning the whole shell into a dashboard.', + ], + ], + 'artifacts' => [ + [ + 'label' => 'Room layout', + 'kind' => 'Note', + 'summary' => 'Current draft for the center room and right-side context split.', + ], + [ + 'label' => 'Brand guide', + 'kind' => 'Guide', + 'summary' => 'Nord palette, quieter type, and the current Katra identity rules.', + ], + ], + 'decisions' => [ + [ + 'label' => 'Persistent rooms', + 'owner' => 'Product', + 'summary' => 'One room per participant set should replace disposable transcript threads.', + ], + [ + 'label' => 'Diagnostics stay hidden', + 'owner' => 'Desktop', + 'summary' => 'Connection and runtime details belong in logs or a developer view, not in the main shell.', + ], + ], + 'messages' => [ + [ + 'speaker' => 'You', + 'role' => 'Human', + 'tone' => 'plain', + 'meta' => 'Just now', + 'body' => 'This room should feel like the place where work already lives, not like a landing page that still needs to explain itself.', + ], + [ + 'speaker' => 'Planner Agent', + 'role' => 'Agent', + 'tone' => 'accent', + 'meta' => 'Note', + 'body' => 'Keep the center focused on the room. Tasks, artifacts, and decisions can stay visible in the context rail without taking over the main flow.', + ], + [ + 'speaker' => 'Research Model', + 'role' => 'Model', + 'tone' => 'subtle', + 'meta' => 'Draft', + 'body' => 'The layout should stay generic enough to support other local-first products later: rooms on the left, active work in the center, linked context on the right.', + ], ], ]; } /** - * @param array $connections + * @return array $workspaces - * @return array + * active: bool, + * prefix: string, + * baseUrl: string|null, + * authenticated: bool, + * accountEmail: string|null, + * isCurrentInstance: bool + * }> */ - private function workspaceLinks(array $workspaces, string $activeWorkspace): array + private function connectionLinks(EloquentCollection $connections, InstanceConnection $activeConnection): array { - return collect($workspaces) - ->map(fn (array $workspace): array => [ - 'label' => $workspace['label'], - 'meta' => $workspace['meta'], - 'active' => $workspace['slug'] === $activeWorkspace, - 'prefix' => $workspace['prefix'], - 'href' => route('home', ['workspace' => $workspace['slug']]), - ]) + return $connections + ->map(function (InstanceConnection $connection) use ($activeConnection): array { + $remoteEmail = $connection->kind === InstanceConnection::KIND_SERVER + ? data_get($connection->session_context, 'user.email') ?? data_get($connection->session_context, 'email') + : null; + + return [ + 'id' => (int) $connection->getKey(), + 'label' => $connection->name, + 'meta' => $this->connectionMeta($connection), + 'active' => (int) $connection->getKey() === (int) $activeConnection->getKey(), + 'prefix' => $this->connectionPrefix($connection), + 'baseUrl' => $connection->base_url, + 'authenticated' => $connection->is_authenticated, + 'accountEmail' => is_string($remoteEmail) && $remoteEmail !== '' ? $remoteEmail : $connection->user?->email, + 'isCurrentInstance' => $connection->is_current_instance, + ]; + }) ->values() ->all(); } /** + * @param array{ + * room: string, + * participants: array + * } $workspace * @return array */ - private function favoriteLinks(string $activeRoom): array + private function favoriteLinks(array $workspace, string $viewerName): array { - return [ - ['label' => $activeRoom, 'active' => true, 'prefix' => '#', 'tone' => 'room'], - ['label' => 'Derek Bourgeois', 'prefix' => 'D', 'tone' => 'human'], - ['label' => 'Planner Agent', 'prefix' => '@', 'tone' => 'bot'], + $favorites = [ + ['label' => $workspace['room'], 'active' => true, 'prefix' => '#', 'tone' => 'room'], + ['label' => $viewerName, 'prefix' => substr($viewerName, 0, 1), 'tone' => 'human'], ]; + + foreach ($workspace['participants'] as $participant) { + if ($participant['meta'] === 'Human') { + continue; + } + + $favorites[] = [ + 'label' => $participant['label'], + 'prefix' => '@', + 'tone' => 'bot', + ]; + + break; + } + + return $favorites; } /** * @return array */ - private function roomLinks(string $activeRoom): array + private function roomLinks(InstanceConnection $activeConnection, string $activeRoom): array { + if ($activeConnection->kind === InstanceConnection::KIND_SERVER) { + return [ + ['label' => $activeRoom, 'active' => true, 'prefix' => '#', 'tone' => 'room'], + ['label' => '# worker-queue', 'prefix' => '#', 'tone' => 'room'], + ['label' => 'Operators', 'prefix' => 'O', 'tone' => 'human'], + ]; + } + return [ ['label' => $activeRoom, 'active' => true, 'prefix' => '#', 'tone' => 'room'], ['label' => '# product-direction', 'prefix' => '#', 'tone' => 'room'], @@ -299,18 +283,32 @@ private function roomLinks(string $activeRoom): array } /** + * @param array $participants * @return array */ - private function chatLinks(): array + private function chatLinks(array $participants, string $viewerName): array { - return [ - ['label' => 'Derek Bourgeois', 'prefix' => 'D', 'tone' => 'human'], - ['label' => 'Planner Agent', 'prefix' => '@', 'tone' => 'bot'], - ['label' => 'Research Model', 'prefix' => '@', 'tone' => 'bot'], + $links = [ + ['label' => $viewerName, 'prefix' => substr($viewerName, 0, 1), 'tone' => 'human'], ]; + + foreach ($participants as $participant) { + if ($participant['meta'] === 'Human') { + continue; + } + + $links[] = [ + 'label' => $participant['label'], + 'prefix' => '@', + 'tone' => 'bot', + ]; + } + + return array_slice($links, 0, 3); } /** + * @param array $participants * @return array */ - private function chatContacts(): array + private function chatContacts(array $participants, string $viewerName): array { - return [ - [ - 'label' => 'Derek Bourgeois', - 'value' => 'derek-bourgeois', - 'prefix' => 'D', - 'tone' => 'human', - 'subtitle' => 'Human', - ], - [ - 'label' => 'Planner Agent', - 'value' => 'planner-agent', - 'prefix' => '@', - 'tone' => 'bot', - 'subtitle' => 'Agent', - ], - [ - 'label' => 'Research Model', - 'value' => 'research-model', + $contacts = [[ + 'label' => $viewerName, + 'value' => str($viewerName)->slug()->value(), + 'prefix' => substr($viewerName, 0, 1), + 'tone' => 'human', + 'subtitle' => 'Human', + ]]; + + foreach ($participants as $participant) { + if ($participant['meta'] === 'Human') { + continue; + } + + $contacts[] = [ + 'label' => $participant['label'], + 'value' => str($participant['label'])->slug()->value(), 'prefix' => '@', 'tone' => 'bot', - 'subtitle' => 'Model', - ], - ]; - } + 'subtitle' => $participant['meta'], + ]; + } - /** - * @return array - */ - private function workspaceTargets(): array - { - return [ - ['label' => 'Katra Local', 'value' => 'local'], - ['label' => 'Relay Cloud', 'value' => 'relay-cloud'], - ]; + return $contacts; } /** @@ -467,8 +454,11 @@ private function conversationNodeTabs(array $workspace): array ]; } - public function __invoke(Request $request, SurrealRuntimeManager $runtimeManager): View - { + public function __invoke( + Request $request, + SurrealRuntimeManager $runtimeManager, + InstanceConnectionManager $connectionManager, + ): View|RedirectResponse { $localReady = false; $desktopUiStates = DesktopUi::states(); $mvpShellEnabled = DesktopUi::enabled($desktopUiStates, MvpShell::class); @@ -484,23 +474,109 @@ public function __invoke(Request $request, SurrealRuntimeManager $runtimeManager } } - $workspaces = $this->workspaces($localReady); - $selectedWorkspace = $request->string('workspace')->value(); - $activeWorkspace = array_key_exists($selectedWorkspace, $workspaces) ? $selectedWorkspace : 'katra-local'; - $activeWorkspaceState = $workspaces[$activeWorkspace]; + $activeConnection = $connectionManager->activeConnectionFor( + $request->user(), + $request->root(), + $request->session(), + ); + + $viewerIdentity = $this->viewerIdentity($request->user(), $activeConnection); + $viewerName = $viewerIdentity['name']; + + if ($activeConnection->kind === InstanceConnection::KIND_SERVER && ! $activeConnection->is_authenticated) { + return to_route('connections.connect', $activeConnection); + } + + $connections = $connectionManager->connectionsFor($request->user()); + $activeWorkspace = $this->activeWorkspaceState($activeConnection, $localReady); return view('welcome', [ 'mvpShellEnabled' => $mvpShellEnabled, - 'activeWorkspace' => $activeWorkspaceState, - 'workspaceLinks' => $this->workspaceLinks($workspaces, $activeWorkspace), - 'workspaceTargets' => $this->workspaceTargets(), - 'favoriteLinks' => $this->favoriteLinks($activeWorkspaceState['room']), - 'roomLinks' => $this->roomLinks($activeWorkspaceState['room']), - 'chatLinks' => $this->chatLinks(), - 'chatContacts' => $this->chatContacts(), - 'conversationNodeTabs' => $this->conversationNodeTabs($activeWorkspaceState), - 'messages' => $activeWorkspaceState['messages'], - 'participants' => $activeWorkspaceState['participants'], + 'activeConnection' => $activeConnection, + 'connectionLinks' => $this->connectionLinks($connections, $activeConnection), + 'activeWorkspace' => $activeWorkspace, + 'favoriteLinks' => $this->favoriteLinks($activeWorkspace, $viewerName), + 'roomLinks' => $this->roomLinks($activeConnection, $activeWorkspace['room']), + 'chatLinks' => $this->chatLinks($activeWorkspace['participants'], $viewerName), + 'chatContacts' => $this->chatContacts($activeWorkspace['participants'], $viewerName), + 'conversationNodeTabs' => $this->conversationNodeTabs($activeWorkspace), + 'messages' => $activeWorkspace['messages'], + 'participants' => $activeWorkspace['participants'], + 'viewerName' => $viewerIdentity['name'], + 'viewerEmail' => $viewerIdentity['email'], + 'viewerInitials' => $viewerIdentity['initials'], ]); } + + private function connectionMeta(InstanceConnection $connection): string + { + if ($connection->kind === InstanceConnection::KIND_CURRENT_INSTANCE) { + return 'This instance'; + } + + if (! $connection->is_authenticated) { + return 'Sign in'; + } + + return parse_url((string) $connection->base_url, PHP_URL_HOST) ?: 'Server'; + } + + private function connectionPrefix(InstanceConnection $connection): string + { + return strtoupper(substr($connection->name, 0, 1)); + } + + /** + * @return array{name: string, email: string, initials: string} + */ + private function viewerIdentity(?User $viewer, InstanceConnection $activeConnection): array + { + $remoteIdentity = $activeConnection->kind === InstanceConnection::KIND_SERVER + ? data_get($activeConnection->session_context, 'user') + : null; + $remoteEmail = $activeConnection->kind === InstanceConnection::KIND_SERVER + ? data_get($activeConnection->session_context, 'email') + : null; + $remoteName = data_get($remoteIdentity, 'name'); + + if ((! is_string($remoteName) || $remoteName === '') && is_string($remoteEmail) && $remoteEmail !== '') { + $remoteName = $this->nameFromEmail($remoteEmail); + } + + $name = $remoteName + ?: $viewer?->name + ?: 'Derek Bourgeois'; + + $email = data_get($remoteIdentity, 'email') + ?: $remoteEmail + ?: $viewer?->email + ?: 'derek@katra.io'; + + $initials = collect(preg_split('/\s+/', trim($name)) ?: []) + ->filter() + ->take(2) + ->map(fn (string $segment): string => strtoupper(substr($segment, 0, 1))) + ->implode(''); + + return [ + 'name' => $name, + 'email' => $email, + 'initials' => $initials !== '' ? $initials : 'K', + ]; + } + + private function nameFromEmail(string $email): string + { + $localPart = (string) str($email)->before('@'); + $segments = preg_split('/[._-]+/', $localPart) ?: []; + $segments = array_values(array_filter(array_map( + fn (string $segment): string => str($segment)->title()->value(), + $segments, + ))); + + $firstName = $segments[0] ?? 'Remote'; + $lastName = count($segments) > 1 ? implode(' ', array_slice($segments, 1)) : 'User'; + + return trim($firstName.' '.$lastName); + } } diff --git a/app/Http/Controllers/InstanceConnectionController.php b/app/Http/Controllers/InstanceConnectionController.php new file mode 100644 index 0000000..f114128 --- /dev/null +++ b/app/Http/Controllers/InstanceConnectionController.php @@ -0,0 +1,337 @@ + $request->session()->get(self::PENDING_SERVER_URL_SESSION_KEY), + 'pendingServerName' => $request->session()->get(self::PENDING_SERVER_NAME_SESSION_KEY), + ]); + } + + public function prepareServerLogin( + ConnectServerRequest $request, + InstanceConnectionManager $connectionManager, + ): RedirectResponse { + $serverUrl = $connectionManager->normalizeUrl($request->validated('server_url')); + + $request->session()->put(self::PENDING_SERVER_URL_SESSION_KEY, $serverUrl); + $request->session()->put(self::PENDING_SERVER_NAME_SESSION_KEY, $this->connectionNameFromUrl($serverUrl)); + + return to_route('server.connect'); + } + + public function store(StoreInstanceConnectionRequest $request, InstanceConnectionManager $connectionManager): RedirectResponse + { + $connection = $connectionManager->createServerConnection( + $request->user(), + $request->validated(), + $request->session(), + ); + + if ($connection->is_authenticated) { + return to_route('home'); + } + + return to_route('connections.connect', $connection); + } + + public function update( + UpdateInstanceConnectionRequest $request, + InstanceConnection $instanceConnection, + InstanceConnectionManager $connectionManager, + ): RedirectResponse { + $this->ensureConnectionOwnership($request, $instanceConnection); + + if ($instanceConnection->is_current_instance) { + $connectionManager->updateCurrentInstanceConnection( + $instanceConnection, + $request->validated('name'), + ); + + return to_route('home'); + } + + $connectionManager->updateServerConnection($instanceConnection, $request->validated()); + + if ( + (int) $request->session()->get('instance_connection.active_id') === (int) $instanceConnection->getKey() + && ! $instanceConnection->fresh()->is_authenticated + ) { + return to_route('connections.connect', $instanceConnection); + } + + return to_route('home'); + } + + public function activate(Request $request, InstanceConnection $instanceConnection, InstanceConnectionManager $connectionManager): RedirectResponse + { + $this->ensureConnectionOwnership($request, $instanceConnection); + + $connectionManager->activate($instanceConnection, $request->session()); + + if ($instanceConnection->kind === InstanceConnection::KIND_SERVER && ! $instanceConnection->is_authenticated) { + return to_route('connections.connect', $instanceConnection); + } + + return to_route('home'); + } + + public function destroy( + Request $request, + InstanceConnection $instanceConnection, + InstanceConnectionManager $connectionManager, + ): RedirectResponse { + $this->ensureConnectionOwnership($request, $instanceConnection); + $this->ensureServerConnection($instanceConnection); + + $isActiveConnection = (int) $request->session()->get('instance_connection.active_id') === (int) $instanceConnection->getKey(); + $connectionOwner = $instanceConnection->user; + + $instanceConnection->delete(); + + if (! $isActiveConnection) { + return to_route('home'); + } + + $fallbackConnection = $connectionOwner->instanceConnections() + ->where('kind', InstanceConnection::KIND_CURRENT_INSTANCE) + ->latest('last_used_at') + ->first() + ?? $connectionManager->ensureCurrentInstanceConnection($connectionOwner, $request->root()); + + Auth::login($fallbackConnection->user); + $connectionManager->activate($fallbackConnection, $request->session()); + + return to_route('home'); + } + + public function connect(Request $request, InstanceConnection $instanceConnection): View + { + $this->ensureConnectionOwnership($request, $instanceConnection); + + return view('auth.connect-server', [ + 'instanceConnection' => $instanceConnection, + ]); + } + + public function authenticate( + AuthenticateRemoteInstanceConnectionRequest $request, + InstanceConnection $instanceConnection, + InstanceConnectionManager $connectionManager, + RemoteInstanceAuthenticator $remoteInstanceAuthenticator, + ): RedirectResponse { + $this->ensureConnectionOwnership($request, $instanceConnection); + + $credentials = $request->validated(); + $sessionContext = $remoteInstanceAuthenticator->authenticate($instanceConnection, $credentials); + + $connectionManager->rememberServerAuthentication( + $request->user(), + $instanceConnection, + $sessionContext, + $request->session(), + ); + + return to_route('home'); + } + + public function authenticateGuestServer( + AuthenticateRemoteInstanceConnectionRequest $request, + InstanceConnectionManager $connectionManager, + RemoteInstanceAuthenticator $remoteInstanceAuthenticator, + ): RedirectResponse { + $serverUrl = $request->session()->get(self::PENDING_SERVER_URL_SESSION_KEY); + + if (! is_string($serverUrl) || $serverUrl === '') { + return to_route('server.connect'); + } + + $connectionName = $request->session()->get(self::PENDING_SERVER_NAME_SESSION_KEY, $this->connectionNameFromUrl($serverUrl)); + + $temporaryConnection = new InstanceConnection([ + 'name' => $connectionName, + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => $serverUrl, + ]); + + $credentials = $request->validated(); + $sessionContext = $remoteInstanceAuthenticator->authenticate($temporaryConnection, $credentials); + $user = $this->resolveRemoteUser( + $credentials['email'], + is_array(data_get($sessionContext, 'user')) ? data_get($sessionContext, 'user') : null, + ); + + Auth::login($user); + + $connection = $connectionManager->createServerConnection( + $user, + [ + 'name' => $connectionName, + 'base_url' => $serverUrl, + ], + $request->session(), + ); + + $connectionManager->rememberServerAuthentication( + $user, + $connection, + $sessionContext, + $request->session(), + ); + + $request->session()->forget([ + self::PENDING_SERVER_URL_SESSION_KEY, + self::PENDING_SERVER_NAME_SESSION_KEY, + ]); + + return to_route('home'); + } + + public function profile(Request $request): JsonResponse + { + $user = $request->user(); + + return response()->json([ + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'name' => $user->name, + 'email' => $user->email, + ]); + } + + private function connectionNameFromUrl(string $url): string + { + $host = parse_url($url, PHP_URL_HOST); + + if (! is_string($host) || $host === '') { + return 'Server connection'; + } + + return str($host) + ->replace(['.test', '.local', '.localhost'], '') + ->replace(['-', '_', '.'], ' ') + ->title() + ->value(); + } + + private function ensureServerConnection(InstanceConnection $instanceConnection): void + { + if ($instanceConnection->kind !== InstanceConnection::KIND_SERVER) { + abort(404); + } + } + + private function ensureConnectionOwnership(Request $request, InstanceConnection $instanceConnection): void + { + if ((int) $instanceConnection->user_id !== (int) $request->user()->getKey()) { + abort(404); + } + } + + /** + * @param array{name?: string, email?: string, first_name?: string, last_name?: string}|null $remoteIdentity + */ + private function resolveRemoteUser(string $email, ?array $remoteIdentity = null): User + { + $existingUser = User::query()->where('email', $email)->first(); + + if ($existingUser instanceof User) { + $identity = $this->remoteIdentityPayload($email, $remoteIdentity); + + $existingUser->forceFill([ + 'first_name' => $identity['first_name'], + 'last_name' => $identity['last_name'], + 'name' => $identity['name'], + ])->save(); + + return $existingUser; + } + + $nameParts = $this->remoteIdentityPayload($email, $remoteIdentity); + + return User::query()->create([ + 'first_name' => $nameParts['first_name'], + 'last_name' => $nameParts['last_name'], + 'name' => $nameParts['name'], + 'email' => $email, + 'email_verified_at' => now(), + 'password' => Str::random(40), + ]); + } + + /** + * @return array{first_name: string, last_name: string, name: string, email: string} + */ + private function remoteIdentityPayload(string $email, ?array $remoteIdentity = null): array + { + $firstName = is_string(data_get($remoteIdentity, 'first_name')) && data_get($remoteIdentity, 'first_name') !== '' + ? data_get($remoteIdentity, 'first_name') + : null; + $lastName = is_string(data_get($remoteIdentity, 'last_name')) && data_get($remoteIdentity, 'last_name') !== '' + ? data_get($remoteIdentity, 'last_name') + : null; + $name = is_string(data_get($remoteIdentity, 'name')) && data_get($remoteIdentity, 'name') !== '' + ? data_get($remoteIdentity, 'name') + : null; + + if ($firstName === null || $lastName === null || $name === null) { + $derivedNameParts = $this->namePartsFromEmail($email); + + $firstName ??= $derivedNameParts['first_name']; + $lastName ??= $derivedNameParts['last_name']; + $name ??= $derivedNameParts['name']; + } + + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'name' => $name, + 'email' => $email, + ]; + } + + /** + * @return array{first_name: string, last_name: string, name: string} + */ + private function namePartsFromEmail(string $email): array + { + $localPart = (string) str($email)->before('@'); + $segments = preg_split('/[._-]+/', $localPart) ?: []; + $segments = array_values(array_filter(array_map( + fn (string $segment): string => str($segment)->title()->value(), + $segments, + ))); + + $firstName = $segments[0] ?? 'Remote'; + $lastName = count($segments) > 1 ? implode(' ', array_slice($segments, 1)) : 'User'; + + return [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'name' => trim($firstName.' '.$lastName), + ]; + } +} diff --git a/app/Http/Requests/AuthenticateRemoteInstanceConnectionRequest.php b/app/Http/Requests/AuthenticateRemoteInstanceConnectionRequest.php new file mode 100644 index 0000000..572f438 --- /dev/null +++ b/app/Http/Requests/AuthenticateRemoteInstanceConnectionRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email:rfc', 'max:255'], + 'password' => ['required', 'string'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'email.required' => 'Enter the email address for the server account you want to use.', + 'email.email' => 'Enter a valid server account email address.', + 'password.required' => 'Enter the password for the selected server connection.', + ]; + } +} diff --git a/app/Http/Requests/ConnectServerRequest.php b/app/Http/Requests/ConnectServerRequest.php new file mode 100644 index 0000000..5874a68 --- /dev/null +++ b/app/Http/Requests/ConnectServerRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'server_url' => ['required', 'url:http,https', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'server_url.required' => 'Enter the Katra server URL you want to use.', + 'server_url.url' => 'Use a full server URL like https://katra.example.com.', + ]; + } +} diff --git a/app/Http/Requests/StoreInstanceConnectionRequest.php b/app/Http/Requests/StoreInstanceConnectionRequest.php new file mode 100644 index 0000000..3af2ca8 --- /dev/null +++ b/app/Http/Requests/StoreInstanceConnectionRequest.php @@ -0,0 +1,36 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'base_url' => ['required', 'url:http,https', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'base_url.required' => 'Enter the Katra server URL you want to connect to.', + 'base_url.url' => 'Use a full server URL like https://katra.example.com.', + ]; + } +} diff --git a/app/Http/Requests/UpdateInstanceConnectionRequest.php b/app/Http/Requests/UpdateInstanceConnectionRequest.php new file mode 100644 index 0000000..2c55b58 --- /dev/null +++ b/app/Http/Requests/UpdateInstanceConnectionRequest.php @@ -0,0 +1,45 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + $instanceConnection = $this->route('instanceConnection'); + + if ($instanceConnection instanceof InstanceConnection && $instanceConnection->is_current_instance) { + return [ + 'name' => ['nullable', 'string', 'max:255'], + ]; + } + + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'base_url' => ['required', 'url:http,https', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'base_url.required' => 'Enter the Katra server URL you want to save.', + 'base_url.url' => 'Use a full server URL like https://katra.example.com.', + ]; + } +} diff --git a/app/Models/InstanceConnection.php b/app/Models/InstanceConnection.php new file mode 100644 index 0000000..ef63731 --- /dev/null +++ b/app/Models/InstanceConnection.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'session_context' => 'encrypted:array', + 'last_authenticated_at' => 'datetime', + 'last_used_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function summary(): Attribute + { + return Attribute::make( + get: fn (): string => $this->kind === self::KIND_CURRENT_INSTANCE + ? 'This Katra instance' + : ($this->base_url ?? 'Remote server'), + ); + } + + protected function isCurrentInstance(): Attribute + { + return Attribute::make( + get: fn (): bool => $this->kind === self::KIND_CURRENT_INSTANCE, + ); + } + + protected function isAuthenticated(): Attribute + { + return Attribute::make( + get: fn (): bool => $this->last_authenticated_at !== null, + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 34473d5..e035015 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -44,4 +45,12 @@ protected function name(): Attribute }, ); } + + /** + * @return HasMany + */ + public function instanceConnections(): HasMany + { + return $this->hasMany(InstanceConnection::class); + } } diff --git a/app/Support/Connections/InstanceConnectionManager.php b/app/Support/Connections/InstanceConnectionManager.php new file mode 100644 index 0000000..08f564a --- /dev/null +++ b/app/Support/Connections/InstanceConnectionManager.php @@ -0,0 +1,198 @@ + + */ + public function connectionsFor(User $user): Collection + { + return $user->instanceConnections() + ->orderByDesc('last_used_at') + ->orderBy('name') + ->get() + ->values(); + } + + public function activeConnectionFor(User $user, string $currentInstanceUrl, Session $session): InstanceConnection + { + $connections = $this->connectionsFor($user); + $activeConnectionId = $session->get(self::ACTIVE_CONNECTION_SESSION_KEY); + $activeConnection = $connections->firstWhere('id', $activeConnectionId); + + if (! $activeConnection instanceof InstanceConnection) { + $activeConnection = $connections->firstWhere('kind', InstanceConnection::KIND_CURRENT_INSTANCE) + ?? $this->ensureCurrentInstanceConnection($user, $currentInstanceUrl); + } + + $this->activate($activeConnection, $session); + + return $activeConnection; + } + + /** + * @param array{name: string, base_url: string} $attributes + */ + public function createServerConnection(User $user, array $attributes, Session $session): InstanceConnection + { + $baseUrl = $this->normalizeUrl($attributes['base_url']); + + $connection = $user->instanceConnections()->updateOrCreate( + [ + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => $baseUrl, + ], + [ + 'name' => $this->connectionName($attributes['name'] ?? null, $baseUrl), + ], + ); + + $this->activate($connection, $session); + + return $connection; + } + + /** + * @param array{name: string|null, base_url: string} $attributes + */ + public function updateServerConnection(InstanceConnection $connection, array $attributes): InstanceConnection + { + $baseUrl = $this->normalizeUrl($attributes['base_url']); + $baseUrlChanged = $connection->base_url !== $baseUrl; + + $payload = [ + 'name' => $this->connectionName($attributes['name'] ?? null, $baseUrl), + 'base_url' => $baseUrl, + ]; + + if ($baseUrlChanged) { + $payload['session_context'] = null; + $payload['last_authenticated_at'] = null; + } + + $connection->forceFill($payload)->save(); + + return $connection; + } + + public function updateCurrentInstanceConnection(InstanceConnection $connection, ?string $name): InstanceConnection + { + $connection->forceFill([ + 'name' => trim((string) $name) !== '' ? trim((string) $name) : $this->applicationConnectionName(), + ])->save(); + + return $connection; + } + + public function activate(InstanceConnection $connection, Session $session): void + { + $session->put(self::ACTIVE_CONNECTION_SESSION_KEY, $connection->getKey()); + + $attributes = [ + 'last_used_at' => now(), + ]; + + if ($connection->kind === InstanceConnection::KIND_CURRENT_INSTANCE && $connection->last_authenticated_at === null) { + $attributes['last_authenticated_at'] = now(); + } + + $connection->forceFill($attributes)->save(); + } + + public function ensureCurrentInstanceConnection(User $user, string $currentInstanceUrl): InstanceConnection + { + $connection = $user->instanceConnections()->firstOrCreate( + [ + 'kind' => InstanceConnection::KIND_CURRENT_INSTANCE, + 'base_url' => $this->normalizeUrl($currentInstanceUrl), + ], + [ + 'name' => $this->applicationConnectionName(), + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + ], + ); + + return $connection; + } + + /** + * @param array $sessionContext + */ + public function rememberServerAuthentication(User $user, InstanceConnection $connection, array $sessionContext, Session $session): void + { + if ((int) $connection->user_id !== (int) $user->getKey()) { + abort(404); + } + + $session->put(self::ACTIVE_CONNECTION_SESSION_KEY, $connection->getKey()); + + $connection->forceFill([ + 'session_context' => $sessionContext, + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + ])->save(); + } + + public function clearActiveConnection(Session $session): void + { + $session->forget(self::ACTIVE_CONNECTION_SESSION_KEY); + } + + public function normalizeUrl(string $url): string + { + $normalized = parse_url(trim($url)); + + if (! is_array($normalized) || ! isset($normalized['scheme'], $normalized['host'])) { + return rtrim(trim($url), '/'); + } + + $scheme = strtolower($normalized['scheme']); + $host = strtolower($normalized['host']); + $port = isset($normalized['port']) ? ':'.$normalized['port'] : ''; + $path = trim((string) ($normalized['path'] ?? ''), '/'); + + return sprintf( + '%s://%s%s%s', + $scheme, + $host, + $port, + $path !== '' ? '/'.$path : '', + ); + } + + private function connectionName(?string $name, string $baseUrl): string + { + $trimmedName = trim((string) $name); + + if ($trimmedName !== '') { + return $trimmedName; + } + + $host = parse_url($baseUrl, PHP_URL_HOST); + + if (! is_string($host) || $host === '') { + return 'Server connection'; + } + + return str($host) + ->replace(['.test', '.local', '.localhost'], '') + ->replace(['-', '_', '.'], ' ') + ->title() + ->value(); + } + + private function applicationConnectionName(): string + { + return (string) config('app.name', 'Katra'); + } +} diff --git a/app/Support/Connections/RemoteInstanceAuthenticator.php b/app/Support/Connections/RemoteInstanceAuthenticator.php new file mode 100644 index 0000000..241a4ea --- /dev/null +++ b/app/Support/Connections/RemoteInstanceAuthenticator.php @@ -0,0 +1,212 @@ + + * } + */ + public function authenticate(InstanceConnection $connection, array $credentials): array + { + if (! is_string($connection->base_url) || $connection->base_url === '') { + throw ValidationException::withMessages([ + 'server' => 'This connection does not have a valid server URL yet.', + ]); + } + + $loginUrl = rtrim($connection->base_url, '/').'/login'; + $homeUrl = rtrim($connection->base_url, '/').'/'; + $profileUrl = rtrim($connection->base_url, '/').'/_katra/profile'; + + $loginPageResponse = $this->request()->accept('text/html,application/xhtml+xml') + ->withoutRedirecting() + ->get($loginUrl); + + if (! $loginPageResponse->successful()) { + throw ValidationException::withMessages([ + 'server' => 'Katra could not reach that server login screen.', + ]); + } + + $csrfToken = $this->extractCsrfToken($loginPageResponse); + + if ($csrfToken === null) { + throw ValidationException::withMessages([ + 'server' => 'The selected server did not return a compatible Katra login form.', + ]); + } + + $cookies = $this->mergeCookies([], $loginPageResponse); + + $loginResponse = $this->request()->accept('text/html,application/xhtml+xml') + ->withoutRedirecting() + ->withHeaders([ + 'Cookie' => $this->cookieHeader($cookies), + 'Referer' => $loginUrl, + ]) + ->asForm() + ->post($loginUrl, [ + '_token' => $csrfToken, + 'email' => $credentials['email'], + 'password' => $credentials['password'], + ]); + + $cookies = $this->mergeCookies($cookies, $loginResponse); + + if ($loginResponse->clientError() || $loginResponse->serverError()) { + throw ValidationException::withMessages([ + 'server' => 'The selected server could not complete the sign-in request.', + ]); + } + + $verificationResponse = $this->request()->accept('text/html,application/xhtml+xml') + ->withoutRedirecting() + ->withHeaders([ + 'Cookie' => $this->cookieHeader($cookies), + ]) + ->get($homeUrl); + + if (! $this->isAuthenticatedResponse($verificationResponse)) { + throw ValidationException::withMessages([ + 'email' => 'Those server credentials were not accepted.', + ]); + } + + $profileResponse = $this->request()->acceptJson() + ->withoutRedirecting() + ->withHeaders([ + 'Cookie' => $this->cookieHeader($cookies), + ]) + ->get($profileUrl); + + return [ + 'base_url' => $connection->base_url, + 'email' => $credentials['email'], + 'user' => $this->extractUserProfile($profileResponse), + 'cookies' => $cookies, + ]; + } + + private function request(): PendingRequest + { + return Http::connectTimeout(5)->timeout(15); + } + + /** + * @param array $cookies + * @return array + */ + private function mergeCookies(array $cookies, Response $response): array + { + foreach ($this->cookieHeaders($response) as $header) { + $pair = trim(explode(';', $header, 2)[0]); + + if ($pair === '' || ! str_contains($pair, '=')) { + continue; + } + + [$name, $value] = explode('=', $pair, 2); + + if ($name === '') { + continue; + } + + $cookies[$name] = $value; + } + + return $cookies; + } + + /** + * @return array + */ + private function cookieHeaders(Response $response): array + { + $headers = $response->headers(); + $cookieHeaders = $headers['Set-Cookie'] ?? $headers['set-cookie'] ?? []; + + return array_values(array_filter( + is_array($cookieHeaders) ? $cookieHeaders : [$cookieHeaders], + fn (mixed $header): bool => is_string($header) && $header !== '', + )); + } + + private function extractCsrfToken(Response $response): ?string + { + $matched = preg_match('/name="_token"[^>]*value="([^"]+)"/', $response->body(), $matches); + + if ($matched !== 1) { + return null; + } + + return html_entity_decode($matches[1], ENT_QUOTES); + } + + /** + * @param array $cookies + */ + private function cookieHeader(array $cookies): string + { + return collect($cookies) + ->map(fn (string $value, string $name): string => sprintf('%s=%s', $name, $value)) + ->implode('; '); + } + + private function isAuthenticatedResponse(Response $response): bool + { + if ($response->successful()) { + return true; + } + + if (! $response->redirect()) { + return false; + } + + return ! str_contains($response->header('Location'), '/login'); + } + + /** + * @return array{name: string, email: string, first_name: string, last_name: string}|null + */ + private function extractUserProfile(Response $response): ?array + { + if (! $response->successful()) { + return null; + } + + $payload = $response->json(); + + if (! is_array($payload)) { + return null; + } + + $name = data_get($payload, 'name'); + $email = data_get($payload, 'email'); + $firstName = data_get($payload, 'first_name'); + $lastName = data_get($payload, 'last_name'); + + if (! is_string($name) || ! is_string($email) || ! is_string($firstName) || ! is_string($lastName)) { + return null; + } + + return [ + 'name' => $name, + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + ]; + } +} diff --git a/database/factories/InstanceConnectionFactory.php b/database/factories/InstanceConnectionFactory.php new file mode 100644 index 0000000..719986c --- /dev/null +++ b/database/factories/InstanceConnectionFactory.php @@ -0,0 +1,42 @@ + + */ +class InstanceConnectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fake()->company(), + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://'.fake()->domainName(), + 'session_context' => null, + 'last_authenticated_at' => null, + 'last_used_at' => null, + ]; + } + + public function currentInstance(): static + { + return $this->state(fn (): array => [ + 'name' => 'Katra', + 'kind' => InstanceConnection::KIND_CURRENT_INSTANCE, + 'base_url' => 'https://katra.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_03_24_083829_create_instance_connections_table.php b/database/migrations/2026_03_24_083829_create_instance_connections_table.php new file mode 100644 index 0000000..78059db --- /dev/null +++ b/database/migrations/2026_03_24_083829_create_instance_connections_table.php @@ -0,0 +1,46 @@ +getDriverName(); + + Schema::create('instance_connections', function (Blueprint $table) use ($driver) { + $table->id(); + + if ($driver === 'surreal') { + $table->unsignedBigInteger('user_id'); + } else { + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + } + + $table->string('name'); + $table->string('kind'); + $table->string('base_url')->nullable(); + $table->text('session_context')->nullable(); + $table->timestamp('last_authenticated_at')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['user_id', 'kind', 'base_url'], 'instance_connections_user_kind_url_unique'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('instance_connections'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index f6df5d3..10b3715 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -203,6 +203,16 @@ html[data-shell-theme='light'] { background: var(--shell-elevated); } +.shell-connection-trigger { + transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.shell-connection-trigger:hover { + background: var(--shell-elevated); + box-shadow: inset 0 0 0 1px var(--shell-border); + transform: translateY(-1px); +} + .shell-danger-button { background: var(--shell-danger-soft); color: var(--shell-danger-text); diff --git a/resources/views/auth/connect-server.blade.php b/resources/views/auth/connect-server.blade.php index 5ddd3c6..7b0b1cd 100644 --- a/resources/views/auth/connect-server.blade.php +++ b/resources/views/auth/connect-server.blade.php @@ -2,7 +2,13 @@ @section('title', 'Connect to a Katra server') @section('heading', 'Connect to a server') -@section('copy', 'Use your server account when you want to sign in to a remote Katra instance.') +@section('copy') + @if (isset($instanceConnection) || filled($pendingServerUrl ?? null)) + Sign in to {{ $instanceConnection->name ?? $pendingServerName }} and attach this device to that Katra server. + @else + Use your server account when you want to sign in to a remote Katra instance. + @endif +@endsection @section('account_selector')
@@ -15,47 +21,69 @@ @endsection @section('content') -
-
- - -
- -
- - -
- -
- - -
- - - -

- Remote connection profiles are next. This view keeps the account choice visible now so the shared auth experience does not overfit the desktop-only path. -

-
+ @if (isset($instanceConnection) || filled($pendingServerUrl ?? null)) +
+ @csrf + +
+

{{ $instanceConnection->name ?? $pendingServerName }}

+

{{ $instanceConnection->base_url ?? $pendingServerUrl }}

+
+ +
+ + +
+ +
+ + +
+ + +
+ @else +
+ @csrf + +
+ + +
+ + + +

+ Continue into the selected Katra server without leaving Katra. The next step stays inside the client and asks for that server account. +

+
+ @endif @endsection diff --git a/resources/views/components/desktop/profile-menu.blade.php b/resources/views/components/desktop/profile-menu.blade.php index a14e737..e0ae362 100644 --- a/resources/views/components/desktop/profile-menu.blade.php +++ b/resources/views/components/desktop/profile-menu.blade.php @@ -4,8 +4,8 @@ 'initials', ]) -
- +
+ {{ $initials }} @@ -44,9 +44,6 @@ - diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 83a89c1..6f84273 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -30,7 +30,7 @@ [ 'label' => 'Conversations', 'items' => [ - ['title' => '# design-room', 'meta' => 'Room', 'summary' => 'Current active room in Katra Local.'], + ['title' => '# design-room', 'meta' => 'Room', 'summary' => 'Current active room in '.$activeWorkspace['label'].'.'], ['title' => '# shell-studies', 'meta' => 'Room', 'summary' => 'Design-focused room for layout and navigation work.'], ], ], @@ -81,23 +81,8 @@ ]; @endphp
- @php - $viewer = request()->user(); - $viewerName = $viewer?->name ?? 'Derek Bourgeois'; - $viewerEmail = $viewer?->email ?? 'derek@katra.io'; - $viewerInitials = collect(explode(' ', $viewerName)) - ->filter() - ->take(2) - ->map(fn (string $segment): string => strtoupper(substr($segment, 0, 1))) - ->implode(''); - @endphp
@@ -585,41 +558,179 @@ class="shell-overlay hidden absolute inset-0 z-20" @endif - -
+ +
- - -
+ @foreach ($connectionLinks as $item) +
+ + {{ $item['prefix'] }} + -
- - -

- Local is always available. Connected servers only appear here when this user can create workspaces on them. -

-
+
+

{{ $item['label'] }}

+

+ {{ $item['baseUrl'] ?: 'This instance' }} +

+
-
- - -
+
+ @if ($item['active']) +

Current

+ @else + + @csrf -
- - + + + @endif + + +
+
+ @endforeach
- + +
+ @csrf + +
+

Add a server

+ + +
+ +
+ + +

+ Add a remote Katra server, then sign in only when you want to use it. +

+
+ + @if ($errors->has('name') || $errors->has('base_url')) +
+

{{ $errors->first('name') ?: $errors->first('base_url') }}

+
+ @endif + +
+ + +
+
+
+ @foreach ($connectionLinks as $item) + +
+
+ @csrf + @method('PATCH') + +
+ + +
+ + @unless ($item['isCurrentInstance']) +
+ + +
+ @endunless + + @if ($item['accountEmail']) +

+ Signed in as {{ $item['accountEmail'] }}. +

+ @endif + +
+ + +
+
+ + @unless ($item['isCurrentInstance']) +
+ @csrf + @method('DELETE') + +
+
+

Remove connection

+

+ Delete this saved server from the device. You can add it again later. +

+
+ + +
+
+ @endunless +
+
+ @endforeach +
diff --git a/routes/web.php b/routes/web.php index 78e3b22..cf74ff5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,20 @@ name('server.connect'); +Route::get('/connect-server', [InstanceConnectionController::class, 'showServerConnect'])->name('server.connect'); +Route::post('/connect-server', [InstanceConnectionController::class, 'prepareServerLogin'])->name('server.connect.prepare'); +Route::post('/connect-server/authenticate', [InstanceConnectionController::class, 'authenticateGuestServer'])->name('server.connect.authenticate'); -Route::middleware('auth')->get('/', HomeController::class)->name('home'); +Route::middleware('auth')->group(function (): void { + Route::get('/', HomeController::class)->name('home'); + Route::get('/_katra/profile', [InstanceConnectionController::class, 'profile'])->name('profile.current'); + Route::post('/connections', [InstanceConnectionController::class, 'store'])->name('connections.store'); + Route::patch('/connections/{instanceConnection}', [InstanceConnectionController::class, 'update'])->name('connections.update'); + Route::delete('/connections/{instanceConnection}', [InstanceConnectionController::class, 'destroy'])->name('connections.destroy'); + Route::post('/connections/{instanceConnection}/activate', [InstanceConnectionController::class, 'activate'])->name('connections.activate'); + Route::get('/connections/{instanceConnection}/connect', [InstanceConnectionController::class, 'connect'])->name('connections.connect'); + Route::post('/connections/{instanceConnection}/connect', [InstanceConnectionController::class, 'authenticate'])->name('connections.authenticate'); +}); diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index bc99811..ba3f8c4 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -1,11 +1,28 @@ set('app.name', 'Katra'); + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); +} function desktopShellUser(): User { - return User::factory()->make([ - 'id' => 1, + return User::factory()->create([ 'first_name' => 'Derek', 'last_name' => 'Bourgeois', 'name' => 'Derek Bourgeois', @@ -13,20 +30,24 @@ function desktopShellUser(): User ]); } -test('the desktop shell exposes the katra bootstrap screen', function () { - config()->set('pennant.default', 'array'); - config()->set('surreal.autostart', false); - config()->set('surreal.host', '127.0.0.1'); - config()->set('surreal.port', 18999); - config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); - config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); +test('the desktop shell exposes the connection-aware workspace shell', function () { + configureDesktopShell(); - $this->actingAs(desktopShellUser()); + $user = desktopShellUser(); - $this->get('/') + InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now()->subMinute(), + ]); + + actingAs($user); + + get('/') ->assertSuccessful() ->assertSee('Katra') - ->assertSee('Workspaces') ->assertSee('Favorites') ->assertSee('Rooms') ->assertSee('Chats') @@ -35,21 +56,14 @@ function desktopShellUser(): User ->assertSee('Planner Agent') ->assertSee('Research Model') ->assertSee('# design-room') - ->assertSee('Create workspace') - ->assertSee('Workspace name') - ->assertSee('Create room') - ->assertSee('Room name') - ->assertSee('Create chat') - ->assertSee('Start conversation') - ->assertSee('Contacts') - ->assertSee('Search people, agents, and models') - ->assertSee('Selected') - ->assertSee('Available contacts') - ->assertSee('No contacts selected yet.') - ->assertSee('Server') - ->assertSee('Katra Local') + ->assertSee('Connections') + ->assertSee('Add a server') + ->assertSee('Connection name') + ->assertSee('Add connection') + ->assertSee('Katra') ->assertSee('Relay Cloud') - ->assertSee('Research Model') + ->assertSee('Edit connection') + ->assertSee('relay.devoption.test') ->assertSee('Collapse sidebar') ->assertSee('Expand sidebar') ->assertSee('Search conversations, people, and nodes') @@ -78,11 +92,13 @@ function desktopShellUser(): User ->assertSee('Profile settings') ->assertSee('Workspace settings') ->assertSee('Administration') - ->assertSee('Manage connections') ->assertSee('Light') ->assertSee('Dark') ->assertSee('System') ->assertSee('Log out') + ->assertDontSee('Create workspace') + ->assertDontSee('Workspace name') + ->assertDontSee('Workspaces') ->assertDontSee('desktop mvp preview') ->assertDontSee('composer native:dev') ->assertDontSee('Surreal Foundation') @@ -112,30 +128,44 @@ function desktopShellUser(): User config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); - $this->actingAs(desktopShellUser()); + actingAs(desktopShellUser()); - $this->get('/') + get('/') ->assertSuccessful() ->assertSee('Katra') ->assertSee('# design-room') ->assertDontSee('Workspace navigation'); }); -test('the desktop shell can switch the active mock workspace from the selector', function () { - config()->set('pennant.default', 'array'); - config()->set('surreal.autostart', false); - config()->set('surreal.host', '127.0.0.1'); - config()->set('surreal.port', 18999); - config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); - config()->set('surreal.binary', 'surreal-missing-binary-for-desktop-shell-test'); +test('the desktop shell can render a saved server connection as the active connection', function () { + configureDesktopShell(); + + $user = desktopShellUser(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + 'session_context' => [ + 'user' => [ + 'name' => 'Relay Operator', + 'email' => 'ops@relay.devoption.test', + ], + ], + ]); - $this->actingAs(desktopShellUser()); + actingAs($user) + ->withSession(['instance_connection.active_id' => $connection->getKey()]); - $this->get('/?workspace=design-lab') + get('/') ->assertSuccessful() - ->assertSee('Design Lab') - ->assertSee('# shell-studies') - ->assertSee('Shared room for people, models, and agents working inside Design Lab.') - ->assertSee('Visual Agent') - ->assertSee('Critique Agent'); + ->assertSee('Relay Cloud') + ->assertSee('Connections') + ->assertSee('# relay-ops') + ->assertSee('Ops Agent') + ->assertSee('Routing Agent') + ->assertSee('Relay Operator') + ->assertSee('ops@relay.devoption.test') + ->assertSee('Signed in as ops@relay.devoption.test.'); }); diff --git a/tests/Feature/DesktopUiFeatureFlagTest.php b/tests/Feature/DesktopUiFeatureFlagTest.php index f1ac718..ed3c8d9 100644 --- a/tests/Feature/DesktopUiFeatureFlagTest.php +++ b/tests/Feature/DesktopUiFeatureFlagTest.php @@ -8,9 +8,12 @@ use App\Features\Desktop\WorkspaceNavigation; use App\Models\User; use App\Support\Features\DesktopUi; +use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Pennant\Attributes\Name; use Laravel\Pennant\Feature; +uses(RefreshDatabase::class); + test('desktop ui rollout uses the desktop pennant scope and feature naming convention', function () { config()->set('pennant.default', 'array'); @@ -70,7 +73,7 @@ Feature::for(DesktopUi::scope())->activate(WorkspaceNavigation::class); - $this->actingAs(User::factory()->make([ + $this->actingAs(User::factory()->create([ 'id' => 1, 'first_name' => 'Derek', 'last_name' => 'Bourgeois', @@ -92,7 +95,7 @@ Feature::for(DesktopUi::scope())->deactivate(MvpShell::class); - $this->actingAs(User::factory()->make([ + $this->actingAs(User::factory()->create([ 'id' => 1, 'first_name' => 'Derek', 'last_name' => 'Bourgeois', diff --git a/tests/Feature/FortifyAuthenticationTest.php b/tests/Feature/FortifyAuthenticationTest.php index b75cfdf..13d516a 100644 --- a/tests/Feature/FortifyAuthenticationTest.php +++ b/tests/Feature/FortifyAuthenticationTest.php @@ -1,10 +1,13 @@ get(route('server.connect')) ->assertSuccessful() - ->assertSee('Connect to a server'); + ->assertSee('Connect to a server') + ->assertSee('Continue to server'); +}); + +test('a guest can continue from the server connect screen into the in-app server credential step', function () { + $this->post(route('server.connect.prepare'), [ + 'server_url' => 'https://katra-server.test/', + ]) + ->assertRedirect(route('server.connect')); + + $this->get(route('server.connect')) + ->assertSuccessful() + ->assertSee('katra-server.test') + ->assertSee('Email') + ->assertSee('Password') + ->assertSee('Connect to server'); +}); + +test('a guest can sign into a remote katra server without leaving the client', function () { + Http::fake([ + 'https://katra-server.test/login' => Http::sequence() + ->push( + '', + 200, + ['Set-Cookie' => ['katra_server_session=bootstrap-session; path=/; httponly']], + ) + ->push( + '', + 302, + [ + 'Location' => 'https://katra-server.test/', + 'Set-Cookie' => ['katra_server_session=authenticated-session; path=/; httponly'], + ], + ), + 'https://katra-server.test/' => Http::response('Relay Cloud', 200), + 'https://katra-server.test/_katra/profile' => Http::response([ + 'first_name' => 'Ops', + 'last_name' => 'Bourgeois', + 'name' => 'Ops Bourgeois', + 'email' => 'ops@relay.devoption.test', + ], 200), + ]); + + $this->post(route('server.connect.prepare'), [ + 'server_url' => 'https://katra-server.test/', + ])->assertRedirect(route('server.connect')); + + $this->post(route('server.connect.authenticate'), [ + 'email' => 'ops@relay.devoption.test', + 'password' => 'password', + ])->assertRedirect(route('home')); + + $user = User::query()->where('email', 'ops@relay.devoption.test')->first(); + $savedConnection = InstanceConnection::query() + ->where('user_id', $user?->getKey()) + ->where('base_url', 'https://katra-server.test') + ->first(); + + $this->assertAuthenticated(); + + expect($user)->not()->toBeNull() + ->and($user?->first_name)->toBe('Ops') + ->and($user?->last_name)->toBe('Bourgeois') + ->and($savedConnection)->not()->toBeNull() + ->and($savedConnection?->last_authenticated_at)->not()->toBeNull() + ->and(data_get($savedConnection?->session_context, 'user.name'))->toBe('Ops Bourgeois') + ->and(data_get($savedConnection?->session_context, 'cookies.katra_server_session'))->toBe('authenticated-session'); + + Http::assertSent(function (HttpRequest $request): bool { + if ($request->url() !== 'https://katra-server.test/login' || $request->method() !== 'POST') { + return false; + } + + return str_contains($request->body(), 'email=ops%40relay.devoption.test') + && str_contains($request->body(), 'password=password') + && $request->hasHeader('Cookie', 'katra_server_session=bootstrap-session'); + }); +}); + +test('a guest remote sign-in updates an existing placeholder local user with the server profile', function () { + $localUser = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'User', + 'name' => 'Derek User', + 'email' => 'derek@devoption.io', + ]); + + Http::fake([ + 'https://katra-server.test/login' => Http::sequence() + ->push( + '
', + 200, + ['Set-Cookie' => ['katra_server_session=bootstrap-session; path=/; httponly']], + ) + ->push( + '', + 302, + [ + 'Location' => 'https://katra-server.test/', + 'Set-Cookie' => ['katra_server_session=authenticated-session; path=/; httponly'], + ], + ), + 'https://katra-server.test/' => Http::response('Relay Cloud', 200), + 'https://katra-server.test/_katra/profile' => Http::response([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@devoption.io', + ], 200), + ]); + + $this->post(route('server.connect.prepare'), [ + 'server_url' => 'https://katra-server.test/', + ])->assertRedirect(route('server.connect')); + + $this->post(route('server.connect.authenticate'), [ + 'email' => 'derek@devoption.io', + 'password' => 'password', + ])->assertRedirect(route('home')); + + expect($localUser->fresh())->not()->toBeNull() + ->and($localUser->fresh()?->first_name)->toBe('Derek') + ->and($localUser->fresh()?->last_name)->toBe('Bourgeois') + ->and($localUser->fresh()?->name)->toBe('Derek Bourgeois'); }); test('the login rate limiter uses the file cache store', function () { diff --git a/tests/Feature/InstanceConnectionManagementTest.php b/tests/Feature/InstanceConnectionManagementTest.php new file mode 100644 index 0000000..4879869 --- /dev/null +++ b/tests/Feature/InstanceConnectionManagementTest.php @@ -0,0 +1,486 @@ +create(); + + actingAs($user); + + post(route('connections.store'), [ + 'name' => 'Relay Cloud', + 'base_url' => 'https://relay.devoption.test/', + ]) + ->assertRedirect() + ->assertSessionHas('instance_connection.active_id'); + + $connection = InstanceConnection::query() + ->where('user_id', $user->getKey()) + ->where('kind', InstanceConnection::KIND_SERVER) + ->first(); + + expect($connection)->not()->toBeNull() + ->and($connection?->name)->toBe('Relay Cloud') + ->and($connection?->base_url)->toBe('https://relay.devoption.test') + ->and($connection?->last_authenticated_at)->toBeNull() + ->and($connection?->last_used_at)->not()->toBeNull(); +}); + +test('an authenticated user can save a server connection profile with only the server url', function () { + $user = User::factory()->create(); + + actingAs($user); + + post(route('connections.store'), [ + 'base_url' => 'https://katra-server.test/', + ]) + ->assertRedirect(); + + $connection = InstanceConnection::query() + ->where('user_id', $user->getKey()) + ->where('kind', InstanceConnection::KIND_SERVER) + ->first(); + + expect($connection)->not()->toBeNull() + ->and($connection?->name)->toBe('Katra Server') + ->and($connection?->base_url)->toBe('https://katra-server.test'); +}); + +test('an authenticated user can activate one of their saved connections', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + ]); + + actingAs($user); + + post(route('connections.activate', $connection)) + ->assertRedirect(route('connections.connect', $connection)) + ->assertSessionHas('instance_connection.active_id', $connection->getKey()); + + $connection->refresh(); + + expect($connection->last_authenticated_at)->toBeNull() + ->and($connection->last_used_at)->not()->toBeNull(); +}); + +test('active connection resolution creates a current instance connection when no connection is active', function () { + $user = User::factory()->create(); + + InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now()->addMinute(), + ]); + + $manager = app(InstanceConnectionManager::class); + $activeConnection = $manager->activeConnectionFor($user, 'https://katra.test', app('session.store')); + $connections = $manager->connectionsFor($user); + + expect($activeConnection->kind)->toBe(InstanceConnection::KIND_CURRENT_INSTANCE) + ->and($connections->firstWhere('kind', InstanceConnection::KIND_CURRENT_INSTANCE))->not()->toBeNull() + ->and($connections->firstWhere('kind', InstanceConnection::KIND_CURRENT_INSTANCE)?->base_url)->toBe('https://katra.test'); +}); + +test('the current instance connection uses the configured app name by default', function () { + config()->set('app.name', 'Laravel'); + + $user = User::factory()->create(); + + $connection = app(InstanceConnectionManager::class)->ensureCurrentInstanceConnection($user, 'https://katra.test'); + + expect($connection->name)->toBe('Laravel'); +}); + +test('an authenticated user can rename their current instance connection', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + ]); + + actingAs($user); + + patch(route('connections.update', $connection), [ + 'name' => 'Studio', + ])->assertRedirect(route('home')); + + expect($connection->fresh()->name)->toBe('Studio'); +}); + +test('a blank current instance connection name resets to the app name', function () { + config()->set('app.name', 'Katra'); + + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Studio', + ]); + + actingAs($user); + + patch(route('connections.update', $connection), [ + 'name' => '', + ])->assertRedirect(route('home')); + + expect($connection->fresh()->name)->toBe('Katra'); +}); + +test('an authenticated user can update a saved server connection profile', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Old Relay', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + 'last_authenticated_at' => now(), + 'session_context' => ['email' => 'ops@relay.devoption.test'], + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + patch(route('connections.update', $connection), [ + 'name' => 'Katra Server', + 'base_url' => 'https://katra-server.test', + ])->assertRedirect(route('connections.connect', $connection)); + + $connection->refresh(); + + expect($connection->name)->toBe('Katra Server') + ->and($connection->base_url)->toBe('https://katra-server.test') + ->and($connection->last_authenticated_at)->toBeNull() + ->and($connection->session_context)->toBeNull(); +}); + +test('an authenticated user can delete an inactive saved server connection', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'kind' => InstanceConnection::KIND_SERVER, + ]); + + actingAs($user); + + delete(route('connections.destroy', $connection)) + ->assertRedirect(route('home')); + + expect(InstanceConnection::query()->find($connection->getKey()))->toBeNull(); +}); + +test('deleting the active server connection falls back to the current instance connection', function () { + $user = User::factory()->create(); + $currentInstanceConnection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Katra', + 'kind' => InstanceConnection::KIND_CURRENT_INSTANCE, + 'base_url' => 'https://katra.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now()->subMinute(), + ]); + $serverConnection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Katra Server', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $serverConnection->getKey(), + ]); + + delete(route('connections.destroy', $serverConnection)) + ->assertRedirect(route('home')) + ->assertSessionHas('instance_connection.active_id', $currentInstanceConnection->getKey()); + + expect(InstanceConnection::query()->find($serverConnection->getKey()))->toBeNull(); +}); + +test('an authenticated user cannot activate another users connection', function () { + $user = User::factory()->create(); + $otherConnection = InstanceConnection::factory()->create(); + + actingAs($user); + + post(route('connections.activate', $otherConnection)) + ->assertNotFound(); +}); + +test('the shell uses the currently active connection profile for the session', function () { + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-connection-test'); + + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://relay.devoption.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + 'session_context' => [ + 'user' => [ + 'name' => 'Relay Ops', + 'email' => 'ops@relay.devoption.test', + ], + ], + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Relay Cloud') + ->assertSee('# relay-ops') + ->assertSee('Ops Agent') + ->assertSee('Relay Ops') + ->assertSee('ops@relay.devoption.test'); +}); + +test('the connection list only includes the authenticated users connections', function () { + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-connection-test'); + + $localUser = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Katra', + 'name' => 'Derek Katra', + 'email' => 'derek@katra.io', + ]); + + $remoteUser = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@devoption.io', + ]); + + InstanceConnection::factory()->for($localUser)->create([ + 'name' => 'Katra', + 'kind' => InstanceConnection::KIND_CURRENT_INSTANCE, + 'base_url' => 'https://katra.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now()->subMinute(), + ]); + + InstanceConnection::factory()->for($remoteUser)->create([ + 'name' => 'Katra Server', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + 'session_context' => [ + 'email' => 'derek@devoption.io', + 'user' => [ + 'name' => 'Derek Bourgeois', + 'email' => 'derek@devoption.io', + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + ], + ], + ]); + + actingAs($localUser); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Derek Katra') + ->assertSee('derek@katra.io') + ->assertDontSee('Katra Server'); +}); + +test('guest server sign-in does not create a current-instance connection automatically', function () { + Http::fake([ + 'https://katra-server.test/login' => Http::sequence() + ->push( + '
', + 200, + ['Set-Cookie' => ['katra_server_session=bootstrap-session; path=/; httponly']], + ) + ->push( + '', + 302, + [ + 'Location' => 'https://katra-server.test/', + 'Set-Cookie' => ['katra_server_session=authenticated-session; path=/; httponly'], + ], + ), + 'https://katra-server.test/' => Http::response('Relay Cloud', 200), + 'https://katra-server.test/_katra/profile' => Http::response([ + 'first_name' => 'Ops', + 'last_name' => 'Bourgeois', + 'name' => 'Ops Bourgeois', + 'email' => 'ops@relay.devoption.test', + ], 200), + ]); + + post(route('server.connect.prepare'), [ + 'server_url' => 'https://katra-server.test/', + ])->assertRedirect(route('server.connect')); + + post(route('server.connect.authenticate'), [ + 'email' => 'ops@relay.devoption.test', + 'password' => 'password', + ])->assertRedirect(route('home')); + + $user = User::query()->where('email', 'ops@relay.devoption.test')->firstOrFail(); + + expect($user->instanceConnections()->where('kind', InstanceConnection::KIND_CURRENT_INSTANCE)->exists())->toBeFalse() + ->and($user->instanceConnections()->where('kind', InstanceConnection::KIND_SERVER)->count())->toBe(1); +}); + +test('the shell falls back to legacy remote connection email metadata for the profile surface', function () { + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-connection-test'); + + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Katra Server', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + 'session_context' => [ + 'email' => 'derek@devoption.io', + ], + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Derek User') + ->assertSee('derek@devoption.io'); +}); + +test('the shell redirects to the server connection screen when the active server is not authenticated', function () { + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-connection-test'); + + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => null, + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + get(route('home')) + ->assertRedirect(route('connections.connect', $connection)); +}); + +test('an authenticated user can sign into a saved server connection', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Relay Cloud', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => null, + ]); + + Http::fake([ + 'https://katra-server.test/login' => Http::sequence() + ->push( + '
', + 200, + ['Set-Cookie' => ['katra_server_session=bootstrap-session; path=/; httponly']], + ) + ->push( + '', + 302, + [ + 'Location' => 'https://katra-server.test/', + 'Set-Cookie' => ['katra_server_session=authenticated-session; path=/; httponly'], + ], + ), + 'https://katra-server.test/' => Http::response('Relay Cloud', 200), + 'https://katra-server.test/_katra/profile' => Http::response([ + 'first_name' => 'Ops', + 'last_name' => 'Bourgeois', + 'name' => 'Ops Bourgeois', + 'email' => 'ops@relay.devoption.test', + ], 200), + ]); + + actingAs($user); + + post(route('connections.authenticate', $connection), [ + 'email' => 'ops@relay.devoption.test', + 'password' => 'password', + ])->assertRedirect(route('home')); + + $connection->refresh(); + + expect($connection->last_authenticated_at)->not()->toBeNull() + ->and(data_get($connection->session_context, 'email'))->toBe('ops@relay.devoption.test') + ->and(data_get($connection->session_context, 'user.name'))->toBe('Ops Bourgeois') + ->and(data_get($connection->session_context, 'user.last_name'))->toBe('Bourgeois') + ->and(data_get($connection->session_context, 'user.email'))->toBe('ops@relay.devoption.test') + ->and(data_get($connection->session_context, 'cookies.katra_server_session'))->toBe('authenticated-session'); + + Http::assertSent(function (HttpRequest $request): bool { + if ($request->url() !== 'https://katra-server.test/login' || $request->method() !== 'POST') { + return false; + } + + return str_contains($request->body(), '_token=csrf-token-123') + && str_contains($request->body(), 'email=ops%40relay.devoption.test') + && str_contains($request->body(), 'password=password') + && $request->hasHeader('Cookie', 'katra_server_session=bootstrap-session'); + }); + + Http::assertSent(function (HttpRequest $request): bool { + return $request->url() === 'https://katra-server.test/' + && $request->method() === 'GET' + && $request->hasHeader('Cookie', 'katra_server_session=authenticated-session'); + }); +});