mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
UX-001: sidebar re-entry + inline team/owner creation in wizard
Closes UX-001 (OnboardingWizard CertificateStep dead-end): users no
longer have to navigate away from the wizard and lose their in-flight
state when the required Owner/Team dropdowns are empty.
Layout.tsx
- Adds persistent 'Setup guide' button in the left sidebar.
- Clears localStorage 'certctl:onboarding-dismissed' then navigates
to /?onboarding=1 as a re-entry signal that overrides dismissal.
- localStorage.removeItem wrapped in try/catch to tolerate storage
access errors (private browsing, quota, etc.).
DashboardPage.tsx
- Reads ?onboarding=1 via useSearchParams as a forceOnboarding flag.
- forceOnboarding bypasses the latched first-run gate so the wizard
reopens even after dismissal or with certs/issuers already present.
- onDismiss now also strips ?onboarding=1 via setSearchParams(next,
{ replace: true }) so a page refresh does not relaunch the wizard.
OnboardingWizard.tsx
- Adds CreateTeamModalInline and CreateOwnerModalInline inside
CertificateStep. Both wire through React Query: createTeam /
createOwner mutation on success invalidates ['teams'] / ['owners']
and calls onCreated(id) so the parent select auto-selects the new
row as soon as the refetch lands.
- '+ New team' and '+ New owner' buttons placed next to the select
labels; empty-state copy replaced with inline 'create one now'
buttons (no more Link back to /owners /teams).
- CreateOwner coerces empty teamId to undefined before mutation so
the server contract matches OwnersPage.
Tests (12 new, all green; total suite 252 passed / 0 failed):
- Layout.test.tsx (4): Setup guide button renders, clicking it clears
the dismissal key and navigates to /?onboarding=1, tolerates
localStorage.removeItem throwing.
- DashboardPage.test.tsx (4): first-run auto-open, ?onboarding=1
re-entry after dismissal, onDismiss writes localStorage + strips
the query param, dismissed-with-no-param stays closed.
- OnboardingWizard.test.tsx (4): Skip-Skip reaches CertificateStep
with '+ New team' / '+ New owner' buttons visible; '+ New team'
happy path with React Query invalidation + parent-select
auto-select via option-parent traversal (label is a sibling, not
htmlFor-linked); '+ New owner' happy path pins team_id: undefined
coercion; Cancel abort never mutates.
Test infrastructure notes:
- Closure-driven vi.fn().mockImplementation pattern drives the
post-invalidation refetch: the mutation mock mutates a closure
variable that the getTeams/getOwners mock reads, so the parent
select's new <option> exists by the time the refetch lands.
- Anchored regex (/^Create Team$/, /^Create Owner$/) disambiguates
the modal submit from the '+ New team' / '+ New owner' triggers.
Verification gates (all green):
- vitest run: 252 passed / 0 failed (8 files, 13.98s)
- tsc --noEmit: 0 errors
- vite build: clean production bundle (851.77 kB js / 226.81 kB gzip)
No new runtime dependencies. Frontend-only change.
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UX-001 Phase 4 — Layout "Setup guide" re-entry button
|
||||||
|
//
|
||||||
|
// Phase 2 added a persistent "Setup guide" button to the sidebar so operators
|
||||||
|
// who dismissed the onboarding wizard (or closed it mid-flow) can always walk
|
||||||
|
// themselves back in. The button must:
|
||||||
|
//
|
||||||
|
// 1. Render with the accessible name "Setup guide".
|
||||||
|
// 2. On click, clear the `certctl:onboarding-dismissed` localStorage key so
|
||||||
|
// DashboardPage's first-run detection re-engages.
|
||||||
|
// 3. On click, navigate to `/?onboarding=1` — the query-param re-entry
|
||||||
|
// signal DashboardPage reads via useSearchParams. The query param is the
|
||||||
|
// contract between Layout and DashboardPage; without it, a user who
|
||||||
|
// already has certs + issuers would not see the wizard again.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Intercept useNavigate so we can assert the destination path without having
|
||||||
|
// to configure every route segment the wizard might push to.
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layout pulls auth state from AuthProvider to decide whether to render the
|
||||||
|
// logout button. Tests don't care about auth — stub the hook with an anonymous
|
||||||
|
// session so Layout renders without needing a real AuthProvider wrapper.
|
||||||
|
vi.mock('./AuthProvider', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
loading: false,
|
||||||
|
authRequired: false,
|
||||||
|
authenticated: true,
|
||||||
|
authType: 'none',
|
||||||
|
user: '',
|
||||||
|
admin: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Imported after vi.mock so the mocks are in effect when Layout's module graph
|
||||||
|
// resolves.
|
||||||
|
import Layout from './Layout';
|
||||||
|
|
||||||
|
function renderLayout() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<div data-testid="outlet-root">root</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Layout — UX-001 Setup guide sidebar button', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a "Setup guide" button in the sidebar', () => {
|
||||||
|
renderLayout();
|
||||||
|
|
||||||
|
// Red-to-green guard: if the button is removed or renamed, this catches
|
||||||
|
// it. We match by accessible name so the assertion survives className /
|
||||||
|
// icon churn.
|
||||||
|
expect(screen.getByRole('button', { name: /Setup guide/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the onboarding-dismissed localStorage key on click', () => {
|
||||||
|
localStorage.setItem('certctl:onboarding-dismissed', 'true');
|
||||||
|
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true');
|
||||||
|
|
||||||
|
renderLayout();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||||
|
|
||||||
|
// DashboardPage reads this key synchronously to decide whether the first-
|
||||||
|
// run wizard can auto-open. Leaving it set would suppress the wizard even
|
||||||
|
// after navigation, defeating the re-entry contract.
|
||||||
|
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to /?onboarding=1 on click', () => {
|
||||||
|
renderLayout();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||||
|
|
||||||
|
// The `?onboarding=1` query param is the explicit signal DashboardPage
|
||||||
|
// checks via useSearchParams. Asserting the exact path pins the contract
|
||||||
|
// both ends rely on.
|
||||||
|
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates localStorage access failure without throwing', () => {
|
||||||
|
// Some browsers / privacy modes throw on localStorage access. Layout
|
||||||
|
// wraps the removal in try/catch so the navigation still fires. Simulate
|
||||||
|
// the failure and verify the navigation path is unaffected.
|
||||||
|
const original = Storage.prototype.removeItem;
|
||||||
|
Storage.prototype.removeItem = vi.fn(() => {
|
||||||
|
throw new Error('localStorage unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderLayout();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1');
|
||||||
|
} finally {
|
||||||
|
Storage.prototype.removeItem = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from './AuthProvider';
|
import { useAuth } from './AuthProvider';
|
||||||
import logo from '../assets/certctl-logo.png';
|
import logo from '../assets/certctl-logo.png';
|
||||||
|
|
||||||
@@ -35,6 +35,12 @@ function Icon({ d }: { d: string }) {
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { authRequired, logout } = useAuth();
|
const { authRequired, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const openSetupGuide = () => {
|
||||||
|
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
|
||||||
|
navigate('/?onboarding=1');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
@@ -71,6 +77,18 @@ export default function Layout() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2 pt-2 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openSetupGuide}
|
||||||
|
title="Reopen the onboarding wizard"
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded text-sidebar-text hover:text-white hover:bg-white/10 transition-all duration-150"
|
||||||
|
>
|
||||||
|
<Icon d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
Setup guide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||||
<span className="text-[10px] text-brand-300/60 font-mono">certctl</span>
|
<span className="text-[10px] text-brand-300/60 font-mono">certctl</span>
|
||||||
{authRequired && (
|
{authRequired && (
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UX-001 Phase 4 — DashboardPage onboarding wizard entry/exit contract
|
||||||
|
//
|
||||||
|
// DashboardPage is the arbiter of when the OnboardingWizard is visible. Two
|
||||||
|
// triggers must both work:
|
||||||
|
//
|
||||||
|
// 1. First-run detection: no certificates + no user-configured issuers +
|
||||||
|
// no dismissal flag → auto-open the wizard.
|
||||||
|
//
|
||||||
|
// 2. Re-entry via URL: `?onboarding=1` query param forces the wizard open
|
||||||
|
// even for users who already have certs or have previously dismissed it.
|
||||||
|
// This is the other end of the Layout "Setup guide" contract tested in
|
||||||
|
// Layout.test.tsx.
|
||||||
|
//
|
||||||
|
// And the close path must clean up after itself:
|
||||||
|
//
|
||||||
|
// 3. On dismiss, DashboardPage must (a) set
|
||||||
|
// `certctl:onboarding-dismissed=true` in localStorage so it doesn't
|
||||||
|
// auto-reopen on refresh, and (b) strip the `?onboarding=1` query param
|
||||||
|
// via setSearchParams(..., { replace: true }) so a subsequent refresh
|
||||||
|
// doesn't relaunch the wizard either.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Mock the entire API client. vi.mock factories are hoisted above the imports
|
||||||
|
// that follow, so these stubs are in effect when DashboardPage's module graph
|
||||||
|
// resolves.
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
getCertificates: vi.fn(),
|
||||||
|
getAgents: vi.fn(),
|
||||||
|
getJobs: vi.fn(),
|
||||||
|
getNotifications: vi.fn(),
|
||||||
|
getHealth: vi.fn(),
|
||||||
|
getDashboardSummary: vi.fn(),
|
||||||
|
getCertificatesByStatus: vi.fn(),
|
||||||
|
getExpirationTimeline: vi.fn(),
|
||||||
|
getJobTrends: vi.fn(),
|
||||||
|
getIssuanceRate: vi.fn(),
|
||||||
|
previewDigest: vi.fn(),
|
||||||
|
sendDigest: vi.fn(),
|
||||||
|
getIssuers: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Replace OnboardingWizard with a paper-thin stub so these tests exercise the
|
||||||
|
// entry/exit contract without pulling OnboardingWizard's (heavy) query tree
|
||||||
|
// and step machinery into the fixture. OnboardingWizard's own behaviour is
|
||||||
|
// covered in OnboardingWizard.test.tsx.
|
||||||
|
vi.mock('./OnboardingWizard', () => ({
|
||||||
|
default: ({ onDismiss }: { onDismiss: () => void }) => (
|
||||||
|
<div data-testid="onboarding-wizard">
|
||||||
|
<button type="button" data-testid="wizard-dismiss-btn" onClick={onDismiss}>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardPage from './DashboardPage';
|
||||||
|
import * as client from '../api/client';
|
||||||
|
|
||||||
|
// Location probe: renders current pathname+search into the DOM so we can
|
||||||
|
// assert that setSearchParams(next, { replace: true }) actually stripped the
|
||||||
|
// `?onboarding=1` query param without relying on router internals.
|
||||||
|
function LocationProbe() {
|
||||||
|
const loc = useLocation();
|
||||||
|
return (
|
||||||
|
<div data-testid="location-probe">
|
||||||
|
{loc.pathname}
|
||||||
|
{loc.search}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(initialEntry = '/') {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[initialEntry]}>
|
||||||
|
<LocationProbe />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical "empty but well-formed" stubs for every query DashboardPage calls
|
||||||
|
// unconditionally. Returning data (rather than leaving queries pending) lets
|
||||||
|
// the component pass its `summary !== undefined && issuersData !== undefined`
|
||||||
|
// gate and compute isFirstRun in a single render pass.
|
||||||
|
function stubAllQueriesEmpty() {
|
||||||
|
vi.mocked(client.getHealth).mockResolvedValue({ status: 'healthy' } as never);
|
||||||
|
vi.mocked(client.getDashboardSummary).mockResolvedValue({
|
||||||
|
total_certificates: 0,
|
||||||
|
expiring_certificates: 0,
|
||||||
|
expired_certificates: 0,
|
||||||
|
revoked_certificates: 0,
|
||||||
|
active_agents: 0,
|
||||||
|
pending_jobs: 0,
|
||||||
|
completed_jobs: 0,
|
||||||
|
failed_jobs: 0,
|
||||||
|
notifications_dead: 0,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getIssuers).mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getCertificatesByStatus).mockResolvedValue([] as never);
|
||||||
|
vi.mocked(client.getExpirationTimeline).mockResolvedValue([] as never);
|
||||||
|
vi.mocked(client.getJobTrends).mockResolvedValue([] as never);
|
||||||
|
vi.mocked(client.getIssuanceRate).mockResolvedValue([] as never);
|
||||||
|
vi.mocked(client.getCertificates).mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getJobs).mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardPage — UX-001 onboarding wizard entry/exit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
stubAllQueriesEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-opens the wizard on first run (no certs, no user-configured issuers, no dismissal)', async () => {
|
||||||
|
renderDashboard('/');
|
||||||
|
|
||||||
|
// First-run detection runs after the summary + issuers queries resolve.
|
||||||
|
// The wizard is gated behind a setTimeout(..., 0) in DashboardPage to
|
||||||
|
// avoid setState-during-render, so we waitFor the stub to appear.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the wizard when URL has ?onboarding=1, even with dismissal flag set', async () => {
|
||||||
|
// Simulate a user who dismissed the wizard previously and is now clicking
|
||||||
|
// the sidebar "Setup guide" button, which navigates to /?onboarding=1.
|
||||||
|
localStorage.setItem('certctl:onboarding-dismissed', 'true');
|
||||||
|
|
||||||
|
// Give the fixture non-zero counts so first-run detection would *not*
|
||||||
|
// fire on its own. Only the query-param override should open the wizard.
|
||||||
|
vi.mocked(client.getDashboardSummary).mockResolvedValue({
|
||||||
|
total_certificates: 42,
|
||||||
|
expiring_certificates: 0,
|
||||||
|
expired_certificates: 0,
|
||||||
|
revoked_certificates: 0,
|
||||||
|
active_agents: 3,
|
||||||
|
pending_jobs: 0,
|
||||||
|
completed_jobs: 0,
|
||||||
|
failed_jobs: 0,
|
||||||
|
notifications_dead: 0,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getIssuers).mockResolvedValue({
|
||||||
|
data: [{ id: 'iss-prod', name: 'Prod ACME', type: 'ACME', source: 'database' }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
per_page: 100,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
renderDashboard('/?onboarding=1');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onDismiss sets localStorage flag and strips ?onboarding=1 from the URL', async () => {
|
||||||
|
renderDashboard('/?onboarding=1');
|
||||||
|
|
||||||
|
// Wait for the wizard to open.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity: URL still carries the re-entry signal before dismiss.
|
||||||
|
expect(screen.getByTestId('location-probe').textContent).toContain('onboarding=1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('wizard-dismiss-btn'));
|
||||||
|
|
||||||
|
// After dismiss: localStorage is set, so a future refresh won't re-open.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The wizard is torn down.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// And the `?onboarding=1` query param is stripped via replace: true so
|
||||||
|
// refreshing the page won't reopen the wizard via the URL path either.
|
||||||
|
await waitFor(() => {
|
||||||
|
const probe = screen.getByTestId('location-probe').textContent ?? '';
|
||||||
|
expect(probe).not.toContain('onboarding=1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open the wizard when dismissed and URL has no onboarding param', async () => {
|
||||||
|
localStorage.setItem('certctl:onboarding-dismissed', 'true');
|
||||||
|
renderDashboard('/');
|
||||||
|
|
||||||
|
// Give queries a tick to settle.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/System healthy|Checking system status/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The wizard should NOT have auto-opened — dismissal flag is respected
|
||||||
|
// when the URL doesn't carry the override signal.
|
||||||
|
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||||
@@ -162,6 +162,7 @@ function DigestCard() {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Onboarding wizard state: once shown, stays shown until explicitly dismissed.
|
// Onboarding wizard state: once shown, stays shown until explicitly dismissed.
|
||||||
// Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away.
|
// Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away.
|
||||||
@@ -170,6 +171,10 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
|
// Re-entry signal: sidebar "Setup guide" button navigates to /?onboarding=1 to reopen the wizard
|
||||||
|
// even after dismissal. Takes precedence over localStorage dismissal; stripped on close.
|
||||||
|
const forceOnboarding = searchParams.get('onboarding') === '1';
|
||||||
|
|
||||||
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
|
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
|
||||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
||||||
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
||||||
@@ -190,17 +195,23 @@ export default function DashboardPage() {
|
|||||||
summary.total_certificates === 0 &&
|
summary.total_certificates === 0 &&
|
||||||
userConfiguredIssuers.length === 0;
|
userConfiguredIssuers.length === 0;
|
||||||
|
|
||||||
if (isFirstRun && !showWizard) {
|
if ((isFirstRun || forceOnboarding) && !showWizard) {
|
||||||
// Can't call setState during render — use a microtask
|
// Can't call setState during render — use a microtask
|
||||||
setTimeout(() => setShowWizard(true), 0);
|
setTimeout(() => setShowWizard(true), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showWizard && !onboardingDismissed) {
|
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
|
||||||
return (
|
return (
|
||||||
<OnboardingWizard onDismiss={() => {
|
<OnboardingWizard onDismiss={() => {
|
||||||
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
||||||
setOnboardingDismissed(true);
|
setOnboardingDismissed(true);
|
||||||
setShowWizard(false);
|
setShowWizard(false);
|
||||||
|
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
|
||||||
|
if (searchParams.has('onboarding')) {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete('onboarding');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
}} />
|
}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UX-001 Phase 3 — CertificateStep inline team + owner creation contract
|
||||||
|
//
|
||||||
|
// The wizard has to satisfy C-001's six required certificate fields (name,
|
||||||
|
// common_name, issuer_id, owner_id, team_id, renewal_policy_id). Before Phase
|
||||||
|
// 3, a fresh install had no teams + no owners, so the two required `<select>`s
|
||||||
|
// were empty and the only way forward was to leave the wizard (losing state)
|
||||||
|
// and visit /owners + /teams. The inline modals close that dead end by letting
|
||||||
|
// users create a team or owner without leaving CertificateStep.
|
||||||
|
//
|
||||||
|
// These tests pin the contract on the inline modals specifically:
|
||||||
|
//
|
||||||
|
// 1. Skip-skip navigation reaches CertificateStep with the "+ New team" and
|
||||||
|
// "+ New owner" buttons present.
|
||||||
|
// 2. "+ New team" opens the inline modal; submit calls `createTeam`, the
|
||||||
|
// React Query cache invalidates, and the parent team `<select>` auto-
|
||||||
|
// selects the new team's id.
|
||||||
|
// 3. "+ New owner" does the same for owners.
|
||||||
|
// 4. Cancel closes the modal without firing the mutation — pins the
|
||||||
|
// "nothing leaks on abort" guarantee.
|
||||||
|
//
|
||||||
|
// DashboardPage's outer wizard entry/exit contract is covered in
|
||||||
|
// DashboardPage.test.tsx. Layout's sidebar re-entry button is covered in
|
||||||
|
// Layout.test.tsx.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Mock the entire API client. vi.mock factories are hoisted above the imports
|
||||||
|
// that follow, so these stubs are in effect when OnboardingWizard's module
|
||||||
|
// graph resolves.
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
getApiKey: vi.fn(() => 'test-api-key'),
|
||||||
|
getIssuers: vi.fn(),
|
||||||
|
getAgents: vi.fn(),
|
||||||
|
getProfiles: vi.fn(),
|
||||||
|
getOwners: vi.fn(),
|
||||||
|
getTeams: vi.fn(),
|
||||||
|
getPolicies: vi.fn(),
|
||||||
|
createIssuer: vi.fn(),
|
||||||
|
testIssuerConnection: vi.fn(),
|
||||||
|
createCertificate: vi.fn(),
|
||||||
|
triggerRenewal: vi.fn(),
|
||||||
|
createTeam: vi.fn(),
|
||||||
|
createOwner: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import OnboardingWizard from './OnboardingWizard';
|
||||||
|
import * as client from '../api/client';
|
||||||
|
|
||||||
|
function renderWizard() {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<OnboardingWizard onDismiss={vi.fn()} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical "empty but well-formed" stubs for every query the wizard calls.
|
||||||
|
// Returning data (rather than leaving queries pending) lets CertificateStep's
|
||||||
|
// dropdowns render their placeholder options immediately.
|
||||||
|
function stubAllQueriesEmpty() {
|
||||||
|
vi.mocked(client.getIssuers).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 100,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getAgents).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 100,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getProfiles).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 100,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getOwners).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 500,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getTeams).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 500,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(client.getPolicies).mockResolvedValue({
|
||||||
|
data: [], total: 0, page: 1, per_page: 500,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive through Skip to reach CertificateStep. Each step renders its own
|
||||||
|
// "Skip this step" button in the WizardFooter; clicking it advances via the
|
||||||
|
// parent goTo() state machine. "Skip setup" in the header (top-right) is a
|
||||||
|
// different button tied to onDismiss and is intentionally not clicked here.
|
||||||
|
async function advanceToCertificateStep() {
|
||||||
|
// IssuerStep renders first. Wait for its heading before clicking skip so
|
||||||
|
// we don't race ahead of the initial render.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: /Connect a Certificate Authority/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Skip this step/i }));
|
||||||
|
|
||||||
|
// AgentStep — wait for its heading, then skip.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: /Deploy a certctl Agent/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Skip this step/i }));
|
||||||
|
|
||||||
|
// CertificateStep — wait for its heading. Caller can now exercise the
|
||||||
|
// inline modals.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: /Add a Certificate/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OnboardingWizard — UX-001 inline team + owner creation in CertificateStep', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
stubAllQueriesEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skip-skip reaches CertificateStep with "+ New team" and "+ New owner" buttons', async () => {
|
||||||
|
renderWizard();
|
||||||
|
await advanceToCertificateStep();
|
||||||
|
|
||||||
|
// The contract buttons are the whole point of UX-001 Phase 3 — if they
|
||||||
|
// disappear, the dead-end is back.
|
||||||
|
expect(screen.getByRole('button', { name: /\+ New team/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /\+ New owner/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('+ New team opens the inline modal, calls createTeam, invalidates the cache, and auto-selects the new team', async () => {
|
||||||
|
// Drive getTeams from a closure variable so React Query's post-mutation
|
||||||
|
// refetch observes the newly-created team. Without this, the parent
|
||||||
|
// <select> would auto-select 't-platform' but the DOM would have no
|
||||||
|
// matching <option> and the browser normalizes select.value back to ''.
|
||||||
|
let teamsData: Array<{
|
||||||
|
id: string; name: string; description: string; created_at: string; updated_at: string;
|
||||||
|
}> = [];
|
||||||
|
vi.mocked(client.getTeams).mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: teamsData,
|
||||||
|
total: teamsData.length,
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
} as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(client.createTeam).mockImplementation(async (data) => {
|
||||||
|
const team = {
|
||||||
|
id: 't-platform',
|
||||||
|
name: data?.name ?? 'unnamed',
|
||||||
|
description: data?.description ?? '',
|
||||||
|
created_at: '2026-04-19T00:00:00Z',
|
||||||
|
updated_at: '2026-04-19T00:00:00Z',
|
||||||
|
};
|
||||||
|
// Mutate the closure so the subsequent invalidation-triggered refetch
|
||||||
|
// of ['teams'] returns the new row. Per
|
||||||
|
// OnboardingWizard.tsx:411-419 the success branch invalidates
|
||||||
|
// queryKey ['teams'] before firing onCreated(team.id) + onClose().
|
||||||
|
teamsData = [team];
|
||||||
|
return team as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWizard();
|
||||||
|
await advanceToCertificateStep();
|
||||||
|
|
||||||
|
// Open the inline team modal.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /\+ New team/i }));
|
||||||
|
|
||||||
|
// Modal is open — "Create Team" heading + the autofocused Name input
|
||||||
|
// are both present.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: /Create Team/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const nameInput = screen.getByPlaceholderText(/Platform Engineering/i);
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'Platform Eng' } });
|
||||||
|
|
||||||
|
// Submit — anchored regex so we don't accidentally match "+ New team"
|
||||||
|
// or any "Create Team" banner elsewhere on the page.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create Team$/ }));
|
||||||
|
|
||||||
|
// Mutation fires with trimmed name + empty description — mirrors the
|
||||||
|
// contract in OnboardingWizard.tsx:411.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(client.createTeam)).toHaveBeenCalledWith({
|
||||||
|
name: 'Platform Eng',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal tears down on success (onClose() in the mutation's onSuccess).
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('heading', { name: /Create Team/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parent <select> auto-selected the new team. Locate the select by
|
||||||
|
// finding the new team's <option> (which only exists on the parent
|
||||||
|
// form's team dropdown after the refetch populates it), then assert
|
||||||
|
// the select's current value is the new id. This avoids relying on
|
||||||
|
// label-for-select association, which the current markup doesn't
|
||||||
|
// provide (label is a sibling, not htmlFor-linked).
|
||||||
|
const newTeamOption = await screen.findByRole('option', { name: /Platform Eng/i });
|
||||||
|
const teamSelect = newTeamOption.closest('select') as HTMLSelectElement;
|
||||||
|
expect(teamSelect).not.toBeNull();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(teamSelect.value).toBe('t-platform');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('+ New owner opens the inline modal, calls createOwner, invalidates the cache, and auto-selects the new owner', async () => {
|
||||||
|
let ownersData: Array<{
|
||||||
|
id: string; name: string; email: string; team_id: string;
|
||||||
|
created_at: string; updated_at: string;
|
||||||
|
}> = [];
|
||||||
|
vi.mocked(client.getOwners).mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: ownersData,
|
||||||
|
total: ownersData.length,
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
} as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(client.createOwner).mockImplementation(async (data) => {
|
||||||
|
const owner = {
|
||||||
|
id: 'o-alice',
|
||||||
|
name: data?.name ?? 'unnamed',
|
||||||
|
email: data?.email ?? '',
|
||||||
|
team_id: data?.team_id ?? '',
|
||||||
|
created_at: '2026-04-19T00:00:00Z',
|
||||||
|
updated_at: '2026-04-19T00:00:00Z',
|
||||||
|
};
|
||||||
|
ownersData = [owner];
|
||||||
|
return owner as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWizard();
|
||||||
|
await advanceToCertificateStep();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /\+ New owner/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: /Create Owner/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Alice Chen/i), {
|
||||||
|
target: { value: 'Alice Chen' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/alice@example\.com/i), {
|
||||||
|
target: { value: 'alice@example.com' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Create Owner$/ }));
|
||||||
|
|
||||||
|
// Per OnboardingWizard.tsx:485-489, team_id is coerced to `undefined`
|
||||||
|
// when the optional Team select is left at its default empty value —
|
||||||
|
// otherwise the server would see `team_id: ""` and 400 on an invalid
|
||||||
|
// FK. This assertion pins that coercion.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(client.createOwner)).toHaveBeenCalledWith({
|
||||||
|
name: 'Alice Chen',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
team_id: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('heading', { name: /Create Owner/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parent Owner <select> auto-selects the new owner. Option text format
|
||||||
|
// from OnboardingWizard.tsx:754-756 is `{name}{email ? ` (${email})` : ''}`
|
||||||
|
// — "Alice Chen (alice@example.com)".
|
||||||
|
const newOwnerOption = await screen.findByRole('option', { name: /Alice Chen/i });
|
||||||
|
const ownerSelect = newOwnerOption.closest('select') as HTMLSelectElement;
|
||||||
|
expect(ownerSelect).not.toBeNull();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ownerSelect.value).toBe('o-alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel on the team modal closes it without firing createTeam', async () => {
|
||||||
|
renderWizard();
|
||||||
|
await advanceToCertificateStep();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /\+ New team/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: /Create Team/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel button is the modal's second footer button — anchored regex to
|
||||||
|
// avoid matching any stray "Cancel" on the page.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Cancel$/ }));
|
||||||
|
|
||||||
|
// Modal tears down and the mutation never fires — abort is clean.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('heading', { name: /Create Team/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(vi.mocked(client.createTeam)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies,
|
getIssuers, getAgents, getProfiles, getOwners, getTeams, getPolicies,
|
||||||
createIssuer, testIssuerConnection,
|
createIssuer, testIssuerConnection,
|
||||||
createCertificate, triggerRenewal,
|
createCertificate, triggerRenewal,
|
||||||
|
createTeam, createOwner,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import { issuerTypes, type IssuerTypeConfig } from '../config/issuerTypes';
|
import { issuerTypes, type IssuerTypeConfig } from '../config/issuerTypes';
|
||||||
@@ -392,6 +393,180 @@ curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-a
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Step 3 helpers: inline team + owner creation ───
|
||||||
|
|
||||||
|
// Inline CreateTeamModal — mirrors TeamsPage.tsx CreateTeamModal pattern.
|
||||||
|
// Used inside CertificateStep so users can create a team without leaving the wizard.
|
||||||
|
function CreateTeamModalInline({ isOpen, onClose, onCreated }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (teamId: string) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => createTeam({ name: name.trim(), description: description.trim() }),
|
||||||
|
onSuccess: (team) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['teams'] });
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setError('');
|
||||||
|
onCreated(team.id);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Create Team</h2>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (!name.trim()) return; mutation.mutate(); }} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Name <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Platform Engineering"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Description <span className="text-xs text-ink-muted font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending || !name.trim()}
|
||||||
|
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Creating...' : 'Create Team'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline CreateOwnerModal — mirrors OwnersPage.tsx CreateOwnerModal pattern.
|
||||||
|
// Used inside CertificateStep so users can create an owner without leaving the wizard.
|
||||||
|
function CreateOwnerModalInline({ isOpen, onClose, onCreated, teams }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (ownerId: string) => void;
|
||||||
|
teams: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [teamId, setTeamId] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => createOwner({
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
team_id: teamId || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (owner) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['owners'] });
|
||||||
|
setName('');
|
||||||
|
setEmail('');
|
||||||
|
setTeamId('');
|
||||||
|
setError('');
|
||||||
|
onCreated(owner.id);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-semibold text-ink mb-4">Create Owner</h2>
|
||||||
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !email.trim()) return;
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Name <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Alice Chen"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Email <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="alice@example.com"
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-ink mb-2">
|
||||||
|
Team <span className="text-xs text-ink-muted font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={teamId}
|
||||||
|
onChange={e => setTeamId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending || !name.trim() || !email.trim()}
|
||||||
|
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Creating...' : 'Create Owner'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 3: Add a Certificate ───────────────────────
|
// ─── Step 3: Add a Certificate ───────────────────────
|
||||||
|
|
||||||
function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
||||||
@@ -411,6 +586,10 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [created, setCreated] = useState(false);
|
const [created, setCreated] = useState(false);
|
||||||
|
|
||||||
|
// Inline-create modals so users never have to leave the wizard (UX-001).
|
||||||
|
const [teamModalOpen, setTeamModalOpen] = useState(false);
|
||||||
|
const [ownerModalOpen, setOwnerModalOpen] = useState(false);
|
||||||
|
|
||||||
// C-001: the server requires name, common_name, issuer_id, owner_id,
|
// C-001: the server requires name, common_name, issuer_id, owner_id,
|
||||||
// team_id, and renewal_policy_id (handler in
|
// team_id, and renewal_policy_id (handler in
|
||||||
// internal/api/handler/certificates.go + ManagedCertificate.required in
|
// internal/api/handler/certificates.go + ManagedCertificate.required in
|
||||||
@@ -553,9 +732,18 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-ink mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
Owner <span className="text-red-600">*</span>
|
<label className="block text-sm font-medium text-ink">
|
||||||
</label>
|
Owner <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOwnerModalOpen(true)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 hover:underline"
|
||||||
|
>
|
||||||
|
+ New owner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={ownerId}
|
value={ownerId}
|
||||||
onChange={e => setOwnerId(e.target.value)}
|
onChange={e => setOwnerId(e.target.value)}
|
||||||
@@ -570,15 +758,32 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
|||||||
</select>
|
</select>
|
||||||
{(owners?.data?.length ?? 0) === 0 && (
|
{(owners?.data?.length ?? 0) === 0 && (
|
||||||
<p className="mt-1 text-xs text-ink-muted">
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
No owners yet — create one from the <Link to="/owners" className="underline hover:text-ink">Owners page</Link> first, then return here.
|
No owners yet —{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOwnerModalOpen(true)}
|
||||||
|
className="underline hover:text-ink"
|
||||||
|
>
|
||||||
|
create one now
|
||||||
|
</button>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-ink mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
Team <span className="text-red-600">*</span>
|
<label className="block text-sm font-medium text-ink">
|
||||||
</label>
|
Team <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTeamModalOpen(true)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 hover:underline"
|
||||||
|
>
|
||||||
|
+ New team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={teamId}
|
value={teamId}
|
||||||
onChange={e => setTeamId(e.target.value)}
|
onChange={e => setTeamId(e.target.value)}
|
||||||
@@ -591,7 +796,15 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
|||||||
</select>
|
</select>
|
||||||
{(teams?.data?.length ?? 0) === 0 && (
|
{(teams?.data?.length ?? 0) === 0 && (
|
||||||
<p className="mt-1 text-xs text-ink-muted">
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
No teams yet — create one from the <Link to="/teams" className="underline hover:text-ink">Teams page</Link> first, then return here.
|
No teams yet —{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTeamModalOpen(true)}
|
||||||
|
className="underline hover:text-ink"
|
||||||
|
>
|
||||||
|
create one now
|
||||||
|
</button>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -653,6 +866,18 @@ function CertificateStep({ onNext, onSkip, createdIssuerId }: {
|
|||||||
createMutation.isPending
|
createMutation.isPending
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateTeamModalInline
|
||||||
|
isOpen={teamModalOpen}
|
||||||
|
onClose={() => setTeamModalOpen(false)}
|
||||||
|
onCreated={(id) => setTeamId(id)}
|
||||||
|
/>
|
||||||
|
<CreateOwnerModalInline
|
||||||
|
isOpen={ownerModalOpen}
|
||||||
|
onClose={() => setOwnerModalOpen(false)}
|
||||||
|
onCreated={(id) => setOwnerId(id)}
|
||||||
|
teams={(teams?.data ?? []).map(t => ({ id: t.id, name: t.name }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user