mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
audioo
This commit is contained in:
@@ -23,10 +23,12 @@ audioForward:
|
|||||||
enabled: true
|
enabled: true
|
||||||
# Optional override; defaults to "ffmpeg"
|
# Optional override; defaults to "ffmpeg"
|
||||||
ffmpegBin: "ffmpeg"
|
ffmpegBin: "ffmpeg"
|
||||||
# Optional test file path for admin-only first-pass playback
|
|
||||||
testAudioPath: "server/assets/test-audio.mp3"
|
|
||||||
# Optional stream suffix for fallback URL generation
|
# Optional stream suffix for fallback URL generation
|
||||||
streamSuffix: "-fwd"
|
streamSuffix: "-fwd"
|
||||||
|
# Max upload payload accepted via VIP forward upload
|
||||||
|
maxUploadBytes: 8388608
|
||||||
|
# Maximum playback duration per upload (seconds)
|
||||||
|
maxUploadSeconds: 45
|
||||||
|
|
||||||
audioLevels:
|
audioLevels:
|
||||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C4govqUc.js"></script>
|
<script type="module" crossorigin src="/assets/index-D3RnimBF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const logger = require('../globals/logger').child('audioForwardService');
|
|||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { isAdmin } = require('./roleService');
|
const { isAdmin } = require('./roleService');
|
||||||
const { audioLevelsEvents } = require('./audioLevelsService');
|
const { isVerified } = require('./verificationService');
|
||||||
|
|
||||||
const audioForwardEvents = new EventEmitter();
|
const audioForwardEvents = new EventEmitter();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -16,15 +16,13 @@ const serviceEnabled = audioForwardConfig.enabled !== false;
|
|||||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||||
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
|
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' ? audioForwardConfig.streamSuffix : '-fwd';
|
||||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||||
const defaultAudioPath = path.join(__dirname, '..', '..', 'assets', 'test-audio.mp3');
|
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||||
const configuredAudioPath = audioForwardConfig.testAudioPath;
|
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||||
const testAudioPath =
|
: 8 * 1024 * 1024;
|
||||||
configuredAudioPath && path.isAbsolute(configuredAudioPath)
|
const maxUploadSeconds = Number.isFinite(audioForwardConfig.maxUploadSeconds)
|
||||||
? configuredAudioPath
|
? Math.max(1, Math.floor(audioForwardConfig.maxUploadSeconds))
|
||||||
: configuredAudioPath
|
: 45;
|
||||||
? path.resolve(repoRoot, configuredAudioPath)
|
|
||||||
: defaultAudioPath;
|
|
||||||
|
|
||||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||||
const workers = new Map(); // roverId -> worker
|
const workers = new Map(); // roverId -> worker
|
||||||
@@ -62,12 +60,43 @@ function ensureServiceEnabled() {
|
|||||||
|
|
||||||
function ensureRuntimeDir() {
|
function ensureRuntimeDir() {
|
||||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRoverId(roverId) {
|
function sanitizeRoverId(roverId) {
|
||||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileStem(name) {
|
||||||
|
return String(name || 'upload')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromUpload(name, mime) {
|
||||||
|
const lowerName = String(name || '').toLowerCase();
|
||||||
|
const lowerMime = String(mime || '').toLowerCase();
|
||||||
|
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||||
|
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||||
|
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||||
|
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVipVerified(socket) {
|
||||||
|
if (!isVerified(socket)) {
|
||||||
|
throw new Error('VIP verification required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAudioForwardPermission(socket, roverId) {
|
||||||
|
ensureVipVerified(socket);
|
||||||
|
if (!isAdmin(socket) && !roverManager.canDrive(roverId, socket)) {
|
||||||
|
throw new Error('Not your turn or no control');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureFifo(fifoPath) {
|
function ensureFifo(fifoPath) {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(fifoPath);
|
const stat = fs.statSync(fifoPath);
|
||||||
@@ -208,7 +237,7 @@ function buildSilenceWriterArgs() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClipWriterArgs(filePath) {
|
function buildUploadWriterArgs(filePath) {
|
||||||
return [
|
return [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel',
|
'-loglevel',
|
||||||
@@ -219,6 +248,8 @@ function buildClipWriterArgs(filePath) {
|
|||||||
'-vn',
|
'-vn',
|
||||||
'-af',
|
'-af',
|
||||||
'aresample=16000',
|
'aresample=16000',
|
||||||
|
'-t',
|
||||||
|
String(maxUploadSeconds),
|
||||||
'-f',
|
'-f',
|
||||||
's16le',
|
's16le',
|
||||||
'-ac',
|
'-ac',
|
||||||
@@ -251,6 +282,16 @@ function attachWriterPipe(worker, proc) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupUploadFile(worker) {
|
||||||
|
if (!worker?.activeUploadPath) return;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(worker.activeUploadPath);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
worker.activeUploadPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
function stopContentWriter(worker) {
|
function stopContentWriter(worker) {
|
||||||
if (!worker?.contentProc) return;
|
if (!worker?.contentProc) return;
|
||||||
stopProc(worker.contentProc);
|
stopProc(worker.contentProc);
|
||||||
@@ -263,6 +304,7 @@ function startSilenceWriter(roverId) {
|
|||||||
if (!worker || worker.stopping) return;
|
if (!worker || worker.stopping) return;
|
||||||
|
|
||||||
stopContentWriter(worker);
|
stopContentWriter(worker);
|
||||||
|
cleanupUploadFile(worker);
|
||||||
const proc = spawnProcess(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
const proc = spawnProcess(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||||
worker.contentProc = proc;
|
worker.contentProc = proc;
|
||||||
worker.contentKind = 'silence';
|
worker.contentKind = 'silence';
|
||||||
@@ -287,18 +329,20 @@ function startSilenceWriter(roverId) {
|
|||||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function startClipWriter(roverId, filePath) {
|
function startUploadWriter(roverId, filePath) {
|
||||||
const worker = workers.get(roverId);
|
const worker = workers.get(roverId);
|
||||||
if (!worker || worker.stopping) return;
|
if (!worker || worker.stopping) return;
|
||||||
|
|
||||||
stopContentWriter(worker);
|
stopContentWriter(worker);
|
||||||
const proc = spawnProcess(roverId, 'clip-writer', buildClipWriterArgs(filePath), { captureStdout: true });
|
cleanupUploadFile(worker);
|
||||||
|
worker.activeUploadPath = filePath;
|
||||||
|
const proc = spawnProcess(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||||
worker.contentProc = proc;
|
worker.contentProc = proc;
|
||||||
worker.contentKind = 'clip';
|
worker.contentKind = 'upload';
|
||||||
const seq = ++worker.writerSeq;
|
const seq = ++worker.writerSeq;
|
||||||
attachWriterPipe(worker, proc);
|
attachWriterPipe(worker, proc);
|
||||||
|
|
||||||
setState(roverId, { state: 'playing', source: 'clip', error: null, startedAt: Date.now() });
|
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||||
|
|
||||||
proc.on('exit', (code, signal) => {
|
proc.on('exit', (code, signal) => {
|
||||||
const current = workers.get(roverId);
|
const current = workers.get(roverId);
|
||||||
@@ -308,7 +352,7 @@ function startClipWriter(roverId, filePath) {
|
|||||||
current.contentKind = null;
|
current.contentKind = null;
|
||||||
|
|
||||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||||
setState(roverId, { state: 'error', source: 'clip', error: `clip writer exited code=${code} signal=${signal || 'none'}` });
|
setState(roverId, { state: 'error', source: 'upload', error: `upload writer exited code=${code} signal=${signal || 'none'}` });
|
||||||
}
|
}
|
||||||
startSilenceWriter(roverId);
|
startSilenceWriter(roverId);
|
||||||
});
|
});
|
||||||
@@ -344,6 +388,7 @@ function ensureWorker(roverId) {
|
|||||||
publisherProc: publisher,
|
publisherProc: publisher,
|
||||||
contentProc: null,
|
contentProc: null,
|
||||||
contentKind: null,
|
contentKind: null,
|
||||||
|
activeUploadPath: null,
|
||||||
writerSeq: 0,
|
writerSeq: 0,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
};
|
};
|
||||||
@@ -366,12 +411,31 @@ function ensureWorker(roverId) {
|
|||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
function playTestAudio(roverId) {
|
function writeUploadFile(roverId, payload = {}) {
|
||||||
if (!fs.existsSync(testAudioPath)) {
|
const { name, mime, dataBase64 } = payload || {};
|
||||||
throw new Error(`Test audio missing: ${testAudioPath}`);
|
const ext = extFromUpload(name, mime);
|
||||||
|
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error('Upload payload missing');
|
||||||
}
|
}
|
||||||
|
const bytes = Buffer.from(encoded, 'base64');
|
||||||
|
if (!bytes.length) {
|
||||||
|
throw new Error('Upload decode failed');
|
||||||
|
}
|
||||||
|
if (bytes.length > maxUploadBytes) {
|
||||||
|
throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||||
|
}
|
||||||
|
ensureRuntimeDir();
|
||||||
|
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||||
|
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||||
|
fs.writeFileSync(filePath, bytes);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playUploadedAudio(roverId, payload = {}) {
|
||||||
|
const uploadPath = writeUploadFile(roverId, payload);
|
||||||
ensureWorker(roverId);
|
ensureWorker(roverId);
|
||||||
startClipWriter(roverId, testAudioPath);
|
startUploadWriter(roverId, uploadPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPlayback(roverId) {
|
function stopPlayback(roverId) {
|
||||||
@@ -385,6 +449,7 @@ function stopWorker(roverId) {
|
|||||||
worker.stopping = true;
|
worker.stopping = true;
|
||||||
|
|
||||||
stopContentWriter(worker);
|
stopContentWriter(worker);
|
||||||
|
cleanupUploadFile(worker);
|
||||||
stopProc(worker.publisherProc);
|
stopProc(worker.publisherProc);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -417,30 +482,23 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
audioLevelsEvents.on('change', () => {
|
|
||||||
// Forward gain now applies at rover ALSA mixer (ForwardMaster), so no writer restart is needed.
|
|
||||||
});
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
|
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
if (!isAdmin(socket)) {
|
const roverId = String(payload?.roverId || '').trim();
|
||||||
throw new Error('Not authorized');
|
ensureAudioForwardPermission(socket, roverId);
|
||||||
}
|
|
||||||
const normalized = String(roverId || '').trim();
|
const normalized = String(roverId || '').trim();
|
||||||
playTestAudio(normalized);
|
playUploadedAudio(normalized, payload);
|
||||||
cb({ success: true, roverId: normalized });
|
cb({ success: true, roverId: normalized });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('audio:testStop', ({ roverId } = {}, cb = () => {}) => {
|
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
if (!isAdmin(socket)) {
|
|
||||||
throw new Error('Not authorized');
|
|
||||||
}
|
|
||||||
const normalized = String(roverId || '').trim();
|
const normalized = String(roverId || '').trim();
|
||||||
|
ensureAudioForwardPermission(socket, normalized);
|
||||||
stopPlayback(normalized);
|
stopPlayback(normalized);
|
||||||
cb({ success: true, roverId: normalized });
|
cb({ success: true, roverId: normalized });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -452,6 +510,6 @@ io.on('connection', (socket) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
getAudioForwardState,
|
getAudioForwardState,
|
||||||
audioForwardEvents,
|
audioForwardEvents,
|
||||||
playTestAudio,
|
playUploadedAudio,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export default function AdminPanel() {
|
|||||||
setAdminReason,
|
setAdminReason,
|
||||||
rebootRover,
|
rebootRover,
|
||||||
rebootServer,
|
rebootServer,
|
||||||
playTestAudio,
|
|
||||||
stopTestAudio,
|
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
llmControl,
|
llmControl,
|
||||||
adminLogs,
|
adminLogs,
|
||||||
@@ -30,7 +28,6 @@ export default function AdminPanel() {
|
|||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
const [rebootStates, setRebootStates] = useState({});
|
const [rebootStates, setRebootStates] = useState({});
|
||||||
const [audioStates, setAudioStates] = useState({});
|
|
||||||
const [serverRebooting, setServerRebooting] = useState(false);
|
const [serverRebooting, setServerRebooting] = useState(false);
|
||||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||||
const health = session?.health || null;
|
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 handleServerReboot = async () => {
|
||||||
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -364,22 +337,6 @@ export default function AdminPanel() {
|
|||||||
>
|
>
|
||||||
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
|
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
|
||||||
</button>
|
</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">
|
<span className="surface-muted">
|
||||||
audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
|
audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import NicknameForm from './NicknameForm.jsx';
|
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
||||||
|
import VipAudioForwardingCard from './vip/VipAudioForwardingCard.jsx';
|
||||||
function maskKey(value) {
|
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||||
const key = String(value || '').trim();
|
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||||
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}$/;
|
|
||||||
|
|
||||||
export default function VipPanel() {
|
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: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
|
|
||||||
@@ -21,24 +15,15 @@ export default function VipPanel() {
|
|||||||
const nickname = (profile?.nickname || '').trim();
|
const nickname = (profile?.nickname || '').trim();
|
||||||
const isVerified = Boolean(session?.isVerified);
|
const isVerified = Boolean(session?.isVerified);
|
||||||
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
||||||
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
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 [message, setMessage] = useState('');
|
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 applyIdentityKey = async (nextRaw) => {
|
||||||
const next = String(nextRaw || '').trim().toLowerCase();
|
const next = String(nextRaw || '').trim().toLowerCase();
|
||||||
if (!next) {
|
if (!next) {
|
||||||
throw new Error('Identity key required.');
|
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.');
|
throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.');
|
||||||
}
|
}
|
||||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
|
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
|
||||||
@@ -46,277 +31,32 @@ export default function VipPanel() {
|
|||||||
return next;
|
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 (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<section className="panel-section space-y-0.5 text-base">
|
||||||
{isVerified ? (
|
{isVerified ? (
|
||||||
<section className="surface text-sm text-slate-200">
|
<VipAudioForwardingCard
|
||||||
VIP controls will show here... when there are some...
|
roster={roster}
|
||||||
</section>
|
audioForwardByRover={session?.audioForward || {}}
|
||||||
) : null}
|
playUploadedAudio={playUploadedAudio}
|
||||||
|
stopUploadedAudio={stopUploadedAudio}
|
||||||
|
onMessage={setMessage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VipVerificationCard
|
||||||
|
pendingRequestId={pendingRequestId}
|
||||||
|
currentStoredKey={currentStoredKey}
|
||||||
|
nickname={nickname}
|
||||||
|
requestVerification={requestVerification}
|
||||||
|
applyIdentityKey={applyIdentityKey}
|
||||||
|
onMessage={setMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isVerified ? (
|
<VipIdentityCard
|
||||||
pendingRequestId ? (
|
currentStoredKey={currentStoredKey}
|
||||||
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
|
applyIdentityKey={applyIdentityKey}
|
||||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
onMessage={setMessage}
|
||||||
</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>
|
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<div className={flowWrapClass}>
|
<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);
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ const SessionContext = createContext({
|
|||||||
setAdminReason: async () => {},
|
setAdminReason: async () => {},
|
||||||
rebootRover: async () => {},
|
rebootRover: async () => {},
|
||||||
rebootServer: async () => {},
|
rebootServer: async () => {},
|
||||||
playTestAudio: async () => {},
|
playUploadedAudio: async () => {},
|
||||||
stopTestAudio: async () => {},
|
stopUploadedAudio: async () => {},
|
||||||
setAudioLevels: async () => {},
|
setAudioLevels: async () => {},
|
||||||
llmControl: async () => {},
|
llmControl: async () => {},
|
||||||
});
|
});
|
||||||
@@ -144,8 +144,9 @@ export function SessionProvider({ children }) {
|
|||||||
rebootRover: (roverId) =>
|
rebootRover: (roverId) =>
|
||||||
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
||||||
rebootServer: () => emitWithAck('server:reboot'),
|
rebootServer: () => emitWithAck('server:reboot'),
|
||||||
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }),
|
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
|
||||||
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }),
|
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
|
||||||
|
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
|
||||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||||
llmControl: (action, controls = {}) =>
|
llmControl: (action, controls = {}) =>
|
||||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||||
|
|||||||
Reference in New Issue
Block a user