Files
maestro/ui/src/components/userfolder/AddBrowserSessionDialog.tsx
T
oss-sync 29ccaf1e92
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

174 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import {
createBrowserSessionProfile, startBrowserSessionLogin,
saveBrowserSession, cancelBrowserSession,
type BrowserSessionProfile,
} from '../../api';
import { usePictureInPicture } from '../../lib/usePictureInPicture.js';
import { PipButton } from '../browser/PipButton.js';
type Phase = 'form' | 'logging-in' | 'saving' | 'done' | 'error';
interface Props {
existingProfile?: BrowserSessionProfile | null;
onClose: () => void;
/** When set, the profile is created under this space (`?spaceId=…`) and the
* space-scoped react-query key is invalidated on save. Omit for personal. */
spaceId?: string;
}
export function AddBrowserSessionDialog({ existingProfile, onClose, spaceId }: Props) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('form');
const [label, setLabel] = useState(existingProfile?.label ?? '');
const [startUrl, setStartUrl] = useState(existingProfile?.startUrl ?? '');
const [loggedInSelector, setLoggedInSelector] = useState(existingProfile?.loggedInSelector ?? '');
const [loginUrl, setLoginUrl] = useState(existingProfile?.loginUrlPatterns?.[0] ?? '');
const [profileId, setProfileId] = useState<number | null>(existingProfile?.id ?? null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [novncPath, setNovncPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const pip = usePictureInPicture(novncPath, label ? t('browserSessions.dialog.novncTitle', { label }) : t('browserSessions.dialog.novncTitleNoLabel'));
async function startLogin() {
setError(null);
try {
let pid = profileId;
if (!pid) {
const created = await createBrowserSessionProfile({
label,
startUrl,
matchPatterns: [],
storageOrigins: [new URL(startUrl).origin],
loggedInSelector: loggedInSelector || undefined,
loginUrlPatterns: loginUrl ? [loginUrl] : [],
}, spaceId);
pid = created.id;
setProfileId(pid);
}
const r = await startBrowserSessionLogin(pid);
setSessionId(r.sessionId);
setNovncPath(r.novncPath);
setPhase('logging-in');
} catch (e) {
setError((e as Error).message);
setPhase('error');
}
}
async function saveNow() {
if (!profileId || !sessionId) return;
setPhase('saving');
try {
await saveBrowserSession(profileId, sessionId);
qc.invalidateQueries({ queryKey: ['browser-session-profiles'] });
if (spaceId) qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] });
setPhase('done');
setTimeout(onClose, 800);
} catch (e) {
setError((e as Error).message);
setPhase('error');
}
}
async function cancel() {
if (profileId && sessionId) await cancelBrowserSession(profileId, sessionId).catch(() => {});
onClose();
}
// The remote noVNC content is rendered at the Xvfb native resolution
// (1280x720, see src/engine/browser-session.ts). At the form-phase 640px
// dialog width the iframe scales down to ~50%, which feels cramped while
// the user is actually logging in. Expand the dialog to ~1320x860 once we
// enter the login phase so the iframe can show 1:1.
const inLogin = phase === 'logging-in';
const dialogSize = inLogin
? 'w-[1320px] h-[860px] max-w-[95vw] max-h-[95vh]'
: 'w-[640px] max-w-[95vw] max-h-[90vh]';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className={`bg-surface rounded-lg shadow-xl ${dialogSize} overflow-hidden flex flex-col`}>
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">
{existingProfile ? t('browserSessions.dialog.reLoginTitle', { label: existingProfile.label }) : t('browserSessions.dialog.addTitle')}
</h3>
<button onClick={cancel} className="text-slate-400 hover:text-slate-700 text-lg leading-none">×</button>
</div>
{phase === 'form' && (
<div className="p-4 space-y-3">
<div>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.label')}</label>
<input value={label} onChange={e => setLabel(e.target.value)}
disabled={!!existingProfile}
placeholder="My Twitter"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md disabled:bg-slate-50 disabled:text-slate-500" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.startUrl')}</label>
<input value={startUrl} onChange={e => setStartUrl(e.target.value)}
placeholder="https://twitter.com/home"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.loggedInSelector')}</label>
<input value={loggedInSelector} onChange={e => setLoggedInSelector(e.target.value)}
placeholder='[data-testid="primaryColumn"]'
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.loginUrlPattern')}</label>
<input value={loginUrl} onChange={e => setLoginUrl(e.target.value)}
placeholder="https://twitter.com/i/flow/login**"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
{error && <div className="text-xs text-rose-600">{error}</div>}
<div className="flex justify-end gap-2 pt-2">
<button onClick={cancel} className="text-xs px-3 py-1.5 rounded-md hover:bg-surface">{t('browserSessions.dialog.cancel')}</button>
<button disabled={!label || !startUrl} onClick={startLogin}
className="text-xs px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:bg-slate-300">
{t('browserSessions.dialog.openLoginWindow')}
</button>
</div>
</div>
)}
{phase === 'logging-in' && novncPath && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-[420px] bg-black">
{pip.isOpen ? (
<div className="w-full h-full flex items-center justify-center text-xs text-slate-300">
{t('browserSessions.dialog.pipShown')}
</div>
) : (
<iframe src={novncPath} title="login" className="w-full h-full" allow="clipboard-read; clipboard-write" />
)}
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-between text-xs">
<span className="text-slate-600">{t('browserSessions.dialog.loginPrompt')}</span>
<div className="flex gap-2 items-center">
<PipButton pip={pip} />
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface">{t('browserSessions.dialog.cancel')}</button>
<button onClick={saveNow} className="px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep">{t('browserSessions.dialog.save')}</button>
</div>
</div>
</div>
)}
{phase === 'saving' && <div className="p-6 text-center text-xs text-slate-500">{t('browserSessions.dialog.savingState')}</div>}
{phase === 'done' && <div className="p-6 text-center text-xs text-emerald-600">{t('browserSessions.dialog.doneState')}</div>}
{phase === 'error' && (
<div className="p-6 space-y-3 text-center">
<div className="text-xs text-rose-600">{error}</div>
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface text-xs">{t('browserSessions.dialog.close')}</button>
</div>
)}
</div>
</div>
);
}