174 lines
8.2 KiB
TypeScript
174 lines
8.2 KiB
TypeScript
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>
|
||
);
|
||
}
|