fix and update

This commit is contained in:
lendry
2026-06-29 15:40:54 +03:00
parent e127df3d6d
commit aebce54bd7
5 changed files with 178 additions and 57 deletions

View File

@@ -134,6 +134,8 @@ export default function LoginPage() {
const [otpChannels, setOtpChannels] = useState<OtpChannel[]>([]);
const [otpBackupChannels, setOtpBackupChannels] = useState<OtpChannel[]>([]);
const [showMethods, setShowMethods] = useState(false);
const [step, setStep] = useState<Step>('identify');
@@ -397,6 +399,8 @@ export default function LoginPage() {
setOtpChannels(result.otpChannels ?? []);
setOtpBackupChannels(result.otpBackupChannels ?? []);
if (result.isTotpEnabled) {
@@ -413,6 +417,8 @@ export default function LoginPage() {
const channels = result.otpChannels ?? [];
const backupChannels = result.otpBackupChannels ?? [];
if (channels.length > 1) {
setStep('otpChannel');
@@ -429,6 +435,24 @@ export default function LoginPage() {
}
// Нет основных каналов, но есть резервные — даём выбрать резервную доставку.
if (backupChannels.length > 1) {
setStep('otpChannel');
return;
}
if (backupChannels.length === 1) {
await requestLoginOtp(backupChannels[0].channel, identifier);
return;
}
if (result.hasPassword) {
setStep('password');
@@ -658,7 +682,7 @@ export default function LoginPage() {
function renderAlternativeMethods(includeOtpRequest: boolean) {
const hasAlternatives = includeOtpRequest || methods.length > 0;
const hasAlternatives = includeOtpRequest || methods.length > 0 || otpBackupChannels.length > 0;
if (!hasAlternatives) return null;
@@ -736,6 +760,50 @@ export default function LoginPage() {
})}
{otpBackupChannels.length ? (
<>
<p className="px-1 pt-1 text-xs uppercase tracking-wide text-[#7d818d]">Резервные контакты</p>
{otpBackupChannels.map((channel) => {
const Icon = channelIcon[channel.channel] ?? Mail;
return (
<button
key={channel.channel}
type="button"
onClick={() => void requestLoginOtp(channel.channel)}
disabled={isSubmitting}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<Icon className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">
{channelLabel[channel.channel] ?? 'Резервный код'} {channel.masked}
</span>
</button>
);
})}
</>
) : null}
</div>
) : null}
@@ -1012,6 +1080,50 @@ export default function LoginPage() {
})}
{otpBackupChannels.length ? (
<>
<p className="px-1 pt-2 text-xs uppercase tracking-wide text-[#7d818d]">Резервные контакты</p>
{otpBackupChannels.map((channel) => {
const Icon = channelIcon[channel.channel] ?? Mail;
return (
<button
key={channel.channel}
type="button"
disabled={isSubmitting}
onClick={() => void requestLoginOtp(channel.channel)}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<Icon className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">
{channelLabel[channel.channel] ?? 'Резервный код'} {channel.masked}
</span>
</button>
);
})}
</>
) : null}
</div>
{totpChallengeToken ? (

View File

@@ -3,29 +3,20 @@ const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3
const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, '');
function resolveBrowserApiBaseUrl(): string {
// На сервере (SSR) обращаемся к API напрямую по настроенному URL.
if (typeof window === 'undefined') {
return configuredApiUrl;
}
// Если URL уже относительный — используем как есть.
if (configuredApiUrl.startsWith('/')) {
return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX;
}
try {
const configured = new URL(configuredApiUrl);
const sameHost =
configured.hostname === window.location.hostname &&
(configured.port || (configured.protocol === 'https:' ? '443' : '80')) ===
(window.location.port || (window.location.protocol === 'https:' ? '443' : '80'));
if (sameHost) {
return API_ORIGIN_PROXY_PREFIX;
}
return configuredApiUrl.replace(/\/+$/, '');
} catch {
return API_ORIGIN_PROXY_PREFIX;
}
// В браузере ВСЕГДА ходим через same-origin прокси /idp-api (Next.js rewrites
// проксируют на внутренний api-gateway). Это исключает CORS и работу через
// отдельный домен API (например api.idpmvk.lpr) для запросов самого UI IdP.
return API_ORIGIN_PROXY_PREFIX;
}
function resolveBrowserWsBaseUrl(): string {
@@ -33,23 +24,8 @@ function resolveBrowserWsBaseUrl(): string {
return configuredWsUrl;
}
const apiBase = resolveBrowserApiBaseUrl();
if (apiBase === API_ORIGIN_PROXY_PREFIX) {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
try {
const configured = new URL(configuredWsUrl);
if (configured.hostname !== window.location.hostname) {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
} catch {
// keep configured URL
}
return configuredWsUrl;
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
export function getApiUrl(): string {
@@ -313,6 +289,7 @@ export interface IdentifyResponse {
isPinEnabled: boolean;
isTotpEnabled?: boolean;
otpChannels?: OtpChannel[];
otpBackupChannels?: OtpChannel[];
methods?: LoginMethod[];
}

View File

@@ -200,30 +200,23 @@ export class AuthService {
async identify(login: string) {
const user = await this.findUserByRecipient(login);
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
// Основные каналы (телефон/почта) — приоритетная доставка OTP по умолчанию.
const otpChannels = user ? this.buildOtpChannels(user) : [];
// Резервные каналы — отдельный список, чтобы пользователь мог явно
// запросить код на резервный телефон/почту, но не получал его туда по умолчанию.
const otpBackupChannels = user ? this.buildOtpBackupChannels(user) : [];
if (user?.passwordHash) {
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
}
if (user) {
const backupChannels: Array<{ channel: string; value: string | null }> = [
{ channel: 'backupEmail', value: user.backupEmail },
{ channel: 'backupPhone', value: user.backupPhone }
];
for (const item of backupChannels) {
if (item.value) {
methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) });
}
}
}
return {
exists: Boolean(user),
hasPassword: Boolean(user?.passwordHash),
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
otpChannels,
otpBackupChannels,
methods
};
}
@@ -357,6 +350,17 @@ export class AuthService {
return channels;
}
private buildOtpBackupChannels(user: { backupEmail: string | null; backupPhone: string | null }) {
const channels: Array<{ channel: string; masked: string }> = [];
if (user.backupEmail) {
channels.push({ channel: 'backupEmail', masked: this.maskTarget(user.backupEmail) });
}
if (user.backupPhone) {
channels.push({ channel: 'backupPhone', masked: this.maskTarget(user.backupPhone) });
}
return channels;
}
private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) {
if (!channel || channel === 'primary' || !user) return recipient;
const map: Record<string, string | null> = {