From 5f8149d0d15db7414427f0fc9bb2f57f4f51f86c Mon Sep 17 00:00:00 2001 From: github-ops Date: Mon, 22 Jun 2026 18:30:56 +0000 Subject: [PATCH] Fix workspace selection and secret modal layout --- apps/admin-api/src/service/auth.rs | 24 ++++-- apps/ui/css/layout.css | 34 ++++++++ apps/ui/css/pages.css | 7 +- apps/ui/css/wizard.css | 2 + apps/ui/css/workspace-setup.css | 2 + apps/ui/html/secrets.html | 5 ++ apps/ui/js/page-transitions.js | 54 ++++++++++++ apps/ui/js/secrets.js | 7 +- apps/ui/js/workspace.js | 35 ++++++-- apps/ui/scripts/build.js | 1 + apps/ui/tests/e2e/secrets.spec.js | 19 +++++ crates/crank-registry/src/postgres/auth.rs | 28 ++++--- .../crank-registry/src/postgres/workspace.rs | 29 ++++--- .../tests/integration/workspace_access.rs | 82 ++++++++++++++++++- 14 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 apps/ui/js/page-transitions.js diff --git a/apps/admin-api/src/service/auth.rs b/apps/admin-api/src/service/auth.rs index c71b57e..e0afade 100644 --- a/apps/admin-api/src/service/auth.rs +++ b/apps/admin-api/src/service/auth.rs @@ -99,15 +99,21 @@ impl AdminService { .registry .list_workspaces_for_user(&authenticated.user.id) .await?; - let current_workspace_id = authenticated - .current_workspace_id - .as_ref() - .map(|workspace_id| workspace_id.as_str().to_owned()) - .or_else(|| { - memberships - .first() - .map(|membership| membership.workspace.id.as_str().to_owned()) - }); + let default_workspace_id = memberships + .iter() + .find(|membership| membership.workspace.id.as_str() == "ws_default") + .map(|membership| membership.workspace.id.as_str().to_owned()); + let current_workspace_id = default_workspace_id.or_else(|| { + authenticated + .current_workspace_id + .as_ref() + .map(|workspace_id| workspace_id.as_str().to_owned()) + .or_else(|| { + memberships + .first() + .map(|membership| membership.workspace.id.as_str().to_owned()) + }) + }); let current_workspace_ref = current_workspace_id .as_ref() .map(|workspace_id| WorkspaceId::new(workspace_id.clone())); diff --git a/apps/ui/css/layout.css b/apps/ui/css/layout.css index a4dc565..8daed24 100644 --- a/apps/ui/css/layout.css +++ b/apps/ui/css/layout.css @@ -154,6 +154,40 @@ max-width: 1160px; margin: 0 auto; padding: 36px 40px 60px; + animation: page-enter 0.18s ease-out both; + transition: opacity 0.12s ease, transform 0.12s ease; +} + +body.page-leaving .page, +body.page-leaving .wizard-body, +body.page-leaving .ws-setup-body { + opacity: 0; + transform: translateY(6px); +} + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .page { + animation: none; + transition: none; + } + + body.page-leaving .page, + body.page-leaving .wizard-body, + body.page-leaving .ws-setup-body { + opacity: 1; + transform: none; + } } /* ── Hamburger button (hidden on desktop) ── */ diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index 48da75e..872517c 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -767,7 +767,10 @@ border-radius: 12px; width: 100%; max-width: 480px; + max-height: calc(100dvh - 48px); box-shadow: 0 8px 40px rgba(0,0,0,0.6); + display: flex; + flex-direction: column; overflow: hidden; } @@ -801,8 +804,8 @@ } .modal-close:hover { background: var(--bg-overlay); color: var(--text-primary); } -.modal-body { padding: 20px; } -.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; } +.modal-body { padding: 20px; overflow: auto; } +.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; } /* ══════════════════════════════════════════════════ RESPONSIVE diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index 8f3048c..a3ac309 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -153,6 +153,8 @@ padding: 40px 40px 140px; gap: 36px; align-items: flex-start; + animation: page-enter 0.18s ease-out both; + transition: opacity 0.12s ease, transform 0.12s ease; } .checkbox-pill { diff --git a/apps/ui/css/workspace-setup.css b/apps/ui/css/workspace-setup.css index 669ca11..de92225 100644 --- a/apps/ui/css/workspace-setup.css +++ b/apps/ui/css/workspace-setup.css @@ -47,6 +47,8 @@ display: flex; justify-content: center; padding: 48px 24px 80px; + animation: page-enter 0.18s ease-out both; + transition: opacity 0.12s ease, transform 0.12s ease; } .ws-setup-container { width: 100%; diff --git a/apps/ui/html/secrets.html b/apps/ui/html/secrets.html index 9d1207a..8e3e0bb 100644 --- a/apps/ui/html/secrets.html +++ b/apps/ui/html/secrets.html @@ -19,6 +19,11 @@ color: var(--text-muted); line-height: 1.55; } + #secret-json-value { + min-height: 132px; + max-height: 220px; + resize: vertical; + } .secrets-card-list { display: none; } @media (max-width: 720px) { #secrets-table-wrap { display: none; } diff --git a/apps/ui/js/page-transitions.js b/apps/ui/js/page-transitions.js new file mode 100644 index 0000000..059b83b --- /dev/null +++ b/apps/ui/js/page-transitions.js @@ -0,0 +1,54 @@ +(function() { + 'use strict'; + + var transitionMs = 120; + + function prefersReducedMotion() { + return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + function isInternalNavigation(link) { + if (!link || !link.href || link.target || link.hasAttribute('download')) return false; + + var targetUrl; + try { + targetUrl = new URL(link.href, window.location.href); + } catch (_error) { + return false; + } + + if (targetUrl.origin !== window.location.origin) return false; + if (targetUrl.pathname === window.location.pathname && targetUrl.search === window.location.search) { + return targetUrl.hash && targetUrl.hash !== window.location.hash; + } + return true; + } + + function initPageTransitions() { + if (prefersReducedMotion()) return; + + document.addEventListener('click', function(event) { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + var link = event.target.closest ? event.target.closest('a[href]') : null; + if (!isInternalNavigation(link)) return; + + var targetUrl = new URL(link.href, window.location.href); + if (targetUrl.pathname === window.location.pathname && targetUrl.search === window.location.search) return; + + event.preventDefault(); + document.body.classList.add('page-leaving'); + window.setTimeout(function() { + window.location.href = targetUrl.href; + }, transitionMs); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initPageTransitions, { once: true }); + } else { + initPageTransitions(); + } +})(); diff --git a/apps/ui/js/secrets.js b/apps/ui/js/secrets.js index 21b80f5..5d755b2 100644 --- a/apps/ui/js/secrets.js +++ b/apps/ui/js/secrets.js @@ -538,12 +538,15 @@ function initSecretsPage() { state.search = event.target.value || ''; renderSecrets(); }); - document.addEventListener('workspace:changed', function () { + window.addEventListener('crank:workspacechange', function () { void load(); }); updateKindFields(); - void load(); + void (async function bootSecretsPage() { + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + await load(); + }()); } if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') { diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js index 0a55261..bdece48 100644 --- a/apps/ui/js/workspace.js +++ b/apps/ui/js/workspace.js @@ -1,6 +1,4 @@ -var WS_LIST = [ - { id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' } -]; +var WS_LIST = []; var workspaceLoadPromise = null; var workspaceLoadFailed = false; @@ -41,8 +39,35 @@ function cacheCurrentWorkspace(workspace) { } catch (_error) {} } +function storedWorkspaceId() { + try { + return localStorage.getItem('crank_workspace_id') || ''; + } catch (_error) { + return ''; + } +} + +function sessionWorkspaceId() { + if (!(window.CrankAuth && typeof window.CrankAuth.getCachedSession === 'function')) { + return ''; + } + var session = window.CrankAuth.getCachedSession(); + return session && session.current_workspace_id ? session.current_workspace_id : ''; +} + function resolveCurrentWorkspace() { - return WS_LIST[0] || null; + var preferredId = sessionWorkspaceId() || storedWorkspaceId(); + if (preferredId) { + var preferred = WS_LIST.find(function(workspace) { + return workspace.id === preferredId; + }); + if (preferred) { + return preferred; + } + } + return WS_LIST.find(function(workspace) { + return workspace.id === 'ws_default'; + }) || WS_LIST[0] || null; } function updateWorkspaceHeader(workspace) { @@ -88,7 +113,7 @@ async function loadWorkspaces() { var response = await window.CrankApi.listWorkspaces(); var items = (response && response.items ? response.items : []).map(mapWorkspace); if (items.length > 0) { - WS_LIST = [items[0]]; + WS_LIST = items; } workspaceLoadFailed = false; } catch (_error) { diff --git a/apps/ui/scripts/build.js b/apps/ui/scripts/build.js index f224bac..0850329 100644 --- a/apps/ui/scripts/build.js +++ b/apps/ui/scripts/build.js @@ -20,6 +20,7 @@ const BUNDLES = { 'js/overlay-loader.js', 'js/dom.js', 'js/diagnostics.js', + 'js/page-transitions.js', 'js/workspace.js', 'js/api.js', 'js/ui-feedback.js', diff --git a/apps/ui/tests/e2e/secrets.spec.js b/apps/ui/tests/e2e/secrets.spec.js index b415601..a563a2e 100644 --- a/apps/ui/tests/e2e/secrets.spec.js +++ b/apps/ui/tests/e2e/secrets.spec.js @@ -65,3 +65,22 @@ test('secret modal shows fields for selected secret kind only', async ({ page }) await expect(basicField).toBeHidden(); await expect(jsonField).toBeHidden(); }); + +test('generic json secret modal stays inside compact viewport', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 520 }); + await login(page); + await page.goto('/secrets'); + + await page.locator('[data-testid="secret-create-button"]').click(); + await page.locator('[data-testid="secret-kind-select"]').selectOption('generic'); + + const modal = page.locator('[data-testid="secret-create-modal"] .modal'); + await expect(modal).toBeVisible(); + + const box = await modal.boundingBox(); + expect(box).not.toBeNull(); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.y + box.height).toBeLessThanOrEqual(520); + await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible(); + await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible(); +}); diff --git a/crates/crank-registry/src/postgres/auth.rs b/crates/crank-registry/src/postgres/auth.rs index 0d5e058..9d45d61 100644 --- a/crates/crank-registry/src/postgres/auth.rs +++ b/crates/crank-registry/src/postgres/auth.rs @@ -272,17 +272,23 @@ impl PostgresRegistry { }; let memberships = self.list_workspaces_for_user(&user_id).await?; let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new); - let current_workspace_id = stored_workspace_id - .filter(|workspace_id| { - memberships - .iter() - .any(|membership| membership.workspace.id == *workspace_id) - }) - .or_else(|| { - memberships - .first() - .map(|membership| membership.workspace.id.clone()) - }); + let default_workspace_id = memberships + .iter() + .find(|membership| membership.workspace.id.as_str() == "ws_default") + .map(|membership| membership.workspace.id.clone()); + let current_workspace_id = default_workspace_id.or_else(|| { + stored_workspace_id + .filter(|workspace_id| { + memberships + .iter() + .any(|membership| membership.workspace.id == *workspace_id) + }) + .or_else(|| { + memberships + .first() + .map(|membership| membership.workspace.id.clone()) + }) + }); Ok(Some(SessionRecord { session_id: UserSessionId::new(row.id), diff --git a/crates/crank-registry/src/postgres/workspace.rs b/crates/crank-registry/src/postgres/workspace.rs index 021a54a..d1def86 100644 --- a/crates/crank-registry/src/postgres/workspace.rs +++ b/crates/crank-registry/src/postgres/workspace.rs @@ -38,22 +38,22 @@ impl PostgresRegistry { &self, user_id: &UserId, ) -> Result, RegistryError> { - let rows = sqlx::query!( + let rows = sqlx::query( "select w.id, w.slug, w.display_name, w.status, w.settings_json, - w.created_at as \"created_at!: time::OffsetDateTime\", - w.updated_at as \"updated_at!: time::OffsetDateTime\", + w.created_at, + w.updated_at, m.role from memberships m join workspaces w on w.id = m.workspace_id where m.user_id = $1 - order by w.slug asc", - user_id.as_str(), + order by (w.id = 'ws_default') desc, w.slug asc", ) + .bind(user_id.as_str()) .fetch_all(&self.pool) .await?; @@ -61,15 +61,18 @@ impl PostgresRegistry { .map(|row| { Ok(WorkspaceMembershipRecord { workspace: Workspace { - id: WorkspaceId::new(row.id), - slug: row.slug, - display_name: row.display_name, - status: deserialize_enum_text(&row.status, "status")?, - settings: row.settings_json, - created_at: row.created_at, - updated_at: row.updated_at, + id: WorkspaceId::new(row.try_get::("id")?), + slug: row.try_get("slug")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text( + &row.try_get::("status")?, + "status", + )?, + settings: row.try_get("settings_json")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, }, - role: deserialize_enum_text(&row.role, "role")?, + role: deserialize_enum_text(&row.try_get::("role")?, "role")?, }) }) .collect() diff --git a/crates/crank-registry/tests/integration/workspace_access.rs b/crates/crank-registry/tests/integration/workspace_access.rs index cf71db3..f6d7b1e 100644 --- a/crates/crank-registry/tests/integration/workspace_access.rs +++ b/crates/crank-registry/tests/integration/workspace_access.rs @@ -24,7 +24,8 @@ use crank_registry::{ DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, + YamlImportJobStatus, }; fn test_workspace_id() -> WorkspaceId { @@ -151,6 +152,85 @@ async fn manages_workspace_read_paths() { database.cleanup().await; } +#[tokio::test] +async fn lists_default_workspace_first_for_community_sessions() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let default_workspace = Workspace { + id: WorkspaceId::new("ws_default"), + slug: "solo".to_owned(), + display_name: "Solo".to_owned(), + status: crank_core::WorkspaceStatus::Active, + settings: json!({}), + created_at: timestamp("2026-03-25T12:00:00Z"), + updated_at: timestamp("2026-03-25T12:00:00Z"), + }; + let legacy_workspace = Workspace { + id: WorkspaceId::new("ws_growth_lab"), + slug: "growth-lab".to_owned(), + display_name: "Growth Lab".to_owned(), + status: crank_core::WorkspaceStatus::Active, + settings: json!({}), + created_at: timestamp("2026-03-25T12:01:00Z"), + updated_at: timestamp("2026-03-25T12:01:00Z"), + }; + let user_id = registry + .upsert_bootstrap_user("community-owner@example.com", "Owner", "hashed-password") + .await + .unwrap(); + + registry + .create_workspace(CreateWorkspaceRequest { + workspace: &legacy_workspace, + }) + .await + .unwrap(); + registry + .update_workspace(UpdateWorkspaceRequest { + workspace: &default_workspace, + }) + .await + .unwrap(); + registry + .ensure_membership(&legacy_workspace.id, &user_id, MembershipRole::Owner) + .await + .unwrap(); + registry + .ensure_membership(&default_workspace.id, &user_id, MembershipRole::Owner) + .await + .unwrap(); + + let workspaces = registry.list_workspaces_for_user(&user_id).await.unwrap(); + + assert_eq!(workspaces[0].workspace.id, WorkspaceId::new("ws_default")); + assert_eq!(workspaces[0].workspace.slug, "solo"); + + let session_id = UserSessionId::new("sess_workspace_default_first"); + let secret_hash = "test-secret-hash"; + registry + .create_user_session( + &session_id, + &user_id, + Some(&legacy_workspace.id), + secret_hash, + ×tamp("2027-03-25T13:00:00Z"), + ) + .await + .unwrap(); + + let session = registry + .get_user_session(&session_id, secret_hash) + .await + .unwrap() + .unwrap(); + assert_eq!( + session.current_workspace_id, + Some(WorkspaceId::new("ws_default")) + ); + + database.cleanup().await; +} + #[tokio::test] async fn manages_user_profile_and_workspace_access_reads() { let database = TestDatabase::new().await;