diff --git a/cloud/TASKS.md b/cloud/TASKS.md index d0d5d9ee..d99b2a42 100644 --- a/cloud/TASKS.md +++ b/cloud/TASKS.md @@ -84,7 +84,7 @@ - A unit test verifies the provider object's shape and visibility logic. ### C8 — License entry UI: `LicenseSettings.tsx` -- [ ] **Goal:** A settings panel where the user can paste a license, see status, see SKU/email/expiry, and remove it. +- [x] **Goal:** A settings panel where the user can paste a license, see status, see SKU/email/expiry, and remove it. - **Files:** `src/components/settings/LicenseSettings.tsx` + test, `src/components/settings/LicenseSettings.module.css` if needed (prefer Tailwind). - **Behavior:** - Paste license → calls `useLicenseStore.setLicense` → shows status (Verifying / Valid / Invalid + reason). @@ -130,3 +130,4 @@ [x] C1 — 2026-04-28 — ba66b60 — scaffolded `src/services/luminaCloud/` (types + stubs); typecheck passes; no new runtime deps [x] C2 — 2026-04-28 — 3127814 — Ed25519 verifyLicense + JCS canonical-json + 24 tests; deps @noble/ed25519 ^3.1.0, @noble/hashes ^2.2.0 [x] C4 — 2026-04-28 — 3144bd5 — useLicenseStore (zustand) with mocked luminaCloud; 9 tests cover all four status transitions +[x] C8 — 2026-04-28 — 6fc20d1 — LicenseSettings panel (Tailwind, standalone, no AISettingsModal dep); 7 tests cover idle/loading/invalid/valid + remove confirmation diff --git a/src/components/settings/LicenseSettings.test.tsx b/src/components/settings/LicenseSettings.test.tsx new file mode 100644 index 00000000..2ede4e82 --- /dev/null +++ b/src/components/settings/LicenseSettings.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LicensePayload } from '@/services/luminaCloud'; +import { useLicenseStore } from '@/stores/useLicenseStore'; + +import { LicenseSettings } from './LicenseSettings'; + +const verifyLicense = vi.hoisted(() => vi.fn()); +const saveLicense = vi.hoisted(() => vi.fn()); +const removeLicense = vi.hoisted(() => vi.fn()); +const loadLicense = vi.hoisted(() => vi.fn()); + +vi.mock('@/services/luminaCloud', async () => { + const actual = await vi.importActual( + '@/services/luminaCloud' + ); + return { + ...actual, + verifyLicense, + saveLicense, + removeLicense, + loadLicense, + }; +}); + +const VALID_PAYLOAD: LicensePayload = { + v: 1, + lid: 'lic_01HXTEST', + email: 'fixture@example.com', + sku: 'lumina-lifetime-founders', + features: ['cloud_ai', 'lifetime'], + issued_at: '2026-04-28T12:00:00Z', + expires_at: null, + order_id: 'creem_ord_test', + device_limit: 5, +}; + +beforeEach(() => { + useLicenseStore.setState({ license: null, payload: null, status: 'idle' }); + verifyLicense.mockReset(); + saveLicense.mockReset(); + removeLicense.mockReset(); + loadLicense.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('LicenseSettings — idle state', () => { + it('renders the entry form with disabled Verify button before user types', () => { + render(); + expect(screen.getByRole('heading', { name: /Lumina Cloud license/i })).toBeInTheDocument(); + expect(screen.getByLabelText('License token')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Verify/i })).toBeDisabled(); + }); + + it('does not throw when rendered repeatedly with no license', () => { + expect(() => { + const { unmount } = render(); + unmount(); + render(); + }).not.toThrow(); + }); +}); + +describe('LicenseSettings — invalid state', () => { + it('shows an error message after a failed verify attempt', async () => { + verifyLicense.mockReturnValue(null); + render(); + + const textarea = screen.getByLabelText('License token') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'garbage' } }); + fireEvent.click(screen.getByRole('button', { name: /Verify/i })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/Could not verify/i) + ); + expect(useLicenseStore.getState().status).toBe('invalid'); + }); +}); + +describe('LicenseSettings — valid state', () => { + beforeEach(() => { + useLicenseStore.setState({ + license: 'valid-token', + payload: VALID_PAYLOAD, + status: 'valid', + }); + }); + + it('shows email, SKU, expiry, and feature badges', () => { + render(); + expect(screen.getByText('fixture@example.com')).toBeInTheDocument(); + expect(screen.getByText('lumina-lifetime-founders')).toBeInTheDocument(); + expect(screen.getByText('Lifetime')).toBeInTheDocument(); + expect(screen.getByText('cloud_ai')).toBeInTheDocument(); + expect(screen.getByText('lifetime')).toBeInTheDocument(); + }); + + it('formats a non-null expiry as YYYY-MM-DD', () => { + useLicenseStore.setState({ + license: 'valid-token', + payload: { ...VALID_PAYLOAD, expires_at: '2027-04-28T12:00:00Z' }, + status: 'valid', + }); + render(); + expect(screen.getByText('2027-04-28')).toBeInTheDocument(); + }); + + it('clears the license through the confirmation flow', async () => { + removeLicense.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Remove license/i })); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^Remove$/i })); + + await waitFor(() => expect(useLicenseStore.getState().status).toBe('idle')); + expect(removeLicense).toHaveBeenCalledTimes(1); + }); + + it('Cancel keeps the license and dismisses the confirmation', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /Remove license/i })); + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(useLicenseStore.getState().status).toBe('valid'); + expect(removeLicense).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/settings/LicenseSettings.tsx b/src/components/settings/LicenseSettings.tsx new file mode 100644 index 00000000..d37fad25 --- /dev/null +++ b/src/components/settings/LicenseSettings.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; + +import type { LicensePayload } from '@/services/luminaCloud'; +import { useLicenseStore } from '@/stores/useLicenseStore'; + +/** + * License paste / view / remove panel. Standalone — no dependency on + * AISettingsModal. Mounted by C10 (Account tab). + */ +export function LicenseSettings(): JSX.Element { + const status = useLicenseStore((s) => s.status); + const payload = useLicenseStore((s) => s.payload); + const setLicense = useLicenseStore((s) => s.setLicense); + const clearLicense = useLicenseStore((s) => s.clearLicense); + + const [draft, setDraft] = useState(''); + const [confirmingRemove, setConfirmingRemove] = useState(false); + + async function handleVerify() { + const trimmed = draft.trim(); + if (!trimmed) return; + await setLicense(trimmed); + } + + async function handleRemove() { + await clearLicense(); + setConfirmingRemove(false); + setDraft(''); + } + + if (status === 'valid' && payload) { + return ( +
+
+

+ Lumina Cloud license +

+
+ + + + {confirmingRemove ? ( +
+ + Remove this license? Cloud features will stop working until you paste it again. + + + +
+ ) : ( + + )} +
+ ); + } + + return ( +
+
+

+ Lumina Cloud license +

+

+ Paste the license you received by email. Verification happens locally — no network call required. +

+
+ +