272 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|