485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
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 (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.625rem',
|
|
padding: '0.375rem 0',
|
|
}}
|
|
>
|
|
<Badge variant={HEALTH_VARIANTS[status]} dot>
|
|
{t(HEALTH_KEY[status])}
|
|
</Badge>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div
|
|
style={{
|
|
fontSize: '0.875rem',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-1)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{inst.name}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
color: 'var(--color-text-3)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{inst.baseUrl}
|
|
</div>
|
|
</div>
|
|
{selected ? (
|
|
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={onSelect}>
|
|
{t('connect.domains.use')}
|
|
</Button>
|
|
)}
|
|
<Dialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
title={t('connect.removeDialog.title')}
|
|
description={t('connect.removeDialog.description', {
|
|
name: inst.name,
|
|
})}
|
|
trigger={
|
|
<button
|
|
type="button"
|
|
className="iconbtn sm"
|
|
title={t('connect.domains.forgetTitle')}
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
}
|
|
footer={
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDialogOpen(false)}
|
|
>
|
|
{t('connect.removeDialog.cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setDialogOpen(false);
|
|
onLogout();
|
|
}}
|
|
>
|
|
{t('connect.removeDialog.logout')}
|
|
</Button>
|
|
<Button
|
|
variant="ember"
|
|
size="sm"
|
|
onClick={() => {
|
|
setDialogOpen(false);
|
|
onRemove();
|
|
}}
|
|
>
|
|
{t('connect.removeDialog.removeAndLogout')}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ConnectPage() {
|
|
const { t } = useTranslation();
|
|
const dispatch = useAppDispatch();
|
|
const navigate = useNavigate();
|
|
|
|
const [rev, setRev] = useState(0);
|
|
const instances = listInstances();
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
|
getActiveInstanceId(),
|
|
);
|
|
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
|
|
const [instanceAddShown, setInstanceAddShown] = useState(false);
|
|
|
|
const [addUrl, setAddUrl] = useState('');
|
|
|
|
const [mode, setMode] = useState<Mode>('login');
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<div
|
|
key={rev}
|
|
style={{
|
|
minHeight: '100vh',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: '2rem',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: '26rem',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '1.25rem',
|
|
}}
|
|
>
|
|
<h1
|
|
style={{
|
|
textAlign: 'center',
|
|
color: 'var(--color-accent)',
|
|
fontSize: '1.75rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
<Icon name="vinyl-record" fill /> MCMA
|
|
</h1>
|
|
|
|
<Card>
|
|
<div
|
|
style={{
|
|
padding: '1.25rem 1.5rem',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '0.5rem',
|
|
}}
|
|
>
|
|
{instances.length > 0 && (
|
|
<>
|
|
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
|
{t('connect.domains.title')}
|
|
</span>
|
|
{instances.map((inst) => (
|
|
<InstanceRow
|
|
key={inst.id}
|
|
inst={inst}
|
|
selected={inst.id === selectedId}
|
|
onSelect={() => selectInstance(inst.id)}
|
|
onLogout={() => handleLogout(inst.id)}
|
|
onRemove={() => handleRemove(inst.id)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
<form
|
|
onSubmit={handleAdd}
|
|
style={{
|
|
display: 'flex',
|
|
gap: '0.5rem',
|
|
marginTop: instances.length > 0 ? '0.5rem' : 0,
|
|
}}
|
|
>
|
|
{instanceAddShown ? (
|
|
<>
|
|
<TextField
|
|
value={addUrl}
|
|
onChange={(e) => setAddUrl(e.target.value)}
|
|
placeholder={t('connect.domains.addPlaceholder')}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<IconButton type="submit" variant="primary">
|
|
<Icon name="plus" />
|
|
</IconButton>
|
|
</>
|
|
) : (
|
|
<Button
|
|
onClick={() => setInstanceAddShown(true)}
|
|
style={{ width: '100%' }}
|
|
variant="ghost"
|
|
>
|
|
<Icon name="plus" /> {t('connect.domains.addButton')}
|
|
</Button>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</Card>
|
|
|
|
{selectedInstance && (
|
|
<Card>
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '1rem',
|
|
padding: '1.5rem',
|
|
}}
|
|
>
|
|
<span className="msk-label">
|
|
{mode === 'register'
|
|
? t('connect.login.registerTitle', {
|
|
name: selectedInstance.name,
|
|
})
|
|
: t('connect.login.title', { name: selectedInstance.name })}
|
|
</span>
|
|
<div>
|
|
<label style={labelStyle}>{t('connect.login.username')}</label>
|
|
<TextField
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
placeholder="username"
|
|
autoComplete="username"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>{t('connect.login.password')}</label>
|
|
<TextField
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="password"
|
|
autoComplete={
|
|
mode === 'register' ? 'new-password' : 'current-password'
|
|
}
|
|
required
|
|
/>
|
|
{mode === 'register' && (
|
|
<span
|
|
style={{
|
|
display: 'block',
|
|
fontSize: '0.75rem',
|
|
color: 'var(--color-text-3)',
|
|
marginTop: '0.375rem',
|
|
}}
|
|
>
|
|
{t('connect.login.passwordHint')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{error && <Callout variant="danger">{t(error)}</Callout>}
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={isLoading}
|
|
style={{ marginTop: '0.5rem' }}
|
|
>
|
|
{isLoading
|
|
? mode === 'register'
|
|
? t('connect.login.registering')
|
|
: t('connect.login.submitting')
|
|
: mode === 'register'
|
|
? t('connect.login.registerSubmit')
|
|
: t('connect.login.submit')}
|
|
</Button>
|
|
|
|
{REGISTRATION_ENABLED && (
|
|
<div
|
|
style={{
|
|
textAlign: 'center',
|
|
fontSize: '0.8125rem',
|
|
color: 'var(--color-text-3)',
|
|
}}
|
|
>
|
|
{mode === 'register' ? (
|
|
<>
|
|
{t('connect.login.haveAccount')}{' '}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => switchMode('login')}
|
|
>
|
|
{t('connect.login.signInLink')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{t('connect.login.noAccount')}{' '}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => switchMode('register')}
|
|
>
|
|
{t('connect.login.registerLink')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|