Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cloud/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
133 changes: 133 additions & 0 deletions src/components/settings/LicenseSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/services/luminaCloud')>(
'@/services/luminaCloud'
);
return {
...actual,
verifyLicense,
saveLicense,
removeLicense,
loadLicense,
};
});

const VALID_PAYLOAD: LicensePayload = {
v: 1,
lid: 'lic_01HXTEST',
email: '[email protected]',
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(<LicenseSettings />);
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(<LicenseSettings />);
unmount();
render(<LicenseSettings />);
}).not.toThrow();
});
});

describe('LicenseSettings — invalid state', () => {
it('shows an error message after a failed verify attempt', async () => {
verifyLicense.mockReturnValue(null);
render(<LicenseSettings />);

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(<LicenseSettings />);
expect(screen.getByText('[email protected]')).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(<LicenseSettings />);
expect(screen.getByText('2027-04-28')).toBeInTheDocument();
});

it('clears the license through the confirmation flow', async () => {
removeLicense.mockResolvedValue(undefined);
render(<LicenseSettings />);

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(<LicenseSettings />);
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();
});
});
173 changes: 173 additions & 0 deletions src/components/settings/LicenseSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="space-y-4" aria-labelledby="license-heading">
<header>
<h2 id="license-heading" className="text-base font-medium">
Lumina Cloud license
</h2>
</header>

<ValidLicenseSummary payload={payload} />

{confirmingRemove ? (
<div className="flex items-center gap-2 text-sm" role="alertdialog" aria-label="Confirm remove">
<span className="text-neutral-600 dark:text-neutral-300">
Remove this license? Cloud features will stop working until you paste it again.
</span>
<button
type="button"
onClick={handleRemove}
className="rounded border border-red-400 px-2 py-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
Remove
</button>
<button
type="button"
onClick={() => setConfirmingRemove(false)}
className="rounded border border-neutral-300 px-2 py-1 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-800"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmingRemove(true)}
className="text-sm text-neutral-600 underline-offset-4 hover:underline dark:text-neutral-400"
>
Remove license
</button>
)}
</section>
);
}

return (
<section className="space-y-4" aria-labelledby="license-heading">
<header className="space-y-1">
<h2 id="license-heading" className="text-base font-medium">
Lumina Cloud license
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Paste the license you received by email. Verification happens locally — no network call required.
</p>
</header>

<label className="block space-y-2">
<span className="text-sm font-medium">License token</span>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="eyJ…(payload).…(signature)"
rows={3}
spellCheck={false}
autoComplete="off"
aria-label="License token"
className="w-full rounded border border-neutral-300 bg-white p-2 font-mono text-xs focus:border-neutral-500 focus:outline-none dark:border-neutral-600 dark:bg-neutral-900"
/>
</label>

<div className="flex items-center gap-3">
<button
type="button"
onClick={handleVerify}
disabled={status === 'loading' || draft.trim().length === 0}
className="rounded bg-neutral-900 px-3 py-1.5 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
>
{status === 'loading' ? 'Verifying…' : 'Verify'}
</button>

<StatusLine status={status} />
</div>
</section>
);
}

function StatusLine({ status }: { status: ReturnType<typeof useLicenseStore.getState>['status'] }): JSX.Element | null {
if (status === 'loading') {
return (
<span role="status" className="text-sm text-neutral-500">
Verifying…
</span>
);
}
if (status === 'invalid') {
return (
<span role="alert" className="text-sm text-red-600 dark:text-red-400">
Could not verify this license. Check the token and try again.
</span>
);
}
return null;
}

function ValidLicenseSummary({ payload }: { payload: LicensePayload }): JSX.Element {
return (
<dl className="space-y-2 text-sm">
<Row label="Email" value={payload.email} />
<Row label="SKU" value={payload.sku} />
<Row label="Expires" value={formatExpiry(payload.expires_at)} />
{payload.features.length > 0 && (
<div className="flex items-baseline gap-3">
<dt className="w-20 shrink-0 text-neutral-500">Features</dt>
<dd className="flex flex-wrap gap-1.5">
{payload.features.map((flag) => (
<span
key={flag}
className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{flag}
</span>
))}
</dd>
</div>
)}
</dl>
);
}

function Row({ label, value }: { label: string; value: string }): JSX.Element {
return (
<div className="flex items-baseline gap-3">
<dt className="w-20 shrink-0 text-neutral-500">{label}</dt>
<dd className="font-mono text-xs">{value}</dd>
</div>
);
}

function formatExpiry(expiresAt: string | null): string {
if (expiresAt === null) return 'Lifetime';
const ms = Date.parse(expiresAt);
if (!Number.isFinite(ms)) return expiresAt;
return new Date(ms).toISOString().slice(0, 10);
}