diff --git a/audit/platform-ui-findings.md b/audit/platform-ui-findings.md new file mode 100644 index 0000000..2adad83 --- /dev/null +++ b/audit/platform-ui-findings.md @@ -0,0 +1,269 @@ +# Cameleer SaaS Platform UI Audit Findings + +**Date:** 2026-04-09 +**Auditor:** Claude Opus 4.6 +**URL:** https://desktop-fb5vgj9.siegeln.internal/ +**Credentials:** admin/admin +**Browser:** Playwright (Chromium) + +--- + +## 1. Login Page (`/sign-in`) + +**Screenshot:** `03-login-page.png`, `04-login-error.png` + +### What works well +- Clean, centered card layout with consistent design system components +- Fun rotating subtitle taglines (e.g., "No ticket, no caravan") add personality +- Cameleer logo is displayed correctly +- Error handling works -- "Invalid username or password" alert appears on bad credentials (red alert banner) +- Sign in button is correctly disabled until both fields are populated +- Loading state on button during authentication +- Uses proper `autoComplete` attributes (`username`, `current-password`) + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| Important | **No password visibility toggle** -- the Password input uses `type="password"` with no eye icon to reveal. Most modern login forms offer this. | Password field | +| Important | **Branding says "cameleer3"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content | +| Nice-to-have | **No "Forgot password" link** -- even if it goes to a "contact admin" page, users expect this | Below password field | +| Nice-to-have | **No Enter-key submit hint** -- though Enter does work via form submit, there's no visual affordance | Form area | +| Nice-to-have | **Page title is "Sign in -- cameleer3"** -- should match product branding ("Cameleer SaaS") | `` tag | + +--- + +## 2. Platform Dashboard (`/platform/`) + +**Screenshots:** `05-platform-dashboard-loggedin.png`, `15-dashboard-desktop-1280.png`, `19-tenant-info-detail.png`, `20-kpi-strip-detail.png` + +### What works well +- Clear tenant name as page heading ("Example Tenant") +- Tier badge next to tenant name provides immediate context +- KPI strip with Tier, Status, License cards is visually clean and well-structured +- License KPI card shows expiry date in green "expires 8.4.2027" trend indicator +- "Server Management" card provides clear description of what the server dashboard does + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **Label/value collision in Tenant Information card** -- "Slugdefault", "Created8.4.2026" have no visual separation between label and value. The source uses `flex justify-between` but the deployed Card component doesn't give the inner `div` full width, so items stack/collapse. | Tenant Information card | +| **Critical** | **"Open Server Dashboard" appears 3 times** on one page: (1) primary button in header area below tenant name, (2) "Server Management" card with secondary button, (3) sidebar footer link. This is redundant and clutters the page. Reduce to 1-2 locations max. | Header area, Server Management card, sidebar footer | +| Important | **Breadcrumb is always empty** -- the `breadcrumb` prop is passed as `[]`. Platform pages should have breadcrumbs like "Platform > Dashboard" or "Platform > License". | TopBar breadcrumb nav | +| Important | **Massive empty space below content** -- the dashboard only has ~4 cards but the page extends far below with blank white/cream space. The page feels sparse and "stub-like." | Below Server Management card | +| Important | **Tier badge color is misleading** -- "LOW" tier uses `primary` (orange) color, which doesn't convey it's the lowest/cheapest tier. The `tierColor()` function in DashboardPage maps to enterprise=success, pro=primary, starter=warning, but the actual data uses LOW/MID/HIGH/BUSINESS tiers (defined in LicensePage). Dashboard and License pages have different tier color mappings. | Tier badge | +| Important | **Status is shown redundantly** -- "ACTIVE" appears in (1) KPI strip Status card, (2) Tenant Information card with badge, and (3) header area badge. This is excessive for a single piece of information. | Multiple locations | +| Nice-to-have | **No tenant ID/slug in breadcrumb or subtitle** -- the slug "default" only appears buried in the Tenant Information card | Page header area | + +--- + +## 3. License Page (`/platform/license`) + +**Screenshots:** `06-license-page.png`, `07-license-token-revealed.png`, `16-license-features-detail.png`, `17-license-limits-detail.png`, `18-license-validity-detail.png` + +### What works well +- Well-structured layout with logical sections (Validity, Features, Limits, License Token) +- Tier badge in header provides context +- Feature matrix clearly shows enabled vs disabled features +- "Days remaining" with color-coded badge (green for healthy, warning for <30 days, red for expired) +- Token show/hide toggle works correctly +- Token revealed in monospace code block with appropriate styling + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **Label/value collision in Validity section** -- "Issued8. April 2026" and "Expires8. April 2027" have no separation. Source code uses `flex items-center justify-between` but the flex container seems to not be stretching to full width. | Validity card rows | +| **Critical** | **Label/value collision in Limits section** -- "Max Agents3", "Retention Days7", "Max Environments1" have labels and values mashed together. Source uses `flex items-center justify-between` layout but the same rendering bug prevents proper spacing. | Limits card rows | +| Important | **No "Copy to clipboard" button** for the license token -- users need to manually select and copy. A copy button with confirmation toast is standard UX for tokens/secrets. | License Token section | +| Important | **Feature badge text mismatch** -- Source code says `'Not included'` for disabled features, but deployed version shows "DISABLED". This suggests the deployed build is out of sync with the source. | Features card badges | +| Important | **"Disabled" badge color** -- disabled features use `color='auto'` (which renders as a neutral/red-ish badge), while "Enabled" uses green. Consider using a muted gray for "Not included" to make it feel less like an error state. Red implies something is wrong, but a feature simply not being in the plan is not an error. | Features card disabled badges | +| Nice-to-have | **Limits values are not right-aligned** -- due to the label/value collision, the numeric values don't align in a column, making comparison harder | Limits card | +| Nice-to-have | **No units on limits** -- "Retention Days7" should be "7 days", "Max Agents3" should be "3 agents" or just "3" with clear formatting | Limits card values | + +--- + +## 4. Admin Pages (`/platform/admin/tenants`) + +**No screenshot available -- page returns HTTP error** + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **Admin page returns HTTP error (net::ERR_HTTP_RESPONSE_CODE_FAILURE)** -- navigating to `/platform/admin/tenants` fails with an HTTP error. The route exists in the router (`AdminTenantsPage`), but the admin section is not visible in the sidebar (no "Platform" item shown). | Admin route | +| Important | **Admin section not visible in sidebar** -- the `platform:admin` scope check in Layout.tsx hides the "Platform" sidebar item. Even though the user is "admin", they apparently don't have the `platform:admin` scope in their JWT. This may be intentional (scope not assigned) or a bug. | Sidebar Platform section | +| Important | **No graceful fallback for unauthorized admin access** -- if a user manually navigates to `/admin/tenants` without the scope, the page should show a "Not authorized" message rather than an HTTP error. | Admin route error handling | + +--- + +## 5. Navigation + +**Screenshots:** `21-sidebar-detail.png`, `12-sidebar-collapsed.png` + +### What works well +- Clean sidebar with Cameleer SaaS branding and logo +- "Open Server Dashboard" in sidebar footer is a good location +- Sidebar has only 2 navigation items (Dashboard, License) which keeps it simple + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **No active state on sidebar navigation items** -- when on the Dashboard page, neither Dashboard nor License is highlighted/active. The sidebar uses `Sidebar.Section` components with `open={false}` as navigation links via `onToggle`, but `Section` is designed for expandable/collapsible groups, not navigation links. There is no visual indicator of the current page. | Sidebar items | +| Important | **Sidebar collapse doesn't work visually** -- clicking "Collapse sidebar" toggles the `active` state on the button but the sidebar doesn't visually collapse. The Layout component passes `collapsed={false}` as a hardcoded prop and `onCollapseToggle={() => {}}` as a no-op. | Sidebar collapse button | +| Important | **No clear distinction between "platform" and "server" levels** -- there's nothing in the sidebar header that says "Platform" vs "Server". The sidebar says "Cameleer SaaS" but when you switch to the server dashboard, it becomes a completely different app. A user might not understand the relationship. | Sidebar header | +| Nice-to-have | **"Open Server Dashboard" opens in new tab** -- `window.open('/server/', '_blank', 'noopener')` is used. While reasonable, there's no visual indicator (external link icon) that it will open a new tab. | Sidebar footer link, dashboard buttons | + +--- + +## 6. Header Bar (TopBar) + +**Screenshot:** `22-header-bar-detail.png` + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **Server-specific controls shown on platform pages** -- the TopBar always renders: (1) Search (Ctrl+K), (2) Status filters (OK/Warn/Error/Running), (3) Time range pills (1h/3h/6h/Today/24h/7d), (4) Auto-refresh toggle (MANUAL/AUTO). None of these are relevant to the platform dashboard or license page. They are observability controls designed for the server's exchange/route monitoring. | Entire TopBar filter area | +| Important | **Search button does nothing** -- clicking "Search..." on the platform does not open a search modal. The CommandPaletteProvider is likely not configured for the platform context. | Search button | +| Important | **Status filter buttons are interactive but meaningless** -- clicking OK/Warn/Error/Running on platform pages toggles state (global filter provider) but has no effect on the displayed content. | Status filter buttons | +| Important | **Time range selector is interactive but meaningless** -- similarly, changing the time range from 1h to 7d has no effect on platform pages. | Time range pills | +| Important | **Auto-refresh toggle is misleading** -- shows "MANUAL" toggle on platform pages where there's nothing to auto-refresh. | Auto-refresh button | + +--- + +## 7. User Menu + +**Screenshot:** `02-user-menu-dropdown.png` + +### What works well +- User name "admin" and avatar initials "AD" displayed correctly +- Dropdown appears on click with Logout option + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| Important | **User menu only has "Logout"** -- there's no "Profile", "Settings", "About", or "Switch Tenant" option. For a SaaS platform, users should at minimum see their role and tenant context. | User dropdown menu | +| Nice-to-have | **Avatar shows "AD" for "admin"** -- the Avatar component appears to use first 2 characters of the name. For "admin" this produces "AD" which looks like initials for a different name. | Avatar component | + +--- + +## 8. Dark Mode + +**Screenshots:** `08-dashboard-dark-mode.png`, `09-license-dark-mode.png` + +### What works well +- Dark mode toggle works and applies globally +- Background transitions to dark brown/charcoal +- Text colors adapt appropriately +- Cards maintain visual distinction from background +- Design system tokens handle the switch smoothly + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| Nice-to-have | **Dark mode is warm-toned (brown)** rather than the more common cool dark gray/charcoal. This is consistent with the design system's cameleer branding but may feel unusual to users accustomed to dark mode in other apps. | Global dark theme | +| Nice-to-have | **The same label/value collision issues appear in dark mode** -- these are layout bugs, not color bugs, so dark mode doesn't help or hurt. | Card content | + +--- + +## 9. Responsiveness + +**Screenshots:** `13-responsive-tablet.png`, `14-responsive-mobile.png` + +### Issues found + +| Severity | Issue | Element | +|----------|-------|---------| +| **Critical** | **Mobile layout is broken** -- at 375px width, the sidebar overlaps the main content. The KPI strip cards are truncated ("LO...", "AC..."). The header bar overflows. Content is unreadable. | Full page at mobile widths | +| Important | **Tablet layout (768px) is functional but crowded** -- sidebar takes significant width, header bar items are compressed ("Se..." for Search), but content is readable. KPI strip wraps correctly. | Full page at tablet widths | +| Important | **Sidebar doesn't collapse on mobile** -- there's no hamburger menu or responsive sidebar behavior. The sidebar is always visible, eating screen space on narrow viewports. | Sidebar | + +--- + +## 10. Cross-cutting Concerns + +### Loading States +- Dashboard and License pages both show a centered `Spinner` during loading -- this works well. +- `EmptyState` component used for "No tenant associated" and "License unavailable" -- good error handling in components. + +### Error States +- Login page error handling is good (alert banner) +- No visible error boundary for unexpected errors on platform pages +- Admin route fails silently with HTTP error -- no user-facing error message + +### Toast Notifications +- No toast notifications observed during the audit +- License token copy should trigger a toast confirmation (if a copy button existed) + +### Confirmation Dialogs +- No destructive actions available on the platform (no delete/deactivate buttons) so no confirmation dialogs needed currently + +--- + +## Summary of Issues by Severity + +### Critical (5) +1. **Label/value collision** throughout Tenant Information card, License Validity, and License Limits sections -- labels and values run together without spacing +2. **"Open Server Dashboard" appears 3 times** on the dashboard page -- excessive redundancy +3. **No active state on sidebar navigation items** -- users can't tell which page they're on +4. **Server-specific header controls shown on platform pages** -- search, status filters, time range, auto-refresh are all meaningless on platform pages +5. **Mobile layout completely broken** -- sidebar overlaps content, content truncated + +### Important (17) +1. No password visibility toggle on login +2. Branding says "cameleer3" instead of product name on login +3. Breadcrumbs always empty on platform pages +4. Massive empty space below dashboard content +5. Tier badge color mapping inconsistent between Dashboard and License pages +6. Status shown redundantly in 3 places on dashboard +7. No clipboard copy button for license token +8. Feature badge text mismatch between source and deployed build +9. "Disabled" badge uses red-ish color (implies error, not "not in plan") +10. Admin page returns HTTP error with no graceful fallback +11. Admin section invisible in sidebar despite being admin user +12. Sidebar collapse button doesn't work (no-op handler) +13. No clear platform vs server level distinction +14. Search button does nothing on platform +15. Status filters and time range interactive but meaningless on platform +16. User menu only has Logout (no profile/settings) +17. Sidebar doesn't collapse/hide on mobile + +### Nice-to-have (8) +1. No "Forgot password" link on login +2. Login page title uses "cameleer3" branding +3. No external link icon on "Open Server Dashboard" +4. Avatar shows "AD" for "admin" +5. No units on limit values +6. Dark mode warm-toned (not standard cool dark) +7. No Enter-key submit hint +8. No tenant ID in breadcrumb/subtitle + +--- + +## Overarching Assessment + +The platform UI currently feels like a **thin shell** around the server dashboard. It has only 2 functioning pages (Dashboard and License), and both suffer from the same fundamental layout bug (label/value collision in Card components). The header bar is entirely borrowed from the server observability UI without any platform-specific adaptation, making 70% of the header controls irrelevant. + +**Key architectural concerns:** +1. The TopBar component from the design system is monolithic -- it always renders server-specific controls (status filters, time range, search). The platform needs either a simplified TopBar variant or the ability to hide these sections. +2. The sidebar uses `Sidebar.Section` (expandable groups) as navigation links, which prevents active-state highlighting. It should use `Sidebar.Link` or a similar component. +3. The platform provides very little actionable functionality -- a user can view their tenant info and license, but can't manage anything. The "Server Management" card is just a link to another app. + +**What works well overall:** +- Design system integration is solid (same look and feel as server) +- Dark mode works correctly +- Loading and error states are handled +- Login page is clean and functional +- KPI strip component is effective at summarizing key info + +**Recommended priorities:** +1. Fix the label/value collision bug (affects 3 cards across 2 pages) +2. Hide or replace server-specific header controls on platform pages +3. Add sidebar active state and fix the collapse behavior +4. Add clipboard copy for license token +5. Fix mobile responsiveness diff --git a/audit/source-code-findings.md b/audit/source-code-findings.md new file mode 100644 index 0000000..04f6679 --- /dev/null +++ b/audit/source-code-findings.md @@ -0,0 +1,433 @@ +# Cameleer SaaS UI — Source Code Audit Findings + +**Audit date:** 2026-04-09 +**Scope:** `ui/src/` (platform SPA) + `ui/sign-in/src/` (custom Logto sign-in) +**Design system:** `@cameleer/design-system@0.1.38` + +--- + +## 1. Layout and Styling Patterns + +### 1.1 Container Padding/Margin + +All three page components use an identical outer wrapper pattern: + +```tsx +// DashboardPage.tsx:67, LicensePage.tsx:82, AdminTenantsPage.tsx:60 +<div className="space-y-6 p-6"> +``` + +**Verdict:** Consistent across all pages. However, this padding is applied by each page individually rather than by the `Layout` component. If a new page omits `p-6`, the layout will be inconsistent. Consider moving container padding to the `Layout` component wrapping `<Outlet />`. + +### 1.2 Use of Design System Components vs Custom HTML + +| Component | DashboardPage | LicensePage | AdminTenantsPage | +|-----------|:---:|:---:|:---:| +| Badge | Yes | Yes | Yes | +| Button | Yes | - | - | +| Card | Yes | Yes | Yes | +| DataTable | - | - | Yes | +| EmptyState | Yes | Yes | - | +| KpiStrip | Yes | - | - | +| Spinner | Yes | Yes | Yes | + +**Issues found:** + +- **LicensePage.tsx:166-170** — Raw `<button>` for "Show token" / "Hide token" toggle instead of DS `Button variant="ghost"`: + ```tsx + <button + type="button" + className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none" + onClick={() => setTokenExpanded((v) => !v)} + > + ``` + This uses hardcoded Tailwind color classes (`text-primary-400`, `hover:text-primary-300`) instead of design tokens or a DS Button. + +- **LicensePage.tsx:174** — Raw `<div>` + `<code>` for token display instead of DS `CodeBlock` (which is available and supports `copyable`): + ```tsx + <div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto"> + <code className="text-xs font-mono text-white/80 break-all"> + {license.token} + </code> + </div> + ``` + +- **AdminTenantsPage.tsx** — No empty state when `tenants` is empty. The DataTable renders with zero rows but no guidance for the admin. + +### 1.3 Card/Section Grouping + +- **DashboardPage** uses: KpiStrip + "Tenant Information" Card + "Server Management" Card. Good grouping. +- **LicensePage** uses: "Validity" Card + "Features" Card + "Limits" Card + "License Token" Card. Well-structured. +- **AdminTenantsPage** uses: single Card wrapping DataTable. Appropriate for a list view. + +### 1.4 Typography + +All pages use the same heading pattern: +```tsx +<h1 className="text-2xl font-semibold text-white">...</h1> +``` + +**Issue:** `text-white` is hardcoded rather than using a DS color token like `var(--text-primary)`. This will break if the design system ever supports a light theme (the DS has `ThemeProvider` and a theme toggle in the TopBar). The same pattern appears: +- `DashboardPage.tsx:73` — `text-white` +- `LicensePage.tsx:85` — `text-white` +- `AdminTenantsPage.tsx:62` — `text-white` + +Similarly, muted text uses `text-white/60` and `text-white/80` throughout: +- `DashboardPage.tsx:96` — `text-white/80` +- `LicensePage.tsx:96,106,109` — `text-white/60`, `text-white` +- `LicensePage.tsx:129` — `text-sm text-white` +- `LicensePage.tsx:150` — `text-sm text-white/60` + +These should use `var(--text-primary)` / `var(--text-secondary)` / `var(--text-muted)` from the design system. + +### 1.5 Color Token Usage + +**Positive:** The sign-in page CSS module (`SignInPage.module.css`) correctly uses DS variables: +```css +color: var(--text-primary); /* line 30 */ +color: var(--text-muted); /* line 40 */ +background: var(--bg-base); /* line 7 */ +font-family: var(--font-body); /* line 20 */ +``` + +**Negative:** The platform SPA pages bypass the design system's CSS variables entirely, using Tailwind utility classes with hardcoded dark-theme colors (`text-white`, `text-white/60`, `bg-white/5`, `border-white/10`, `divide-white/10`). + +--- + +## 2. Interaction Patterns + +### 2.1 Button Placement and Order + +- **DashboardPage.tsx:81-87** — "Open Server Dashboard" button is top-right (standard). Also repeated inside a Card at line 119-125. Two identical CTAs on the same page is redundant. +- No forms exist in the platform pages. No create/edit/delete operations are exposed in the UI (read-only dashboard). + +### 2.2 Confirmation Dialogs for Destructive Actions + +- The DS provides `ConfirmDialog` and `AlertDialog` — neither is used anywhere. +- **AdminTenantsPage.tsx:47-57** — Row click silently switches tenant context and navigates to `/`. No confirmation dialog for context switching, which could be disorienting. The user clicks a row in the admin table, and their entire session context changes. + +### 2.3 Loading States + +All pages use the same loading pattern — centered `<Spinner />` in a fixed-height container: +```tsx +<div className="flex items-center justify-center h-64"> + <Spinner /> +</div> +``` + +**Issues:** +- Full-page auth loading screens (LoginPage, CallbackPage, ProtectedRoute, OrgResolver) use inline styles instead of Tailwind: + ```tsx + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}> + ``` + This is inconsistent with the page components which use Tailwind classes. + +- The `main.tsx` app bootstrap loading (line 59) also uses inline styles. Six files use this identical inline style pattern — it should be a shared component or consistent class. + +- No `Skeleton` components are used anywhere, despite the DS providing `Skeleton`. For the dashboard and license pages which fetch data, skeletons would give better perceived performance than a generic spinner. + +### 2.4 Error Handling + +- **API client (`api/client.ts`):** Errors are thrown as generic `Error` objects. No toast notifications on failure. +- **LicensePage.tsx:63-69** — Shows `EmptyState` for `isError`. Good. +- **DashboardPage.tsx** — No error state handling at all. If `useTenant()` or `useLicense()` fails, the page renders with fallback `-` values silently. No `isError` check. +- **AdminTenantsPage.tsx** — No error state. If `useAllTenants()` fails, falls through to rendering the table with empty data. +- **OrgResolver.tsx:88-89** — On error, renders `null` (blank screen). The user sees nothing — no error message, no retry option, no redirect. This is the worst error UX in the app. +- No component imports or uses `useToast()` from the DS. Toasts are never shown for any operation. + +### 2.5 Empty States + +- **DashboardPage.tsx:57-63** — `EmptyState` for no tenant. Good. +- **LicensePage.tsx:54-60** — `EmptyState` for no tenant. Good. +- **LicensePage.tsx:63-69** — `EmptyState` for license fetch error. Good. +- **AdminTenantsPage.tsx** — **Missing.** No empty state when `tenants` array is empty. DataTable will render an empty table body. + +--- + +## 3. Component Usage + +### 3.1 DS Imports by File + +| File | DS Components Imported | +|------|----------------------| +| `main.tsx` | ThemeProvider, ToastProvider, BreadcrumbProvider, GlobalFilterProvider, CommandPaletteProvider, Spinner | +| `Layout.tsx` | AppShell, Sidebar, TopBar | +| `DashboardPage.tsx` | Badge, Button, Card, EmptyState, KpiStrip, Spinner | +| `LicensePage.tsx` | Badge, Card, EmptyState, Spinner | +| `AdminTenantsPage.tsx` | Badge, Card, DataTable, Spinner + Column type | +| `LoginPage.tsx` | Spinner | +| `CallbackPage.tsx` | Spinner | +| `ProtectedRoute.tsx` | Spinner | +| `OrgResolver.tsx` | Spinner | +| `SignInPage.tsx` (sign-in) | Card, Input, Button, Alert, FormField | + +### 3.2 Available but Unused DS Components + +These DS components are relevant to the platform UI but unused: + +| Component | Could be used for | +|-----------|------------------| +| `AlertDialog` / `ConfirmDialog` | Confirming tenant context switch in AdminTenantsPage | +| `CodeBlock` | License token display (currently raw HTML) | +| `Skeleton` | Loading states instead of spinner | +| `Tooltip` | Badge hover explanations, info about features | +| `StatusDot` | Tenant status indicators | +| `Breadcrumb` / `useBreadcrumb` | Page navigation context (currently empty `[]`) | +| `LoginForm` | Could replace the custom sign-in form (DS already has one) | +| `useToast` | Error/success notifications | + +### 3.3 Raw HTML Where DS Components Exist + +1. **LicensePage.tsx:166-170** — Raw `<button>` instead of `Button variant="ghost"` +2. **LicensePage.tsx:174-178** — Raw `<div><code>` instead of `CodeBlock` +3. **Layout.tsx:26-62** — Four inline SVG icon components instead of using `lucide-react` icons (the DS depends on lucide-react) +4. **DashboardPage.tsx:95-112** — Manual label/value list with `<div className="flex justify-between">` instead of using a DS pattern (the DS has no explicit key-value list component, so this is acceptable) + +### 3.4 Styling Approach + +- **Platform SPA pages:** Tailwind CSS utility classes (via class names like `space-y-6`, `p-6`, `flex`, `items-center`, etc.) +- **Sign-in page:** CSS modules (`SignInPage.module.css`) with DS CSS variables +- **Auth loading screens:** Inline `style={{}}` objects +- **No CSS modules** in the platform SPA at all (zero `.module.css` files in `ui/src/`) + +This is a three-way inconsistency: Tailwind in pages, CSS modules in sign-in, inline styles in auth components. + +--- + +## 4. Navigation + +### 4.1 Sidebar + +**File:** `ui/src/components/Layout.tsx:70-118` + +The sidebar uses `Sidebar.Section` with `open={false}` and `{null}` children as a workaround to make sections act as navigation links (via `onToggle`). This is a semantic misuse — sections are designed as collapsible containers, not nav links. + +```tsx +<Sidebar.Section + icon={<DashboardIcon />} + label="Dashboard" + open={false} + onToggle={() => navigate('/')} +> + {null} +</Sidebar.Section> +``` + +**Issues:** +- No `active` state is set on any section. The DS supports `active?: boolean` on `SidebarSectionProps` (line 988 of DS types), but it's never passed. The user has no visual indicator of which page they're on. +- `collapsed={false}` is hardcoded with `onCollapseToggle={() => {}}` — the sidebar cannot be collapsed. This is a no-op handler. +- Only three nav items: Dashboard, License, Platform (admin-only). Very sparse. + +### 4.2 "Open Server Dashboard" + +Two implementations, both identical: +1. **Sidebar footer** (`Layout.tsx:112-116`): `Sidebar.FooterLink` with `window.open('/server/', '_blank', 'noopener')` +2. **Dashboard page** (`DashboardPage.tsx:84`): Primary Button, same `window.open` call +3. **Dashboard page** (`DashboardPage.tsx:120-125`): Secondary Button in a Card, same `window.open` call + +Three separate "Open Server Dashboard" triggers on the dashboard. The footer link is good; the two dashboard buttons are redundant. + +### 4.3 Breadcrumbs + +**File:** `Layout.tsx:124` — `<TopBar breadcrumb={[]} ... />` + +Breadcrumbs are permanently empty. The DS provides `useBreadcrumb()` hook (exported, see line 1255 of DS types) that pages can call to set page-specific breadcrumbs, but none of the pages use it. The TopBar renders an empty breadcrumb area. + +### 4.4 User Menu / Avatar + +**File:** `Layout.tsx:125-126` + +```tsx +<TopBar + user={username ? { name: username } : undefined} + onLogout={logout} +/> +``` + +The TopBar's `user` prop triggers a `Dropdown` with only a "Logout" option. The avatar is rendered by the DS using the `Avatar` component with the user's name. + +**Issue:** When `username` is `null` (common if the Logto ID token doesn't have `username`, `name`, or `email` claims), no user indicator is shown at all — no avatar, no logout button. The user has no way to log out from the UI. + +--- + +## 5. Header Bar + +### 5.1 Shared TopBar with Server + +The platform SPA and the server SPA both use the same `TopBar` component from `@cameleer/design-system`. This means they share identical header chrome. + +### 5.2 Irrelevant Controls on Platform Pages + +**Critical issue.** The `TopBar` component (DS source, lines 5569-5588 of `index.es.js`) **always** renders: + +1. **Status filter pills** (Completed, Warning, Error, Running) — `ButtonGroup` with global filter status values +2. **Time range dropdown** — `TimeRangeDropdown` with presets like "Last 1h", "Last 24h" +3. **Auto-refresh toggle** — "AUTO" / "MANUAL" button +4. **Theme toggle** — Light/dark mode switch +5. **Command palette search** — "Search... Ctrl+K" button + +These controls are hardcoded in the DS `TopBar` component. They read from `useGlobalFilters()` and operate on exchange status filters and time ranges — concepts that are **completely irrelevant** to the SaaS platform pages (Dashboard, License, Admin Tenants). + +The platform wraps everything in `GlobalFilterProvider` (in `main.tsx:96`), which initializes the filter state, but nothing in the platform UI reads or uses these filters. They are dead UI elements that confuse users. + +**Recommendation:** Either: +- The DS should make these controls optional/configurable on `TopBar` +- The platform should use a simpler header component +- The platform should not wrap in `GlobalFilterProvider` / `CommandPaletteProvider` (but this may cause runtime errors if TopBar assumes they exist) + +--- + +## 6. Specific Issues + +### 6.1 Label/Value Formatting — "Slugdefault" Concatenation Bug + +**Not found in source code.** The source code properly formats label/value pairs with `flex justify-between` layout: + +```tsx +// DashboardPage.tsx:96-99 +<div className="flex justify-between text-white/80"> + <span>Slug</span> + <span className="font-mono">{tenant?.slug ?? '-'}</span> +</div> +``` + +If "Slugdefault" concatenation is visible in the UI, it's a **rendering/CSS issue** rather than a template bug — the `flex justify-between` may collapse if the container is too narrow, or there may be a DS Card padding issue causing the spans to not separate. The code itself has proper separation. + +Similarly for limits on the License page: +```tsx +// LicensePage.tsx:147-155 +<span className="text-sm text-white/60">{label}</span> +<span className="text-sm font-mono text-white">{value !== undefined ? value : '—'}</span> +``` + +Labels and values are in separate `<span>` elements within `flex justify-between` containers. The code is correct. + +### 6.2 Badge Colors + +**Feature badges (LicensePage.tsx:130-133):** +```tsx +<Badge + label={enabled ? 'Enabled' : 'Not included'} + color={enabled ? 'success' : 'auto'} +/> +``` + +- Enabled features: `color="success"` (green) — appropriate +- Disabled features: `color="auto"` — this uses the DS's auto-color logic (hash-based). For a disabled/not-included state, `color="error"` or a neutral muted variant would be more appropriate to clearly communicate "not available." + +**Tenant status badges (DashboardPage.tsx:102-105, AdminTenantsPage.tsx:24-29):** +```tsx +color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'} +color={row.status === 'ACTIVE' ? 'success' : 'warning'} +``` + +- ACTIVE: green — appropriate +- Anything else (SUSPENDED, PENDING): yellow/warning — reasonable but SUSPENDED should arguably be `error` (red) + +**Tier badges:** Use `tierColor()` function but it's defined differently in each file: + +- `DashboardPage.tsx:12-18` maps: enterprise->success, pro->primary, starter->warning +- `LicensePage.tsx:25-33` maps: BUSINESS->success, HIGH->primary, MID->warning, LOW->error + +These use **different tier names** (enterprise/pro/starter vs BUSINESS/HIGH/MID/LOW). One is for tenant tiers, the other for license tiers, but the inconsistency suggests either the data model has diverged or one mapping is stale. + +### 6.3 Sign-In Page (`ui/sign-in/src/`) + +**Positive findings:** +- Uses DS components: `Card`, `Input`, `Button`, `Alert`, `FormField` +- Uses CSS modules with DS CSS variables (`var(--bg-base)`, `var(--text-primary)`, etc.) +- Proper form with `aria-label="Sign in"`, `autoComplete` attributes +- Loading state on submit button via `loading` prop +- Error display via DS `Alert variant="error"` +- Creative rotating subtitle strings — good personality touch + +**Issues:** +1. **No `ThemeProvider` wrapper** (`sign-in/src/main.tsx`): + ```tsx + createRoot(document.getElementById('root')!).render( + <StrictMode> + <App /> + </StrictMode>, + ); + ``` + The sign-in page imports `@cameleer/design-system/style.css` which provides CSS variable defaults, so it works. But the theme toggle won't function, and if the DS ever requires `ThemeProvider` for initialization, this will break. + +2. **No `ToastProvider`** — if any DS component internally uses `useToast()`, it will throw. + +3. **Hardcoded branding** (`SignInPage.tsx:61`): + ```tsx + cameleer3 + ``` + The brand name is hardcoded text, not sourced from configuration. + +4. **`React` import unused** (`SignInPage.tsx:1`): `useMemo` and `useState` are imported from `react` but the `import React` default import is absent, which is fine for React 19. + +5. **No "forgot password" flow** — the form has username + password only. No recovery link. The DS `LoginForm` component supports `onForgotPassword` and `onSignUp` callbacks. + +--- + +## 7. Architecture Observations + +### 7.1 Provider Stack Over-provisioning + +`main.tsx` wraps the app in: +``` +ThemeProvider > ToastProvider > BreadcrumbProvider > GlobalFilterProvider > CommandPaletteProvider +``` + +`GlobalFilterProvider` and `CommandPaletteProvider` are server-dashboard concepts (exchange status filters, time range, search). They are unused by any platform page but are required because `TopBar` reads from them internally. This creates coupling between the server's observability UI concerns and the SaaS platform pages. + +### 7.2 Route Guard Nesting + +The route structure is: +``` +ProtectedRoute > OrgResolver > Layout > (pages) +``` + +`OrgResolver` fetches `/api/me` and resolves tenant context. If it fails (`isError`), it renders `null` — a blank screen inside the Layout shell. This means the sidebar and TopBar render but the content area is completely empty with no explanation. + +### 7.3 Unused Import + +- `LicensePage.tsx:1` imports `React` and `useState` — `React` import is not needed with React 19's JSX transform, and `useState` is used so that's fine. But `React` as a namespace import isn't used. + +### 7.4 DataTable Requires `id` Field + +`AdminTenantsPage.tsx:67` passes `tenants` to `DataTable`. The DS type requires `T extends { id: string }`. The `TenantResponse` type has `id: string`, so this works, but the `createdAt` column (line 31) renders the raw ISO timestamp string without formatting — unlike DashboardPage which formats it with `toLocaleDateString()`. + +--- + +## 8. Summary of Issues by Severity + +### High Priority +| # | Issue | File(s) | Line(s) | +|---|-------|---------|---------| +| H1 | TopBar shows irrelevant status filters, time range, auto-refresh for platform pages | `Layout.tsx` / DS `TopBar` | 122-128 | +| H2 | OrgResolver error state renders blank screen (no error UI) | `OrgResolver.tsx` | 88-89 | +| H3 | Hardcoded `text-white` colors break light theme | All pages | Multiple | + +### Medium Priority +| # | Issue | File(s) | Line(s) | +|---|-------|---------|---------| +| M1 | No active state on sidebar navigation items | `Layout.tsx` | 79-108 | +| M2 | Breadcrumbs permanently empty | `Layout.tsx` | 124 | +| M3 | DashboardPage has no error handling for failed API calls | `DashboardPage.tsx` | 23-26 | +| M4 | AdminTenantsPage missing empty state | `AdminTenantsPage.tsx` | 67-72 | +| M5 | AdminTenantsPage row click silently switches tenant context | `AdminTenantsPage.tsx` | 47-57 | +| M6 | Toasts never used despite ToastProvider being mounted | All pages | - | +| M7 | Raw `<button>` and `<code>` instead of DS components in LicensePage | `LicensePage.tsx` | 166-178 | +| M8 | AdminTenantsPage `createdAt` column renders raw ISO string | `AdminTenantsPage.tsx` | 31 | +| M9 | `tierColor()` defined twice with different tier mappings | `DashboardPage.tsx`, `LicensePage.tsx` | 12-18, 25-33 | +| M10 | "Not included" feature badge uses `color="auto"` instead of muted/neutral | `LicensePage.tsx` | 133 | + +### Low Priority +| # | Issue | File(s) | Line(s) | +|---|-------|---------|---------| +| L1 | Three "Open Server Dashboard" buttons/links on dashboard | `Layout.tsx`, `DashboardPage.tsx` | 112-116, 81-87, 119-125 | +| L2 | Inconsistent loading style (inline styles vs Tailwind) | Auth files vs pages | Multiple | +| L3 | No Skeleton loading used (all Spinner) | All pages | - | +| L4 | Sidebar collapse disabled (no-op handler) | `Layout.tsx` | 71 | +| L5 | Sign-in page missing ThemeProvider wrapper | `sign-in/src/main.tsx` | 6-9 | +| L6 | Sign-in page has no forgot-password or sign-up link | `sign-in/src/SignInPage.tsx` | - | +| L7 | Custom SVG icons in Layout instead of lucide-react | `Layout.tsx` | 26-62 | +| L8 | Username null = no logout button visible | `Layout.tsx` | 125-126 | +| L9 | Page padding `p-6` repeated per-page instead of in Layout | All pages | - | diff --git a/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md b/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md new file mode 100644 index 0000000..459120d --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md @@ -0,0 +1,760 @@ +# SaaS Platform UX Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix layout bugs, replace hardcoded dark-only colors with design system tokens, improve navigation/header, add error handling, and adopt design system components consistently across the SaaS platform UI. + +**Architecture:** All changes are in the existing SaaS platform UI (`ui/src/`) and sign-in page (`ui/sign-in/src/`). The platform uses `@cameleer/design-system` components and Tailwind CSS. The key issue is that pages use hardcoded `text-white` Tailwind classes instead of DS CSS variables, and the DS `TopBar` renders server-specific controls that are irrelevant on platform pages. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS, `@cameleer/design-system`, React Router v6, Logto SDK + +**Spec:** `docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md` + +--- + +## Task 1: Fix label/value collision and replace hardcoded colors + +**Spec items:** 1.1, 1.2 + +**Files:** +- Create: `ui/src/styles/platform.module.css` +- Modify: `ui/src/pages/DashboardPage.tsx` +- Modify: `ui/src/pages/LicensePage.tsx` +- Modify: `ui/src/pages/AdminTenantsPage.tsx` + +- [ ] **Step 1: Create shared platform CSS module** + +Create `ui/src/styles/platform.module.css` with DS-variable-based classes replacing the hardcoded Tailwind colors: + +```css +.heading { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.textPrimary { + color: var(--text-primary); +} + +.textSecondary { + color: var(--text-secondary); +} + +.textMuted { + color: var(--text-muted); +} + +.mono { + font-family: var(--font-mono); +} + +.kvRow { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.kvLabel { + font-size: 0.875rem; + color: var(--text-muted); +} + +.kvValue { + font-size: 0.875rem; + color: var(--text-primary); +} + +.kvValueMono { + font-size: 0.875rem; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.dividerList { + display: flex; + flex-direction: column; +} + +.dividerList > * + * { + border-top: 1px solid var(--border-subtle); +} + +.dividerRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; +} + +.dividerRow:first-child { + padding-top: 0; +} + +.dividerRow:last-child { + padding-bottom: 0; +} + +.description { + font-size: 0.875rem; + color: var(--text-muted); +} + +.tokenBlock { + margin-top: 0.5rem; + border-radius: var(--radius-sm); + background: var(--bg-inset); + border: 1px solid var(--border-subtle); + padding: 0.75rem; + overflow-x: auto; +} + +.tokenCode { + font-size: 0.75rem; + font-family: var(--font-mono); + color: var(--text-secondary); + word-break: break-all; +} +``` + +- [ ] **Step 2: Update DashboardPage to use CSS module + fix label/value** + +In `ui/src/pages/DashboardPage.tsx`: + +1. Add import: +```typescript +import s from '../styles/platform.module.css'; +``` + +2. Replace all hardcoded color classes: +- Line 71: `text-2xl font-semibold text-white` → `className={s.heading}` +- Lines 96, 100, 107: `className="flex justify-between text-white/80"` → `className={s.kvRow}` +- Inner label spans: wrap with `className={s.kvLabel}` +- Inner value spans: wrap with `className={s.kvValueMono}` (for mono) or `className={s.kvValue}` +- Line 116: `text-sm text-white/60` → `className={s.description}` + +3. The label/value collision fix: the `kvRow` class uses explicit `display: flex; width: 100%; justify-content: space-between` which ensures the flex container stretches to full Card width regardless of Card's inner layout. + +- [ ] **Step 3: Update LicensePage to use CSS module** + +In `ui/src/pages/LicensePage.tsx`: + +1. Add import: `import s from '../styles/platform.module.css';` + +2. Replace all hardcoded color classes: +- Line 85: heading → `className={s.heading}` +- Lines 95-115 (Validity rows): `flex items-center justify-between` → `className={s.kvRow}`, labels → `className={s.kvLabel}`, values → `className={s.kvValue}` +- Lines 121-136 (Features): `divide-y divide-white/10` → `className={s.dividerList}`, rows → `className={s.dividerRow}`, feature name `text-sm text-white` → `className={s.textPrimary}` + `text-sm` +- Lines 142-157 (Limits): same dividerList/dividerRow pattern, label → `className={s.kvLabel}`, value → `className={s.kvValueMono}` +- Line 163: description text → `className={s.description}` +- Lines 174-178: token code block → `className={s.tokenBlock}` on outer div, `className={s.tokenCode}` on code element + +- [ ] **Step 4: Update AdminTenantsPage to use CSS module** + +In `ui/src/pages/AdminTenantsPage.tsx`: +- Line 62: `text-2xl font-semibold text-white` → `className={s.heading}` + +- [ ] **Step 5: Verify in both themes** + +1. Open the platform dashboard in browser +2. Check label/value pairs have proper spacing (Slug on left, "default" on right) +3. Toggle to light theme via TopBar toggle +4. Verify all text is readable in light mode (no invisible white-on-white) +5. Toggle back to dark mode — should look the same as before + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/styles/platform.module.css ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx ui/src/pages/AdminTenantsPage.tsx +git commit -m "fix: replace hardcoded text-white with DS variables, fix label/value layout" +``` + +--- + +## Task 2: Remove redundant dashboard elements + +**Spec items:** 1.3, 2.4 + +**Files:** +- Modify: `ui/src/pages/DashboardPage.tsx` + +- [ ] **Step 1: Remove primary "Open Server Dashboard" button from header** + +In `ui/src/pages/DashboardPage.tsx`, find the header area (lines ~75-88). Remove the primary Button for "Open Server Dashboard" (lines ~81-87). Keep: +- The Server Management Card with its secondary button (lines ~113-126) +- The sidebar footer link (in Layout.tsx — don't touch) + +The header area should just have the tenant name heading + tier badge, no button. + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/DashboardPage.tsx +git commit -m "fix: remove redundant Open Server Dashboard button from dashboard header" +``` + +--- + +## Task 3: Fix header controls and sidebar navigation + +**Spec items:** 2.1, 2.2, 2.3, 2.5 + +**Files:** +- Modify: `ui/src/components/Layout.tsx` +- Modify: `ui/src/main.tsx` (possibly) + +- [ ] **Step 1: Investigate TopBar props for hiding controls** + +The DS `TopBar` interface (from types): +```typescript +interface TopBarProps { + breadcrumb: BreadcrumbItem[]; + environment?: ReactNode; + user?: { name: string }; + onLogout?: () => void; + className?: string; +} +``` + +The TopBar has NO props to hide status filters, time range, auto-refresh, or search. These are hardcoded inside the component. + +**Options:** +1. Check if removing `GlobalFilterProvider` and `CommandPaletteProvider` from `main.tsx` makes TopBar gracefully hide those sections (test this first) +2. If that causes errors, add `display: none` CSS overrides for the irrelevant sections +3. If neither works, build a simplified platform header + +Try option 1 first. In `main.tsx`, remove `GlobalFilterProvider` and `CommandPaletteProvider` from the provider stack. Test if the app still renders. If TopBar crashes without them, revert and try option 2. + +- [ ] **Step 2: Add sidebar active state** + +In `ui/src/components/Layout.tsx`, add route-based active state: + +```typescript +import { useLocation } from 'react-router'; + +// Inside the Layout component: +const location = useLocation(); +``` + +Update each `Sidebar.Section`: +```tsx +<Sidebar.Section + icon={<DashboardIcon />} + label="Dashboard" + open={false} + active={location.pathname === '/' || location.pathname === ''} + onToggle={() => navigate('/')} +> + {null} +</Sidebar.Section> + +<Sidebar.Section + icon={<LicenseIcon />} + label="License" + open={false} + active={location.pathname === '/license'} + onToggle={() => navigate('/license')} +> + {null} +</Sidebar.Section> + +{scopes.has('platform:admin') && ( + <Sidebar.Section + icon={<PlatformIcon />} + label="Platform" + open={false} + active={location.pathname.startsWith('/admin')} + onToggle={() => navigate('/admin/tenants')} + > + {null} + </Sidebar.Section> +)} +``` + +- [ ] **Step 3: Add breadcrumbs** + +In Layout.tsx, compute breadcrumbs from the current route: + +```typescript +const breadcrumb = useMemo((): BreadcrumbItem[] => { + const path = location.pathname; + if (path.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }]; + if (path.startsWith('/license')) return [{ label: 'License' }]; + return [{ label: 'Dashboard' }]; +}, [location.pathname]); +``` + +Pass to TopBar: +```tsx +<TopBar breadcrumb={breadcrumb} ... /> +``` + +Import `BreadcrumbItem` type from `@cameleer/design-system` if needed. + +- [ ] **Step 4: Fix sidebar collapse** + +Replace the hardcoded collapse state: +```typescript +const [sidebarCollapsed, setSidebarCollapsed] = useState(false); +``` + +```tsx +<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}> +``` + +- [ ] **Step 5: Fix username null fallback** + +Update the user prop (line ~125): +```tsx +const displayName = username || 'User'; + +<TopBar + breadcrumb={breadcrumb} + user={{ name: displayName }} + onLogout={logout} +/> +``` + +This ensures the logout button is always visible. + +- [ ] **Step 6: Replace custom SVG icons with lucide-react** + +Replace the 4 custom SVG icon components (lines 25-62) with lucide-react icons: + +```typescript +import { LayoutDashboard, ShieldCheck, Building, Server } from 'lucide-react'; +``` + +Then update sidebar sections: +```tsx +icon={<LayoutDashboard size={18} />} // was <DashboardIcon /> +icon={<ShieldCheck size={18} />} // was <LicenseIcon /> +icon={<Building size={18} />} // was <PlatformIcon /> +``` + +Remove the 4 custom SVG component functions (DashboardIcon, LicenseIcon, ObsIcon, PlatformIcon). + +- [ ] **Step 7: Verify** + +1. Sidebar shows active highlight on current page +2. Breadcrumbs show "Dashboard", "License", or "Admin > Tenants" +3. Sidebar collapse works (click collapse button, sidebar minimizes) +4. User avatar/logout always visible +5. Icons render correctly from lucide-react +6. Check if server controls are hidden (depending on step 1 result) + +- [ ] **Step 8: Commit** + +```bash +git add ui/src/components/Layout.tsx ui/src/main.tsx +git commit -m "fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons" +``` + +--- + +## Task 4: Error handling and OrgResolver fix + +**Spec items:** 3.1, 3.2, 3.7 + +**Files:** +- Modify: `ui/src/auth/OrgResolver.tsx` +- Modify: `ui/src/pages/DashboardPage.tsx` +- Modify: `ui/src/pages/AdminTenantsPage.tsx` + +- [ ] **Step 1: Fix OrgResolver error state** + +In `ui/src/auth/OrgResolver.tsx`, find the error handling (lines 88-90): + +```tsx +// BEFORE: +if (isError) { + return null; +} + +// AFTER: +if (isError) { + return ( + <div className="flex flex-col items-center justify-center h-64 gap-4"> + <EmptyState + title="Unable to load account" + description="Failed to retrieve your organization. Please try again or contact support." + /> + <Button variant="secondary" size="sm" onClick={() => refetch()}> + Retry + </Button> + </div> + ); +} +``` + +Add imports: `EmptyState`, `Button` from `@cameleer/design-system`. Ensure `refetch` is available from the query hook (check if `useQuery` returns it). + +- [ ] **Step 2: Add error handling to DashboardPage** + +In `ui/src/pages/DashboardPage.tsx`, after the loading check (line ~49) and tenant check (line ~57), add error handling: + +```tsx +const { data: tenant, isError: tenantError } = useTenant(); +const { data: license, isError: licenseError } = useLicense(); + +// After loading spinner check: +if (tenantError || licenseError) { + return ( + <div className="p-6"> + <EmptyState + title="Unable to load dashboard" + description="Failed to retrieve tenant information. Please try again later." + /> + </div> + ); +} +``` + +Check how `useTenant()` and `useLicense()` expose error state — they may use `isError` from React Query. + +- [ ] **Step 3: Add empty state and date formatting to AdminTenantsPage** + +In `ui/src/pages/AdminTenantsPage.tsx`: + +1. Add error handling: +```tsx +if (isError) { + return ( + <div className="p-6"> + <EmptyState + title="Unable to load tenants" + description="You may not have admin permissions, or the server is unavailable." + /> + </div> + ); +} +``` + +2. Format the `createdAt` column (line 31): +```tsx +// BEFORE: +{ key: 'createdAt', header: 'Created' }, + +// AFTER: +{ key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() }, +``` + +3. Add empty state to DataTable (if supported) or show EmptyState when tenants is empty: +```tsx +{(!tenants || tenants.length === 0) ? ( + <EmptyState title="No tenants" description="No tenants have been created yet." /> +) : ( + <DataTable columns={columns} data={tenants} onRowClick={handleRowClick} /> +)} +``` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/auth/OrgResolver.tsx ui/src/pages/DashboardPage.tsx ui/src/pages/AdminTenantsPage.tsx +git commit -m "fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage" +``` + +--- + +## Task 5: DS component adoption and license token copy + +**Spec items:** 3.3, 3.4 + +**Files:** +- Modify: `ui/src/pages/LicensePage.tsx` + +- [ ] **Step 1: Replace raw button with DS Button** + +In `ui/src/pages/LicensePage.tsx`, find the token toggle button (lines ~166-172): + +```tsx +// BEFORE: +<button + type="button" + className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none" + onClick={() => setTokenExpanded((v) => !v)} +> + {tokenExpanded ? 'Hide token' : 'Show token'} +</button> + +// AFTER: +<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}> + {tokenExpanded ? 'Hide token' : 'Show token'} +</Button> +``` + +Ensure `Button` is imported from `@cameleer/design-system`. + +- [ ] **Step 2: Add copy-to-clipboard button** + +Add `useToast` import and `Copy` icon: +```typescript +import { useToast } from '@cameleer/design-system'; +import { Copy } from 'lucide-react'; +``` + +Add toast hook in component: +```typescript +const { toast } = useToast(); +``` + +Next to the show/hide button, add a copy button (only when expanded): +```tsx +<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> + <Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}> + {tokenExpanded ? 'Hide token' : 'Show token'} + </Button> + {tokenExpanded && ( + <Button variant="ghost" size="sm" onClick={() => { + navigator.clipboard.writeText(license.token); + toast({ title: 'Token copied to clipboard', variant: 'success' }); + }}> + <Copy size={14} /> Copy + </Button> + )} +</div> +``` + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/LicensePage.tsx +git commit -m "fix: replace raw button with DS Button, add token copy-to-clipboard" +``` + +--- + +## Task 6: Sign-in page improvements + +**Spec items:** 3.6, 4.5 + +**Files:** +- Modify: `ui/sign-in/src/SignInPage.tsx` + +- [ ] **Step 1: Add password visibility toggle** + +In `ui/sign-in/src/SignInPage.tsx`, add state and imports: + +```typescript +import { Eye, EyeOff } from 'lucide-react'; +const [showPassword, setShowPassword] = useState(false); +``` + +Update the password FormField (lines ~84-94): +```tsx +<FormField label="Password" htmlFor="login-password"> + <div style={{ position: 'relative' }}> + <Input + id="login-password" + type={showPassword ? 'text' : 'password'} + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> + <button + type="button" + onClick={() => setShowPassword(!showPassword)} + style={{ + position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', + background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', + padding: 4, display: 'flex', alignItems: 'center', + }} + tabIndex={-1} + > + {showPassword ? <EyeOff size={16} /> : <Eye size={16} />} + </button> + </div> +</FormField> +``` + +Note: Using a raw `<button>` here because the sign-in page may not have the full DS Button available (it's a separate Vite build). Use inline styles for positioning since the sign-in page uses CSS modules. + +- [ ] **Step 2: Fix branding text** + +In `ui/sign-in/src/SignInPage.tsx`, find the logo text (line ~61): + +```tsx +// BEFORE: +<div className={styles.logo}> + <img src={cameleerLogo} alt="" className={styles.logoImg} /> + cameleer3 +</div> + +// AFTER: +<div className={styles.logo}> + <img src={cameleerLogo} alt="" className={styles.logoImg} /> + Cameleer +</div> +``` + +Also update the page title if it's set anywhere (check `index.html` in `ui/sign-in/`): +```html +<title>Sign in — Cameleer +``` + +- [ ] **Step 3: Commit** + +```bash +git add ui/sign-in/src/SignInPage.tsx ui/sign-in/index.html +git commit -m "fix: add password visibility toggle and fix branding to 'Cameleer'" +``` + +--- + +## Task 7: Unify tier colors and fix badges + +**Spec items:** 4.1, 4.2 + +**Files:** +- Create: `ui/src/utils/tier.ts` +- Modify: `ui/src/pages/DashboardPage.tsx` +- Modify: `ui/src/pages/LicensePage.tsx` + +- [ ] **Step 1: Create shared tier utility** + +Create `ui/src/utils/tier.ts`: + +```typescript +export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto'; + +export function tierColor(tier: string): TierColor { + switch (tier?.toUpperCase()) { + case 'BUSINESS': + case 'ENTERPRISE': + return 'success'; + case 'HIGH': + case 'PRO': + return 'primary'; + case 'MID': + case 'STARTER': + return 'warning'; + case 'LOW': + case 'FREE': + return 'auto'; + default: + return 'auto'; + } +} +``` + +- [ ] **Step 2: Replace local tierColor in both pages** + +In `DashboardPage.tsx`, remove the local `tierColor` function (lines 12-19) and add: +```typescript +import { tierColor } from '../utils/tier'; +``` + +In `LicensePage.tsx`, remove the local `tierColor` function (lines 25-33) and add: +```typescript +import { tierColor } from '../utils/tier'; +``` + +- [ ] **Step 3: Fix feature badge color** + +In `LicensePage.tsx`, find the feature badge (line ~131-132): + +```tsx +// BEFORE: +color={enabled ? 'success' : 'auto'} + +// Check what neutral badge colors the DS supports. +// If 'auto' hashes to inconsistent colors, use a fixed muted option. +// AFTER: +color={enabled ? 'success' : 'warning'} +``` + +Use `'warning'` (amber/muted) for "Not included" — it's neutral without implying error. If the DS has a better neutral option, use that. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/utils/tier.ts ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx +git commit -m "fix: unify tier color mapping, fix feature badge colors" +``` + +--- + +## Task 8: AdminTenantsPage confirmation and polish + +**Spec items:** 4.3 + +**Files:** +- Modify: `ui/src/pages/AdminTenantsPage.tsx` + +- [ ] **Step 1: Add confirmation before tenant context switch** + +In `ui/src/pages/AdminTenantsPage.tsx`, add state and import: + +```typescript +import { AlertDialog } from '@cameleer/design-system'; + +const [switchTarget, setSwitchTarget] = useState(null); +``` + +Update the row click handler: +```tsx +// BEFORE: +const handleRowClick = (tenant: TenantResponse) => { + const orgs = useOrgStore.getState().organizations; + const match = orgs.find((o) => o.name === tenant.name || o.slug === tenant.slug); + if (match) { + setCurrentOrg(match.id); + navigate('/'); + } +}; + +// AFTER: +const handleRowClick = (tenant: TenantResponse) => { + setSwitchTarget(tenant); +}; + +const confirmSwitch = () => { + if (!switchTarget) return; + const orgs = useOrgStore.getState().organizations; + const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug); + if (match) { + setCurrentOrg(match.id); + navigate('/'); + } + setSwitchTarget(null); +}; +``` + +Add the AlertDialog at the bottom of the component return: +```tsx + setSwitchTarget(null)} + onConfirm={confirmSwitch} + title="Switch tenant?" + description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`} + confirmLabel="Switch" + variant="warning" +/> +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/AdminTenantsPage.tsx +git commit -m "fix: add confirmation dialog before tenant context switch" +``` + +--- + +## Summary + +| Task | Batch | Key Changes | Commit | +|------|-------|-------------|--------| +| 1 | Layout | CSS module with DS variables, fix label/value, replace text-white | `fix: replace hardcoded text-white with DS variables, fix label/value layout` | +| 2 | Layout | Remove redundant "Open Server Dashboard" button | `fix: remove redundant Open Server Dashboard button` | +| 3 | Navigation | Sidebar active state, breadcrumbs, collapse, username fallback, lucide icons | `fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons` | +| 4 | Error Handling | OrgResolver error UI, DashboardPage error state, AdminTenantsPage error + date format | `fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage` | +| 5 | Components | DS Button for token toggle, copy-to-clipboard with toast | `fix: replace raw button with DS Button, add token copy-to-clipboard` | +| 6 | Sign-in | Password visibility toggle, branding fix to "Cameleer" | `fix: add password visibility toggle and fix branding to 'Cameleer'` | +| 7 | Polish | Shared tierColor(), fix feature badge colors | `fix: unify tier color mapping, fix feature badge colors` | +| 8 | Polish | Confirmation dialog for admin tenant switch | `fix: add confirmation dialog before tenant context switch` | diff --git a/docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md b/docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md new file mode 100644 index 0000000..4e7c500 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md @@ -0,0 +1,413 @@ +# SaaS Platform UX Polish — Design Spec + +**Date:** 2026-04-09 +**Scope:** Bug fixes, design consistency, error handling, component quality for the cameleer-saas platform UI +**Out of scope:** Mobile responsiveness (deferred), new features (billing, team management), admin tenant creation (#37) + +## Context + +Playwright-driven audit of the live SaaS platform (22 screenshots) plus source code audit of `ui/src/` (3 pages, sign-in page, layout, auth components). The platform has only 3 pages — Dashboard, License, Admin Tenants — plus a custom Logto sign-in page. Issues are concentrated and structural. + +Audit artifacts in `audit/`: +- `platform-ui-findings.md` — 30 issues from live UI audit +- `source-code-findings.md` — 22 issues from source code analysis + +## Implementation Strategy + +4 batches, ordered by impact. Smaller scope than the server UI polish (~25 items vs ~52). + +--- + +## Batch 1: Layout Fixes + +**Effort:** 0.5 days + +### 1.1 Fix Label/Value Collision + +**Problem:** Throughout Dashboard and License pages, labels and values run together: "Slugdefault", "Max Agents3", "Issued8. April 2026". The code uses `flex justify-between` but the flex container doesn't stretch to full Card width. + +**Root cause (source audit):** The `
` elements are inside Card components. If the Card's inner container doesn't apply `w-full` or the flex children don't have enough space, `justify-between` collapses. + +**Fix:** Ensure the container divs inside Cards have `w-full` (or `className="flex justify-between w-full"`). Check all label/value rows in: +- `DashboardPage.tsx:95-112` — Tenant Information section +- `LicensePage.tsx:94-115` — Validity section +- `LicensePage.tsx:145-158` — Limits section + +If the Card component's children wrapper is the constraint, wrap the content in `
`. + +**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx` + +### 1.2 Replace Hardcoded `text-white` with DS Variables + +**Problem:** Every page uses Tailwind `text-white`, `text-white/60`, `text-white/80`, `bg-white/5`, `border-white/10` instead of DS CSS variables. This breaks light theme (TopBar has a working theme toggle). + +**Fix:** Replace all hardcoded color classes with DS CSS variable equivalents using inline styles or a CSS module: + +| Tailwind class | DS variable | +|---------------|-------------| +| `text-white` | `var(--text-primary)` | +| `text-white/80` | `var(--text-secondary)` | +| `text-white/60` | `var(--text-muted)` | +| `text-white/40` | `var(--text-faint)` | +| `bg-white/5` | `var(--bg-hover)` | +| `bg-white/10` | `var(--bg-inset)` | +| `border-white/10` | `var(--border-subtle)` | +| `divide-white/10` | `var(--border-subtle)` | + +**Approach:** Create a shared CSS module (`ui/src/styles/platform.module.css`) with classes mapping to DS variables, or switch to inline `style={{ color: 'var(--text-primary)' }}`. The sign-in page already demonstrates the correct pattern with CSS modules + DS variables. + +**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`, `ui/src/pages/AdminTenantsPage.tsx` + +### 1.3 Reduce Redundant Dashboard Content + +**Problem:** "Open Server Dashboard" appears 3 times. Status "ACTIVE" appears 3 times. Tier badge appears 2 times. + +**Fix:** +- Remove the "Open Server Dashboard" primary button from the header area (keep the Server Management card + sidebar footer link — 2 locations max) +- Remove the status badge from the header area (keep KPI strip + Tenant Information) +- The tier badge next to the heading is fine (quick context) + +**Files:** `ui/src/pages/DashboardPage.tsx` + +--- + +## Batch 2: Header & Navigation + +**Effort:** 1 day + +### 2.1 Hide Server Controls on Platform Pages + +**Problem:** TopBar always renders status filters (OK/Warn/Error/Running), time range pills (1h-7d), auto-refresh toggle, and command palette search. All are irrelevant on platform pages. + +**Fix options (pick one):** + +**Option A (recommended): Use TopBar props to hide sections.** +Check if the DS `TopBar` component accepts props to control which sections render. If it has `showFilters`, `showTimeRange`, `showAutoRefresh`, `showSearch` props — set them all to `false` in `Layout.tsx`. + +**Option B: Remove providers that feed the controls.** +Don't wrap the platform app in `GlobalFilterProvider` and `CommandPaletteProvider` (in `main.tsx`). This may cause runtime errors if TopBar assumes they exist — test carefully. + +**Option C: Custom simplified header.** +Replace `TopBar` with a simpler platform-specific header that only renders: breadcrumb, theme toggle, user menu. Use DS primitives (`Breadcrumb`, `Avatar`, `Dropdown`, `Button`) to compose it. + +Investigate which option is viable by checking the DS `TopBar` component API. + +**Files:** `ui/src/components/Layout.tsx`, possibly `ui/src/main.tsx` + +### 2.2 Fix Sidebar Active State + +**Problem:** `Sidebar.Section` used as navigation links via `onToggle` hack. No `active` prop set. Users can't tell which page they're on. + +**Fix:** Pass `active={true}` to the current page's `Sidebar.Section` based on the route: + +```tsx +const location = useLocation(); +const isActive = (path: string) => location.pathname === path || location.pathname === path + '/'; + +} + label="Dashboard" + open={false} + active={isActive('/') || isActive('/platform')} + onToggle={() => navigate('/')} +> + {null} + + +} + label="License" + open={false} + active={isActive('/license')} + onToggle={() => navigate('/license')} +> + {null} + +``` + +Check the DS `Sidebar.Section` props — if `active` doesn't exist on Section, check if there's a `Sidebar.Link` or `Sidebar.NavItem` component that supports it. + +**Files:** `ui/src/components/Layout.tsx` + +### 2.3 Add Breadcrumbs + +**Problem:** `breadcrumb={[]}` is always empty. + +**Fix:** Set breadcrumbs per page: + +```tsx +// Layout.tsx: +const location = useLocation(); +const breadcrumb = useMemo(() => { + if (location.pathname.includes('/license')) return [{ label: 'License' }]; + if (location.pathname.includes('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }]; + return [{ label: 'Dashboard' }]; +}, [location.pathname]); + + +``` + +Check the DS `TopBar` breadcrumb prop type to match the expected shape. + +**Files:** `ui/src/components/Layout.tsx` + +### 2.4 Remove One "Open Server Dashboard" Button + +**Problem:** 3 locations for the same action. + +**Fix:** Keep: +1. Sidebar footer link (always accessible) +2. Server Management card on Dashboard (contextual with description) + +Remove: The primary "Open Server Dashboard" button in the header area of DashboardPage (line ~81-87). + +**Files:** `ui/src/pages/DashboardPage.tsx` + +### 2.5 Fix Sidebar Collapse + +**Problem:** `collapsed={false}` hardcoded, `onCollapseToggle` is no-op. + +**Fix:** Add state: + +```tsx +const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + setSidebarCollapsed(c => !c)}> +``` + +**Files:** `ui/src/components/Layout.tsx` + +--- + +## Batch 3: Error Handling & Components + +**Effort:** 1 day + +### 3.1 OrgResolver Error State + +**Problem:** Returns `null` on error — blank screen with sidebar/TopBar but no content. + +**Fix:** Replace `return null` with an error display: + +```tsx +if (isError) return ( + refetch()}>Retry} + /> +); +``` + +Import `EmptyState` and `Button` from DS. + +**Files:** `ui/src/auth/OrgResolver.tsx` + +### 3.2 DashboardPage Error Handling + +**Problem:** No `isError` check. Silently renders with `-` fallback values. + +**Fix:** Add error state similar to LicensePage: + +```tsx +if (tenantError || licenseError) return ( + +); +``` + +**Files:** `ui/src/pages/DashboardPage.tsx` + +### 3.3 Replace Raw HTML with DS Components + +**Problem:** LicensePage uses raw ` + +// AFTER: + +``` + +Replace raw code block (line ~174-178) with DS `CodeBlock` if available, or at minimum use DS CSS variables instead of hardcoded Tailwind colors. + +**Files:** `ui/src/pages/LicensePage.tsx` + +### 3.4 Add Copy-to-Clipboard for License Token + +**Problem:** Users must manually select and copy the token. + +**Fix:** Add a copy button next to the token: + +```tsx + +``` + +Import `Copy` from `lucide-react` and `useToast` from DS. + +**Files:** `ui/src/pages/LicensePage.tsx` + +### 3.5 Fix Username Null = No Logout + +**Problem:** When `username` is null, no user indicator or logout button appears. + +**Fix:** Always pass a user object to TopBar — fallback to email or "User": + +```tsx +const displayName = username || user?.email || 'User'; + +``` + +**Files:** `ui/src/components/Layout.tsx` + +### 3.6 Add Password Visibility Toggle to Sign-In + +**Problem:** No eye icon to reveal password. + +**Fix:** The DS `Input` component may support a `type` toggle. If not, wrap with a show/hide toggle: + +```tsx +const [showPassword, setShowPassword] = useState(false); + +
+ + +
+``` + +**Files:** `ui/sign-in/src/SignInPage.tsx` + +### 3.7 Admin Page Error Fallback + +**Problem:** `/platform/admin/tenants` returns HTTP error with no graceful fallback. + +**Fix:** Add error boundary or error state in AdminTenantsPage: + +```tsx +if (isError) return ( + +); +``` + +**Files:** `ui/src/pages/AdminTenantsPage.tsx` + +--- + +## Batch 4: Polish + +**Effort:** 0.5 days + +### 4.1 Unify `tierColor()` Mapping + +**Problem:** Defined twice with different tier names: +- `DashboardPage.tsx:12-18` maps enterprise/pro/starter +- `LicensePage.tsx:25-33` maps BUSINESS/HIGH/MID/LOW + +**Fix:** Extract a single `tierColor()` to a shared utility (`ui/src/utils/tier.ts`). Map all known tier names: + +```typescript +export function tierColor(tier: string): BadgeColor { + switch (tier?.toUpperCase()) { + case 'BUSINESS': case 'ENTERPRISE': return 'success'; + case 'HIGH': case 'PRO': return 'primary'; + case 'MID': case 'STARTER': return 'warning'; + case 'LOW': case 'FREE': return 'auto'; + default: return 'auto'; + } +} +``` + +Import from both pages. + +**Files:** New `ui/src/utils/tier.ts`, modify `DashboardPage.tsx`, `LicensePage.tsx` + +### 4.2 Fix Feature Badge Colors + +**Problem:** Disabled features use `color="auto"` (hash-based, inconsistent). Should use muted neutral. + +**Fix:** Check if DS Badge supports a `neutral` or `default` color variant. If not, use the closest muted option. The goal: enabled = green success, disabled = gray/muted (not red, not random). + +**Files:** `ui/src/pages/LicensePage.tsx` + +### 4.3 AdminTenantsPage Improvements + +**Problem:** Row click silently switches tenant context. `createdAt` renders raw ISO. No empty state. + +**Fix:** +- Add confirmation before tenant switch: `if (!confirm('Switch to tenant "X"?')) return;` (or DS AlertDialog) +- Format date: `new Date(row.createdAt).toLocaleDateString()` +- Add empty state: `` + +**Files:** `ui/src/pages/AdminTenantsPage.tsx` + +### 4.4 Replace Custom SVG Icons with Lucide + +**Problem:** Layout.tsx has 4 inline SVG icon components instead of using lucide-react. + +**Fix:** Replace with lucide icons: +- `DashboardIcon` -> `` +- `LicenseIcon` -> `` +- `PlatformIcon` -> `` +- `ServerIcon` -> `` + +Import from `lucide-react`. + +**Files:** `ui/src/components/Layout.tsx` + +### 4.5 Sign-In Branding + +**Problem:** Login says "cameleer3" — internal repo name, not product brand. + +**Fix:** Change to "Cameleer" (product name). Update the page title from "Sign in — cameleer3" to "Sign in — Cameleer". + +**Files:** `ui/sign-in/src/SignInPage.tsx` + +--- + +## Implementation Order + +| Order | Batch | Items | Effort | +|-------|-------|-------|--------| +| 1 | **Batch 1: Layout Fixes** | 3 | 0.5 days | +| 2 | **Batch 2: Header & Navigation** | 5 | 1 day | +| 3 | **Batch 3: Error Handling & Components** | 7 | 1 day | +| 4 | **Batch 4: Polish** | 5 | 0.5 days | + +**Total: ~20 items across 4 batches, ~3 days of work.** + +--- + +## Related Issues + +| Issue | Relevance | +|-------|-----------| +| #1 | Epic: SaaS Management Platform — this spec covers polish only | +| #37 | Admin: Tenant creation UI — not covered (feature work) | +| #38 | Cross-app session management — not covered (parked) | + +## Out of Scope + +- Mobile responsiveness (deferred per user request) +- New features (billing, team management, tenant creation) +- Admin tenant CRUD workflow (#37) +- Cross-app session sync (#38) diff --git a/ui/sign-in/index.html b/ui/sign-in/index.html index 4be561f..65e3a2c 100644 --- a/ui/sign-in/index.html +++ b/ui/sign-in/index.html @@ -3,7 +3,7 @@ - Sign in — cameleer3 + Sign in — Cameleer diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index 5f0315f..a46b404 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useMemo, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system'; import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg'; import { signIn } from './experience-api'; @@ -36,6 +37,7 @@ export function SignInPage() { const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -58,7 +60,7 @@ export function SignInPage() {
- cameleer3 + Cameleer

{subtitle}

@@ -82,15 +84,29 @@ export function SignInPage() { - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - disabled={loading} - /> +
+ setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> + +
+
+ ); } return <>{children}; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 73b4d05..7f93f50 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,6 @@ -import { Outlet, useNavigate } from 'react-router'; +import { useState, useMemo } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router'; +import { LayoutDashboard, ShieldCheck, Building, Activity } from 'lucide-react'; import { AppShell, Sidebar, @@ -21,54 +23,23 @@ function CameleerLogo() { ); } -// Nav icon helpers -function DashboardIcon() { - return ( - - ); -} - -function LicenseIcon() { - return ( - - ); -} - -function ObsIcon() { - return ( - - ); -} - -function PlatformIcon() { - return ( - - ); -} - export function Layout() { const navigate = useNavigate(); + const location = useLocation(); const { logout } = useAuth(); const scopes = useScopes(); const { username } = useOrgStore(); + const [collapsed, setCollapsed] = useState(false); + + const breadcrumb = useMemo(() => { + if (location.pathname.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }]; + if (location.pathname.startsWith('/license')) return [{ label: 'License' }]; + return [{ label: 'Dashboard' }]; + }, [location.pathname]); + const sidebar = ( - {}}> + setCollapsed(c => !c)}> } title="Cameleer SaaS" @@ -77,9 +48,10 @@ export function Layout() { {/* Dashboard */} } + icon={} label="Dashboard" open={false} + active={location.pathname === '/' || location.pathname === ''} onToggle={() => navigate('/')} > {null} @@ -87,9 +59,10 @@ export function Layout() { {/* License */} } + icon={} label="License" open={false} + active={location.pathname.startsWith('/license')} onToggle={() => navigate('/license')} > {null} @@ -98,9 +71,10 @@ export function Layout() { {/* Platform Admin section */} {scopes.has('platform:admin') && ( } + icon={} label="Platform" open={false} + active={location.pathname.startsWith('/admin')} onToggle={() => navigate('/admin/tenants')} > {null} @@ -110,7 +84,7 @@ export function Layout() { {/* Link to the server observability dashboard */} } + icon={} label="Open Server Dashboard" onClick={() => window.open('/server/', '_blank', 'noopener')} /> @@ -120,9 +94,17 @@ export function Layout() { return ( + {/* + * TopBar always renders status filters, time range pills, auto-refresh, and + * command palette search via useGlobalFilters() / useCommandPalette(). Both + * hooks throw if their providers are absent, so GlobalFilterProvider and + * CommandPaletteProvider cannot be removed from main.tsx without crashing the + * app. The TopBar API has no props to suppress these server-oriented controls. + * Hiding them on platform pages would require a DS change. + */} diff --git a/ui/src/pages/AdminTenantsPage.tsx b/ui/src/pages/AdminTenantsPage.tsx index 09360ed..7f24413 100644 --- a/ui/src/pages/AdminTenantsPage.tsx +++ b/ui/src/pages/AdminTenantsPage.tsx @@ -1,14 +1,18 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router'; import { + AlertDialog, Badge, Card, DataTable, + EmptyState, Spinner, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; import { useAllTenants } from '../api/hooks'; import { useOrgStore } from '../auth/useOrganization'; import type { TenantResponse } from '../types/api'; +import styles from '../styles/platform.module.css'; const columns: Column[] = [ { key: 'name', header: 'Name' }, @@ -28,13 +32,14 @@ const columns: Column[] = [ /> ), }, - { key: 'createdAt', header: 'Created' }, + { key: 'createdAt', header: 'Created', render: (_: unknown, row: TenantResponse) => new Date(row.createdAt).toLocaleDateString() }, ]; export function AdminTenantsPage() { const navigate = useNavigate(); - const { data: tenants, isLoading } = useAllTenants(); + const { data: tenants, isLoading, isError } = useAllTenants(); const { setCurrentOrg } = useOrgStore(); + const [switchTarget, setSwitchTarget] = useState(null); if (isLoading) { return ( @@ -44,32 +49,56 @@ export function AdminTenantsPage() { ); } - const handleRowClick = (tenant: TenantResponse) => { - // Find the matching org from the store and switch context - const orgs = useOrgStore.getState().organizations; - const match = orgs.find( - (o) => o.name === tenant.name || o.slug === tenant.slug, + if (isError) { + return ( +
+ +
); + } + + const handleRowClick = (tenant: TenantResponse) => { + setSwitchTarget(tenant); + }; + + const confirmSwitch = () => { + if (!switchTarget) return; + const orgs = useOrgStore.getState().organizations; + const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug); if (match) { setCurrentOrg(match.id); navigate('/'); } + setSwitchTarget(null); }; return (
-

All Tenants

+

All Tenants

- + {(!tenants || tenants.length === 0) ? ( + + ) : ( + + )} + + setSwitchTarget(null)} + onConfirm={confirmSwitch} + title="Switch tenant?" + description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`} + confirmLabel="Switch" + variant="warning" + />
); } diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index 940dd98..3eb80e7 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -8,21 +8,14 @@ import { } from '@cameleer/design-system'; import { useAuth } from '../auth/useAuth'; import { useTenant, useLicense } from '../api/hooks'; - -function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' { - switch (tier?.toLowerCase()) { - case 'enterprise': return 'success'; - case 'pro': return 'primary'; - case 'starter': return 'warning'; - default: return 'primary'; - } -} +import styles from '../styles/platform.module.css'; +import { tierColor } from '../utils/tier'; export function DashboardPage() { const { tenantId } = useAuth(); - const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? ''); - const { data: license, isLoading: licenseLoading } = useLicense(tenantId ?? ''); + const { data: tenant, isLoading: tenantLoading, isError: tenantError } = useTenant(tenantId ?? ''); + const { data: license, isLoading: licenseLoading, isError: licenseError } = useLicense(tenantId ?? ''); const isLoading = tenantLoading || licenseLoading; @@ -54,6 +47,17 @@ export function DashboardPage() { ); } + if (tenantError || licenseError) { + return ( +
+ +
+ ); + } + if (!tenantId) { return ( {/* Tenant Header */} -
-
-

- {tenant?.name ?? tenantId} -

- {tenant?.tier && ( - - )} -
- +
+

+ {tenant?.name ?? tenantId} +

+ {tenant?.tier && ( + + )}
{/* KPI Strip */} @@ -92,28 +87,28 @@ export function DashboardPage() { {/* Tenant Info */} -
-
- Slug - {tenant?.slug ?? '-'} +
+
+ Slug + {tenant?.slug ?? '-'}
-
- Status +
+ Status
-
- Created - {tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'} +
+ Created + {tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}
{/* Server Dashboard Link */} -

+

Environments, applications, and deployments are managed through the server dashboard.

+
+ + {tokenExpanded && ( + + )} +
{tokenExpanded && ( -
- +
+ {license.token}
diff --git a/ui/src/styles/platform.module.css b/ui/src/styles/platform.module.css new file mode 100644 index 0000000..ed61388 --- /dev/null +++ b/ui/src/styles/platform.module.css @@ -0,0 +1,20 @@ +.heading { font-size: 1.5rem; font-weight: 600; color: var(--text-primary); } +.textPrimary { color: var(--text-primary); } +.textMuted { color: var(--text-muted); } +.mono { font-family: var(--font-mono); } + +.kvRow { display: flex; align-items: center; justify-content: space-between; width: 100%; } +.kvLabel { font-size: 0.875rem; color: var(--text-muted); } +.kvValue { font-size: 0.875rem; color: var(--text-primary); } +.kvValueMono { font-size: 0.875rem; color: var(--text-primary); font-family: var(--font-mono); } + +.dividerList { display: flex; flex-direction: column; } +.dividerList > * + * { border-top: 1px solid var(--border-subtle); } +.dividerRow { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 0; } +.dividerRow:first-child { padding-top: 0; } +.dividerRow:last-child { padding-bottom: 0; } + +.description { font-size: 0.875rem; color: var(--text-muted); } + +.tokenBlock { margin-top: 0.5rem; border-radius: var(--radius-sm); background: var(--bg-inset); border: 1px solid var(--border-subtle); padding: 0.75rem; overflow-x: auto; } +.tokenCode { font-size: 0.75rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; } diff --git a/ui/src/utils/tier.ts b/ui/src/utils/tier.ts new file mode 100644 index 0000000..13c02b7 --- /dev/null +++ b/ui/src/utils/tier.ts @@ -0,0 +1,20 @@ +export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto'; + +export function tierColor(tier: string): TierColor { + switch (tier?.toUpperCase()) { + case 'BUSINESS': + case 'ENTERPRISE': + return 'success'; + case 'HIGH': + case 'PRO': + return 'primary'; + case 'MID': + case 'STARTER': + return 'warning'; + case 'LOW': + case 'FREE': + return 'auto'; + default: + return 'auto'; + } +}