2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2025-12-15 14:28:39 +02:00
|
|
|
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-02-05 01:59:49 +02:00
|
|
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
|
|
|
|
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
2026-02-05 01:59:49 +02:00
|
|
|
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
|
|
|
|
|
|
|
|
|
const fetchSessionStatus = async (): Promise<Response> => {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] Checking session status...');
|
|
|
|
|
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
2025-12-07 19:32:53 +02:00
|
|
|
method: 'GET',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] Session status response:', response.status, response.statusText);
|
|
|
|
|
return response;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const submitPassword = async (password: string): Promise<Response> => {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] Submitting password...');
|
|
|
|
|
const response = await fetch(STATUS_CHECK_ENDPOINT, {
|
2025-12-07 19:32:53 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ password }),
|
|
|
|
|
});
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] Password submit response:', response.status, response.statusText);
|
|
|
|
|
return response;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
|
|
|
<div
|
|
|
|
|
className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground"
|
|
|
|
|
style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="pointer-events-none absolute inset-0 opacity-55"
|
|
|
|
|
style={{
|
|
|
|
|
background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className="pointer-events-none absolute inset-0"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'var(--surface-subtle)',
|
|
|
|
|
opacity: 0.22,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing workspace…' }) => (
|
|
|
|
|
<AuthShell>
|
|
|
|
|
<div className="w-full max-w-sm rounded-3xl border border-border/40 bg-card/90 px-6 py-5 text-center shadow-none backdrop-blur">
|
|
|
|
|
<p className="typography-ui-label text-muted-foreground">{message}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</AuthShell>
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-03 00:06:38 +08:00
|
|
|
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
|
|
|
|
const isRateLimit = errorType === 'rate-limit';
|
|
|
|
|
const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AuthShell>
|
|
|
|
|
<div className="flex flex-col items-center gap-6 text-center">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h1 className="typography-ui-header font-semibold text-destructive">
|
|
|
|
|
{isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="typography-meta text-muted-foreground max-w-xs">
|
|
|
|
|
{isRateLimit
|
|
|
|
|
? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
|
|
|
|
|
: "We couldn't verify the UI session. Check that the service is running and try again."}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
|
|
|
|
|
Retry
|
|
|
|
|
</Button>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-02-03 00:06:38 +08:00
|
|
|
</AuthShell>
|
|
|
|
|
);
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
interface SessionAuthGateProps {
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 00:06:38 +08:00
|
|
|
type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
|
|
|
|
|
|
|
|
|
|
interface ErrorScreenProps {
|
|
|
|
|
onRetry: () => void;
|
|
|
|
|
errorType?: 'network' | 'rate-limit';
|
|
|
|
|
retryAfter?: number;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
2025-12-13 16:34:17 +02:00
|
|
|
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
2026-02-05 01:59:49 +02:00
|
|
|
const skipAuth = vscodeRuntime;
|
|
|
|
|
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
|
2025-12-13 16:34:17 +02:00
|
|
|
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
|
2025-12-07 19:32:53 +02:00
|
|
|
const [password, setPassword] = React.useState('');
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
|
|
|
const [errorMessage, setErrorMessage] = React.useState('');
|
2026-02-03 00:06:38 +08:00
|
|
|
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
2026-02-28 04:21:46 +02:00
|
|
|
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
2025-12-13 16:34:17 +02:00
|
|
|
const hasResyncedRef = React.useRef(skipAuth);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const checkStatus = React.useCallback(async () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (skipAuth) {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] VSCode runtime, skipping auth');
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:40:28 +08:00
|
|
|
// 检查 cookie 是否存在
|
|
|
|
|
const cookies = document.cookie;
|
|
|
|
|
const hasAccessToken = cookies.includes('oc_ui_session=');
|
|
|
|
|
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
|
|
|
|
console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
|
|
|
|
console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0]));
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetchSessionStatus();
|
2026-02-26 01:40:28 +08:00
|
|
|
const responseText = await response.text();
|
|
|
|
|
console.log('[Frontend Auth] Raw response:', response.status, responseText);
|
|
|
|
|
|
2026-02-28 04:21:46 +02:00
|
|
|
if (response.ok) {
|
|
|
|
|
console.log('[Frontend Auth] Session is authenticated');
|
|
|
|
|
setState('authenticated');
|
|
|
|
|
setIsTunnelLocked(false);
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
setRetryAfter(undefined);
|
|
|
|
|
return;
|
2026-02-26 01:40:28 +08:00
|
|
|
}
|
2026-02-28 04:21:46 +02:00
|
|
|
if (response.status === 401) {
|
|
|
|
|
let data: { tunnelLocked?: boolean; debug?: { hasRefreshToken: boolean; message: string } } = {};
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(responseText);
|
|
|
|
|
} catch {
|
|
|
|
|
data = {};
|
|
|
|
|
}
|
2026-02-26 01:40:28 +08:00
|
|
|
console.warn('[Frontend Auth] Session is locked (401)', data);
|
2026-02-28 04:21:46 +02:00
|
|
|
if (data.debug) {
|
|
|
|
|
console.warn('[Frontend Auth] Debug info:', data.debug);
|
|
|
|
|
}
|
|
|
|
|
setIsTunnelLocked(data.tunnelLocked === true);
|
|
|
|
|
setState('locked');
|
|
|
|
|
setRetryAfter(undefined);
|
|
|
|
|
return;
|
2026-02-26 01:40:28 +08:00
|
|
|
}
|
2026-02-03 00:06:38 +08:00
|
|
|
if (response.status === 429) {
|
2026-02-26 01:40:28 +08:00
|
|
|
let data: { retryAfter?: number } = {};
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(responseText);
|
|
|
|
|
} catch {
|
|
|
|
|
data = {};
|
|
|
|
|
}
|
2026-02-03 00:06:38 +08:00
|
|
|
setRetryAfter(data.retryAfter);
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2026-02-03 00:06:38 +08:00
|
|
|
setState('rate-limited');
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-26 01:40:28 +08:00
|
|
|
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('error');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to check session status:', error);
|
|
|
|
|
setState('error');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
}, [skipAuth]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (skipAuth) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void checkStatus();
|
2025-12-13 16:34:17 +02:00
|
|
|
}, [checkStatus, skipAuth]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!skipAuth && state === 'locked') {
|
2025-12-07 19:32:53 +02:00
|
|
|
hasResyncedRef.current = false;
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
}, [skipAuth, state]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (state === 'locked' && passwordInputRef.current) {
|
|
|
|
|
passwordInputRef.current.focus();
|
|
|
|
|
passwordInputRef.current.select();
|
|
|
|
|
}
|
|
|
|
|
}, [state]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (skipAuth) {
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (state === 'authenticated' && !hasResyncedRef.current) {
|
|
|
|
|
hasResyncedRef.current = true;
|
|
|
|
|
void (async () => {
|
|
|
|
|
await syncDesktopSettings();
|
|
|
|
|
await initializeAppearancePreferences();
|
|
|
|
|
await applyPersistedDirectoryPreferences();
|
|
|
|
|
})();
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
}, [skipAuth, state]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
2026-02-28 04:21:46 +02:00
|
|
|
if (isTunnelLocked) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
if (!password || isSubmitting) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await submitPassword(password);
|
|
|
|
|
if (response.ok) {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.log('[Frontend Auth] Login successful');
|
|
|
|
|
// 检查登录后 cookie 是否被设置
|
|
|
|
|
const cookies = document.cookie;
|
|
|
|
|
const hasAccessToken = cookies.includes('oc_ui_session=');
|
|
|
|
|
const hasRefreshToken = cookies.includes('oc_ui_refresh=');
|
|
|
|
|
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
|
|
|
|
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
2025-12-07 19:32:53 +02:00
|
|
|
setPassword('');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status === 401) {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.warn('[Frontend Auth] Login failed: Invalid password');
|
2025-12-07 19:32:53 +02:00
|
|
|
setErrorMessage('Incorrect password. Try again.');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('locked');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 00:06:38 +08:00
|
|
|
if (response.status === 429) {
|
2026-02-26 01:40:28 +08:00
|
|
|
console.warn('[Frontend Auth] Login failed: Rate limited');
|
2026-02-03 00:06:38 +08:00
|
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
|
setRetryAfter(data.retryAfter);
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2026-02-03 00:06:38 +08:00
|
|
|
setState('rate-limited');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 01:40:28 +08:00
|
|
|
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
2025-12-07 19:32:53 +02:00
|
|
|
setErrorMessage('Unexpected response from server.');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('error');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to submit UI password:', error);
|
|
|
|
|
setErrorMessage('Network error. Check connection and retry.');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('error');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state === 'pending') {
|
|
|
|
|
return <LoadingScreen />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state === 'error') {
|
2026-02-03 00:06:38 +08:00
|
|
|
return <ErrorScreen onRetry={() => void checkStatus()} errorType="network" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state === 'rate-limited') {
|
|
|
|
|
return <ErrorScreen onRetry={() => void checkStatus()} errorType="rate-limit" retryAfter={retryAfter} />;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state === 'locked') {
|
|
|
|
|
return (
|
|
|
|
|
<AuthShell>
|
2025-12-15 14:28:39 +02:00
|
|
|
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
|
|
|
|
|
<div className="flex flex-col items-center gap-1 text-center">
|
|
|
|
|
<h1 className="text-xl font-semibold text-foreground">
|
2026-02-28 04:21:46 +02:00
|
|
|
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
|
2025-12-15 14:28:39 +02:00
|
|
|
</h1>
|
|
|
|
|
<p className="typography-meta text-muted-foreground">
|
2026-02-28 04:21:46 +02:00
|
|
|
{isTunnelLocked
|
|
|
|
|
? 'Open this tunnel using the one-time connect link from the desktop app.'
|
|
|
|
|
: 'This session is password-protected.'}
|
2025-12-15 14:28:39 +02:00
|
|
|
</p>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 04:21:46 +02:00
|
|
|
{!isTunnelLocked && (
|
|
|
|
|
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
|
|
|
|
<Input
|
|
|
|
|
id="openchamber-ui-password"
|
|
|
|
|
ref={passwordInputRef}
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
placeholder="Enter password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
setPassword(event.target.value);
|
|
|
|
|
if (errorMessage) {
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="pl-10"
|
|
|
|
|
aria-invalid={Boolean(errorMessage) || undefined}
|
|
|
|
|
aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
size="icon"
|
|
|
|
|
disabled={!password || isSubmitting}
|
|
|
|
|
aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
|
|
|
|
|
>
|
|
|
|
|
{isSubmitting ? (
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiLockUnlockLine className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
2025-12-15 14:28:39 +02:00
|
|
|
</div>
|
2026-02-28 04:21:46 +02:00
|
|
|
{errorMessage && (
|
|
|
|
|
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
|
|
|
|
{errorMessage}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-02-05 01:59:49 +02:00
|
|
|
|
|
|
|
|
{showHostSwitcher && (
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
<DesktopHostSwitcherInline />
|
|
|
|
|
<p className="mt-1 text-center typography-micro text-muted-foreground">
|
|
|
|
|
Use Local if remote is unreachable.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
</AuthShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <>{children}</>;
|
|
|
|
|
};
|