11 Commits

Author SHA1 Message Date
0ba896ada4 Merge pull request 'SaaS platform UX polish: layout, navigation, error handling' (#39) from feature/saas-ux-polish into main
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 11s
Reviewed-on: #39
2026-04-09 19:56:24 +02:00
hsiegeln
af7abc3eac fix: add confirmation dialog before tenant context switch
All checks were successful
CI / build (push) Successful in 1m18s
CI / build (pull_request) Successful in 1m19s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:59 +02:00
hsiegeln
ce1655bba6 fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:28 +02:00
hsiegeln
798ec4850d fix: replace raw button with DS Button, add token copy-to-clipboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:03 +02:00
hsiegeln
7d4126ad4e fix: unify tier color mapping, fix feature badge colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:50:28 +02:00
hsiegeln
e3d9a3bd18 fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:42 +02:00
hsiegeln
7c7d574aa7 fix: replace hardcoded text-white with DS variables, fix label/value layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:32 +02:00
hsiegeln
f9b1628e14 fix: add password visibility toggle and fix branding to 'Cameleer'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:14 +02:00
hsiegeln
e84e53f835 Add SaaS platform UX polish implementation plan (8 tasks)
Detailed step-by-step plan covering layout fixes (label/value collision,
DS variable adoption), header/navigation (sidebar active state,
breadcrumbs, collapse), error handling, DS component adoption, sign-in
improvements, and polish (tier colors, badges, confirmations).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:46:44 +02:00
hsiegeln
1133763520 Add SaaS platform UX polish design spec with audit findings
Playwright audit (22 screenshots) + source code audit covering all
platform pages. Spec defines 4 batches: layout fixes (label/value
collision, hardcoded colors), header/navigation (hide server controls,
sidebar active state), error handling & components (DS adoption,
copy-to-clipboard, error states), and polish (tier colors, badges).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:39:33 +02:00
hsiegeln
5c4a84e64c fix: platform label/value spacing and neutral license badge colors
Disabled features on the license page now show 'Not included' with a
neutral (auto) badge color instead of 'Disabled' in error red, which
looked like an actionable error rather than a plan tier indicator.

Label/value spacing on DashboardPage already used flex justify-between
correctly — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:46:09 +02:00
13 changed files with 2103 additions and 160 deletions

View File

@@ -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") | `<title>` 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

View File

@@ -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 | - |

View File

@@ -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</title>
```
- [ ] **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<TenantResponse | null>(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
<AlertDialog
open={!!switchTarget}
onCancel={() => 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` |

View File

@@ -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 `<div className="flex justify-between">` 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 `<div className="w-full space-y-2">`.
**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 + '/';
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
active={isActive('/') || isActive('/platform')}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
<Sidebar.Section
icon={<LicenseIcon />}
label="License"
open={false}
active={isActive('/license')}
onToggle={() => navigate('/license')}
>
{null}
</Sidebar.Section>
```
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]);
<TopBar breadcrumb={breadcrumb} ... />
```
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);
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => 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 (
<EmptyState
title="Unable to load account"
description="Failed to retrieve your organization. Please try again."
action={<Button onClick={() => refetch()}>Retry</Button>}
/>
);
```
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 (
<EmptyState
title="Unable to load dashboard"
description="Failed to retrieve tenant information. Please try again."
/>
);
```
**Files:** `ui/src/pages/DashboardPage.tsx`
### 3.3 Replace Raw HTML with DS Components
**Problem:** LicensePage uses raw `<button>` and `<code>` where DS components exist.
**Fix:**
Replace raw button (line ~166-170):
```tsx
// BEFORE:
<button type="button" className="text-sm text-primary-400 ...">Show token</button>
// AFTER:
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded(v => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
```
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
<Button variant="ghost" size="sm" onClick={() => {
navigator.clipboard.writeText(license.token);
toast({ title: 'Token copied', variant: 'success' });
}}>
<Copy size={14} /> Copy
</Button>
```
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';
<TopBar user={{ name: displayName }} onLogout={logout} />
```
**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);
<div style={{ position: 'relative' }}>
<Input type={showPassword ? 'text' : 'password'} ... />
<Button variant="ghost" size="sm"
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
```
**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 (
<EmptyState
title="Unable to load tenants"
description="You may not have admin permissions, or the server is unavailable."
/>
);
```
**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: `<EmptyState title="No tenants" description="Create a tenant to get started." />`
**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` -> `<LayoutDashboard size={18} />`
- `LicenseIcon` -> `<ShieldCheck size={18} />`
- `PlatformIcon` -> `<Building size={18} />`
- `ServerIcon` -> `<Server size={18} />`
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)

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sign in — cameleer3</title> <title>Sign in — Cameleer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head> </head>
<body> <body>

View File

@@ -1,4 +1,5 @@
import { type FormEvent, useMemo, useState } from 'react'; import { type FormEvent, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system'; import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg'; import cameleerLogo from '@cameleer/design-system/assets/cameleer3-logo.svg';
import { signIn } from './experience-api'; import { signIn } from './experience-api';
@@ -36,6 +37,7 @@ export function SignInPage() {
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -58,7 +60,7 @@ export function SignInPage() {
<div className={styles.loginForm}> <div className={styles.loginForm}>
<div className={styles.logo}> <div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} /> <img src={cameleerLogo} alt="" className={styles.logoImg} />
cameleer3 Cameleer
</div> </div>
<p className={styles.subtitle}>{subtitle}</p> <p className={styles.subtitle}>{subtitle}</p>
@@ -82,15 +84,29 @@ export function SignInPage() {
</FormField> </FormField>
<FormField label="Password" htmlFor="login-password"> <FormField label="Password" htmlFor="login-password">
<Input <div style={{ position: 'relative' }}>
id="login-password" <Input
type="password" id="login-password"
value={password} type={showPassword ? 'text' : 'password'}
onChange={(e) => setPassword(e.target.value)} value={password}
placeholder="••••••••" onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" placeholder="••••••••"
disabled={loading} 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> </FormField>
<Button <Button

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLogto } from '@logto/react'; import { useLogto } from '@logto/react';
import { Spinner } from '@cameleer/design-system'; import { Button, EmptyState, Spinner } from '@cameleer/design-system';
import { useMe } from '../api/hooks'; import { useMe } from '../api/hooks';
import { useOrgStore } from './useOrganization'; import { useOrgStore } from './useOrganization';
import { fetchConfig } from '../config'; import { fetchConfig } from '../config';
@@ -11,7 +11,7 @@ import { fetchConfig } from '../config';
* Renders children once resolved. * Renders children once resolved.
*/ */
export function OrgResolver({ children }: { children: React.ReactNode }) { export function OrgResolver({ children }: { children: React.ReactNode }) {
const { data: me, isLoading, isError } = useMe(); const { data: me, isLoading, isError, refetch } = useMe();
const { getAccessToken } = useLogto(); const { getAccessToken } = useLogto();
const { getIdTokenClaims } = useLogto(); const { getIdTokenClaims } = useLogto();
const { setOrganizations, setCurrentOrg, setScopes, setUsername, currentOrgId } = useOrgStore(); const { setOrganizations, setCurrentOrg, setScopes, setUsername, currentOrgId } = useOrgStore();
@@ -86,7 +86,17 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
} }
if (isError) { if (isError) {
return null; return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh', gap: '1rem' }}>
<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>
);
} }
return <>{children}</>; return <>{children}</>;

View File

@@ -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 { import {
AppShell, AppShell,
Sidebar, Sidebar,
@@ -21,54 +23,23 @@ function CameleerLogo() {
); );
} }
// Nav icon helpers
function DashboardIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" />
<rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" />
<rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" />
</svg>
);
}
function LicenseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 8h6M5 5h6M5 11h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function ObsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 8h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8 4v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function PlatformIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 1l6 3.5v7L8 15l-6-3.5v-7L8 1z" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 1v14M2 4.5L14 4.5M2 11.5L14 11.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
</svg>
);
}
export function Layout() { export function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth(); const { logout } = useAuth();
const scopes = useScopes(); const scopes = useScopes();
const { username } = useOrgStore(); 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 = ( const sidebar = (
<Sidebar collapsed={false} onCollapseToggle={() => {}}> <Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed(c => !c)}>
<Sidebar.Header <Sidebar.Header
logo={<CameleerLogo />} logo={<CameleerLogo />}
title="Cameleer SaaS" title="Cameleer SaaS"
@@ -77,9 +48,10 @@ export function Layout() {
{/* Dashboard */} {/* Dashboard */}
<Sidebar.Section <Sidebar.Section
icon={<DashboardIcon />} icon={<LayoutDashboard size={18} />}
label="Dashboard" label="Dashboard"
open={false} open={false}
active={location.pathname === '/' || location.pathname === ''}
onToggle={() => navigate('/')} onToggle={() => navigate('/')}
> >
{null} {null}
@@ -87,9 +59,10 @@ export function Layout() {
{/* License */} {/* License */}
<Sidebar.Section <Sidebar.Section
icon={<LicenseIcon />} icon={<ShieldCheck size={18} />}
label="License" label="License"
open={false} open={false}
active={location.pathname.startsWith('/license')}
onToggle={() => navigate('/license')} onToggle={() => navigate('/license')}
> >
{null} {null}
@@ -98,9 +71,10 @@ export function Layout() {
{/* Platform Admin section */} {/* Platform Admin section */}
{scopes.has('platform:admin') && ( {scopes.has('platform:admin') && (
<Sidebar.Section <Sidebar.Section
icon={<PlatformIcon />} icon={<Building size={18} />}
label="Platform" label="Platform"
open={false} open={false}
active={location.pathname.startsWith('/admin')}
onToggle={() => navigate('/admin/tenants')} onToggle={() => navigate('/admin/tenants')}
> >
{null} {null}
@@ -110,7 +84,7 @@ export function Layout() {
<Sidebar.Footer> <Sidebar.Footer>
{/* Link to the server observability dashboard */} {/* Link to the server observability dashboard */}
<Sidebar.FooterLink <Sidebar.FooterLink
icon={<ObsIcon />} icon={<Activity size={18} />}
label="Open Server Dashboard" label="Open Server Dashboard"
onClick={() => window.open('/server/', '_blank', 'noopener')} onClick={() => window.open('/server/', '_blank', 'noopener')}
/> />
@@ -120,9 +94,17 @@ export function Layout() {
return ( return (
<AppShell sidebar={sidebar}> <AppShell sidebar={sidebar}>
{/*
* 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.
*/}
<TopBar <TopBar
breadcrumb={[]} breadcrumb={breadcrumb}
user={username ? { name: username } : undefined} user={{ name: username || 'User' }}
onLogout={logout} onLogout={logout}
/> />
<Outlet /> <Outlet />

View File

@@ -1,14 +1,18 @@
import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { import {
AlertDialog,
Badge, Badge,
Card, Card,
DataTable, DataTable,
EmptyState,
Spinner, Spinner,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useAllTenants } from '../api/hooks'; import { useAllTenants } from '../api/hooks';
import { useOrgStore } from '../auth/useOrganization'; import { useOrgStore } from '../auth/useOrganization';
import type { TenantResponse } from '../types/api'; import type { TenantResponse } from '../types/api';
import styles from '../styles/platform.module.css';
const columns: Column<TenantResponse>[] = [ const columns: Column<TenantResponse>[] = [
{ key: 'name', header: 'Name' }, { key: 'name', header: 'Name' },
@@ -28,13 +32,14 @@ const columns: Column<TenantResponse>[] = [
/> />
), ),
}, },
{ key: 'createdAt', header: 'Created' }, { key: 'createdAt', header: 'Created', render: (_: unknown, row: TenantResponse) => new Date(row.createdAt).toLocaleDateString() },
]; ];
export function AdminTenantsPage() { export function AdminTenantsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: tenants, isLoading } = useAllTenants(); const { data: tenants, isLoading, isError } = useAllTenants();
const { setCurrentOrg } = useOrgStore(); const { setCurrentOrg } = useOrgStore();
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
if (isLoading) { if (isLoading) {
return ( return (
@@ -44,32 +49,56 @@ export function AdminTenantsPage() {
); );
} }
const handleRowClick = (tenant: TenantResponse) => { if (isError) {
// Find the matching org from the store and switch context return (
const orgs = useOrgStore.getState().organizations; <div className="p-6">
const match = orgs.find( <EmptyState
(o) => o.name === tenant.name || o.slug === tenant.slug, title="Unable to load tenants"
description="You may not have admin permissions, or the server is unavailable."
/>
</div>
); );
}
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) { if (match) {
setCurrentOrg(match.id); setCurrentOrg(match.id);
navigate('/'); navigate('/');
} }
setSwitchTarget(null);
}; };
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-white">All Tenants</h1> <h1 className={styles.heading}>All Tenants</h1>
<Badge label="Platform Admin" color="warning" /> <Badge label="Platform Admin" color="warning" />
</div> </div>
<Card title={`${tenants?.length ?? 0} Tenants`}> <Card title={`${tenants?.length ?? 0} Tenants`}>
<DataTable {(!tenants || tenants.length === 0) ? (
columns={columns} <EmptyState title="No tenants" description="No tenants have been created yet." />
data={tenants ?? []} ) : (
onRowClick={handleRowClick} <DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
/> )}
</Card> </Card>
<AlertDialog
open={!!switchTarget}
onClose={() => setSwitchTarget(null)}
onConfirm={confirmSwitch}
title="Switch tenant?"
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
confirmLabel="Switch"
variant="warning"
/>
</div> </div>
); );
} }

View File

@@ -8,21 +8,14 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { useTenant, useLicense } from '../api/hooks'; import { useTenant, useLicense } from '../api/hooks';
import styles from '../styles/platform.module.css';
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' { import { tierColor } from '../utils/tier';
switch (tier?.toLowerCase()) {
case 'enterprise': return 'success';
case 'pro': return 'primary';
case 'starter': return 'warning';
default: return 'primary';
}
}
export function DashboardPage() { export function DashboardPage() {
const { tenantId } = useAuth(); const { tenantId } = useAuth();
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? ''); const { data: tenant, isLoading: tenantLoading, isError: tenantError } = useTenant(tenantId ?? '');
const { data: license, isLoading: licenseLoading } = useLicense(tenantId ?? ''); const { data: license, isLoading: licenseLoading, isError: licenseError } = useLicense(tenantId ?? '');
const isLoading = tenantLoading || licenseLoading; const isLoading = tenantLoading || licenseLoading;
@@ -54,6 +47,17 @@ export function DashboardPage() {
); );
} }
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>
);
}
if (!tenantId) { if (!tenantId) {
return ( return (
<EmptyState <EmptyState
@@ -66,25 +70,16 @@ export function DashboardPage() {
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Tenant Header */} {/* Tenant Header */}
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <h1 className={styles.heading}>
<h1 className="text-2xl font-semibold text-white"> {tenant?.name ?? tenantId}
{tenant?.name ?? tenantId} </h1>
</h1> {tenant?.tier && (
{tenant?.tier && ( <Badge
<Badge label={tenant.tier.toUpperCase()}
label={tenant.tier.toUpperCase()} color={tierColor(tenant.tier)}
color={tierColor(tenant.tier)} />
/> )}
)}
</div>
<Button
variant="primary"
size="sm"
onClick={() => window.open('/server/', '_blank', 'noopener')}
>
Open Server Dashboard
</Button>
</div> </div>
{/* KPI Strip */} {/* KPI Strip */}
@@ -92,28 +87,28 @@ export function DashboardPage() {
{/* Tenant Info */} {/* Tenant Info */}
<Card title="Tenant Information"> <Card title="Tenant Information">
<div className="space-y-2 text-sm"> <div className="space-y-2">
<div className="flex justify-between text-white/80"> <div className={styles.kvRow}>
<span>Slug</span> <span className={styles.kvLabel}>Slug</span>
<span className="font-mono">{tenant?.slug ?? '-'}</span> <span className={styles.kvValueMono}>{tenant?.slug ?? '-'}</span>
</div> </div>
<div className="flex justify-between text-white/80"> <div className={styles.kvRow}>
<span>Status</span> <span className={styles.kvLabel}>Status</span>
<Badge <Badge
label={tenant?.status ?? 'UNKNOWN'} label={tenant?.status ?? 'UNKNOWN'}
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'} color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
/> />
</div> </div>
<div className="flex justify-between text-white/80"> <div className={styles.kvRow}>
<span>Created</span> <span className={styles.kvLabel}>Created</span>
<span>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span> <span className={styles.kvValue}>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span>
</div> </div>
</div> </div>
</Card> </Card>
{/* Server Dashboard Link */} {/* Server Dashboard Link */}
<Card title="Server Management"> <Card title="Server Management">
<p className="text-sm text-white/60 mb-3"> <p className={`${styles.description} mb-3`}>
Environments, applications, and deployments are managed through the server dashboard. Environments, applications, and deployments are managed through the server dashboard.
</p> </p>
<Button <Button

View File

@@ -1,12 +1,17 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Badge, Badge,
Button,
Card, Card,
EmptyState, EmptyState,
Spinner, Spinner,
useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { Copy } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { useLicense } from '../api/hooks'; import { useLicense } from '../api/hooks';
import styles from '../styles/platform.module.css';
import { tierColor } from '../utils/tier';
const FEATURE_LABELS: Record<string, string> = { const FEATURE_LABELS: Record<string, string> = {
topology: 'Topology', topology: 'Topology',
@@ -22,16 +27,6 @@ const LIMIT_LABELS: Record<string, string> = {
max_environments: 'Max Environments', max_environments: 'Max Environments',
}; };
function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
switch (tier?.toUpperCase()) {
case 'BUSINESS': return 'success';
case 'HIGH': return 'primary';
case 'MID': return 'warning';
case 'LOW': return 'error';
default: return 'primary';
}
}
function daysRemaining(expiresAt: string): number { function daysRemaining(expiresAt: string): number {
const now = Date.now(); const now = Date.now();
const exp = new Date(expiresAt).getTime(); const exp = new Date(expiresAt).getTime();
@@ -42,6 +37,7 @@ export function LicensePage() {
const { tenantId } = useAuth(); const { tenantId } = useAuth();
const { data: license, isLoading, isError } = useLicense(tenantId ?? ''); const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
const [tokenExpanded, setTokenExpanded] = useState(false); const [tokenExpanded, setTokenExpanded] = useState(false);
const { toast } = useToast();
if (isLoading) { if (isLoading) {
return ( return (
@@ -82,7 +78,7 @@ export function LicensePage() {
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-white">License</h1> <h1 className={styles.heading}>License</h1>
<Badge <Badge
label={license.tier.toUpperCase()} label={license.tier.toUpperCase()}
color={tierColor(license.tier)} color={tierColor(license.tier)}
@@ -91,10 +87,10 @@ export function LicensePage() {
{/* Expiry info */} {/* Expiry info */}
<Card title="Validity"> <Card title="Validity">
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className={styles.kvRow}>
<span className="text-white/60">Issued</span> <span className={styles.kvLabel}>Issued</span>
<span className="text-white"> <span className={styles.kvValue}>
{new Date(license.issuedAt).toLocaleDateString(undefined, { {new Date(license.issuedAt).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -102,12 +98,12 @@ export function LicensePage() {
})} })}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className={styles.kvRow}>
<span className="text-white/60">Expires</span> <span className={styles.kvLabel}>Expires</span>
<span className="text-white">{expDate}</span> <span className={styles.kvValue}>{expDate}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className={styles.kvRow}>
<span className="text-white/60">Days remaining</span> <span className={styles.kvLabel}>Days remaining</span>
<Badge <Badge
label={isExpired ? 'Expired' : `${days} days`} label={isExpired ? 'Expired' : `${days} days`}
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'} color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
@@ -118,18 +114,15 @@ export function LicensePage() {
{/* Feature matrix */} {/* Feature matrix */}
<Card title="Features"> <Card title="Features">
<div className="divide-y divide-white/10"> <div className={styles.dividerList}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => { {Object.entries(FEATURE_LABELS).map(([key, label]) => {
const enabled = license.features[key] ?? false; const enabled = license.features[key] ?? false;
return ( return (
<div <div key={key} className={styles.dividerRow}>
key={key} <span className={styles.kvLabel}>{label}</span>
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<span className="text-sm text-white">{label}</span>
<Badge <Badge
label={enabled ? 'Enabled' : 'Disabled'} label={enabled ? 'Enabled' : 'Not included'}
color={enabled ? 'success' : 'error'} color={enabled ? 'success' : 'warning'}
/> />
</div> </div>
); );
@@ -139,16 +132,13 @@ export function LicensePage() {
{/* Limits */} {/* Limits */}
<Card title="Limits"> <Card title="Limits">
<div className="divide-y divide-white/10"> <div className={styles.dividerList}>
{Object.entries(LIMIT_LABELS).map(([key, label]) => { {Object.entries(LIMIT_LABELS).map(([key, label]) => {
const value = license.limits[key]; const value = license.limits[key];
return ( return (
<div <div key={key} className={styles.dividerRow}>
key={key} <span className={styles.kvLabel}>{label}</span>
className="flex items-center justify-between py-3 first:pt-0 last:pb-0" <span className={styles.kvValueMono}>
>
<span className="text-sm text-white/60">{label}</span>
<span className="text-sm font-mono text-white">
{value !== undefined ? value : '—'} {value !== undefined ? value : '—'}
</span> </span>
</div> </div>
@@ -160,19 +150,25 @@ export function LicensePage() {
{/* License token */} {/* License token */}
<Card title="License Token"> <Card title="License Token">
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-white/60"> <p className={styles.description}>
Use this token when registering Cameleer agents with your tenant. Use this token when registering Cameleer agents with your tenant.
</p> </p>
<button <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
type="button" <Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none" {tokenExpanded ? 'Hide token' : 'Show token'}
onClick={() => setTokenExpanded((v) => !v)} </Button>
> {tokenExpanded && (
{tokenExpanded ? 'Hide token' : 'Show token'} <Button variant="ghost" size="sm" onClick={() => {
</button> navigator.clipboard.writeText(license.token);
toast({ title: 'Token copied to clipboard', variant: 'success' });
}}>
<Copy size={14} /> Copy
</Button>
)}
</div>
{tokenExpanded && ( {tokenExpanded && (
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto"> <div className={styles.tokenBlock}>
<code className="text-xs font-mono text-white/80 break-all"> <code className={styles.tokenCode}>
{license.token} {license.token}
</code> </code>
</div> </div>

View File

@@ -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; }

20
ui/src/utils/tier.ts Normal file
View File

@@ -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';
}
}