From 6fc20d15ad561bb224b9f9b2c8d3bd9036a02bdd Mon Sep 17 00:00:00 2001 From: blueberrycongee Date: Tue, 28 Apr 2026 13:07:10 -0700 Subject: [PATCH 1/2] cloud/C8: LicenseSettings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone settings panel for entering / viewing / removing the Lumina Cloud license. Reads useLicenseStore directly; no dependency on AISettingsModal. Tailwind styled. States: - idle: heading, helper text, textarea, Verify button (disabled while empty or while status is 'loading'). - loading: Verify button label flips to "Verifying…", status line with role="status" mirrors it. - invalid: role="alert" line — "Could not verify this license." - valid: dl with email / SKU / expiry / feature badges, plus a "Remove license" button that opens an inline alertdialog confirmation before calling clearLicense. Tests cover the three status states + idle entry-form rendering + the confirmation flow (Remove and Cancel paths) + ISO date formatting for non-lifetime expiries. 7 tests, all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/LicenseSettings.test.tsx | 133 ++++++++++++++ src/components/settings/LicenseSettings.tsx | 173 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/components/settings/LicenseSettings.test.tsx create mode 100644 src/components/settings/LicenseSettings.tsx 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. +

+
+ +