velxio/frontend/src/desktop/DesktopWelcomePage.tsx

225 lines
7.2 KiB
TypeScript

/**
* Velxio Desktop welcome / sign-in screen.
*
* Rendered by the desktop overlay (`./index.ts::mountDesktop`) when
* the stored license key fails validation (or doesn't exist yet).
* Two flows:
*
* 1. Sign in with Velxio — opens the system browser at
* `https://velxio.dev/auth/desktop?state=<nonce>`. The page hands
* a license token back via `velxio-desktop://auth?token=…&state=…`,
* Tauri's deep-link handler verifies the nonce, persists the key
* and emits `velxio://auth-completed`.
*
* 2. Paste license key — manual fallback for headless environments
* or shared machines. Validates the key, stores it, hides the
* welcome screen.
*
* The screen DOES NOT cover the SPA — the SPA isn't mounted yet when
* we're in welcome mode (see `index.ts::renderWelcomeOrEditor`).
*/
import { useEffect, useState } from 'react';
import {
beginSignIn,
invoke,
isTauri,
listen,
openExternal,
type ValidationResult,
} from './tauriBridge';
type Props = {
onAuthorised: (result: ValidationResult) => void;
/**
* v0.3.0+: when set, the welcome screen renders the grandfather
* variant - a friendly "you have N days of free grace" with a
* "continue without signing in" escape hatch. `null` (the legacy
* default) keeps the original behaviour: no escape, sign-in or
* paste-key are the only options.
*/
grandfatherDaysRemaining?: number | null;
};
type Mode = 'choose' | 'paste';
const VELXIO_BASE = 'https://velxio.dev';
export const DesktopWelcomePage = ({ onAuthorised, grandfatherDaysRemaining = null }: Props) => {
const [mode, setMode] = useState<Mode>('choose');
const [pastedKey, setPastedKey] = useState('');
const [busy, setBusy] = useState(false);
const [waiting, setWaiting] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Listen for the deep-link callback that fires after the user
// completes sign-in in the browser. Cleanup on unmount.
useEffect(() => {
let dispose: (() => void) | null = null;
listen<{ ok: boolean; result?: ValidationResult; error?: string }>(
'velxio://auth-completed',
(event) => {
setWaiting(false);
if (event.payload.ok && event.payload.result?.valid) {
onAuthorised(event.payload.result);
} else {
setErr(event.payload.error || 'Sign-in failed. Try again.');
}
},
).then((off) => {
dispose = off;
});
return () => {
if (dispose) dispose();
};
}, [onAuthorised]);
const handleSignIn = async () => {
setErr(null);
if (!isTauri()) {
setErr('This window is running outside Tauri — paste your key instead.');
setMode('paste');
return;
}
setBusy(true);
try {
await beginSignIn(VELXIO_BASE);
setWaiting(true);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const handlePaste = async () => {
setErr(null);
const trimmed = pastedKey.trim();
if (!trimmed) {
setErr('Paste a license key first.');
return;
}
if (!/^vlx_[a-z_]+_[0-9a-f]+$/.test(trimmed)) {
setErr('That doesn\'t look like a Velxio key (vlx_<plan>_<hex>).');
return;
}
setBusy(true);
try {
await invoke('license_save_key', { key: trimmed });
const result = await invoke<ValidationResult>('license_validate', { key: trimmed });
if (result.valid && result.entitlements?.desktop) {
onAuthorised(result);
} else {
setErr(
result.reason_code === 'trial_expired'
? 'Your trial has ended. Upgrade to Pro to continue.'
: 'Key was accepted but the Velxio Pro entitlement is not active.',
);
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const isGrandfather = grandfatherDaysRemaining !== null && grandfatherDaysRemaining > 0;
return (
<div className="vlx-desktop-welcome">
<div className="vlx-desktop-welcome-card">
<div className="vlx-desktop-welcome-brand">
<h1>{isGrandfather ? 'Welcome back to Velxio Desktop' : 'Velxio Desktop'}</h1>
<p>
{isGrandfather
? `You have ${grandfatherDaysRemaining} days to keep using Velxio Desktop before a Velxio Pro subscription is required.`
: 'Offline Arduino, RP2040 and ESP32 simulator.'}
</p>
</div>
{err && <div className="vlx-desktop-welcome-error">{err}</div>}
{mode === 'choose' && (
<div className="vlx-desktop-welcome-actions">
<button
type="button"
className="vlx-desktop-welcome-primary"
onClick={handleSignIn}
disabled={busy || waiting}
>
{waiting ? 'Waiting for browser...' : 'Sign in with Velxio'}
</button>
<button
type="button"
className="vlx-desktop-welcome-secondary"
onClick={() => setMode('paste')}
disabled={busy}
>
I have a license key
</button>
{isGrandfather && (
<button
type="button"
className="vlx-desktop-welcome-link"
onClick={() => onAuthorised({ valid: true, plan: 'grandfather' })}
>
Continue without signing in
</button>
)}
<button
type="button"
className="vlx-desktop-welcome-link"
onClick={() => openExternal(`${VELXIO_BASE}/pricing`)}
>
View pricing
</button>
</div>
)}
{mode === 'paste' && (
<div className="vlx-desktop-welcome-paste">
<label htmlFor="vlx-paste-key">License key</label>
<input
id="vlx-paste-key"
type="text"
value={pastedKey}
onChange={(e) => setPastedKey(e.target.value)}
placeholder="vlx_pro_… or vlx_trial_…"
spellCheck={false}
autoFocus
disabled={busy}
/>
<div className="vlx-desktop-welcome-actions">
<button
type="button"
className="vlx-desktop-welcome-primary"
onClick={handlePaste}
disabled={busy}
>
{busy ? 'Validating…' : 'Activate'}
</button>
<button
type="button"
className="vlx-desktop-welcome-secondary"
onClick={() => {
setMode('choose');
setErr(null);
}}
disabled={busy}
>
Back
</button>
</div>
</div>
)}
<p className="vlx-desktop-welcome-trust">
{isGrandfather
? 'No charge during your grace period. Sign in any time to start your trial without losing this window.'
: '30-day free trial included. After that, requires a Velxio Pro subscription.'}
</p>
</div>
</div>
);
};