fix and update
This commit is contained in:
@@ -134,6 +134,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [otpChannels, setOtpChannels] = useState<OtpChannel[]>([]);
|
const [otpChannels, setOtpChannels] = useState<OtpChannel[]>([]);
|
||||||
|
|
||||||
|
const [otpBackupChannels, setOtpBackupChannels] = useState<OtpChannel[]>([]);
|
||||||
|
|
||||||
const [showMethods, setShowMethods] = useState(false);
|
const [showMethods, setShowMethods] = useState(false);
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>('identify');
|
const [step, setStep] = useState<Step>('identify');
|
||||||
@@ -397,6 +399,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
setOtpChannels(result.otpChannels ?? []);
|
setOtpChannels(result.otpChannels ?? []);
|
||||||
|
|
||||||
|
setOtpBackupChannels(result.otpBackupChannels ?? []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (result.isTotpEnabled) {
|
if (result.isTotpEnabled) {
|
||||||
@@ -413,6 +417,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const channels = result.otpChannels ?? [];
|
const channels = result.otpChannels ?? [];
|
||||||
|
|
||||||
|
const backupChannels = result.otpBackupChannels ?? [];
|
||||||
|
|
||||||
if (channels.length > 1) {
|
if (channels.length > 1) {
|
||||||
|
|
||||||
setStep('otpChannel');
|
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) {
|
if (result.hasPassword) {
|
||||||
|
|
||||||
setStep('password');
|
setStep('password');
|
||||||
@@ -658,7 +682,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function renderAlternativeMethods(includeOtpRequest: boolean) {
|
function renderAlternativeMethods(includeOtpRequest: boolean) {
|
||||||
|
|
||||||
const hasAlternatives = includeOtpRequest || methods.length > 0;
|
const hasAlternatives = includeOtpRequest || methods.length > 0 || otpBackupChannels.length > 0;
|
||||||
|
|
||||||
if (!hasAlternatives) return null;
|
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>
|
</div>
|
||||||
|
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{totpChallengeToken ? (
|
{totpChallengeToken ? (
|
||||||
|
|||||||
@@ -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(/\/$/, '');
|
const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, '');
|
||||||
|
|
||||||
function resolveBrowserApiBaseUrl(): string {
|
function resolveBrowserApiBaseUrl(): string {
|
||||||
|
// На сервере (SSR) обращаемся к API напрямую по настроенному URL.
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return configuredApiUrl;
|
return configuredApiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если URL уже относительный — используем как есть.
|
||||||
if (configuredApiUrl.startsWith('/')) {
|
if (configuredApiUrl.startsWith('/')) {
|
||||||
return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX;
|
return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// В браузере ВСЕГДА ходим через same-origin прокси /idp-api (Next.js rewrites
|
||||||
const configured = new URL(configuredApiUrl);
|
// проксируют на внутренний api-gateway). Это исключает CORS и работу через
|
||||||
const sameHost =
|
// отдельный домен API (например api.idpmvk.lpr) для запросов самого UI IdP.
|
||||||
configured.hostname === window.location.hostname &&
|
return API_ORIGIN_PROXY_PREFIX;
|
||||||
(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrowserWsBaseUrl(): string {
|
function resolveBrowserWsBaseUrl(): string {
|
||||||
@@ -33,23 +24,8 @@ function resolveBrowserWsBaseUrl(): string {
|
|||||||
return configuredWsUrl;
|
return configuredWsUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = resolveBrowserApiBaseUrl();
|
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
if (apiBase === API_ORIGIN_PROXY_PREFIX) {
|
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApiUrl(): string {
|
export function getApiUrl(): string {
|
||||||
@@ -313,6 +289,7 @@ export interface IdentifyResponse {
|
|||||||
isPinEnabled: boolean;
|
isPinEnabled: boolean;
|
||||||
isTotpEnabled?: boolean;
|
isTotpEnabled?: boolean;
|
||||||
otpChannels?: OtpChannel[];
|
otpChannels?: OtpChannel[];
|
||||||
|
otpBackupChannels?: OtpChannel[];
|
||||||
methods?: LoginMethod[];
|
methods?: LoginMethod[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,30 +200,23 @@ export class AuthService {
|
|||||||
async identify(login: string) {
|
async identify(login: string) {
|
||||||
const user = await this.findUserByRecipient(login);
|
const user = await this.findUserByRecipient(login);
|
||||||
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
|
const methods: Array<{ kind: string; channel: string; masked: string }> = [];
|
||||||
|
// Основные каналы (телефон/почта) — приоритетная доставка OTP по умолчанию.
|
||||||
const otpChannels = user ? this.buildOtpChannels(user) : [];
|
const otpChannels = user ? this.buildOtpChannels(user) : [];
|
||||||
|
// Резервные каналы — отдельный список, чтобы пользователь мог явно
|
||||||
|
// запросить код на резервный телефон/почту, но не получал его туда по умолчанию.
|
||||||
|
const otpBackupChannels = user ? this.buildOtpBackupChannels(user) : [];
|
||||||
|
|
||||||
if (user?.passwordHash) {
|
if (user?.passwordHash) {
|
||||||
methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' });
|
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 {
|
return {
|
||||||
exists: Boolean(user),
|
exists: Boolean(user),
|
||||||
hasPassword: Boolean(user?.passwordHash),
|
hasPassword: Boolean(user?.passwordHash),
|
||||||
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
|
isPinEnabled: Boolean(user?.pinCode?.isEnabled),
|
||||||
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
|
isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false,
|
||||||
otpChannels,
|
otpChannels,
|
||||||
|
otpBackupChannels,
|
||||||
methods
|
methods
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -357,6 +350,17 @@ export class AuthService {
|
|||||||
return channels;
|
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) {
|
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;
|
if (!channel || channel === 'primary' || !user) return recipient;
|
||||||
const map: Record<string, string | null> = {
|
const map: Record<string, string | null> = {
|
||||||
|
|||||||
51
install.sh
51
install.sh
@@ -1735,15 +1735,50 @@ build_ws_block() {
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Блок location / для отдельного домена API (api.example.com).
|
||||||
|
# Так как фронтенд и API на разных доменах (split-domain), браузер шлёт
|
||||||
|
# cross-origin запросы и preflight (OPTIONS). Nginx сам отвечает на preflight
|
||||||
|
# и проставляет CORS-заголовки, отражая Origin (нужно для credentials: cookie/JWT).
|
||||||
|
# Заголовки CORS от upstream скрываем, чтобы не было дублей Access-Control-Allow-Origin.
|
||||||
|
build_nginx_api_location_block() {
|
||||||
|
local upstream
|
||||||
|
upstream="$(nginx_upstream api)"
|
||||||
|
cat <<EOF
|
||||||
|
location / {
|
||||||
|
set \$cors_origin "";
|
||||||
|
if (\$http_origin) { set \$cors_origin \$http_origin; }
|
||||||
|
|
||||||
|
if (\$request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin \$cors_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, Accept, Origin, X-CSRF-Token" always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
proxy_hide_header Access-Control-Allow-Credentials;
|
||||||
|
add_header Access-Control-Allow-Origin \$cors_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
|
||||||
|
proxy_pass ${upstream};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
${nginx_proxy_headers}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
write_nginx_site_api() {
|
write_nginx_site_api() {
|
||||||
local domain="$1"
|
local domain="$1"
|
||||||
local ssl_type="${2:-none}"
|
local ssl_type="${2:-none}"
|
||||||
local ws_on_api="${3:-true}"
|
local ws_on_api="${3:-true}"
|
||||||
local conf upstream ws_block http_preamble ssl_block
|
local conf ws_block http_preamble ssl_block api_location
|
||||||
conf="$(nginx_conf_target api)"
|
conf="$(nginx_conf_target api)"
|
||||||
upstream="$(nginx_upstream api)"
|
|
||||||
ws_block="$(build_ws_block "$ws_on_api")"
|
ws_block="$(build_ws_block "$ws_on_api")"
|
||||||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||||||
|
api_location="$(build_nginx_api_location_block)"
|
||||||
|
|
||||||
if [[ "$ssl_type" == "none" ]]; then
|
if [[ "$ssl_type" == "none" ]]; then
|
||||||
nginx_write_conf "$conf" <<EOF
|
nginx_write_conf "$conf" <<EOF
|
||||||
@@ -1753,11 +1788,7 @@ server {
|
|||||||
server_name ${domain};
|
server_name ${domain};
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
${ws_block}
|
${ws_block}
|
||||||
location / {
|
${api_location}
|
||||||
proxy_pass ${upstream};
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
${nginx_proxy_headers}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
@@ -1770,11 +1801,7 @@ ${http_preamble}server {
|
|||||||
${ssl_block}
|
${ssl_block}
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
${ws_block}
|
${ws_block}
|
||||||
location / {
|
${api_location}
|
||||||
proxy_pass ${upstream};
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
${nginx_proxy_headers}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ message IdentifyResponse {
|
|||||||
repeated LoginMethod methods = 4;
|
repeated LoginMethod methods = 4;
|
||||||
bool isTotpEnabled = 5;
|
bool isTotpEnabled = 5;
|
||||||
repeated OtpChannel otpChannels = 6;
|
repeated OtpChannel otpChannels = 6;
|
||||||
|
repeated OtpChannel otpBackupChannels = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message OtpChannel {
|
message OtpChannel {
|
||||||
|
|||||||
Reference in New Issue
Block a user