mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
audioo
This commit is contained in:
@@ -20,8 +20,6 @@ export default function AdminPanel() {
|
||||
setAdminReason,
|
||||
rebootRover,
|
||||
rebootServer,
|
||||
playTestAudio,
|
||||
stopTestAudio,
|
||||
setAudioLevels,
|
||||
llmControl,
|
||||
adminLogs,
|
||||
@@ -30,7 +28,6 @@ export default function AdminPanel() {
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const [rebootStates, setRebootStates] = useState({});
|
||||
const [audioStates, setAudioStates] = useState({});
|
||||
const [serverRebooting, setServerRebooting] = useState(false);
|
||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||
const health = session?.health || null;
|
||||
@@ -94,30 +91,6 @@ export default function AdminPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayTestAudio = async (roverId) => {
|
||||
if (!roverId) return;
|
||||
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
|
||||
try {
|
||||
await playTestAudio(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTestAudio = async (roverId) => {
|
||||
if (!roverId) return;
|
||||
setAudioStates((prev) => ({ ...prev, [roverId]: true }));
|
||||
try {
|
||||
await stopTestAudio(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setAudioStates((prev) => ({ ...prev, [roverId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerReboot = async () => {
|
||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||
if (!ok) return;
|
||||
@@ -364,22 +337,6 @@ export default function AdminPanel() {
|
||||
>
|
||||
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePlayTestAudio(rover.id)}
|
||||
disabled={Boolean(audioStates[rover.id])}
|
||||
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Play Test Audio
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStopTestAudio(rover.id)}
|
||||
disabled={Boolean(audioStates[rover.id])}
|
||||
className="button-dark disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Stop Test Audio
|
||||
</button>
|
||||
<span className="surface-muted">
|
||||
audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
|
||||
</span>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import NicknameForm from './NicknameForm.jsx';
|
||||
|
||||
function maskKey(value) {
|
||||
const key = String(value || '').trim();
|
||||
if (!key) return '';
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
const cookieKeyRegex = /^cu_[a-f0-9]{32}$/;
|
||||
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
||||
import VipAudioForwardingCard from './vip/VipAudioForwardingCard.jsx';
|
||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||
|
||||
export default function VipPanel() {
|
||||
const { session, identifySession, requestVerification } = useSession();
|
||||
const { session, identifySession, requestVerification, playUploadedAudio, stopUploadedAudio } = useSession();
|
||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
|
||||
@@ -21,24 +15,15 @@ export default function VipPanel() {
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const isVerified = Boolean(session?.isVerified);
|
||||
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
||||
|
||||
const [requestFlowStep, setRequestFlowStep] = useState(0);
|
||||
const [requestKeyInput, setRequestKeyInput] = useState('');
|
||||
const [confirmNickname, setConfirmNickname] = useState(false);
|
||||
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
|
||||
const [restoreKeyInput, setRestoreKeyInput] = useState('');
|
||||
const [working, setWorking] = useState(false);
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [message, setMessage] = useState('');
|
||||
const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
|
||||
const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
|
||||
const innerFlowClass = 'mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center';
|
||||
|
||||
const applyIdentityKey = async (nextRaw) => {
|
||||
const next = String(nextRaw || '').trim().toLowerCase();
|
||||
if (!next) {
|
||||
throw new Error('Identity key required.');
|
||||
}
|
||||
if (!cookieKeyRegex.test(next)) {
|
||||
if (!COOKIE_KEY_REGEX.test(next)) {
|
||||
throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.');
|
||||
}
|
||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
|
||||
@@ -46,277 +31,32 @@ export default function VipPanel() {
|
||||
return next;
|
||||
};
|
||||
|
||||
const beginRequestFlow = () => {
|
||||
setRequestFlowStep(1);
|
||||
setRequestKeyInput(currentStoredKey);
|
||||
setConfirmNickname(false);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const cancelRequestFlow = () => {
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
};
|
||||
|
||||
const beginRestoreFlow = () => {
|
||||
setRestoreFlowStep(1);
|
||||
setRestoreKeyInput('');
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const cancelRestoreFlow = () => {
|
||||
setRestoreFlowStep(0);
|
||||
setRestoreKeyInput('');
|
||||
};
|
||||
|
||||
const handleRequestSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!confirmNickname) {
|
||||
setMessage('Please confirm your nickname agreement before sending.');
|
||||
return;
|
||||
}
|
||||
if (requestFlowStep < 3) {
|
||||
setMessage('Complete all request steps before sending.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const applied = await applyIdentityKey(requestKeyInput);
|
||||
await requestVerification();
|
||||
setRequestKeyInput(applied);
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
setMessage('Verification request sent to lockdown admins.');
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to submit request.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (restoreFlowStep < 2) return;
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await applyIdentityKey(restoreKeyInput);
|
||||
setRestoreFlowStep(0);
|
||||
setRestoreKeyInput('');
|
||||
setMessage('Identity key restored.');
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to restore key.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{isVerified ? (
|
||||
<section className="surface text-sm text-slate-200">
|
||||
VIP controls will show here... when there are some...
|
||||
</section>
|
||||
) : null}
|
||||
<VipAudioForwardingCard
|
||||
roster={roster}
|
||||
audioForwardByRover={session?.audioForward || {}}
|
||||
playUploadedAudio={playUploadedAudio}
|
||||
stopUploadedAudio={stopUploadedAudio}
|
||||
onMessage={setMessage}
|
||||
/>
|
||||
) : (
|
||||
<VipVerificationCard
|
||||
pendingRequestId={pendingRequestId}
|
||||
currentStoredKey={currentStoredKey}
|
||||
nickname={nickname}
|
||||
requestVerification={requestVerification}
|
||||
applyIdentityKey={applyIdentityKey}
|
||||
onMessage={setMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isVerified ? (
|
||||
pendingRequestId ? (
|
||||
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
||||
</section>
|
||||
) : requestFlowStep === 0 ? (
|
||||
<section className={`surface ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Verification</p>
|
||||
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
||||
Request Verification
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Request verification</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Step {requestFlowStep} of 3
|
||||
</p>
|
||||
|
||||
{requestFlowStep === 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Confirm your nickname, which is used as part of the verification process. You can edit it here before requesting.
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
|
||||
Current nickname: <span className="font-semibold">{nickname || '(not set)'}</span>
|
||||
</div>
|
||||
<label className="flex items-center justify-center gap-0.5 text-xs text-slate-300 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
checked={confirmNickname}
|
||||
onChange={(event) => setConfirmNickname(event.target.checked)}
|
||||
/>
|
||||
<span>I understand this nickname is tied to my verification.</span>
|
||||
</label>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!nickname || !confirmNickname}
|
||||
onClick={() => setRequestFlowStep(2)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={cancelRequestFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requestFlowStep === 2 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Save your identity key in a safe place. You can use it to restore your identity in another browser.
|
||||
</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_request"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={requestKeyInput}
|
||||
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Identity key for this request"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!requestKeyInput}
|
||||
onClick={() => navigator.clipboard?.writeText(requestKeyInput)}
|
||||
>
|
||||
Copy Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!requestKeyInput}
|
||||
onClick={() => setRequestFlowStep(3)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requestFlowStep === 3 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Final step: confirm and send your verification request.
|
||||
</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_request_final"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={requestKeyInput}
|
||||
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Identity key for this request"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="submit" className="button-dark text-sm" disabled={working || !requestKeyInput}>
|
||||
Confirm Request
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<section className={`surface ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Identity key</p>
|
||||
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_current"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={currentStoredKey}
|
||||
readOnly
|
||||
placeholder="Identity key"
|
||||
/>
|
||||
{restoreFlowStep === 0 ? (
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!currentStoredKey}
|
||||
onClick={() => navigator.clipboard?.writeText(currentStoredKey)}
|
||||
>
|
||||
Copy Key
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={beginRestoreFlow} disabled={working}>
|
||||
Restore Key
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{restoreFlowStep === 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Restoring your key should only be done when needed. Use this to move your identity to another browser.
|
||||
</p>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(2)}>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={cancelRestoreFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{restoreFlowStep === 2 ? (
|
||||
<form className="space-y-0.5" onSubmit={handleRestoreSubmit}>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_restore"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={restoreKeyInput}
|
||||
onChange={(event) => setRestoreKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Paste key to restore"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="submit" className="button-dark text-sm" disabled={working || !restoreKeyInput}>
|
||||
Confirm Restore
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<VipIdentityCard
|
||||
currentStoredKey={currentStoredKey}
|
||||
applyIdentityKey={applyIdentityKey}
|
||||
onMessage={setMessage}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className={flowWrapClass}>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MAX_UPLOAD_BYTES, bytesToBase64, fieldClass } from './constants.js';
|
||||
|
||||
export default function VipAudioForwardingCard({
|
||||
roster = [],
|
||||
audioForwardByRover = {},
|
||||
playUploadedAudio,
|
||||
stopUploadedAudio,
|
||||
onMessage,
|
||||
}) {
|
||||
const [selectedRoverId, setSelectedRoverId] = useState('');
|
||||
const [selectedUpload, setSelectedUpload] = useState(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const selectedForwardState = useMemo(
|
||||
() => (selectedRoverId ? audioForwardByRover?.[selectedRoverId] || null : null),
|
||||
[audioForwardByRover, selectedRoverId],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
const roverId = String(selectedRoverId || '').trim();
|
||||
if (!roverId) {
|
||||
onMessage?.('Select a rover first.');
|
||||
return;
|
||||
}
|
||||
if (!selectedUpload) {
|
||||
onMessage?.('Select an audio file first.');
|
||||
return;
|
||||
}
|
||||
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
|
||||
onMessage?.(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const base64 = bytesToBase64(new Uint8Array(buffer));
|
||||
await playUploadedAudio?.({
|
||||
roverId,
|
||||
name: selectedUpload.name,
|
||||
mime: selectedUpload.type || '',
|
||||
dataBase64: base64,
|
||||
});
|
||||
onMessage?.(`Playing upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
onMessage?.(err.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadStop = async () => {
|
||||
const roverId = String(selectedRoverId || '').trim();
|
||||
if (!roverId) {
|
||||
onMessage?.('Select a rover first.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
onMessage?.(`Stopped upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
onMessage?.(err.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="surface space-y-0.5 text-sm text-slate-200">
|
||||
<p className="text-slate-300">VIP Audio Forwarding</p>
|
||||
<label className="grid gap-0.5 text-xs text-slate-300">
|
||||
<span>Target rover</span>
|
||||
<select
|
||||
className={fieldClass}
|
||||
value={selectedRoverId}
|
||||
onChange={(event) => setSelectedRoverId(event.target.value)}
|
||||
disabled={working}
|
||||
>
|
||||
<option value="">Select rover</option>
|
||||
{roster.map((rover) => (
|
||||
<option key={rover.id} value={rover.id}>
|
||||
{rover.name || rover.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-xs text-slate-300">
|
||||
<span>Audio file (mp3 / wav / ogg)</span>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="file"
|
||||
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
|
||||
disabled={working}
|
||||
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
{selectedUpload ? (
|
||||
<div className="surface-muted text-xs text-slate-300 text-center">
|
||||
{selectedUpload.name} ({selectedUpload.size} bytes)
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="button" className="button-dark text-sm" disabled={working} onClick={handleUploadPlay}>
|
||||
{working ? 'Working...' : 'Play Upload'}
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" disabled={working} onClick={handleUploadStop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
{selectedForwardState ? (
|
||||
<div className="surface-muted text-xs text-slate-300 text-center">
|
||||
state: {selectedForwardState.state || 'idle'}
|
||||
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
|
||||
|
||||
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage }) {
|
||||
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
|
||||
const [restoreKeyInput, setRestoreKeyInput] = useState('');
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const beginRestoreFlow = () => {
|
||||
setRestoreFlowStep(1);
|
||||
setRestoreKeyInput('');
|
||||
onMessage?.('');
|
||||
};
|
||||
|
||||
const cancelRestoreFlow = () => {
|
||||
setRestoreFlowStep(0);
|
||||
setRestoreKeyInput('');
|
||||
};
|
||||
|
||||
const handleRestoreSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (restoreFlowStep < 2) return;
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
await applyIdentityKey(restoreKeyInput);
|
||||
setRestoreFlowStep(0);
|
||||
setRestoreKeyInput('');
|
||||
onMessage?.('Identity key restored.');
|
||||
} catch (err) {
|
||||
onMessage?.(err.message || 'Failed to restore key.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`surface ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Identity key</p>
|
||||
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_current"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={currentStoredKey}
|
||||
readOnly
|
||||
placeholder="Identity key"
|
||||
/>
|
||||
{restoreFlowStep === 0 ? (
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!currentStoredKey}
|
||||
onClick={() => navigator.clipboard?.writeText(currentStoredKey)}
|
||||
>
|
||||
Copy Key
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={beginRestoreFlow} disabled={working}>
|
||||
Restore Key
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{restoreFlowStep === 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Restoring your key should only be done when needed. Use this to move your identity to another browser.
|
||||
</p>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(2)}>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={cancelRestoreFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{restoreFlowStep === 2 ? (
|
||||
<form className="space-y-0.5" onSubmit={handleRestoreSubmit}>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_restore"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={restoreKeyInput}
|
||||
onChange={(event) => setRestoreKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Paste key to restore"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="submit" className="button-dark text-sm" disabled={working || !restoreKeyInput}>
|
||||
Confirm Restore
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRestoreFlowStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import NicknameForm from '../NicknameForm.jsx';
|
||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||
|
||||
export default function VipVerificationCard({
|
||||
pendingRequestId,
|
||||
currentStoredKey,
|
||||
nickname,
|
||||
requestVerification,
|
||||
applyIdentityKey,
|
||||
onMessage,
|
||||
}) {
|
||||
const [requestFlowStep, setRequestFlowStep] = useState(0);
|
||||
const [requestKeyInput, setRequestKeyInput] = useState('');
|
||||
const [confirmNickname, setConfirmNickname] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const beginRequestFlow = () => {
|
||||
setRequestFlowStep(1);
|
||||
setRequestKeyInput(currentStoredKey);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('');
|
||||
};
|
||||
|
||||
const cancelRequestFlow = () => {
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
};
|
||||
|
||||
const handleRequestSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!confirmNickname) {
|
||||
onMessage?.('Please confirm your nickname agreement before sending.');
|
||||
return;
|
||||
}
|
||||
if (requestFlowStep < 3) {
|
||||
onMessage?.('Complete all request steps before sending.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
const applied = await applyIdentityKey(requestKeyInput);
|
||||
await requestVerification?.();
|
||||
setRequestKeyInput(applied);
|
||||
setRequestFlowStep(0);
|
||||
setConfirmNickname(false);
|
||||
onMessage?.('Verification request sent to lockdown admins.');
|
||||
} catch (err) {
|
||||
onMessage?.(err.message || 'Failed to submit request.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (pendingRequestId) {
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (requestFlowStep === 0) {
|
||||
return (
|
||||
<section className={`surface ${flowWrapClass}`}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Verification</p>
|
||||
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
||||
Request Verification
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Request verification</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Step {requestFlowStep} of 3
|
||||
</p>
|
||||
|
||||
{requestFlowStep === 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Confirm your nickname, which is used as part of the verification process. You can edit it here before requesting.
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
|
||||
Current nickname: <span className="font-semibold">{nickname || '(not set)'}</span>
|
||||
</div>
|
||||
<label className="flex items-center justify-center gap-0.5 text-xs text-slate-300 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
checked={confirmNickname}
|
||||
onChange={(event) => setConfirmNickname(event.target.checked)}
|
||||
/>
|
||||
<span>I understand this nickname is tied to my verification.</span>
|
||||
</label>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!nickname || !confirmNickname}
|
||||
onClick={() => setRequestFlowStep(2)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={cancelRequestFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requestFlowStep === 2 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Save your identity key in a safe place. You can use it to restore your identity in another browser.
|
||||
</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_request"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={requestKeyInput}
|
||||
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Identity key for this request"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!requestKeyInput}
|
||||
onClick={() => navigator.clipboard?.writeText(requestKeyInput)}
|
||||
>
|
||||
Copy Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm"
|
||||
disabled={!requestKeyInput}
|
||||
onClick={() => setRequestFlowStep(3)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requestFlowStep === 3 ? (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
Final step: confirm and send your verification request.
|
||||
</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="password"
|
||||
name="identity_key_request_final"
|
||||
autoComplete="current-password"
|
||||
maxLength={35}
|
||||
value={requestKeyInput}
|
||||
onChange={(event) => setRequestKeyInput(event.target.value.toLowerCase())}
|
||||
placeholder="Identity key for this request"
|
||||
/>
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="submit" className="button-dark text-sm" disabled={working || !requestKeyInput}>
|
||||
Confirm Request
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" onClick={() => setRequestFlowStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const COOKIE_KEY_REGEX = /^cu_[a-f0-9]{32}$/;
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
|
||||
export const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
|
||||
export const innerFlowClass = 'mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center';
|
||||
|
||||
export function maskKey(value) {
|
||||
const key = String(value || '').trim();
|
||||
if (!key) return '';
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
Reference in New Issue
Block a user