feat: V2 WebView 컴포넌트 + SSO 연동 구현
- V2WebView 컴포넌트: iframe 기반 외부 웹 임베딩 - SSO 연동: 현재 로그인 JWT를 sso_token 파라미터로 자동 전달 - /api/system/raw-token: 범용 JWT 토큰 조회 API - V2WebViewConfigPanel: URL, SSO, sandbox 등 설정 UI + 개발자 가이드 Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { V2WebViewConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface V2WebViewComponentProps extends ComponentRendererProps {}
|
||||
|
||||
export const V2WebViewComponent: React.FC<V2WebViewComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const config = (component.componentConfig || {}) as V2WebViewConfig;
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const baseUrl = config.url ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl) {
|
||||
setIframeSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.useSSO) {
|
||||
setIframeSrc(baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const paramName = "sso_token";
|
||||
|
||||
fetch("/api/system/raw-token")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data.token) {
|
||||
const separator = baseUrl.includes("?") ? "&" : "?";
|
||||
setIframeSrc(`${baseUrl}${separator}${encodeURIComponent(paramName)}=${encodeURIComponent(data.token)}`);
|
||||
} else {
|
||||
setError(data.error ?? "토큰을 가져올 수 없습니다");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("토큰 조회 실패");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseUrl, config.useSSO]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 400}px`,
|
||||
height: `${component.style?.height || 300}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : config.showBorder ? "1px solid #e0e0e0" : "none",
|
||||
borderRadius: config.borderRadius || "8px",
|
||||
overflow: "hidden",
|
||||
background: "#fafafa",
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
const domProps = filterDOMProps(props);
|
||||
|
||||
// 디자인 모드: URL 미리보기 표시
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div style={containerStyle} className="v2-web-view-component" onClick={handleClick} {...domProps}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
color: "#666",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span style={{ fontWeight: 500 }}>웹 뷰</span>
|
||||
{baseUrl ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#999",
|
||||
maxWidth: "90%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{baseUrl}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: "11px", color: "#bbb" }}>URL을 설정하세요</span>
|
||||
)}
|
||||
{config.useSSO && <span style={{ fontSize: "10px", color: "#4caf50" }}>SSO: ?sso_token=JWT</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 런타임 모드
|
||||
return (
|
||||
<div style={containerStyle} className="v2-web-view-component" {...domProps}>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
{config.loadingText || "로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#f44336",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && iframeSrc && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
style={{ width: "100%", height: "100%", border: "none" }}
|
||||
sandbox={config.sandbox ? "allow-scripts allow-same-origin allow-forms allow-popups" : undefined}
|
||||
allowFullScreen={config.allowFullscreen}
|
||||
title="Web View"
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && !iframeSrc && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#bbb",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
URL이 설정되지 않았습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const V2WebViewWrapper: React.FC<V2WebViewComponentProps> = (props) => {
|
||||
return <V2WebViewComponent {...props} />;
|
||||
};
|
||||
Reference in New Issue
Block a user