2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-04-11 13:36:02 -06:00
|
|
|
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-04-11 13:36:02 -06:00
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { Input } from '@/components/ui/input';
|
2026-04-11 13:36:02 -06:00
|
|
|
import { toast } from '@/components/ui';
|
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';
|
2026-03-04 19:16:45 +02:00
|
|
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
|
|
|
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
|
|
|
|
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
2026-04-11 13:36:02 -06:00
|
|
|
import {
|
|
|
|
|
authenticateWithPasskey,
|
|
|
|
|
cancelPasskeyCeremony,
|
|
|
|
|
defaultPasskeyStatus,
|
|
|
|
|
fetchPasskeyStatus,
|
|
|
|
|
isPasskeyCeremonyAbort,
|
|
|
|
|
type PasskeyStatus,
|
|
|
|
|
registerCurrentDevicePasskey,
|
|
|
|
|
} from '@/lib/passkeys';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
2026-04-11 13:36:02 -06:00
|
|
|
const TRUST_DEVICE_STORAGE_KEY = 'openchamber.uiAuth.trustDevice';
|
2026-06-02 00:43:05 +03:00
|
|
|
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
|
|
|
|
|
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
|
|
|
|
|
|
|
|
|
|
const readLocalOrigin = (): string => {
|
|
|
|
|
if (typeof window === 'undefined') return '';
|
|
|
|
|
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
|
|
|
|
|
return typeof injected === 'string' ? injected.trim() : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sameOrigin = (left: string, right: string): boolean => {
|
|
|
|
|
const normalizedLeft = normalizeHostUrl(left);
|
|
|
|
|
const normalizedRight = normalizeHostUrl(right);
|
|
|
|
|
if (!normalizedLeft || !normalizedRight) return false;
|
|
|
|
|
try {
|
|
|
|
|
return new URL(normalizedLeft).origin === new URL(normalizedRight).origin;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const shouldIssueDesktopClientToken = (): boolean => {
|
|
|
|
|
return isDesktopShell();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isLocalDesktopRuntime = (): boolean => {
|
|
|
|
|
if (!isDesktopShell()) return false;
|
|
|
|
|
const apiBaseUrl = getRuntimeApiBaseUrl();
|
|
|
|
|
const localOrigin = readLocalOrigin();
|
|
|
|
|
return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => {
|
|
|
|
|
if (!isLocalDesktopRuntime()) return {};
|
|
|
|
|
return {
|
|
|
|
|
clientKind: LOCAL_DESKTOP_CLIENT_KIND,
|
|
|
|
|
dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY,
|
|
|
|
|
};
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const fetchSessionStatus = async (): Promise<Response> => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(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
|
|
|
return response;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-11 13:36:02 -06:00
|
|
|
const readStoredTrustDevice = (): boolean => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return window.localStorage.getItem(TRUST_DEVICE_STORAGE_KEY) === 'true';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const submitPassword = async (password: string, trustDevice: boolean): Promise<Response> => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const issueClientToken = shouldIssueDesktopClientToken();
|
|
|
|
|
const response = await runtimeFetch(STATUS_CHECK_ENDPOINT, {
|
2025-12-07 19:32:53 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
2026-06-02 00:43:05 +03:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
password,
|
|
|
|
|
trustDevice,
|
|
|
|
|
issueClientToken,
|
|
|
|
|
clientLabel: 'OpenChamber Desktop',
|
|
|
|
|
...desktopClientAuthMetadata(),
|
|
|
|
|
}),
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
2026-02-26 01:40:28 +08:00
|
|
|
return response;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const issueDesktopClientToken = async (): Promise<string> => {
|
|
|
|
|
if (!isDesktopShell()) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await runtimeFetch('/api/client-auth/clients', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ label: 'OpenChamber Desktop', ...desktopClientAuthMetadata() }),
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!response?.ok) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => null) as { token?: unknown } | null;
|
|
|
|
|
return typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<string> => {
|
|
|
|
|
if (!isDesktopShell() || typeof window === 'undefined') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
const invoke = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__?.core?.invoke;
|
|
|
|
|
if (typeof invoke !== 'function') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
const response = await invoke('desktop_remote_password_login', {
|
|
|
|
|
url: getRuntimeApiBaseUrl(),
|
|
|
|
|
password,
|
|
|
|
|
trustDevice,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (!response || typeof response !== 'object') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
const token = (response as { token?: unknown }).token;
|
|
|
|
|
return typeof token === 'string' ? token.trim() : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
|
|
|
|
|
if (!isDesktopShell() || !clientToken) return;
|
|
|
|
|
const cfg = await desktopHostsGet().catch(() => null);
|
|
|
|
|
if (!cfg) return;
|
|
|
|
|
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) {
|
|
|
|
|
await desktopHostsSet({
|
|
|
|
|
hosts: cfg.hosts,
|
|
|
|
|
defaultHostId: cfg.defaultHostId,
|
|
|
|
|
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
|
|
|
|
localClientToken: clientToken,
|
|
|
|
|
}).catch(() => undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let changed = false;
|
|
|
|
|
const hosts = cfg.hosts.map((host) => {
|
|
|
|
|
if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) {
|
|
|
|
|
return host;
|
|
|
|
|
}
|
|
|
|
|
if (host.clientToken === clientToken) {
|
|
|
|
|
return host;
|
|
|
|
|
}
|
|
|
|
|
changed = true;
|
|
|
|
|
return { ...host, clientToken };
|
|
|
|
|
});
|
|
|
|
|
if (!changed) return;
|
|
|
|
|
await desktopHostsSet({
|
|
|
|
|
hosts,
|
|
|
|
|
defaultHostId: cfg.defaultHostId,
|
|
|
|
|
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
|
|
|
|
}).catch(() => undefined);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
|
|
|
|
|
if (!clientToken) return;
|
|
|
|
|
const apiBaseUrl = getRuntimeApiBaseUrl();
|
|
|
|
|
await persistDesktopClientToken(apiBaseUrl, clientToken);
|
|
|
|
|
switchRuntimeEndpoint({ apiBaseUrl, clientToken, runtimeKey: getRuntimeKey() });
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
|
|
|
const titlebarDragStyle = React.useMemo<React.CSSProperties>(() => {
|
|
|
|
|
return {
|
|
|
|
|
height: 'var(--oc-wco-titlebar-height, 0px)',
|
|
|
|
|
right: 'var(--oc-wco-right-inset, 0px)',
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-07 19:32:53 +02:00
|
|
|
<div
|
2026-05-26 11:13:59 -04:00
|
|
|
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="app-region-drag fixed left-0 top-0 z-20" style={titlebarDragStyle} aria-hidden />
|
|
|
|
|
<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="app-region-no-drag relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-05-26 11:13:59 -04:00
|
|
|
);
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-04 19:16:45 +02:00
|
|
|
const LoadingScreen: React.FC = () => (
|
|
|
|
|
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
2026-04-03 16:44:31 +03:00
|
|
|
<OpenChamberLogo width={120} height={120} />
|
2026-03-04 19:16:45 +02:00
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
|
|
|
|
|
2026-02-03 00:06:38 +08:00
|
|
|
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-02-03 00:06:38 +08:00
|
|
|
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">
|
2026-04-26 14:03:39 +03:00
|
|
|
{isRateLimit ? t('sessionAuth.error.rateLimitTitle') : t('sessionAuth.error.networkTitle')}
|
2026-02-03 00:06:38 +08:00
|
|
|
</h1>
|
|
|
|
|
<p className="typography-meta text-muted-foreground max-w-xs">
|
|
|
|
|
{isRateLimit
|
2026-04-26 14:03:39 +03:00
|
|
|
? (minutes > 1
|
|
|
|
|
? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes })
|
|
|
|
|
: t('sessionAuth.error.rateLimitDescriptionSingle', { minutes }))
|
|
|
|
|
: t('sessionAuth.error.networkDescription')}
|
2026-02-03 00:06:38 +08:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('sessionAuth.error.retry')}
|
2026-02-03 00:06:38 +08:00
|
|
|
</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 }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
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);
|
2026-04-11 13:36:02 -06:00
|
|
|
const [passkeyStatus, setPasskeyStatus] = React.useState<PasskeyStatus>(defaultPasskeyStatus);
|
|
|
|
|
const [supportsPasskeys, setSupportsPasskeys] = React.useState(false);
|
|
|
|
|
const [isPasskeyBusy, setIsPasskeyBusy] = React.useState(false);
|
|
|
|
|
const [trustDevice, setTrustDevice] = React.useState<boolean>(() => readStoredTrustDevice());
|
|
|
|
|
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
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
|
|
|
|
2026-04-11 13:36:02 -06:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
|
|
|
|
|
}, [trustDevice]);
|
|
|
|
|
|
|
|
|
|
const refreshPasskeyStatus = React.useCallback(async () => {
|
|
|
|
|
if (skipAuth) {
|
|
|
|
|
return defaultPasskeyStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const nextStatus = await fetchPasskeyStatus();
|
|
|
|
|
setPasskeyStatus(nextStatus);
|
|
|
|
|
return nextStatus;
|
|
|
|
|
} catch {
|
|
|
|
|
setPasskeyStatus(defaultPasskeyStatus);
|
|
|
|
|
return defaultPasskeyStatus;
|
|
|
|
|
}
|
|
|
|
|
}, [skipAuth]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
if (skipAuth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setSupportsPasskeys(false);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setSupportsPasskeys(true);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setSupportsPasskeys(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [skipAuth]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const checkStatus = React.useCallback(async () => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (skipAuth) {
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
|
|
|
|
try {
|
2026-04-11 13:36:02 -06:00
|
|
|
const [response, latestPasskeyStatus] = await Promise.all([
|
|
|
|
|
fetchSessionStatus(),
|
|
|
|
|
refreshPasskeyStatus(),
|
|
|
|
|
]);
|
2026-02-26 01:40:28 +08:00
|
|
|
const responseText = await response.text();
|
|
|
|
|
|
2026-02-28 04:21:46 +02:00
|
|
|
if (response.ok) {
|
|
|
|
|
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 = {};
|
|
|
|
|
}
|
|
|
|
|
setIsTunnelLocked(data.tunnelLocked === true);
|
2026-04-11 13:36:02 -06:00
|
|
|
setPasskeyStatus(latestPasskeyStatus);
|
2026-02-28 04:21:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
2026-04-11 13:36:02 -06:00
|
|
|
}, [refreshPasskeyStatus, 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
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (skipAuth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return subscribeRuntimeEndpointChanged(() => {
|
|
|
|
|
setPassword('');
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
setRetryAfter(undefined);
|
|
|
|
|
setIsTunnelLocked(false);
|
|
|
|
|
setState('pending');
|
|
|
|
|
void checkStatus();
|
|
|
|
|
});
|
|
|
|
|
}, [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-04-11 13:36:02 -06:00
|
|
|
await handlePasswordUnlock(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const registerPasskeyForCurrentSession = React.useCallback(async () => {
|
|
|
|
|
setActivePasskeyAction('register');
|
|
|
|
|
setIsPasskeyBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
await registerCurrentDevicePasskey();
|
|
|
|
|
} finally {
|
|
|
|
|
setActivePasskeyAction(null);
|
|
|
|
|
setIsPasskeyBusy(false);
|
|
|
|
|
}
|
|
|
|
|
await refreshPasskeyStatus();
|
|
|
|
|
}, [refreshPasskeyStatus]);
|
|
|
|
|
|
|
|
|
|
const cancelActivePasskey = React.useCallback(() => {
|
|
|
|
|
cancelPasskeyCeremony();
|
|
|
|
|
setActivePasskeyAction(null);
|
|
|
|
|
setIsPasskeyBusy(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handlePasswordUnlock = React.useCallback(async (enrollPasskey: boolean) => {
|
2026-02-28 04:21:46 +02:00
|
|
|
if (isTunnelLocked) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
if (!password || isSubmitting) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 13:36:02 -06:00
|
|
|
if (isPasskeyBusy) {
|
|
|
|
|
cancelActivePasskey();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
setIsSubmitting(true);
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-11 13:36:02 -06:00
|
|
|
const response = await submitPassword(password, trustDevice);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (response.ok) {
|
2026-06-02 00:43:05 +03:00
|
|
|
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
|
|
|
|
const shouldUseClientToken = shouldIssueDesktopClientToken();
|
|
|
|
|
const clientToken = shouldUseClientToken
|
|
|
|
|
? (typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
|
|
|
|
? payload.clientToken.trim()
|
|
|
|
|
: await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken())
|
|
|
|
|
: '';
|
2025-12-07 19:32:53 +02:00
|
|
|
setPassword('');
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2026-06-02 00:43:05 +03:00
|
|
|
if (clientToken) {
|
|
|
|
|
await applyDesktopClientToken(clientToken);
|
|
|
|
|
}
|
2026-04-11 13:36:02 -06:00
|
|
|
if (enrollPasskey && supportsPasskeys) {
|
|
|
|
|
try {
|
|
|
|
|
await registerPasskeyForCurrentSession();
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('sessionAuth.toast.passkeyAdded'));
|
2026-04-11 13:36:02 -06:00
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isPasskeyCeremonyAbort(error)) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
|
2026-04-11 13:36:02 -06:00
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
|
2026-04-11 13:36:02 -06:00
|
|
|
toast.error(message);
|
|
|
|
|
}
|
|
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('authenticated');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status === 401) {
|
2026-04-26 14:03:39 +03:00
|
|
|
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
|
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) {
|
|
|
|
|
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-04-26 14:03:39 +03:00
|
|
|
setErrorMessage(t('sessionAuth.error.unexpectedResponse'));
|
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);
|
2026-04-26 14:03:39 +03:00
|
|
|
setErrorMessage(t('sessionAuth.error.networkRetry'));
|
2026-02-28 04:21:46 +02:00
|
|
|
setIsTunnelLocked(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
setState('error');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]);
|
2026-04-11 13:36:02 -06:00
|
|
|
|
|
|
|
|
const handlePasskeyUnlock = React.useCallback(async () => {
|
|
|
|
|
if (isSubmitting || !supportsPasskeys) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPasskeyBusy) {
|
|
|
|
|
cancelActivePasskey();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsPasskeyBusy(true);
|
|
|
|
|
setActivePasskeyAction('auth');
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-02 00:43:05 +03:00
|
|
|
const payload = await authenticateWithPasskey(trustDevice, {
|
|
|
|
|
issueClientToken: shouldIssueDesktopClientToken(),
|
|
|
|
|
clientLabel: 'OpenChamber Desktop',
|
|
|
|
|
...desktopClientAuthMetadata(),
|
|
|
|
|
}) as { clientToken?: unknown } | null;
|
|
|
|
|
const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
|
|
|
|
? payload.clientToken.trim()
|
|
|
|
|
: '';
|
|
|
|
|
if (clientToken) {
|
|
|
|
|
await applyDesktopClientToken(clientToken);
|
|
|
|
|
}
|
2026-04-11 13:36:02 -06:00
|
|
|
|
|
|
|
|
setPassword('');
|
|
|
|
|
setState('authenticated');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isPasskeyCeremonyAbort(error)) {
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
} else {
|
2026-04-26 14:03:39 +03:00
|
|
|
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySignInCanceled');
|
2026-04-11 13:36:02 -06:00
|
|
|
setErrorMessage(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setActivePasskeyAction(null);
|
|
|
|
|
setIsPasskeyBusy(false);
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]);
|
2026-04-11 13:36:02 -06:00
|
|
|
|
|
|
|
|
const handlePasskeySetupOnly = React.useCallback(async () => {
|
|
|
|
|
if (isSubmitting || isTunnelLocked || !supportsPasskeys) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPasskeyBusy) {
|
|
|
|
|
cancelActivePasskey();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state !== 'authenticated') {
|
|
|
|
|
if (!password) {
|
2026-04-26 14:03:39 +03:00
|
|
|
setErrorMessage(t('sessionAuth.error.enterPasswordForPasskey'));
|
2026-04-11 13:36:02 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await handlePasswordUnlock(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setErrorMessage('');
|
|
|
|
|
try {
|
|
|
|
|
await registerPasskeyForCurrentSession();
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('sessionAuth.toast.passkeyAdded'));
|
2026-04-11 13:36:02 -06:00
|
|
|
} catch (error) {
|
|
|
|
|
if (isPasskeyCeremonyAbort(error)) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.message(t('sessionAuth.toast.passkeySetupCanceled'));
|
2026-04-11 13:36:02 -06:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed');
|
2026-04-11 13:36:02 -06:00
|
|
|
toast.error(message);
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
}, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys, t]);
|
2026-04-11 13:36:02 -06:00
|
|
|
|
|
|
|
|
const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled;
|
|
|
|
|
const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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-04-26 14:03:39 +03:00
|
|
|
{isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}
|
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
|
2026-04-26 14:03:39 +03:00
|
|
|
? t('sessionAuth.locked.tunnelDescription')
|
|
|
|
|
: t('sessionAuth.locked.passwordDescription')}
|
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 && (
|
2026-04-24 09:33:02 +03:00
|
|
|
<form onSubmit={handleSubmit} className="w-full space-y-2">
|
2026-04-11 13:36:02 -06:00
|
|
|
{canUsePasskey && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="w-full"
|
|
|
|
|
onClick={() => void handlePasskeyUnlock()}
|
|
|
|
|
disabled={isSubmitting || (isPasskeyBusy && activePasskeyAction !== 'auth')}
|
|
|
|
|
>
|
|
|
|
|
{isPasskeyBusy ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
2026-04-11 13:36:02 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="lock-unlock" className="h-4 w-4" />
|
2026-04-11 13:36:02 -06:00
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{isPasskeyBusy && activePasskeyAction === 'auth'
|
|
|
|
|
? t('sessionAuth.actions.cancelPasskey')
|
|
|
|
|
: t('sessionAuth.actions.usePasskey')}</span>
|
2026-04-11 13:36:02 -06:00
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-02-28 04:21:46 +02:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex-1">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="lock" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
|
2026-02-28 04:21:46 +02:00
|
|
|
<Input
|
|
|
|
|
id="openchamber-ui-password"
|
|
|
|
|
ref={passwordInputRef}
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="current-password"
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('sessionAuth.password.placeholder')}
|
2026-02-28 04:21:46 +02:00
|
|
|
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}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')}
|
2026-02-28 04:21:46 +02:00
|
|
|
>
|
|
|
|
|
{isSubmitting ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
2026-02-28 04:21:46 +02:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="lock-unlock" className="h-4 w-4" />
|
2026-02-28 04:21:46 +02:00
|
|
|
)}
|
|
|
|
|
</Button>
|
2025-12-15 14:28:39 +02:00
|
|
|
</div>
|
2026-04-11 13:36:02 -06:00
|
|
|
{canOfferPasskeySetup ? (
|
|
|
|
|
<div className="flex items-center justify-between pt-1">
|
|
|
|
|
<label className="flex items-center gap-2 text-center typography-micro text-muted-foreground">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={trustDevice}
|
|
|
|
|
onChange={setTrustDevice}
|
|
|
|
|
disabled={isSubmitting}
|
2026-04-26 14:03:39 +03:00
|
|
|
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
|
2026-04-11 13:36:02 -06:00
|
|
|
className="size-4"
|
|
|
|
|
iconClassName="size-4"
|
|
|
|
|
/>
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('sessionAuth.actions.trustDevice')}</span>
|
2026-04-11 13:36:02 -06:00
|
|
|
</label>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={() => void handlePasskeySetupOnly()}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{isPasskeyBusy && activePasskeyAction === 'register'
|
|
|
|
|
? t('sessionAuth.actions.cancelPasskeySetup')
|
|
|
|
|
: t('sessionAuth.actions.addPasskey')}
|
2026-04-11 13:36:02 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<label className="flex items-center justify-center gap-2 pt-1 text-center typography-micro text-muted-foreground">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={trustDevice}
|
|
|
|
|
onChange={setTrustDevice}
|
|
|
|
|
disabled={isSubmitting}
|
2026-04-26 14:03:39 +03:00
|
|
|
ariaLabel={t('sessionAuth.actions.trustDeviceAria')}
|
2026-04-11 13:36:02 -06:00
|
|
|
className="size-4"
|
|
|
|
|
iconClassName="size-4"
|
|
|
|
|
/>
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('sessionAuth.actions.trustDevice')}</span>
|
2026-04-11 13:36:02 -06:00
|
|
|
</label>
|
|
|
|
|
)}
|
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">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('sessionAuth.locked.hostSwitcherHint')}
|
2026-02-05 01:59:49 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
</AuthShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <>{children}</>;
|
|
|
|
|
};
|