fix and update
This commit is contained in:
@@ -8,7 +8,7 @@ import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
AdminBotMetrics,
|
||||
AdminUser,
|
||||
@@ -107,7 +107,7 @@ export default function AdminBotsPage() {
|
||||
<p className="text-sm text-[#667085]">Найдено: {total}</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -191,7 +191,7 @@ export default function AdminBotsPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
@@ -218,7 +218,7 @@ export default function AdminBotsPage() {
|
||||
<p className="mb-4 text-sm text-[#667085]">
|
||||
Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -270,7 +270,7 @@ export default function AdminBotsPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus } from '@/lib/api';
|
||||
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
||||
@@ -324,7 +324,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-[24px] border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@@ -446,7 +446,7 @@ export default function AdminUsersPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ChatImageLightboxProps {
|
||||
open: boolean;
|
||||
images: ChatMessage[];
|
||||
index: number;
|
||||
mediaUrls: Record<string, { blobUrl: string; fileName?: string } | undefined>;
|
||||
mediaUrls: Record<string, { blobUrl?: string; fileName?: string } | undefined>;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onDownload?: (message: ChatMessage) => void;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatMediaAlbumGridProps {
|
||||
messages: ChatMessage[];
|
||||
mediaUrls: Record<string, { blobUrl: string } | undefined>;
|
||||
mediaUrls: Record<string, { blobUrl?: string } | undefined>;
|
||||
className?: string;
|
||||
onImageClick?: (message: ChatMessage, index: number) => void;
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ChatMediaComposeDialog({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
accept="*/*"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) onAddFiles(files);
|
||||
|
||||
@@ -152,10 +152,12 @@ interface PresignedUploadResponse {
|
||||
}
|
||||
|
||||
interface LoadedChatMedia {
|
||||
blobUrl: string;
|
||||
expiresAt: number;
|
||||
blobUrl?: string;
|
||||
expiresAt?: number;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function formatMessageTime(value: string) {
|
||||
@@ -318,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||
const mediaLoadInFlightRef = useRef<Set<string>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
@@ -678,7 +681,37 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
if (!token || !activeRoomId || !message.storageKey) return;
|
||||
const storageKey = message.storageKey;
|
||||
const existing = mediaUrlsRef.current[storageKey];
|
||||
if (!force && existing && !shouldRefreshMedia(existing.expiresAt)) return;
|
||||
if (!force && existing?.status === 'ready' && existing.blobUrl && existing.expiresAt && !shouldRefreshMedia(existing.expiresAt)) {
|
||||
return;
|
||||
}
|
||||
if (mediaLoadInFlightRef.current.has(storageKey) && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.isEncrypted && activeRoomIsE2E) {
|
||||
if (peerKeyLoading) return;
|
||||
if (!peerE2eKey || !user?.id || !message.content) {
|
||||
setMediaUrls((current) => ({
|
||||
...current,
|
||||
[storageKey]: {
|
||||
...current[storageKey],
|
||||
status: 'error',
|
||||
errorMessage: 'Не удалось расшифровать файл'
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
mediaLoadInFlightRef.current.add(storageKey);
|
||||
setMediaUrls((current) => ({
|
||||
...current,
|
||||
[storageKey]: {
|
||||
...current[storageKey],
|
||||
status: 'loading',
|
||||
errorMessage: undefined
|
||||
}
|
||||
}));
|
||||
|
||||
let meta = parseMessageMetadata(message.metadataJson);
|
||||
let fileName = meta.fileName || message.content || undefined;
|
||||
@@ -709,23 +742,50 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
blobUrl,
|
||||
expiresAt: mediaExpiresAtMs(access.expiresAt),
|
||||
fileName,
|
||||
mimeType
|
||||
mimeType,
|
||||
status: 'ready'
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// ignore failed media loads in background
|
||||
} catch (error) {
|
||||
setMediaUrls((current) => ({
|
||||
...current,
|
||||
[storageKey]: {
|
||||
...current[storageKey],
|
||||
status: 'error',
|
||||
errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл'
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
mediaLoadInFlightRef.current.delete(storageKey);
|
||||
}
|
||||
},
|
||||
[activeRoomId, activeRoomIsE2E, peerE2eKey, token, user?.id]
|
||||
[activeRoomId, activeRoomIsE2E, peerE2eKey, peerKeyLoading, token, user?.id]
|
||||
);
|
||||
|
||||
const cacheUploadedMedia = useCallback((storageKey: string, file: File) => {
|
||||
const blobUrl = createBlobObjectUrl(file);
|
||||
setMediaUrls((current) => {
|
||||
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
|
||||
return {
|
||||
...current,
|
||||
[storageKey]: {
|
||||
blobUrl,
|
||||
expiresAt: Date.now() + 30 * 60_000,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || resolveChatContentType(file),
|
||||
status: 'ready'
|
||||
}
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !activeRoomId) return;
|
||||
messages.forEach((message) => {
|
||||
if (message.storageKey) void loadChatMedia(message);
|
||||
});
|
||||
}, [activeRoomId, loadChatMedia, messages, token]);
|
||||
}, [activeRoomId, loadChatMedia, messages, peerE2eKey, peerKeyLoading, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !activeRoomId) return;
|
||||
@@ -733,7 +793,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
messages.forEach((message) => {
|
||||
if (!message.storageKey) return;
|
||||
const cached = mediaUrlsRef.current[message.storageKey];
|
||||
if (cached && shouldRefreshMedia(cached.expiresAt)) {
|
||||
if (cached?.status === 'ready' && cached.expiresAt && shouldRefreshMedia(cached.expiresAt)) {
|
||||
void loadChatMedia(message, true);
|
||||
}
|
||||
});
|
||||
@@ -1323,6 +1383,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
token
|
||||
);
|
||||
appendMessage(message);
|
||||
cacheUploadedMedia(presigned.storageKey, file);
|
||||
void loadGroup();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
|
||||
@@ -1366,7 +1427,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
peerE2eKey,
|
||||
encryptOutgoing
|
||||
});
|
||||
uploaded.forEach((message) => appendMessage(message));
|
||||
uploaded.forEach((message, index) => {
|
||||
appendMessage(message);
|
||||
const sourceFile = mediaComposer.items[index]?.file;
|
||||
if (message.storageKey && sourceFile) {
|
||||
cacheUploadedMedia(message.storageKey, sourceFile);
|
||||
}
|
||||
});
|
||||
mediaComposer.close();
|
||||
void loadGroup();
|
||||
} catch (error) {
|
||||
@@ -1972,7 +2039,21 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const media = mediaUrls[message.storageKey];
|
||||
const meta = parseMessageMetadata(message.metadataJson);
|
||||
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
|
||||
if (!media?.blobUrl) {
|
||||
if (media?.status === 'error') {
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-[#667085]">
|
||||
<p>{media.errorMessage ?? 'Не удалось загрузить файл'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-[#3390ec] hover:underline"
|
||||
onClick={() => void loadChatMedia(message, true)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!media?.blobUrl || media.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-[#667085]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -2275,7 +2356,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
accept="*/*"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) queueMediaFiles(files);
|
||||
|
||||
@@ -1386,7 +1386,7 @@ export function MiniFamilyChat() {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip,.rar,.7z"
|
||||
accept="*/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
|
||||
@@ -11,6 +11,8 @@ import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MOBILE_HEADER_HEIGHT = 'calc(3.5rem + env(safe-area-inset-top))';
|
||||
|
||||
export function IdShell({
|
||||
active,
|
||||
children,
|
||||
@@ -25,10 +27,13 @@ export function IdShell({
|
||||
return (
|
||||
<FamilyOverlayProvider>
|
||||
<EmojiSpriteProvider />
|
||||
<div className="min-h-screen overflow-x-hidden bg-white">
|
||||
<div className="min-h-screen bg-white">
|
||||
<Sidebar active={active} />
|
||||
{!fullBleed ? (
|
||||
<div className="fixed left-0 right-0 top-0 z-40 flex items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 py-3 backdrop-blur lg:hidden">
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-40 flex h-[var(--mobile-header-height)] items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 backdrop-blur lg:hidden"
|
||||
style={{ ['--mobile-header-height' as string]: MOBILE_HEADER_HEIGHT, paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<Link href="/" className="shrink-0">
|
||||
<BrandLogo size="sm" variant="dark" />
|
||||
</Link>
|
||||
@@ -44,9 +49,10 @@ export function IdShell({
|
||||
</div>
|
||||
<main
|
||||
className={cn(
|
||||
'pb-[calc(4.5rem+env(safe-area-inset-bottom))] lg:pb-8 lg:pl-[220px] lg:pr-8',
|
||||
fullBleed ? 'px-0 pt-0 lg:pt-8' : 'px-0 pt-14 lg:pt-8'
|
||||
'min-w-0 pb-[calc(4.5rem+env(safe-area-inset-bottom))] lg:pb-8 lg:pl-[220px] lg:pr-8',
|
||||
fullBleed ? 'px-0 pt-0 lg:pt-8' : 'px-0 pt-[var(--mobile-header-height)] lg:pt-8'
|
||||
)}
|
||||
style={fullBleed ? undefined : ({ ['--mobile-header-height' as string]: MOBILE_HEADER_HEIGHT } as React.CSSProperties)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -2,7 +2,21 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
|
||||
return <table className={cn('w-full min-w-[720px] caption-bottom text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableContainer({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-full overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
|
||||
@@ -31,7 +31,7 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
rar: 'application/vnd.rar',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml'
|
||||
apk: 'application/vnd.android.package-archive'
|
||||
};
|
||||
|
||||
const MIME_ALIASES: Record<string, string> = {
|
||||
@@ -110,6 +110,7 @@ export function fileIconLabel(fileName?: string, mimeType?: string) {
|
||||
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS';
|
||||
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
|
||||
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
|
||||
if (extension === 'apk') return 'APK';
|
||||
if (extension) return extension.toUpperCase().slice(0, 4);
|
||||
return 'FILE';
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ const nextConfig: NextConfig = {
|
||||
source: '/oauth/:path*',
|
||||
destination: `${internalApiUrl}/oauth/:path*`
|
||||
},
|
||||
{
|
||||
source: '/fedcm/:path*',
|
||||
destination: `${internalApiUrl}/fedcm/:path*`
|
||||
},
|
||||
{
|
||||
source: '/.well-known/:path*',
|
||||
destination: `${internalApiUrl}/.well-known/:path*`
|
||||
|
||||
274
apps/frontend/public/sso-widget.js
Normal file
274
apps/frontend/public/sso-widget.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(function lendrySsoWidget(global) {
|
||||
'use strict';
|
||||
|
||||
var MESSAGE_TYPE = 'lendry-sso-onetap';
|
||||
var DEFAULT_SCOPE = 'openid profile email';
|
||||
var widgetRootId = 'lendry-sso-onetap-root';
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveScriptElement() {
|
||||
var current = global.document.currentScript;
|
||||
if (current) return current;
|
||||
var scripts = global.document.getElementsByTagName('script');
|
||||
for (var i = scripts.length - 1; i >= 0; i -= 1) {
|
||||
var src = scripts[i].getAttribute('src') || '';
|
||||
if (src.indexOf('sso-widget.js') !== -1) {
|
||||
return scripts[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveIdpApiBase(script) {
|
||||
var override = script && script.getAttribute('data-idp-url');
|
||||
if (override) return trimSlash(override);
|
||||
try {
|
||||
var origin = new URL(script.src).origin;
|
||||
return trimSlash(origin + '/idp-api');
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFrontendBase(script, idpApiBase) {
|
||||
var override = script && script.getAttribute('data-idp-frontend-url');
|
||||
if (override) return trimSlash(override);
|
||||
try {
|
||||
return new URL(script.src).origin;
|
||||
} catch (_error) {
|
||||
if (idpApiBase.indexOf('/idp-api') !== -1) {
|
||||
return idpApiBase.replace(/\/idp-api$/, '');
|
||||
}
|
||||
return idpApiBase;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProviderName(script) {
|
||||
return (script && script.getAttribute('data-provider-name')) || 'MVK ID';
|
||||
}
|
||||
|
||||
function dispatchSuccess(payload, script) {
|
||||
var callbackName = script && script.getAttribute('data-on-success');
|
||||
if (callbackName && typeof global[callbackName] === 'function') {
|
||||
global[callbackName](payload);
|
||||
}
|
||||
try {
|
||||
global.dispatchEvent(new CustomEvent('lendry-sso-onetap-success', { detail: payload }));
|
||||
} catch (_error) {
|
||||
// IE fallback not required
|
||||
}
|
||||
}
|
||||
|
||||
function supportsFedCM() {
|
||||
return 'IdentityCredential' in global && global.navigator && typeof global.navigator.credentials !== 'undefined';
|
||||
}
|
||||
|
||||
function loginWithFedCM(idpApiBase, clientId) {
|
||||
if (!supportsFedCM()) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return global.navigator.credentials
|
||||
.get({
|
||||
identity: {
|
||||
providers: [
|
||||
{
|
||||
configURL: idpApiBase + '/fedcm/config.json',
|
||||
clientId: clientId
|
||||
}
|
||||
]
|
||||
},
|
||||
mediation: 'optional'
|
||||
})
|
||||
.then(function (credential) {
|
||||
if (!credential || typeof credential !== 'object') return null;
|
||||
var token = credential.token;
|
||||
if (!token) return null;
|
||||
return {
|
||||
token: token,
|
||||
method: 'fedcm'
|
||||
};
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (global.console && typeof global.console.warn === 'function') {
|
||||
global.console.warn('[MVK ID] FedCM недоступен:', error);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function injectWidgetStyles() {
|
||||
if (global.document.getElementById('lendry-sso-onetap-style')) return;
|
||||
var style = global.document.createElement('style');
|
||||
style.id = 'lendry-sso-onetap-style';
|
||||
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}' +
|
||||
'.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}';
|
||||
global.document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function createWidget(providerName, onClick) {
|
||||
injectWidgetStyles();
|
||||
if (global.document.getElementById(widgetRootId)) return;
|
||||
|
||||
var root = global.document.createElement('div');
|
||||
root.id = widgetRootId;
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
function buildPopupUrl(frontendBase, clientId, origin, redirectUri, scope) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('client_id', clientId);
|
||||
params.set('redirect_uri', redirectUri);
|
||||
params.set('response_type', 'code');
|
||||
params.set('scope', scope || DEFAULT_SCOPE);
|
||||
params.set('display', 'popup');
|
||||
params.set('popup_origin', origin);
|
||||
params.set('state', 'onetap_' + Math.random().toString(36).slice(2));
|
||||
return frontendBase + '/auth/oauth/authorize?' + params.toString();
|
||||
}
|
||||
|
||||
function openPopup(url, expectedOrigin, onToken) {
|
||||
var width = 480;
|
||||
var height = 640;
|
||||
var left = Math.max(0, Math.round((global.screen.width - width) / 2));
|
||||
var top = Math.max(0, Math.round((global.screen.height - height) / 2));
|
||||
var features =
|
||||
'popup=yes,width=' +
|
||||
width +
|
||||
',height=' +
|
||||
height +
|
||||
',left=' +
|
||||
left +
|
||||
',top=' +
|
||||
top +
|
||||
',noopener,noreferrer';
|
||||
|
||||
var popup = global.open(url, 'lendry_sso_onetap', features);
|
||||
if (!popup) {
|
||||
if (global.console && typeof global.console.error === 'function') {
|
||||
global.console.error('[MVK ID] Не удалось открыть popup. Разрешите всплывающие окна.');
|
||||
}
|
||||
return function cleanup() {};
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
if (!event || !event.data || event.data.type !== MESSAGE_TYPE) return;
|
||||
if (expectedOrigin && event.origin !== expectedOrigin && event.origin !== trimSlash(expectedOrigin)) return;
|
||||
|
||||
cleanup();
|
||||
onToken(event.data);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
global.removeEventListener('message', handleMessage);
|
||||
if (popup && !popup.closed) {
|
||||
try {
|
||||
popup.close();
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.addEventListener('message', handleMessage);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function initOneTap(options) {
|
||||
var script = resolveScriptElement();
|
||||
var clientId = (options && options.clientId) || (script && script.getAttribute('data-client-id'));
|
||||
if (!clientId) {
|
||||
if (global.console && typeof global.console.error === 'function') {
|
||||
global.console.error('[MVK ID] Укажите data-client-id в теге script или clientId в LendryIdOneTap.init().');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var idpApiBase = trimSlash((options && options.idpUrl) || resolveIdpApiBase(script));
|
||||
var frontendBase = trimSlash((options && options.frontendUrl) || resolveFrontendBase(script, idpApiBase));
|
||||
var providerName = (options && options.providerName) || resolveProviderName(script);
|
||||
var scope = (options && options.scope) || (script && script.getAttribute('data-scope')) || DEFAULT_SCOPE;
|
||||
var redirectUri =
|
||||
(options && options.redirectUri) ||
|
||||
(script && script.getAttribute('data-redirect-uri')) ||
|
||||
global.location.origin + '/auth/callback';
|
||||
|
||||
loginWithFedCM(idpApiBase, clientId).then(function (result) {
|
||||
if (result && result.token) {
|
||||
dispatchSuccess(
|
||||
{
|
||||
token: result.token,
|
||||
method: 'fedcm'
|
||||
},
|
||||
script
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (supportsFedCM()) {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
global.LendryIdOneTap = {
|
||||
init: initOneTap,
|
||||
loginWithFedCM: loginWithFedCM,
|
||||
MESSAGE_TYPE: MESSAGE_TYPE
|
||||
};
|
||||
|
||||
var scriptEl = resolveScriptElement();
|
||||
if (scriptEl && scriptEl.getAttribute('data-auto-init') !== 'false') {
|
||||
if (global.document.readyState === 'loading') {
|
||||
global.document.addEventListener('DOMContentLoaded', function () {
|
||||
initOneTap();
|
||||
});
|
||||
} else {
|
||||
initOneTap();
|
||||
}
|
||||
}
|
||||
})(window);
|
||||
Reference in New Issue
Block a user