diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts index 882798d..c0cb5d6 100644 --- a/apps/api-gateway/src/lib/fedcm-config.ts +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -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 { 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 { diff --git a/apps/api-gateway/src/lib/public-url.ts b/apps/api-gateway/src/lib/public-url.ts index 3459873..f04da24 100644 --- a/apps/api-gateway/src/lib/public-url.ts +++ b/apps/api-gateway/src/lib/public-url.ts @@ -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 { diff --git a/apps/docs/components/doc-block-renderer.tsx b/apps/docs/components/doc-block-renderer.tsx index 2fa786c..61c4246 100644 --- a/apps/docs/components/doc-block-renderer.tsx +++ b/apps/docs/components/doc-block-renderer.tsx @@ -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 ; case 'one-tap-examples': return ; + case 'one-tap-builder': + return ; case 'auth-login-examples': return ; case 'auth-ldap-examples': diff --git a/apps/docs/components/one-tap-button-builder.tsx b/apps/docs/components/one-tap-button-builder.tsx new file mode 100644 index 0000000..9f5d64a --- /dev/null +++ b/apps/docs/components/one-tap-button-builder.tsx @@ -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 ( + + ); +} + +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(DEFAULT_BUILDER_OPTIONS); + const [hovered, setHovered] = useState(false); + const [status, setStatus] = useState(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(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 ( +
+ {/* Левая колонка: превью + код */} +
+
+ +
+ + {status ? ( +

+ {status} +

+ ) : null} + + +
+ + {/* Правая колонка: параметры */} +
+ + update('clientId', event.target.value)} + placeholder="YOUR_CLIENT_ID" + /> + + + + update('redirectUri', event.target.value)} + placeholder="https://app.example.com/auth/callback" + /> + + + + update('providerName', event.target.value)} + placeholder={projectName} + /> + + +
+ + + + + + + + + + + + + + + +
+ + + update('radius', Number(event.target.value))} + className="w-full accent-zinc-900 dark:accent-zinc-100" + /> + + +
+

+ CSS-цвета (необязательно — иначе берётся цвет темы) +

+ {COLOR_FIELDS.map((field) => { + const enabled = Boolean(options[field.key]); + const fallback = style.palette[field.key] || '#ffffff'; + const value = options[field.key] || fallback; + return ( +
+ toggleColor(field.key, event.target.checked)} + className="h-4 w-4 accent-zinc-900 dark:accent-zinc-100" + /> + 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')} + /> + {field.label} + {enabled ? ( + {options[field.key]} + ) : null} +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index a739171..3b5d3a5 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -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 вручную', diff --git a/apps/docs/lib/one-tap-builder.ts b/apps/docs/lib/one-tap-builder.ts new file mode 100644 index 0000000..92169c8 --- /dev/null +++ b/apps/docs/lib/one-tap-builder.ts @@ -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 = { + 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 ` +
+ + + + +`; +} diff --git a/apps/frontend/public/sso-widget.js b/apps/frontend/public/sso-widget.js index 7570135..289c7d8 100644 --- a/apps/frontend/public/sso-widget.js +++ b/apps/frontend/public/sso-widget.js @@ -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'); - badge.className = 'lendry-sso-onetap-badge'; - badge.textContent = 'ID'; + var badge = null; + if (btn.icon !== 'none') { + badge = global.document.createElement('span'); + badge.className = 'lendry-sso-onetap-badge'; + badge.textContent = 'ID'; + button.appendChild(badge); + } - var label = global.document.createElement('span'); - label.textContent = 'Войти через ' + providerName; + if (!iconOnly) { + var label = global.document.createElement('span'); + label.textContent = 'Войти через ' + providerName; + button.appendChild(label); + } - button.appendChild(badge); - button.appendChild(label); + applyButtonStyles(button, badge, providerName, btn); button.addEventListener('click', onClick); - - root.appendChild(button); - global.document.body.appendChild(root); + 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,22 +413,26 @@ return; } - createWidget(providerName, function () { - var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope); - openPopup(popupUrl, frontendBase, function (payload) { - dispatchSuccess( - { - token: payload.token, - accessToken: payload.accessToken, - idToken: payload.idToken, - refreshToken: payload.refreshToken, - code: payload.code, - method: 'popup' - }, - script - ); - }); - }); + createWidget( + providerName, + function () { + var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope); + openPopup(popupUrl, frontendBase, function (payload) { + dispatchSuccess( + { + token: payload.token, + accessToken: payload.accessToken, + idToken: payload.idToken, + refreshToken: payload.refreshToken, + code: payload.code, + method: 'popup' + }, + script + ); + }); + }, + buttonOptions + ); }); } diff --git a/install.sh b/install.sh index 2e883d6..8897e7b 100644 --- a/install.sh +++ b/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:///.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" </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="" + 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 (общий для всех доменов)"