fix and update
This commit is contained in:
@@ -3,11 +3,8 @@ import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer';
|
||||
import {
|
||||
hostsMatch,
|
||||
normalizePublicBaseUrl,
|
||||
preferBrowserReachableBase,
|
||||
readRequestHost,
|
||||
readRequestProto,
|
||||
preferCanonicalBase,
|
||||
resolvePublicApiBaseFromRequest,
|
||||
resolvePublicFrontendBaseFromRequest
|
||||
} from './public-url';
|
||||
@@ -47,76 +44,17 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string {
|
||||
const fromRequest = resolvePublicFrontendBaseFromRequest(req);
|
||||
const preferred = preferBrowserReachableBase(storedFrontend, fromRequest);
|
||||
if (preferred !== storedFrontend) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const host = readRequestHost(req);
|
||||
if (!host) {
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
const requestOrigin = normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||
|
||||
try {
|
||||
const frontendUrl = new URL(storedFrontend);
|
||||
const requestUrl = new URL(requestOrigin);
|
||||
|
||||
if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) {
|
||||
return requestOrigin;
|
||||
}
|
||||
} catch {
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string {
|
||||
const fromRequest = resolvePublicApiBaseFromRequest(req);
|
||||
const preferred = preferBrowserReachableBase(storedIssuer, fromRequest);
|
||||
if (preferred !== storedIssuer) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const host = readRequestHost(req);
|
||||
if (!host) {
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
const requestOrigin = normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||
const sameOriginApi = `${requestOrigin}/idp-api`;
|
||||
|
||||
try {
|
||||
const issuerUrl = new URL(storedIssuer);
|
||||
const frontendUrl = new URL(storedFrontend);
|
||||
const requestUrl = new URL(requestOrigin);
|
||||
|
||||
const hostMatches =
|
||||
hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname);
|
||||
|
||||
if (!hostMatches) {
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
if (normalizePublicBaseUrl(storedIssuer) !== sameOriginApi) {
|
||||
return sameOriginApi;
|
||||
}
|
||||
} catch {
|
||||
return fromRequest ?? storedIssuer;
|
||||
}
|
||||
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise<FedcmEndpoints> {
|
||||
const storedIssuer = await resolveOAuthIssuer(core);
|
||||
const storedFrontend = await resolveFrontendUrl(core);
|
||||
const frontendUrl = resolveSameOriginFrontend(storedFrontend, req);
|
||||
const issuer = resolveSameOriginIssuer(storedIssuer, storedFrontend, req);
|
||||
// ВАЖНО для FedCM: configUrl должен быть стабильным независимо от того, с какого
|
||||
// хоста пришёл запрос. well-known запрашивается браузером всегда с eTLD+1
|
||||
// (например https://idpmvk.lpr/.well-known/web-identity), а discover.json — с
|
||||
// поддомена (https://sso.idpmvk.lpr/idp-api/...). Оба обязаны вернуть ОДИН и тот
|
||||
// же configUrl, иначе браузер отвергнет провайдера. Поэтому берём канонический
|
||||
// публичный URL из настроек, а не из текущего хоста запроса.
|
||||
const issuer = preferCanonicalBase(storedIssuer, resolvePublicApiBaseFromRequest(req));
|
||||
const frontendUrl = preferCanonicalBase(storedFrontend, resolvePublicFrontendBaseFromRequest(req));
|
||||
let projectName = 'MVK ID';
|
||||
|
||||
try {
|
||||
|
||||
@@ -94,6 +94,25 @@ export function resolvePublicFrontendBaseFromRequest(req?: Request): string | nu
|
||||
return resolvePublicOriginFromRequest(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает канонический публичный base-URL для FedCM.
|
||||
*
|
||||
* FedCM требует, чтобы well-known файл (всегда запрашивается с eTLD+1, например
|
||||
* idpmvk.lpr) и discover.json (запрашивается с поддомена sso.idpmvk.lpr)
|
||||
* возвращали ОДИН и тот же configUrl. Поэтому здесь приоритет всегда у значения
|
||||
* из настроек (PUBLIC_API_URL / PUBLIC_FRONTEND_URL), а на хост запроса
|
||||
* переключаемся только если в настройках указан внутренний Docker-хост.
|
||||
*/
|
||||
export function preferCanonicalBase(stored: string, fromRequest: string | null) {
|
||||
if (stored && isBrowserReachableBaseUrl(stored)) {
|
||||
return normalizePublicBaseUrl(stored);
|
||||
}
|
||||
if (fromRequest && isBrowserReachableBaseUrl(fromRequest)) {
|
||||
return normalizePublicBaseUrl(fromRequest);
|
||||
}
|
||||
return normalizePublicBaseUrl(stored);
|
||||
}
|
||||
|
||||
export function preferBrowserReachableBase(stored: string, fromRequest: string | null) {
|
||||
if (fromRequest && isBrowserReachableBaseUrl(fromRequest)) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DocBlock } from '@/lib/docs-pages';
|
||||
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
|
||||
import { OneTapCodeTabs } from '@/components/one-tap-code-tabs';
|
||||
import { OneTapButtonBuilder } from '@/components/one-tap-button-builder';
|
||||
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
|
||||
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
|
||||
import { BotCodeTabs } from '@/components/bot-code-tabs';
|
||||
@@ -70,6 +71,8 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) {
|
||||
return <OAuthCodeTabs />;
|
||||
case 'one-tap-examples':
|
||||
return <OneTapCodeTabs />;
|
||||
case 'one-tap-builder':
|
||||
return <OneTapButtonBuilder />;
|
||||
case 'auth-login-examples':
|
||||
return <AuthLoginCodeTabs />;
|
||||
case 'auth-ldap-examples':
|
||||
|
||||
297
apps/docs/components/one-tap-button-builder.tsx
Normal file
297
apps/docs/components/one-tap-button-builder.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { fetchPublicSettingsClient } from '@/lib/api';
|
||||
import { buildOneTapUrls, type OneTapUrls } from '@/lib/one-tap-examples';
|
||||
import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||
import { CodeBlock } from '@/components/code-block';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
buildButtonSnippet,
|
||||
DEFAULT_BUILDER_OPTIONS,
|
||||
ICON_OPTIONS,
|
||||
resolveButtonStyle,
|
||||
SIZE_OPTIONS,
|
||||
THEME_OPTIONS,
|
||||
VIEW_OPTIONS,
|
||||
type ButtonBuilderOptions
|
||||
} from '@/lib/one-tap-builder';
|
||||
|
||||
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
|
||||
|
||||
const COLOR_FIELDS: Array<{ key: ColorKey; label: string }> = [
|
||||
{ key: 'bg', label: 'Цвет фона' },
|
||||
{ key: 'bgHover', label: 'Фон при наведении' },
|
||||
{ key: 'border', label: 'Цвет обводки' },
|
||||
{ key: 'borderHover', label: 'Обводка при наведении' },
|
||||
{ key: 'text', label: 'Цвет текста' }
|
||||
];
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-zinc-700';
|
||||
|
||||
export function OneTapButtonBuilder() {
|
||||
const [apiBase, setApiBase] = useState('http://localhost:3000');
|
||||
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
|
||||
const [projectName, setProjectName] = useState('MVK ID');
|
||||
const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPublicSettingsClient()
|
||||
.then((settings) => {
|
||||
setApiBase(resolveOAuthApiBase(settings));
|
||||
setFrontendBase(resolveFrontendBase(settings));
|
||||
if (settings.PROJECT_NAME?.trim()) {
|
||||
const name = settings.PROJECT_NAME.trim();
|
||||
setProjectName(name);
|
||||
setOptions((prev) =>
|
||||
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: name } : prev
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const urls: OneTapUrls = useMemo(
|
||||
() => buildOneTapUrls(apiBase, frontendBase, projectName),
|
||||
[apiBase, frontendBase, projectName]
|
||||
);
|
||||
const snippet = useMemo(() => buildButtonSnippet(options, urls), [options, urls]);
|
||||
const style = useMemo(() => resolveButtonStyle(options), [options]);
|
||||
|
||||
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
|
||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleColor(key: ColorKey, enabled: boolean) {
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
[key]: enabled ? (prev[key] || style.palette[key] || '#ffffff') : ''
|
||||
}));
|
||||
}
|
||||
|
||||
const buttonStyle: CSSProperties = {
|
||||
height: style.preset.height,
|
||||
fontSize: style.preset.font,
|
||||
gap: style.preset.gap,
|
||||
padding: style.iconOnly ? 0 : `0 ${style.preset.padX}px`,
|
||||
width: style.iconOnly ? style.preset.height : 'auto',
|
||||
borderRadius: style.radius,
|
||||
background: hovered ? style.bgHover : style.bg,
|
||||
color: style.text,
|
||||
border: `1px solid ${hovered ? style.borderHover : style.border}`,
|
||||
boxShadow: '0 8px 24px rgba(31,36,48,.12)',
|
||||
transition: 'all .18s ease'
|
||||
};
|
||||
|
||||
const badgeStyle: CSSProperties = {
|
||||
minWidth: style.preset.badge,
|
||||
height: style.preset.badge,
|
||||
padding: `0 ${Math.round(style.preset.badge / 4)}px`,
|
||||
fontSize: Math.round(style.preset.font * 0.85),
|
||||
background: style.palette.badgeBg,
|
||||
color: style.palette.badgeColor
|
||||
};
|
||||
|
||||
function tryLogin() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams({
|
||||
client_id: options.clientId || 'YOUR_CLIENT_ID',
|
||||
redirect_uri: options.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
display: 'popup',
|
||||
popup_origin: window.location.origin,
|
||||
state: 'preview_' + Math.random().toString(36).slice(2)
|
||||
});
|
||||
const url = `${frontendBase}/auth/oauth/authorize?${params.toString()}`;
|
||||
const popup = window.open(url, 'mvkid_preview', 'popup,width=480,height=640');
|
||||
if (!popup) {
|
||||
setStatus('Браузер заблокировал popup — разрешите всплывающие окна для теста.');
|
||||
return;
|
||||
}
|
||||
setStatus('Открыт popup авторизации. После входа токен придёт через postMessage.');
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.data?.type !== 'lendry-sso-onetap') return;
|
||||
window.removeEventListener('message', onMessage);
|
||||
setStatus(`Получен ответ: метод ${event.data.method ?? 'popup'}.`);
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
|
||||
{/* Левая колонка: превью + код */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-h-[140px] items-center justify-center rounded-xl border border-zinc-200 bg-[#e9edf3] p-6 dark:border-zinc-800 dark:bg-[#15171c]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center justify-center font-semibold leading-none"
|
||||
style={buttonStyle}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={tryLogin}
|
||||
aria-label={`Войти через ${options.providerName || projectName}`}
|
||||
>
|
||||
{style.showBadge ? (
|
||||
<span className="inline-flex items-center justify-center rounded-full font-bold" style={badgeStyle}>
|
||||
ID
|
||||
</span>
|
||||
) : null}
|
||||
{!style.iconOnly ? <span>Войти через {options.providerName || projectName}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<CodeBlock code={snippet} language="html" title="Код для вставки" />
|
||||
</div>
|
||||
|
||||
{/* Правая колонка: параметры */}
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<Field label="Client ID (из админки: RBAC → OAuth приложения)">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={options.clientId}
|
||||
onChange={(event) => update('clientId', event.target.value)}
|
||||
placeholder="YOUR_CLIENT_ID"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Redirect URI">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={options.redirectUri}
|
||||
onChange={(event) => update('redirectUri', event.target.value)}
|
||||
placeholder="https://app.example.com/auth/callback"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Название провайдера (текст на кнопке)">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={options.providerName}
|
||||
onChange={(event) => update('providerName', event.target.value)}
|
||||
placeholder={projectName}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Размер">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={options.size}
|
||||
onChange={(event) => update('size', event.target.value as ButtonBuilderOptions['size'])}
|
||||
>
|
||||
{SIZE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Тема">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={options.theme}
|
||||
onChange={(event) => update('theme', event.target.value as ButtonBuilderOptions['theme'])}
|
||||
>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Вид">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={options.view}
|
||||
onChange={(event) => update('view', event.target.value as ButtonBuilderOptions['view'])}
|
||||
>
|
||||
{VIEW_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Тип иконки">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={options.icon}
|
||||
onChange={(event) => update('icon', event.target.value as ButtonBuilderOptions['icon'])}
|
||||
>
|
||||
{ICON_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={`Радиус скругления — ${options.radius}px`}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={32}
|
||||
value={options.radius}
|
||||
onChange={(event) => update('radius', Number(event.target.value))}
|
||||
className="w-full accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
||||
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
CSS-цвета (необязательно — иначе берётся цвет темы)
|
||||
</p>
|
||||
{COLOR_FIELDS.map((field) => {
|
||||
const enabled = Boolean(options[field.key]);
|
||||
const fallback = style.palette[field.key] || '#ffffff';
|
||||
const value = options[field.key] || fallback;
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => toggleColor(field.key, event.target.checked)}
|
||||
className="h-4 w-4 accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={value.startsWith('#') ? value : '#ffffff'}
|
||||
disabled={!enabled}
|
||||
onChange={(event) => update(field.key, event.target.value)}
|
||||
className={cn('h-8 w-10 cursor-pointer rounded border border-zinc-300 bg-transparent dark:border-zinc-700', !enabled && 'opacity-40')}
|
||||
/>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-300">{field.label}</span>
|
||||
{enabled ? (
|
||||
<code className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">{options[field.key]}</code>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type DocBlock =
|
||||
| { type: 'table'; headers: string[]; rows: string[][] }
|
||||
| { type: 'oauth-examples' }
|
||||
| { type: 'one-tap-examples' }
|
||||
| { type: 'one-tap-builder' }
|
||||
| { type: 'auth-login-examples' }
|
||||
| { type: 'auth-ldap-examples' }
|
||||
| { type: 'bot-examples' }
|
||||
@@ -1079,6 +1080,23 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'button-builder',
|
||||
title: 'Конструктор кнопок',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. Выберите параметры — справа обновится живое превью, а слева сформируется готовый код виджета. Скопируйте его и вставьте на свою страницу: атрибут data-button-container указывает, в какой элемент встроить кнопку.'
|
||||
},
|
||||
{ type: 'one-tap-builder' },
|
||||
{
|
||||
type: 'callout',
|
||||
variant: 'tip',
|
||||
title: 'Как это работает',
|
||||
text: 'Кнопка рендерится скриптом sso-widget.js внутри контейнера с указанным id. Параметры размера, темы, вида, радиуса, иконки и CSS-цветов передаются через data-button-* атрибуты. Клик по превью открывает реальный popup авторизации для проверки.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fedcm-integration',
|
||||
title: 'Интеграция FedCM вручную',
|
||||
|
||||
186
apps/docs/lib/one-tap-builder.ts
Normal file
186
apps/docs/lib/one-tap-builder.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { OneTapUrls } from '@/lib/one-tap-examples';
|
||||
|
||||
export type ButtonSize = 's' | 'm' | 'l' | 'xl';
|
||||
export type ButtonTheme = 'light' | 'dark';
|
||||
export type ButtonView = 'main' | 'icon';
|
||||
export type ButtonIcon = 'id' | 'none';
|
||||
|
||||
export interface ButtonBuilderOptions {
|
||||
clientId: string;
|
||||
providerName: string;
|
||||
redirectUri: string;
|
||||
size: ButtonSize;
|
||||
theme: ButtonTheme;
|
||||
view: ButtonView;
|
||||
radius: number;
|
||||
icon: ButtonIcon;
|
||||
// Пустая строка = использовать цвет темы по умолчанию.
|
||||
bg: string;
|
||||
bgHover: string;
|
||||
border: string;
|
||||
borderHover: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SizePreset {
|
||||
height: number;
|
||||
font: number;
|
||||
padX: number;
|
||||
gap: number;
|
||||
badge: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
// Должно совпадать с SIZE_PRESETS в apps/frontend/public/sso-widget.js.
|
||||
export const SIZE_PRESETS: Record<ButtonSize, SizePreset> = {
|
||||
s: { height: 32, font: 13, padX: 12, gap: 8, badge: 20, radius: 16 },
|
||||
m: { height: 40, font: 14, padX: 16, gap: 10, badge: 24, radius: 20 },
|
||||
l: { height: 44, font: 15, padX: 18, gap: 10, badge: 28, radius: 22 },
|
||||
xl: { height: 52, font: 16, padX: 22, gap: 12, badge: 32, radius: 26 }
|
||||
};
|
||||
|
||||
export interface ButtonPalette {
|
||||
bg: string;
|
||||
bgHover: string;
|
||||
border: string;
|
||||
borderHover: string;
|
||||
text: string;
|
||||
badgeBg: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
// Должно совпадать с themePalette в apps/frontend/public/sso-widget.js.
|
||||
export function themePalette(theme: ButtonTheme): ButtonPalette {
|
||||
if (theme === 'dark') {
|
||||
return {
|
||||
bg: '#1f2430',
|
||||
bgHover: '#2a3040',
|
||||
border: 'transparent',
|
||||
borderHover: 'transparent',
|
||||
text: '#ffffff',
|
||||
badgeBg: '#ffffff',
|
||||
badgeColor: '#1f2430'
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: '#ffffff',
|
||||
bgHover: '#f6f8fb',
|
||||
border: '#e4e8ef',
|
||||
borderHover: '#d4dae6',
|
||||
text: '#1f2430',
|
||||
badgeBg: '#111111',
|
||||
badgeColor: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
export const SIZE_OPTIONS: Array<{ value: ButtonSize; label: string }> = [
|
||||
{ value: 's', label: 'S — 32px' },
|
||||
{ value: 'm', label: 'M — 40px' },
|
||||
{ value: 'l', label: 'L — 44px' },
|
||||
{ value: 'xl', label: 'XL — 52px' }
|
||||
];
|
||||
|
||||
export const THEME_OPTIONS: Array<{ value: ButtonTheme; label: string }> = [
|
||||
{ value: 'light', label: 'Светлая' },
|
||||
{ value: 'dark', label: 'Тёмная' }
|
||||
];
|
||||
|
||||
export const VIEW_OPTIONS: Array<{ value: ButtonView; label: string }> = [
|
||||
{ value: 'main', label: 'Кнопка с текстом' },
|
||||
{ value: 'icon', label: 'Только иконка' }
|
||||
];
|
||||
|
||||
export const ICON_OPTIONS: Array<{ value: ButtonIcon; label: string }> = [
|
||||
{ value: 'id', label: 'Значок ID' },
|
||||
{ value: 'none', label: 'Без значка' }
|
||||
];
|
||||
|
||||
export const DEFAULT_BUILDER_OPTIONS: ButtonBuilderOptions = {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
providerName: 'MVK ID',
|
||||
redirectUri: 'https://app.example.com/auth/callback',
|
||||
size: 'xl',
|
||||
theme: 'light',
|
||||
view: 'main',
|
||||
radius: 26,
|
||||
icon: 'id',
|
||||
bg: '',
|
||||
bgHover: '',
|
||||
border: '',
|
||||
borderHover: '',
|
||||
text: ''
|
||||
};
|
||||
|
||||
export interface ResolvedButtonStyle {
|
||||
preset: SizePreset;
|
||||
palette: ButtonPalette;
|
||||
bg: string;
|
||||
bgHover: string;
|
||||
border: string;
|
||||
borderHover: string;
|
||||
text: string;
|
||||
radius: number;
|
||||
iconOnly: boolean;
|
||||
showBadge: boolean;
|
||||
}
|
||||
|
||||
export function resolveButtonStyle(options: ButtonBuilderOptions): ResolvedButtonStyle {
|
||||
const preset = SIZE_PRESETS[options.size] ?? SIZE_PRESETS.xl;
|
||||
const palette = themePalette(options.theme === 'dark' ? 'dark' : 'light');
|
||||
const radius = Number.isFinite(options.radius) ? options.radius : preset.radius;
|
||||
return {
|
||||
preset,
|
||||
palette,
|
||||
bg: options.bg || palette.bg,
|
||||
bgHover: options.bgHover || palette.bgHover,
|
||||
border: options.border || palette.border,
|
||||
borderHover: options.borderHover || palette.borderHover,
|
||||
text: options.text || palette.text,
|
||||
radius,
|
||||
iconOnly: options.view === 'icon',
|
||||
showBadge: options.icon !== 'none'
|
||||
};
|
||||
}
|
||||
|
||||
export function buildButtonSnippet(options: ButtonBuilderOptions, urls: OneTapUrls): string {
|
||||
const providerName = options.providerName || urls.projectName;
|
||||
const lines: string[] = [
|
||||
` src="${urls.widgetUrl}"`,
|
||||
` data-client-id="${options.clientId || 'YOUR_CLIENT_ID'}"`,
|
||||
` data-idp-url="${urls.apiBase}"`,
|
||||
` data-idp-frontend-url="${urls.frontendBase}"`,
|
||||
` data-provider-name="${providerName}"`,
|
||||
` data-redirect-uri="${options.redirectUri}"`,
|
||||
` data-button-container="mvkid-button"`,
|
||||
` data-button-size="${options.size}"`,
|
||||
` data-button-theme="${options.theme}"`,
|
||||
` data-button-view="${options.view}"`,
|
||||
` data-button-radius="${options.radius}"`,
|
||||
` data-button-icon="${options.icon}"`
|
||||
];
|
||||
|
||||
if (options.bg) lines.push(` data-button-bg="${options.bg}"`);
|
||||
if (options.bgHover) lines.push(` data-button-bg-hover="${options.bgHover}"`);
|
||||
if (options.border) lines.push(` data-button-border="${options.border}"`);
|
||||
if (options.borderHover) lines.push(` data-button-border-hover="${options.borderHover}"`);
|
||||
if (options.text) lines.push(` data-button-text="${options.text}"`);
|
||||
|
||||
lines.push(' data-on-success="onMvkIdLogin"');
|
||||
|
||||
return `<!-- 1) Контейнер, в котором появится кнопка -->
|
||||
<div id="mvkid-button"></div>
|
||||
|
||||
<!-- 2) Подключение виджета ${providerName} -->
|
||||
<script
|
||||
${lines.join('\n')}
|
||||
></script>
|
||||
|
||||
<script>
|
||||
function onMvkIdLogin(payload) {
|
||||
// payload.token — id_token (FedCM) или токен из popup
|
||||
// payload.method — 'fedcm' | 'popup'
|
||||
console.log('${providerName}: вход через', payload.method, payload.token);
|
||||
// Отправьте токен на ваш backend для проверки и создания сессии
|
||||
}
|
||||
</script>`;
|
||||
}
|
||||
@@ -169,6 +169,61 @@
|
||||
});
|
||||
}
|
||||
|
||||
var SIZE_PRESETS = {
|
||||
s: { height: 32, font: 13, padX: 12, gap: 8, badge: 20, radius: 16 },
|
||||
m: { height: 40, font: 14, padX: 16, gap: 10, badge: 24, radius: 20 },
|
||||
l: { height: 44, font: 15, padX: 18, gap: 10, badge: 28, radius: 22 },
|
||||
xl: { height: 52, font: 16, padX: 22, gap: 12, badge: 32, radius: 26 }
|
||||
};
|
||||
|
||||
function themePalette(theme) {
|
||||
if (theme === 'dark') {
|
||||
return {
|
||||
bg: '#1f2430',
|
||||
bgHover: '#2a3040',
|
||||
border: 'transparent',
|
||||
borderHover: 'transparent',
|
||||
text: '#ffffff',
|
||||
badgeBg: '#ffffff',
|
||||
badgeColor: '#1f2430'
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: '#ffffff',
|
||||
bgHover: '#f6f8fb',
|
||||
border: '#e4e8ef',
|
||||
borderHover: '#d4dae6',
|
||||
text: '#1f2430',
|
||||
badgeBg: '#111111',
|
||||
badgeColor: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
function readButtonOptions(script, options) {
|
||||
function attr(name) {
|
||||
return script ? script.getAttribute(name) : null;
|
||||
}
|
||||
var o = options || {};
|
||||
var size = String(o.buttonSize || attr('data-button-size') || 'xl').toLowerCase();
|
||||
if (!SIZE_PRESETS[size]) size = 'xl';
|
||||
var radiusRaw = o.buttonRadius != null ? o.buttonRadius : attr('data-button-radius');
|
||||
return {
|
||||
size: size,
|
||||
theme: String(o.buttonTheme || attr('data-button-theme') || 'light').toLowerCase(),
|
||||
view: String(o.buttonView || attr('data-button-view') || 'main').toLowerCase(),
|
||||
icon: String(o.buttonIcon || attr('data-button-icon') || 'id').toLowerCase(),
|
||||
radius: radiusRaw != null && radiusRaw !== '' ? parseInt(radiusRaw, 10) : null,
|
||||
container: o.buttonContainer || attr('data-button-container') || null,
|
||||
colors: {
|
||||
bg: o.buttonBg || attr('data-button-bg') || null,
|
||||
bgHover: o.buttonBgHover || attr('data-button-bg-hover') || null,
|
||||
border: o.buttonBorder || attr('data-button-border') || null,
|
||||
borderHover: o.buttonBorderHover || attr('data-button-border-hover') || null,
|
||||
text: o.buttonText || attr('data-button-text') || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function injectWidgetStyles() {
|
||||
if (global.document.getElementById('lendry-sso-onetap-style')) return;
|
||||
var style = global.document.createElement('style');
|
||||
@@ -176,39 +231,95 @@
|
||||
style.textContent =
|
||||
'#' +
|
||||
widgetRootId +
|
||||
'{position:fixed;top:16px;right:16px;z-index:2147483000;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}' +
|
||||
'.lendry-sso-onetap-btn{display:flex;align-items:center;gap:10px;padding:10px 16px;border-radius:999px;border:1px solid #e4e8ef;background:#fff;color:#1f2430;font-size:14px;font-weight:600;line-height:1;cursor:pointer;box-shadow:0 8px 24px rgba(31,36,48,.12);transition:transform .18s ease,box-shadow .18s ease}' +
|
||||
'{position:fixed;top:16px;right:16px;z-index:2147483000}' +
|
||||
'.lendry-sso-onetap-btn{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-weight:600;line-height:1;cursor:pointer;white-space:nowrap;transition:transform .18s ease,box-shadow .18s ease,background .18s ease,border-color .18s ease;box-shadow:0 8px 24px rgba(31,36,48,.12)}' +
|
||||
'.lendry-sso-onetap-btn:hover{transform:translateY(-1px);box-shadow:0 12px 28px rgba(31,36,48,.16)}' +
|
||||
'.lendry-sso-onetap-btn:active{transform:translateY(0)}' +
|
||||
'.lendry-sso-onetap-badge{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#111;color:#fff;font-size:12px;font-weight:700}';
|
||||
'.lendry-sso-onetap-badge{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;font-weight:700}';
|
||||
global.document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function createWidget(providerName, onClick) {
|
||||
injectWidgetStyles();
|
||||
if (global.document.getElementById(widgetRootId)) return;
|
||||
function applyButtonStyles(button, badge, providerName, btn) {
|
||||
var preset = SIZE_PRESETS[btn.size] || SIZE_PRESETS.xl;
|
||||
var palette = themePalette(btn.theme === 'dark' ? 'dark' : 'light');
|
||||
var bg = btn.colors.bg || palette.bg;
|
||||
var bgHover = btn.colors.bgHover || palette.bgHover;
|
||||
var border = btn.colors.border || palette.border;
|
||||
var borderHover = btn.colors.borderHover || palette.borderHover;
|
||||
var textColor = btn.colors.text || palette.text;
|
||||
var radius = btn.radius != null && !isNaN(btn.radius) ? btn.radius : preset.radius;
|
||||
var iconOnly = btn.view === 'icon';
|
||||
|
||||
var root = global.document.createElement('div');
|
||||
root.id = widgetRootId;
|
||||
button.style.height = preset.height + 'px';
|
||||
button.style.fontSize = preset.font + 'px';
|
||||
button.style.gap = preset.gap + 'px';
|
||||
button.style.padding = iconOnly ? '0' : '0 ' + preset.padX + 'px';
|
||||
button.style.width = iconOnly ? preset.height + 'px' : 'auto';
|
||||
button.style.borderRadius = radius + 'px';
|
||||
button.style.background = bg;
|
||||
button.style.color = textColor;
|
||||
button.style.border = '1px solid ' + border;
|
||||
|
||||
button.addEventListener('mouseenter', function () {
|
||||
button.style.background = bgHover;
|
||||
button.style.borderColor = borderHover;
|
||||
});
|
||||
button.addEventListener('mouseleave', function () {
|
||||
button.style.background = bg;
|
||||
button.style.borderColor = border;
|
||||
});
|
||||
|
||||
if (badge) {
|
||||
badge.style.minWidth = preset.badge + 'px';
|
||||
badge.style.height = preset.badge + 'px';
|
||||
badge.style.padding = '0 ' + Math.round(preset.badge / 4) + 'px';
|
||||
badge.style.fontSize = Math.round(preset.font * 0.85) + 'px';
|
||||
badge.style.background = palette.badgeBg;
|
||||
badge.style.color = palette.badgeColor;
|
||||
}
|
||||
}
|
||||
|
||||
function createWidget(providerName, onClick, options) {
|
||||
injectWidgetStyles();
|
||||
var btn = options || readButtonOptions(null, null);
|
||||
var iconOnly = btn.view === 'icon';
|
||||
|
||||
var mount = null;
|
||||
if (btn.container) {
|
||||
mount = global.document.getElementById(btn.container);
|
||||
if (mount) {
|
||||
mount.innerHTML = '';
|
||||
}
|
||||
}
|
||||
if (!mount) {
|
||||
if (global.document.getElementById(widgetRootId)) return;
|
||||
mount = global.document.createElement('div');
|
||||
mount.id = widgetRootId;
|
||||
global.document.body.appendChild(mount);
|
||||
}
|
||||
|
||||
var button = global.document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'lendry-sso-onetap-btn';
|
||||
button.setAttribute('aria-label', 'Войти через ' + providerName);
|
||||
|
||||
var badge = global.document.createElement('span');
|
||||
var badge = null;
|
||||
if (btn.icon !== 'none') {
|
||||
badge = global.document.createElement('span');
|
||||
badge.className = 'lendry-sso-onetap-badge';
|
||||
badge.textContent = 'ID';
|
||||
button.appendChild(badge);
|
||||
}
|
||||
|
||||
if (!iconOnly) {
|
||||
var label = global.document.createElement('span');
|
||||
label.textContent = 'Войти через ' + providerName;
|
||||
|
||||
button.appendChild(badge);
|
||||
button.appendChild(label);
|
||||
button.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
root.appendChild(button);
|
||||
global.document.body.appendChild(root);
|
||||
applyButtonStyles(button, badge, providerName, btn);
|
||||
button.addEventListener('click', onClick);
|
||||
mount.appendChild(button);
|
||||
}
|
||||
|
||||
function buildPopupUrl(frontendBase, clientId, origin, redirectUri, scope) {
|
||||
@@ -288,6 +399,7 @@
|
||||
(options && options.redirectUri) ||
|
||||
(script && script.getAttribute('data-redirect-uri')) ||
|
||||
global.location.origin + '/auth/callback';
|
||||
var buttonOptions = readButtonOptions(script, options);
|
||||
|
||||
loginWithFedCM(idpApiBase, clientId).then(function (result) {
|
||||
if (result && result.token) {
|
||||
@@ -301,7 +413,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
createWidget(providerName, function () {
|
||||
createWidget(
|
||||
providerName,
|
||||
function () {
|
||||
var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope);
|
||||
openPopup(popupUrl, frontendBase, function (payload) {
|
||||
dispatchSuccess(
|
||||
@@ -316,7 +430,9 @@
|
||||
script
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
buttonOptions
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
116
install.sh
116
install.sh
@@ -1384,6 +1384,25 @@ intranet_wildcard_base_domain() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Регистрируемый домен (eTLD+1) — нужен для FedCM well-known.
|
||||
# FedCM ВСЕГДА запрашивает /.well-known/web-identity с eTLD+1 домена IdP:
|
||||
# sso.idpmvk.lpr -> idpmvk.lpr
|
||||
# api.id.example.ru -> example.ru
|
||||
# Берём два последних лейбла (корректно для большинства зон и кастомных TLD .lpr).
|
||||
# Исключение — многосоставные суффиксы вида .co.uk; для них укажите домен вручную.
|
||||
registrable_domain() {
|
||||
local domain="${1:-}"
|
||||
[[ -z "$domain" ]] && return 0
|
||||
local -a labels
|
||||
IFS='.' read -ra labels <<< "$domain"
|
||||
local n=${#labels[@]}
|
||||
if (( n <= 2 )); then
|
||||
echo "$domain"
|
||||
else
|
||||
echo "${labels[n-2]}.${labels[n-1]}"
|
||||
fi
|
||||
}
|
||||
|
||||
collect_self_signed_sans() {
|
||||
local -a sans=()
|
||||
local base domain
|
||||
@@ -1400,6 +1419,13 @@ collect_self_signed_sans() {
|
||||
sans+=("DNS:${base}")
|
||||
fi
|
||||
|
||||
# eTLD+1 для FedCM well-known (например idpmvk.lpr) — гарантированно в сертификате.
|
||||
local apex
|
||||
apex="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||||
if [[ -n "$apex" && "$apex" != "$base" ]]; then
|
||||
sans+=("DNS:${apex}")
|
||||
fi
|
||||
|
||||
(IFS=,; echo "${sans[*]}")
|
||||
}
|
||||
|
||||
@@ -1896,6 +1922,56 @@ EOF
|
||||
nginx_publish_config "frontend"
|
||||
}
|
||||
|
||||
# Сервер для eTLD+1 (например idpmvk.lpr): обслуживает FedCM well-known.
|
||||
# Браузер при FedCM-логине из стороннего приложения запрашивает
|
||||
# https://<eTLD+1>/.well-known/web-identity. Если этого домена нет в DNS —
|
||||
# получаем ERR_NAME_NOT_RESOLVED. Зеркалим idp-api/fedcm/oauth/.well-known на
|
||||
# api-gateway и корень на фронтенд, чтобы домен был полноценным алиасом IdP.
|
||||
write_nginx_site_fedcm_apex() {
|
||||
local domain="$1"
|
||||
local ssl_type="${2:-none}"
|
||||
local conf upstream http_preamble ssl_block idp_api_block
|
||||
conf="$(nginx_conf_target fedcm-apex)"
|
||||
upstream="$(nginx_upstream frontend)"
|
||||
http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")"
|
||||
idp_api_block="$(build_nginx_idp_api_proxy_block)"
|
||||
|
||||
if [[ "$ssl_type" == "none" ]]; then
|
||||
nginx_write_conf "$conf" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${domain};
|
||||
client_max_body_size 50m;
|
||||
${idp_api_block}
|
||||
location / {
|
||||
proxy_pass ${upstream};
|
||||
proxy_http_version 1.1;
|
||||
${nginx_proxy_headers}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
else
|
||||
ssl_block="$(nginx_ssl_directives "$domain" "$ssl_type")"
|
||||
nginx_write_conf "$conf" <<EOF
|
||||
${http_preamble}server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ${domain};
|
||||
${ssl_block}
|
||||
client_max_body_size 50m;
|
||||
${idp_api_block}
|
||||
location / {
|
||||
proxy_pass ${upstream};
|
||||
proxy_http_version 1.1;
|
||||
${nginx_proxy_headers}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
nginx_publish_config "fedcm-apex"
|
||||
}
|
||||
|
||||
write_nginx_site_docs() {
|
||||
local domain="$1"
|
||||
local ssl_type="${2:-none}"
|
||||
@@ -2059,6 +2135,21 @@ write_all_nginx_configs() {
|
||||
write_nginx_site_frontend "$DOMAIN_FRONTEND" "$ssl_type"
|
||||
write_nginx_site_docs "$DOMAIN_DOCS" "$ssl_type"
|
||||
|
||||
# FedCM well-known всегда запрашивается с eTLD+1 (idpmvk.lpr). Если фронтенд/API
|
||||
# живут на поддоменах, поднимаем отдельный server для корневого домена, иначе
|
||||
# сторонние приложения получают ERR_NAME_NOT_RESOLVED при FedCM-логине.
|
||||
local apex_domain
|
||||
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||||
if [[ -n "$apex_domain" \
|
||||
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
|
||||
&& "$apex_domain" != "$DOMAIN_API" \
|
||||
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
|
||||
write_nginx_site_fedcm_apex "$apex_domain" "$ssl_type"
|
||||
else
|
||||
rm -f "${LOCAL_CONF_DIR}/${NGINX_PREFIX}-fedcm-apex.conf" 2>/dev/null || true
|
||||
run_root rm -f "${NGINX_ENABLED}/${NGINX_PREFIX}-fedcm-apex.conf" "${NGINX_AVAILABLE}/${NGINX_PREFIX}-fedcm-apex.conf" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$ws_on_api" == "false" ]]; then
|
||||
write_nginx_site_ws "$DOMAIN_WS" "$ssl_type"
|
||||
else
|
||||
@@ -2117,6 +2208,16 @@ obtain_all_certificates() {
|
||||
obtain_certificate "$DOMAIN_FRONTEND" "$email"
|
||||
obtain_certificate "$DOMAIN_DOCS" "$email"
|
||||
|
||||
# Сертификат для eTLD+1 (FedCM well-known), если он отдельный server.
|
||||
local apex_domain
|
||||
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||||
if [[ -n "$apex_domain" \
|
||||
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
|
||||
&& "$apex_domain" != "$DOMAIN_API" \
|
||||
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
|
||||
obtain_certificate "$apex_domain" "$email"
|
||||
fi
|
||||
|
||||
if [[ -n "${DOMAIN_WS:-}" && "${DOMAIN_WS}" != "${DOMAIN_API}" ]]; then
|
||||
obtain_certificate "$DOMAIN_WS" "$email"
|
||||
fi
|
||||
@@ -2248,9 +2349,22 @@ print_intranet_hint() {
|
||||
server_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
[[ -z "$server_ip" ]] && server_ip="<IP-сервера>"
|
||||
|
||||
local apex_domain extra_apex=""
|
||||
apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")"
|
||||
if [[ -n "$apex_domain" \
|
||||
&& "$apex_domain" != "$DOMAIN_FRONTEND" \
|
||||
&& "$apex_domain" != "$DOMAIN_API" \
|
||||
&& "$apex_domain" != "${DOMAIN_DOCS:-}" ]]; then
|
||||
extra_apex=" ${apex_domain}"
|
||||
fi
|
||||
|
||||
echo -e " ${BOLD}Локальная сеть (DNS / hosts):${NC}"
|
||||
echo " Пропишите во внутреннем DNS или C:\\Windows\\System32\\drivers\\etc\\hosts:"
|
||||
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}"
|
||||
echo " ${server_ip} ${DOMAIN_API} ${DOMAIN_FRONTEND} ${DOMAIN_DOCS}${extra_apex}"
|
||||
if [[ -n "$extra_apex" ]]; then
|
||||
echo " (${apex_domain} обязателен для входа через FedCM/One Tap из сторонних приложений —"
|
||||
echo " браузер берёт /.well-known/web-identity именно с корневого домена)"
|
||||
fi
|
||||
echo ""
|
||||
if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then
|
||||
echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов)"
|
||||
|
||||
Reference in New Issue
Block a user