Files
omnichannel/src/pages/Login.tsx
T

272 lines
11 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { useAuth } from '../context/AuthContext';
import { UserRole } from '../types';
import { AlertCircle, HelpCircle, Loader2, Shield, Zap } from 'lucide-react';
import { cn } from '../lib/utils';
import { AuthSession, getOidcConfig, roleFromBackend, roleToBackend } from '../lib/api';
import { motion } from 'motion/react';
const rolePresets: Record<UserRole, { label: string; description: string; username: string; password: string }> = {
[UserRole.OPERATOR]: {
label: 'Оператор',
description: 'Работа с запросами',
username: 'operator',
password: 'op12345',
},
[UserRole.SUPERVISOR]: {
label: 'Супервайзер',
description: 'Команды и очереди',
username: 'supervisor',
password: 'sup12345',
},
[UserRole.ADMIN]: {
label: 'Админ',
description: 'Настройка системы',
username: 'admin',
password: 'admin123',
},
[UserRole.ANALYST]: {
label: 'Аналитик',
description: 'KPI и инсайты',
username: 'analyst',
password: 'an12345',
},
};
export const Login: React.FC = () => {
const { login, applySession } = useAuth();
const [username, setUsername] = React.useState(rolePresets[UserRole.OPERATOR].username);
const [password, setPassword] = React.useState(rolePresets[UserRole.OPERATOR].password);
const [role, setRole] = React.useState<UserRole>(UserRole.OPERATOR);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [oidc, setOidc] = React.useState({ enabled: false, loginPath: '', providerLabel: 'SSO' });
React.useEffect(() => {
getOidcConfig()
.then((config) => {
setOidc({
enabled: Boolean(config.enabled),
loginPath: config.login_path || '/auth/oidc/start?return_mode=popup',
providerLabel: config.provider_label || 'Keycloak',
});
})
.catch(() => {
setOidc((current) => ({ ...current, enabled: false }));
});
}, []);
React.useEffect(() => {
const handleOidcMessage = (event: MessageEvent) => {
const data = event.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'oidc-login' && data.access_token) {
const backendRole = roleToBackend(data.role);
const session: AuthSession = {
accessToken: data.access_token,
tokenType: 'bearer',
username: data.username || data.email || 'oidc-user',
fullName: data.full_name || data.username || data.email,
role: roleFromBackend(backendRole),
backendRole,
authSource: data.auth_source || 'oidc',
provider: data.provider,
};
applySession(session);
}
if (data.type === 'oidc-error') {
setError(data.message || 'Корпоративная авторизация не выполнена');
}
};
window.addEventListener('message', handleOidcMessage);
return () => window.removeEventListener('message', handleOidcMessage);
}, [applySession]);
const selectRole = (nextRole: UserRole) => {
const preset = rolePresets[nextRole];
setRole(nextRole);
setUsername(preset.username);
setPassword(preset.password);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await login(username, password);
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось авторизоваться через backend';
setError(message);
} finally {
setIsSubmitting(false);
}
};
const startCorporateLogin = () => {
if (!oidc.enabled) {
setError('Корпоративный вход сейчас недоступен в auth-service.');
return;
}
const popup = window.open(`/proxy/auth${oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
if (!popup) {
setError('Браузер заблокировал окно корпоративного входа.');
}
};
return (
<div className="min-h-screen grid lg:grid-cols-2">
<div className="hidden lg:flex bg-indigo-600 relative overflow-hidden flex-col justify-between p-12 text-white">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-12">
<div className="w-10 h-10 bg-white/20 backdrop-blur-md rounded-xl flex items-center justify-center">
<Zap className="text-white w-6 h-6 fill-current" />
</div>
<span className="font-bold text-2xl tracking-tight">DigOpsCC</span>
</div>
<h1 className="text-5xl font-bold leading-[1.1] mb-6">
Новый интерфейс <br />
<span className="text-indigo-200 text-6xl">того же call-center ядра.</span>
</h1>
<p className="text-indigo-100 text-lg max-w-md">
DigOpsCC работает через существующий API Gateway, auth-service, routing, supervisor,
reporting и канальные адаптеры.
</p>
</div>
<div className="relative z-10 grid grid-cols-2 gap-8">
<div>
<h3 className="text-indigo-200 text-xs font-semibold uppercase tracking-widest mb-1">Gateway</h3>
<span className="text-2xl font-mono">:8080</span>
</div>
<div>
<h3 className="text-indigo-200 text-xs font-semibold uppercase tracking-widest mb-1">Backend</h3>
<span className="text-2xl font-mono">/proxy</span>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-8 sm:px-16 lg:px-24 bg-white">
<div className="max-w-md w-full mx-auto">
<div className="lg:hidden flex items-center gap-2 mb-12">
<Zap className="text-indigo-600 w-8 h-8 fill-current" />
<span className="font-bold text-2xl">DigOpsCC</span>
</div>
<div className="mb-8 font-sans">
<h2 className="text-3xl font-bold text-slate-900 mb-2">Вход через call-center</h2>
<p className="text-slate-500">Используются учетные записи auth-service, как в старом UI.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<label className="text-sm font-semibold text-slate-700">Быстрый профиль</label>
<div className="grid grid-cols-2 gap-3">
{Object.values(UserRole).map((item) => (
<button
key={item}
type="button"
onClick={() => selectRole(item)}
className={cn(
'px-3 py-3 rounded-xl border text-sm font-medium transition-all text-left flex flex-col gap-1',
role === item
? 'border-indigo-600 bg-indigo-50 text-indigo-700 ring-4 ring-indigo-500/10'
: 'border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50',
)}
>
<span>{rolePresets[item].label}</span>
<span className="text-[10px] opacity-70 font-normal">{rolePresets[item].description}</span>
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700" htmlFor="username">
Логин
</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="operator"
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700" htmlFor="password">
Пароль
</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="op12345"
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all"
required
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span className="break-words">{error}</span>
</div>
)}
<button
disabled={isSubmitting}
type="submit"
className="w-full bg-slate-900 text-white rounded-xl py-4 font-semibold hover:bg-slate-800 focus:ring-4 focus:ring-slate-900/20 transition-all flex items-center justify-center gap-2 group disabled:bg-slate-400"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Проверка в auth-service...
</>
) : (
<>
Войти в консоль
<motion.span animate={{ x: [0, 4, 0] }} transition={{ repeat: Infinity, duration: 1.5 }}>
</motion.span>
</>
)}
</button>
</form>
<div className="mt-8 pt-8 border-t border-slate-100 flex items-center justify-between">
<button
type="button"
onClick={startCorporateLogin}
className="text-sm text-slate-500 hover:text-indigo-600 flex items-center gap-2 transition-colors disabled:text-slate-300"
disabled={!oidc.enabled}
>
<Shield className="w-4 h-4" />
{oidc.enabled ? `SSO ${oidc.providerLabel}` : 'SSO недоступен'}
</button>
<a
href="/health"
className="text-sm text-slate-500 hover:text-indigo-600 flex items-center gap-2 transition-colors"
>
<HelpCircle className="w-4 h-4" />
Health
</a>
</div>
</div>
</div>
</div>
);
};