import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import {
Card,
TextField,
Button,
Callout,
Badge,
Dialog,
IconButton,
} from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { setTokens, setUser } from '../../store/slices/auth';
import {
useLoginMutation,
useRegisterMutation,
} from '../../api/endpoints/auth';
import { REGISTRATION_ENABLED } from '../../config/env';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
clearInstanceAuth,
upsertInstance,
type Instance,
} from '../../config/instances';
type Mode = 'login' | 'register';
const HEALTH_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
const HEALTH_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
/** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 401) return 'connect.errors.badCredentials';
}
return 'connect.errors.generic';
}
/** Map an RTKQ register failure to a user-facing i18n key. */
function resolveRegisterError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 409) return 'connect.errors.usernameTaken';
if (e.status === 422) return 'connect.errors.passwordTooShort';
if (e.status === 403) return 'connect.errors.registrationDisabled';
}
return 'connect.errors.registerFailed';
}
function InstanceRow({
inst,
selected,
onSelect,
onLogout,
onRemove,
}: {
inst: Instance;
selected: boolean;
onSelect: () => void;
onLogout: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const status = useConnectionStatus(inst.baseUrl);
const [dialogOpen, setDialogOpen] = useState(false);
return (
{t(HEALTH_KEY[status])}
{inst.name}
{inst.baseUrl}
{selected ? (
{t('connect.domains.selected')}
) : (
)}
);
}
export function ConnectPage() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [rev, setRev] = useState(0);
const instances = listInstances();
const [selectedId, setSelectedId] = useState(() =>
getActiveInstanceId(),
);
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [instanceAddShown, setInstanceAddShown] = useState(false);
const [addUrl, setAddUrl] = useState('');
const [mode, setMode] = useState('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
const [register, { isLoading: isRegistering }] = useRegisterMutation();
const isLoading = isLoggingIn || isRegistering;
const switchMode = (next: Mode) => {
setMode(next);
setError(null);
};
// Switching the active instance and reloading lets the app pick the saved
// session for that instance back up (if any); if it has none, ProtectedRoute
// bounces back here and `selectedId` defaults to it, surfacing the login card.
const selectInstance = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const url = addUrl.trim();
if (!url || url === 'https://') return;
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
setAddUrl('https://');
setSelectedId(inst.id);
setRev((r) => r + 1);
};
const handleLogout = (id: string) => {
clearInstanceAuth(id);
setRev((r) => r + 1);
};
const handleRemove = (id: string) => {
removeInstance(id);
if (selectedId === id) setSelectedId(getActiveInstanceId());
setRev((r) => r + 1);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedInstance) return;
setError(null);
try {
const action =
mode === 'register'
? register({ username, password })
: login({ username, password });
const { user, tokens } = await action.unwrap();
dispatch(setTokens(tokens));
dispatch(setUser(user));
void navigate('/');
} catch (err) {
setError(
mode === 'register'
? resolveRegisterError(err)
: resolveLoginError(err),
);
}
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
return (
MCMA
{instances.length > 0 && (
<>
{t('connect.domains.title')}
{instances.map((inst) => (
selectInstance(inst.id)}
onLogout={() => handleLogout(inst.id)}
onRemove={() => handleRemove(inst.id)}
/>
))}
>
)}
{selectedInstance && (
)}
);
}