diff --git a/.env.sample b/.env.sample index 60e96060..bd8f46b3 100644 --- a/.env.sample +++ b/.env.sample @@ -88,8 +88,12 @@ HYDRA_VERSION=v25.4.0-distroless # Ory Keto Configuration KETO_VERSION=v25.4.0-distroless +KETO_READ_URL=http://keto:4466 +KETO_WRITE_URL=http://keto:4467 # KETO_READ_PORT=4466 # Internal only # KETO_WRITE_PORT=4467 # Internal only +KETO_READ_URL=http://keto:4466 +KETO_WRITE_URL=http://keto:4467 # Kratos Selfservice UI upstreams (override for deployments) ORY_SDK_URL=http://kratos:4433 diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml new file mode 100644 index 00000000..8d4bfa0a --- /dev/null +++ b/.gitea/workflows/staging_code_pull.yml @@ -0,0 +1,179 @@ +name: Release Baron SSO to Staging + +on: + workflow_dispatch: + inputs: + target_branch: + description: "Branch to deploy" + required: true + default: "dev" + +jobs: + deploy-staging: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }} + + - name: Deploy to Staging by git pull + env: + DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + STAGE_HOST: ${{ vars.STAGE_HOST }} + STAGE_USER: ${{ vars.STAGE_USER }} + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + set -euo pipefail + + echo "DEBUG: STAGE_USER='${STAGE_USER}'" + echo "DEBUG: STAGE_HOST='${STAGE_HOST}'" + echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'" + echo "DEBUG: TARGET_BRANCH='${TARGET_BRANCH}'" + + # Sanity check + if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ] || [ -z "${TARGET_BRANCH}" ]; then + echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH/TARGET_BRANCH)." + exit 1 + fi + + ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts + ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'" + + # .env 파일 생성 + cat <<'EOF' > .env + APP_ENV=${{ vars.APP_ENV }} + TZ=Asia/Seoul + IDP_PROVIDER=ory + + # DB & Clickhouse + DB_PORT=${{ vars.DB_PORT }} + CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }} + CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }} + CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }} + CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }} + CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }} + + + BACKEND_PORT=${{ vars.BACKEND_PORT }} + ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }} + DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }} + USERFRONT_PORT=${{ vars.USERFRONT_PORT }} + + OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }} + + DB_USER=${{ vars.DB_USER }} + DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }} + DB_NAME=${{ vars.DB_NAME }} + COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }} + JWT_SECRET=${{ secrets.STG_JWT_SECRET }} + REDIS_ADDR=${{ vars.REDIS_ADDR }} + CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }} + AUDIT_WORKER_COUNT=5 + AUDIT_QUEUE_SIZE=2000 + PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }} + DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }} + NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }} + NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }} + NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }} + NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }} + AWS_REGION=${{ vars.AWS_REGION }} + AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }} + ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }} + ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }} + USERFRONT_URL=${{ vars.USERFRONT_URL }} + BACKEND_URL=${{ vars.BACKEND_URL }} + OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }} + ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }} + ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }} + ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }} + ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }} + KRATOS_DB=${{ vars.KRATOS_DB }} + HYDRA_DB=${{ vars.HYDRA_DB }} + KETO_DB=${{ vars.KETO_DB }} + KRATOS_VERSION=${{ vars.KRATOS_VERSION }} + KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }} + HYDRA_VERSION=${{ vars.HYDRA_VERSION }} + KETO_VERSION=${{ vars.KETO_VERSION }} + ORY_SDK_URL=${{ vars.ORY_SDK_URL }} + KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }} + KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }} + KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }} + KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} + HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} + HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} + JWKS_URL=${{ vars.JWKS_URL }} + OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} + OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} + OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }} + OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }} + OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }} + OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }} + OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }} + CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }} + CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }} + # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} + # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} + EOF + + # 코드 업데이트 (Git) + ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \ + if [ ! -d .git ]; then + git init + git remote add origin ssh://git@172.16.10.175:222/baron/baron-sso.git + else + git remote set-url origin ssh://git@172.16.10.175:222/baron/baron-sso.git + fi + git fetch --depth 1 origin '${TARGET_BRANCH}' && \ + git reset --hard FETCH_HEAD && \ + git clean -fd && \ + git checkout -B '${TARGET_BRANCH}' FETCH_HEAD" + + # .env 파일 복사 + scp .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/" + + # 배포 실행 + ssh "${STAGE_USER}@${STAGE_HOST}" "DEPLOY_PATH='${DEPLOY_PATH}' bash -s" <<'EOSSH' + set -euo pipefail + cd "${DEPLOY_PATH}" + set -a; . ./.env; set +a; + + # 네트워크 생성 + for net in baron_net public_net ory-net hydranet kratosnet; do + docker network inspect "${net}" >/dev/null 2>&1 || docker network create "${net}" + done + + + # [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨) + chmod -R 777 docker/ory || true + + cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml + + docker compose -f staging_pull_compose.yaml pull + + # [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨. + docker compose -f staging_pull_compose.yaml down || true + + # 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등) + docker compose -f staging_pull_compose.yaml build --pull + + docker compose -f staging_pull_compose.yaml up -d --remove-orphans + + # 배포 후 상태 확인 (실패 시 로그 출력을 위함) + sleep 10 + kratos_migrate_cid="$(docker compose -f staging_pull_compose.yaml ps -q kratos-migrate || true)" + if [ -n "${kratos_migrate_cid}" ]; then + if [ "$(docker inspect -f '{{.State.ExitCode}}' "${kratos_migrate_cid}")" -ne 0 ]; then + echo 'Kratos Migrate Failed. Logs:' + docker logs "${kratos_migrate_cid}" + exit 1 + fi + else + echo "WARN: kratos-migrate container not found; skipping exit-code check." + fi + EOSSH diff --git a/.gitignore b/.gitignore index 31c87998..31b94f32 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ # Docker Services Data (Volumes) postgres_data/ clickhouse_data/ +docker/ory/oathkeeper/logs/ # Backend (Go) backend/main diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html index 8371ce48..b2747991 100644 --- a/adminfront/playwright-report/index.html +++ b/adminfront/playwright-report/index.html @@ -1,85 +1,24255 @@ - - - - + + - - - + + + Playwright Test Report - - +`.trimStart(); + async function zv({ + testInfo: l, + metadata: u, + errorContext: c, + errors: f, + buildCodeFrame: r, + stdout: o, + stderr: h, + }) { + var S; + const v = new Set( + f + .filter( + (O) => + O.message && + !O.message.includes(` +`), + ) + .map((O) => O.message), + ); + for (const O of f) + for (const X of v.keys()) + (S = O.message) != null && S.includes(X) && v.delete(X); + const y = f.filter( + (O) => + !( + !O.message || + (!O.message.includes(` +`) && + !v.has(O.message)) + ), + ); + if (!y.length) return; + const A = [Qv, "# Test info", "", l]; + (o && A.push("", "# Stdout", "", "```", Jf(o), "```"), + h && A.push("", "# Stderr", "", "```", Jf(h), "```"), + A.push("", "# Error details")); + for (const O of y) A.push("", "```", Jf(O.message || ""), "```"); + c && A.push(c); + const E = await r(y[y.length - 1]); + return ( + E && A.push("", "# Test source", "", "```ts", E, "```"), + u != null && + u.gitDiff && + A.push("", "# Local changes", "", "```diff", u.gitDiff, "```"), + A.join(` +`) + ); + } + const Yv = new RegExp( + "([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", + "g", + ); + function Jf(l) { + return l.replace(Yv, ""); + } + function Lv(l, u) { + var f; + const c = new Map(); + for (const r of l) { + const o = r.name.match( + /^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/, + ); + if (!o) continue; + const [, h, v, y = ""] = o, + A = h + y; + let E = c.get(A); + (E || ((E = { name: A, anchors: [`attachment-${h}`] }), c.set(A, E)), + E.anchors.push(`attachment-${u.attachments.indexOf(r)}`), + v === "actual" && (E.actual = { attachment: r }), + v === "expected" && + (E.expected = { attachment: r, title: "Expected" }), + v === "previous" && + (E.expected = { attachment: r, title: "Previous" }), + v === "diff" && (E.diff = { attachment: r })); + } + for (const [r, o] of c) + !o.actual || !o.expected + ? c.delete(r) + : (l.delete(o.actual.attachment), + l.delete(o.expected.attachment), + l.delete((f = o.diff) == null ? void 0 : f.attachment)); + return [...c.values()]; + } + const Gv = ({ test: l, result: u, testRunMetadata: c, options: f }) => { + const { + screenshots: r, + videos: o, + traces: h, + otherAttachments: v, + diffs: y, + errors: A, + otherAttachmentAnchors: E, + screenshotAnchors: S, + errorContext: O, + } = ct.useMemo(() => { + const B = u.attachments.filter((N) => !N.name.startsWith("_")), + b = new Set(B.filter((N) => N.contentType.startsWith("image/"))), + p = [...b].map((N) => `attachment-${B.indexOf(N)}`), + x = B.filter((N) => N.contentType.startsWith("video/")), + R = B.filter((N) => N.name === "trace"), + U = B.find((N) => N.name === "error-context"), + Z = new Set(B); + [...b, ...x, ...R].forEach((N) => Z.delete(N)); + const F = [...Z].map((N) => `attachment-${B.indexOf(N)}`), + j = Lv(b, u), + D = u.errors.map((N) => N.message); + return { + screenshots: [...b], + videos: x, + traces: R, + otherAttachments: Z, + diffs: j, + errors: D, + otherAttachmentAnchors: F, + screenshotAnchors: p, + errorContext: U, + }; + }, [u]), + X = P5( + async () => { + if (f != null && f.noCopyPrompt) return; + const B = u.attachments.find((R) => R.name === "stdout"), + b = u.attachments.find((R) => R.name === "stderr"), + p = + B != null && B.body && B.contentType === "text/plain" + ? B.body + : void 0, + x = + b != null && b.body && b.contentType === "text/plain" + ? b.body + : void 0; + return await zv({ + testInfo: [ + `- Name: ${l.path.join(" >> ")} >> ${l.title}`, + `- Location: ${l.location.file}:${l.location.line}:${l.location.column}`, + ].join(` +`), + metadata: c, + errorContext: + O != null && O.path + ? await fetch(O.path).then((R) => R.text()) + : O == null + ? void 0 + : O.body, + errors: u.errors, + buildCodeFrame: async (R) => R.codeframe, + stdout: p, + stderr: x, + }); + }, + [l, O, c, u], + void 0, + ); + return m.jsxs("div", { + className: "test-result", + children: [ + !!A.length && + m.jsxs(ke, { + header: "Errors", + children: [ + X && + m.jsx("div", { + style: { + position: "absolute", + right: "16px", + padding: "10px", + zIndex: 1, + }, + children: m.jsx(Nv, { prompt: X }), + }), + A.map((B, b) => { + const p = Xv(B, y); + return m.jsxs(m.Fragment, { + children: [ + m.jsx( + wr, + { code: B }, + "test-result-error-message-" + b, + ), + p && m.jsx(Bv, { diff: p }), + ], + }); + }), + ], + }), + !!u.steps.length && + m.jsx(ke, { + header: "Test Steps", + children: u.steps.map((B, b) => + m.jsx( + cm, + { step: B, result: u, test: l, depth: 0 }, + `step-${b}`, + ), + ), + }), + y.map((B, b) => + m.jsx( + Si, + { + id: B.anchors, + children: m.jsx(ke, { + dataTestId: "test-results-image-diff", + header: `Image mismatch: ${B.name}`, + revealOnAnchorId: B.anchors, + children: m.jsx(um, { diff: B }), + }), + }, + `diff-${b}`, + ), + ), + !!r.length && + m.jsx(ke, { + header: "Screenshots", + revealOnAnchorId: S, + children: r.map((B, b) => + m.jsxs( + Si, + { + id: `attachment-${u.attachments.indexOf(B)}`, + children: [ + m.jsx("a", { + href: Ve(B.path), + children: m.jsx("img", { + className: "screenshot", + src: Ve(B.path), + }), + }), + m.jsx(nc, { attachment: B, result: u }), + ], + }, + `screenshot-${b}`, + ), + ), + }), + !!h.length && + m.jsx(Si, { + id: "attachment-trace", + children: m.jsx(ke, { + header: "Traces", + revealOnAnchorId: "attachment-trace", + children: m.jsxs("div", { + children: [ + m.jsx("a", { + href: Ve(nm(h)), + children: m.jsx("img", { + className: "screenshot", + src: Cv, + style: { width: 192, height: 117, marginLeft: 20 }, + }), + }), + h.map((B, b) => + m.jsx( + nc, + { + attachment: B, + result: u, + linkName: + h.length === 1 ? "trace" : `trace-${b + 1}`, + }, + `trace-${b}`, + ), + ), + ], + }), + }), + }), + !!o.length && + m.jsx(Si, { + id: "attachment-video", + children: m.jsx(ke, { + header: "Videos", + revealOnAnchorId: "attachment-video", + children: o.map((B) => + m.jsxs( + "div", + { + children: [ + m.jsx("video", { + controls: !0, + children: m.jsx("source", { + src: Ve(B.path), + type: B.contentType, + }), + }), + m.jsx(nc, { attachment: B, result: u }), + ], + }, + B.path, + ), + ), + }), + }), + !!v.size && + m.jsx(ke, { + header: "Attachments", + revealOnAnchorId: E, + dataTestId: "attachments", + children: [...v].map((B, b) => + m.jsx( + Si, + { + id: `attachment-${u.attachments.indexOf(B)}`, + children: m.jsx(nc, { + attachment: B, + result: u, + openInNewTab: B.contentType.startsWith("text/html"), + }), + }, + `attachment-link-${b}`, + ), + ), + }), + ], + }); + }; + function Xv(l, u) { + const c = l.split(` +`)[0]; + if ( + !(!c.includes("toHaveScreenshot") && !c.includes("toMatchSnapshot")) + ) + return u.find((f) => l.includes(f.name)); + } + const cm = ({ test: l, step: u, result: c, depth: f }) => { + const r = se(); + return m.jsx(Tv, { + title: m.jsxs("div", { + "aria-label": u.title, + className: "step-title-container", + children: [ + hc( + u.error || u.duration === -1 + ? "failed" + : u.skipped + ? "skipped" + : "passed", + ), + m.jsxs("span", { + className: "step-title-text", + children: [ + m.jsx("span", { children: u.title }), + u.count > 1 && + m.jsxs(m.Fragment, { + children: [ + " ✕ ", + m.jsx("span", { + className: "test-result-counter", + children: u.count, + }), + ], + }), + u.location && + m.jsxs("span", { + className: "test-result-path", + children: ["— ", u.location.file, ":", u.location.line], + }), + ], + }), + m.jsx("span", { className: "step-spacer" }), + u.attachments.length > 0 && + m.jsx("a", { + className: "step-attachment-link", + title: "reveal attachment", + href: Ve( + Cn( + { + test: l, + result: c, + anchor: `attachment-${u.attachments[0]}`, + }, + r, + ), + ), + onClick: (o) => { + o.stopPropagation(); + }, + children: Ih(), + }), + m.jsx("span", { + className: "step-duration", + children: Ol(u.duration), + }), + ], + }), + loadChildren: + u.steps.length || u.snippet + ? () => { + const o = u.snippet + ? [ + m.jsx( + wr, + { testId: "test-snippet", code: u.snippet }, + "line", + ), + ] + : [], + h = u.steps.map((v, y) => + m.jsx( + cm, + { step: v, depth: f + 1, result: c, test: l }, + y, + ), + ); + return o.concat(h); + } + : void 0, + depth: f, + }); + }, + Vv = ({ + projectNames: l, + test: u, + testRunMetadata: c, + run: f, + next: r, + prev: o, + options: h, + }) => { + const [v, y] = ct.useState(f), + A = se(), + E = u.annotations.filter((S) => !S.type.startsWith("_")) ?? []; + return m.jsxs(m.Fragment, { + children: [ + m.jsx(Or, { + title: u.title, + leftSuperHeader: m.jsx("div", { + className: "test-case-path", + children: u.path.join(" › "), + }), + rightSuperHeader: m.jsxs(m.Fragment, { + children: [ + m.jsx("div", { + className: Ze(!o && "hidden"), + children: m.jsx(Tn, { + href: Cn({ test: o }, A), + children: "« previous", + }), + }), + m.jsx("div", { style: { width: 10 } }), + m.jsx("div", { + className: Ze(!r && "hidden"), + children: m.jsx(Tn, { + href: Cn({ test: r }, A), + children: "next »", + }), + }), + ], + }), + }), + m.jsxs("div", { + className: "hbox", + style: { lineHeight: "24px" }, + children: [ + m.jsx("div", { + className: "test-case-location", + children: m.jsxs(Sr, { + value: `${u.location.file}:${u.location.line}`, + children: [u.location.file, ":", u.location.line], + }), + }), + m.jsx("div", { style: { flex: "auto" } }), + m.jsx(tm, { test: u, trailingSeparator: !0 }), + m.jsx("div", { + className: "test-case-duration", + children: Ol(u.duration), + }), + ], + }), + m.jsx($h, { + style: { marginLeft: "6px" }, + projectNames: l, + activeProjectName: u.projectName, + otherLabels: u.tags, + }), + u.results.length === 0 && + E.length !== 0 && + m.jsx(ke, { + header: "Annotations", + dataTestId: "test-case-annotations", + children: E.map((S, O) => m.jsx(z2, { annotation: S }, O)), + }), + m.jsx(Sv, { + tabs: + u.results.map((S, O) => ({ + id: String(O), + title: m.jsxs("div", { + style: { display: "flex", alignItems: "center" }, + children: [ + hc(S.status), + " ", + Zv(O), + u.results.length > 1 && + m.jsx("span", { + className: "test-case-run-duration", + children: Ol(S.duration), + }), + ], + }), + render: () => { + const X = S.annotations.filter( + (B) => !B.type.startsWith("_"), + ); + return m.jsxs(m.Fragment, { + children: [ + !!X.length && + m.jsx(ke, { + header: "Annotations", + dataTestId: "test-case-annotations", + children: X.map((B, b) => + m.jsx(z2, { annotation: B }, b), + ), + }), + m.jsx(Gv, { + test: u, + result: S, + testRunMetadata: c, + options: h, + }), + ], + }); + }, + })) || [], + selectedTab: String(v), + setSelectedTab: (S) => y(+S), + }), + ], + }); + }; + function z2({ annotation: { type: l, description: u } }) { + return m.jsxs("div", { + className: "test-case-annotation", + children: [ + m.jsx("span", { style: { fontWeight: "bold" }, children: l }), + u && m.jsxs(Sr, { value: u, children: [": ", Di(u)] }), + ], + }); + } + function Zv(l) { + return l ? `Retry #${l}` : "Run"; + } + const sm = ({ + file: l, + projectNames: u, + isFileExpanded: c, + setFileExpanded: f, + footer: r, + }) => { + const o = se(); + return m.jsx(im, { + expanded: c ? c(l.fileId) : void 0, + noInsets: !0, + setExpanded: f ? (h) => f(l.fileId, h) : void 0, + header: m.jsx("span", { + className: "chip-header-allow-selection", + children: l.fileName, + }), + footer: r, + children: l.tests.map((h) => + m.jsxs( + "div", + { + className: Ze( + "test-file-test", + "test-file-test-outcome-" + h.outcome, + ), + children: [ + m.jsxs("div", { + className: "hbox", + style: { alignItems: "flex-start" }, + children: [ + m.jsxs("div", { + className: "hbox", + children: [ + m.jsx("span", { + className: "test-file-test-status-icon", + children: hc(h.outcome), + }), + m.jsxs("span", { + children: [ + m.jsx(Tn, { + href: Cn({ test: h }, o), + title: [...h.path, h.title].join(" › "), + children: m.jsx("span", { + className: "test-file-title", + children: [...h.path, h.title].join(" › "), + }), + }), + m.jsx($h, { + style: { marginLeft: "6px" }, + projectNames: u, + activeProjectName: h.projectName, + otherLabels: h.tags, + }), + ], + }), + ], + }), + m.jsx("span", { + "data-testid": "test-duration", + style: { minWidth: "50px", textAlign: "right" }, + children: Ol(h.duration), + }), + ], + }), + m.jsx("div", { + className: "test-file-details-row", + children: m.jsxs("div", { + className: "test-file-details-row-items", + children: [ + m.jsx(Tn, { + href: Cn({ test: h }, o), + title: [...h.path, h.title].join(" › "), + className: "test-file-path-link", + children: m.jsxs("span", { + className: "test-file-path", + children: [h.location.file, ":", h.location.line], + }), + }), + m.jsx(qv, { test: h }), + m.jsx(Iv, { test: h }), + m.jsx(tm, { test: h, dim: !0 }), + ], + }), + }), + ], + }, + `test-${h.testId}`, + ), + ), + }); + }; + function qv({ test: l }) { + const u = se(); + for (const c of l.results) + for (const f of c.attachments) + if ( + f.contentType.startsWith("image/") && + f.name.match(/-(expected|actual|diff)/) + ) + return m.jsx(Tr, { + href: Cn( + { + test: l, + result: c, + anchor: `attachment-${c.attachments.indexOf(f)}`, + }, + u, + ), + title: "View images", + dim: !0, + children: k5(), + }); + } + function Iv({ test: l }) { + const u = se(), + c = l.results.find((f) => + f.attachments.some((r) => r.name === "video"), + ); + return c + ? m.jsx(Tr, { + href: Cn({ test: l, result: c, anchor: "attachment-video" }, u), + title: "View video", + dim: !0, + children: J5(), + }) + : void 0; + } + class Kv extends ct.Component { + constructor() { + super(...arguments); + yn(this, "state", { error: null, errorInfo: null }); + } + componentDidCatch(c, f) { + this.setState({ error: c, errorInfo: f }); + } + render() { + var c, f, r; + return this.state.error || this.state.errorInfo + ? m.jsxs("div", { + className: "metadata-view p-3", + children: [ + m.jsx("p", { + children: + "An error was encountered when trying to render metadata.", + }), + m.jsx("p", { + children: m.jsxs("pre", { + style: { overflow: "scroll" }, + children: [ + (c = this.state.error) == null ? void 0 : c.message, + m.jsx("br", {}), + (f = this.state.error) == null ? void 0 : f.stack, + m.jsx("br", {}), + (r = this.state.errorInfo) == null + ? void 0 + : r.componentStack, + ], + }), + }), + ], + }) + : this.props.children; + } + } + const kv = (l) => + m.jsx(Kv, { children: m.jsx(Jv, { metadata: l.metadata }) }), + Jv = (l) => { + const u = l.metadata, + c = se().has("show-metadata-other") + ? Object.entries(l.metadata).filter(([r]) => !fm.has(r)) + : []; + if (u.ci || u.gitCommit || c.length > 0) + return m.jsxs("div", { + className: "metadata-view", + children: [ + u.ci && !u.gitCommit && m.jsx(Fv, { info: u.ci }), + u.gitCommit && m.jsx(Wv, { ci: u.ci, commit: u.gitCommit }), + c.length > 0 && + m.jsxs(m.Fragment, { + children: [ + (u.gitCommit || u.ci) && + m.jsx("div", { className: "metadata-separator" }), + m.jsx("div", { + className: "metadata-section metadata-properties", + role: "list", + children: c.map(([r, o]) => { + const h = + typeof o != "object" || o === null || o === void 0 + ? String(o) + : JSON.stringify(o), + v = h.length > 1e3 ? h.slice(0, 1e3) + "…" : h; + return m.jsx( + "div", + { + className: "copyable-property", + role: "listitem", + children: m.jsxs(Sr, { + value: h, + children: [ + m.jsx("span", { + style: { fontWeight: "bold" }, + title: r, + children: r, + }), + ": ", + m.jsx("span", { title: v, children: Di(v) }), + ], + }), + }, + r, + ); + }), + }), + ], + }), + ], + }); + }, + Fv = ({ info: l }) => { + const u = l.prTitle || `Commit ${l.commitHash}`, + c = l.prHref || l.commitHref; + return m.jsx("div", { + className: "metadata-section", + role: "list", + children: m.jsx("div", { + role: "listitem", + children: m.jsx("a", { + href: Ve(c), + target: "_blank", + rel: "noopener noreferrer", + title: u, + children: u, + }), + }), + }); + }, + Wv = ({ ci: l, commit: u }) => { + const c = (l == null ? void 0 : l.prTitle) || u.subject, + f = + (l == null ? void 0 : l.prHref) || + (l == null ? void 0 : l.commitHref), + r = ` <${u.author.email}>`, + o = `${u.author.name}${r}`, + h = Intl.DateTimeFormat(void 0, { dateStyle: "medium" }).format( + u.committer.time, + ), + v = Intl.DateTimeFormat(void 0, { + dateStyle: "full", + timeStyle: "long", + }).format(u.committer.time); + return m.jsxs("div", { + className: "metadata-section", + role: "list", + children: [ + m.jsxs("div", { + role: "listitem", + children: [ + f && + m.jsx("a", { + href: Ve(f), + target: "_blank", + rel: "noopener noreferrer", + title: c, + children: c, + }), + !f && m.jsx("span", { title: c, children: c }), + ], + }), + m.jsxs("div", { + role: "listitem", + className: "hbox", + children: [ + m.jsx("span", { className: "mr-1", children: o }), + m.jsxs("span", { title: v, children: [" on ", h] }), + ], + }), + ], + }); + }, + fm = new Set(["ci", "gitCommit", "gitDiff", "actualWorkers"]), + _v = (l) => { + const u = Object.entries(l).filter(([c]) => !fm.has(c)); + return !l.ci && !l.gitCommit && !u.length; + }, + Pv = ({ + files: l, + expandedFiles: u, + setExpandedFiles: c, + projectNames: f, + }) => { + const r = ct.useMemo(() => { + const o = []; + let h = 0; + for (const v of l) + ((h += v.tests.length), + o.push({ file: v, defaultExpanded: h < 200 })); + return o; + }, [l]); + return m.jsx(m.Fragment, { + children: + r.length > 0 + ? r.map(({ file: o, defaultExpanded: h }) => + m.jsx( + sm, + { + file: o, + projectNames: f, + isFileExpanded: (v) => { + const y = u.get(v); + return y === void 0 ? h : !!y; + }, + setFileExpanded: (v, y) => { + const A = new Map(u); + (A.set(v, y), c(A)); + }, + }, + `file-${o.fileId}`, + ), + ) + : m.jsx("div", { + className: "chip-header test-file-no-files", + children: "No tests found", + }), + }); + }, + Y2 = ({ + report: l, + filteredStats: u, + metadataVisible: c, + toggleMetadataVisible: f, + }) => { + if (!l) return null; + const r = l.projectNames.length === 1 && !!l.projectNames[0], + o = !r && !u, + h = + !_v(l.metadata) && + m.jsxs("div", { + className: Ze( + "metadata-toggle", + !o && "metadata-toggle-second-line", + ), + role: "button", + onClick: f, + title: c ? "Hide metadata" : "Show metadata", + children: [c ? Ni() : Cl(), "Metadata"], + }), + v = m.jsxs("div", { + className: "test-file-header-info", + children: [ + r && + m.jsxs("div", { + "data-testid": "project-name", + children: ["Project: ", l.projectNames[0]], + }), + u && + m.jsxs("div", { + "data-testid": "filtered-tests-count", + children: [ + "Filtered: ", + u.total, + " ", + !!u.total && "(" + Ol(u.duration) + ")", + ], + }), + o && h, + ], + }), + y = m.jsxs(m.Fragment, { + children: [ + m.jsx("div", { + "data-testid": "overall-time", + style: { marginRight: "10px" }, + children: l ? new Date(l.startTime).toLocaleString() : "", + }), + m.jsxs("div", { + "data-testid": "overall-duration", + children: ["Total time: ", Ol(l.duration ?? 0)], + }), + ], + }); + return m.jsxs(m.Fragment, { + children: [ + m.jsx(Or, { + title: l.options.title, + leftSuperHeader: v, + rightSuperHeader: y, + }), + !o && h, + c && m.jsx(kv, { metadata: l.metadata }), + !!l.errors.length && + m.jsx(ke, { + header: "Errors", + dataTestId: "report-errors", + children: l.errors.map((A, E) => + m.jsx(wr, { code: A }, "test-report-error-message-" + E), + ), + }), + ], + }); + }, + rm = (l) => { + const u = Math.round(l / 1e3), + c = Math.floor(u / 60), + f = u % 60; + return c === 0 ? `${f}s` : `${c}m ${f}s`; + }, + $v = ({ entries: l }) => { + const f = Math.max(...l.map((D) => D.label.length)) * 10, + o = { + top: 20, + right: 20, + bottom: 40, + left: Math.min(800 * 0.5, Math.max(50, f)), + }, + h = 800 - o.left - o.right, + v = Math.min(...l.map((D) => D.startTime)), + y = Math.max(...l.map((D) => D.startTime + D.duration)); + let A, E; + const S = y - v; + S < 60 * 1e3 + ? ((A = 10 * 1e3), (E = !0)) + : S < 300 * 1e3 + ? ((A = 30 * 1e3), (E = !0)) + : S < 1800 * 1e3 + ? ((A = 300 * 1e3), (E = !1)) + : ((A = 600 * 1e3), (E = !1)); + const O = Math.ceil(v / A) * A, + X = (D, N) => { + const K = new Date(D).toLocaleTimeString(void 0, { + hour: "2-digit", + minute: "2-digit", + second: E ? "2-digit" : void 0, + }); + if (N) return K; + if (K.endsWith(" AM") || K.endsWith(" PM")) return K.slice(0, -3); + }, + b = (y - v) * 1.1, + p = Math.ceil(b / A) * A, + x = h / p, + R = 20, + U = 8, + Z = l.length * (R + U), + F = []; + for (let D = O; D <= v + p; D += A) { + const N = D - v; + F.push({ x: N * x, label: X(D, D === O) }); + } + const j = Z + o.top + o.bottom; + return m.jsx("svg", { + viewBox: `0 0 800 ${j}`, + preserveAspectRatio: "xMidYMid meet", + style: { width: "100%", height: "auto" }, + role: "img", + children: m.jsxs("g", { + transform: `translate(${o.left}, ${o.top})`, + role: "presentation", + children: [ + F.map(({ x: D, label: N }, K) => + m.jsxs( + "g", + { + "aria-hidden": "true", + children: [ + m.jsx("line", { + x1: D, + y1: 0, + x2: D, + y2: Z, + stroke: "var(--color-border-muted)", + strokeWidth: "1", + }), + m.jsx("text", { + x: D, + y: Z + 20, + textAnchor: "middle", + dominantBaseline: "middle", + fontSize: "12", + fill: "var(--color-fg-muted)", + children: N, + }), + ], + }, + K, + ), + ), + l.map((D, N) => { + const K = D.startTime - v, + J = D.duration * x, + k = K * x, + nt = N * (R + U), + P = [ + "var(--color-scale-blue-2)", + "var(--color-scale-blue-3)", + "var(--color-scale-blue-4)", + ], + st = P[N % P.length]; + return m.jsxs( + "g", + { + role: "listitem", + "aria-label": D.tooltip, + children: [ + m.jsx("rect", { + className: "gantt-bar", + x: k, + y: nt, + width: J, + height: R, + fill: st, + rx: "2", + tabIndex: 0, + children: m.jsx("title", { children: D.tooltip }), + }), + m.jsx("text", { + x: k + J + 6, + y: nt + R / 2, + dominantBaseline: "middle", + fontSize: "12", + fill: "var(--color-fg-muted)", + "aria-hidden": "true", + children: rm(D.duration), + }), + m.jsx("text", { + x: -10, + y: nt + R / 2, + textAnchor: "end", + dominantBaseline: "middle", + fontSize: "12", + fill: "var(--color-fg-muted)", + "aria-hidden": "true", + children: D.label, + }), + ], + }, + N, + ); + }), + m.jsx("line", { + x1: 0, + y1: 0, + x2: 0, + y2: Z, + stroke: "var(--color-fg-muted)", + strokeWidth: "1", + "aria-hidden": "true", + }), + m.jsx("line", { + x1: 0, + y1: Z, + x2: h, + y2: Z, + stroke: "var(--color-fg-muted)", + strokeWidth: "1", + "aria-hidden": "true", + }), + ], + }), + }); + }; + function ty({ report: l, tests: u }) { + return m.jsxs(m.Fragment, { + children: [ + m.jsx(ny, { report: l }), + m.jsx(ey, { report: l, tests: u }), + ], + }); + } + function ey({ report: l, tests: u }) { + const [c, f] = ue.useState(50); + return m.jsx(sm, { + file: { + fileId: "slowest", + fileName: "Slowest Tests", + tests: u.slice(0, c), + stats: null, + }, + projectNames: l.json().projectNames, + footer: + c < u.length + ? m.jsxs("button", { + className: "link-badge fullwidth-link", + style: { padding: "8px 5px" }, + onClick: () => f((r) => r + 50), + children: [Ni(), "Show 50 more"], + }) + : void 0, + }); + } + function ny({ report: l }) { + const u = l.json().machines; + if (u.length === 0) return null; + const c = u + .map((f) => { + const r = f.tag.join(" "), + o = new Date(f.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }); + let h = `${r} started at ${o}, runs ${rm(f.duration)}`; + return ( + f.shardIndex && (h += ` (shard ${f.shardIndex})`), + { + label: r, + tooltip: h, + startTime: f.startTime, + duration: f.duration, + shardIndex: f.shardIndex ?? 1, + } + ); + }) + .sort( + (f, r) => + f.label.localeCompare(r.label) || f.shardIndex - r.shardIndex, + ); + return m.jsx(ke, { + header: "Timeline", + children: m.jsx($v, { entries: c }), + }); + } + const ay = (l) => !l.has("testId") && !l.has("speedboard"), + ly = (l) => l.has("testId"), + iy = (l) => l.has("speedboard") && !l.has("testId"), + uy = ({ report: l }) => { + var Z, F; + const u = se(), + [c, f] = ct.useState(new Map()), + [r, o] = ct.useState(u.get("q") || ""), + [h, v] = ct.useState(!1), + y = u.has("speedboard"), + [A] = _h("mergeFiles", !1), + E = u.get("testId"), + S = ((Z = u.get("q")) == null ? void 0 : Z.toString()) || "", + O = S ? "&q=" + S : "", + X = + (F = l == null ? void 0 : l.json()) == null + ? void 0 + : F.options.title, + B = ct.useMemo(() => { + const j = new Map(); + for (const D of (l == null ? void 0 : l.json().files) || []) + for (const N of D.tests) j.set(N.testId, D.fileId); + return j; + }, [l]), + b = ct.useMemo(() => rc.parse(r), [r]), + p = ct.useMemo( + () => + b.empty() + ? void 0 + : sy((l == null ? void 0 : l.json().files) || [], b), + [l, b], + ), + x = ct.useMemo( + () => (y ? oy(l, b) : A ? ry(l, b) : fy(l, b)), + [l, b, A, y], + ), + { prev: R, next: U } = ct.useMemo(() => { + const j = x.tests.findIndex((K) => K.testId === E), + D = j > 0 ? x.tests[j - 1] : void 0, + N = j < x.tests.length - 1 ? x.tests[j + 1] : void 0; + return { prev: D, next: N }; + }, [E, x]); + return ( + ct.useEffect(() => { + const j = (D) => { + if ( + D.target instanceof HTMLInputElement || + D.target instanceof HTMLTextAreaElement || + D.shiftKey || + D.ctrlKey || + D.metaKey || + D.altKey + ) + return; + const N = new URLSearchParams(u); + switch (D.key) { + case "a": + (D.preventDefault(), ca("#?")); + break; + case "p": + (D.preventDefault(), + N.delete("testId"), + N.delete("speedboard"), + ca(Na(N, "s:passed", !1))); + break; + case "f": + (D.preventDefault(), + N.delete("testId"), + N.delete("speedboard"), + ca(Na(N, "s:failed", !1))); + break; + case "ArrowLeft": + R && + (D.preventDefault(), + N.delete("testId"), + ca(Cn({ test: R }, N) + O)); + break; + case "ArrowRight": + U && + (D.preventDefault(), + N.delete("testId"), + ca(Cn({ test: U }, N) + O)); + break; + } + }; + return ( + document.addEventListener("keydown", j), + () => document.removeEventListener("keydown", j) + ); + }, [R, U, O, S, u]), + ct.useEffect(() => { + X + ? (document.title = X) + : (document.title = "Playwright Test Report"); + }, [X]), + m.jsx("div", { + className: "htmlreport vbox px-4 pb-4", + children: m.jsxs("main", { + children: [ + l && + m.jsx(Ev, { + stats: l.json().stats, + filterText: r, + setFilterText: o, + }), + m.jsxs(Kf, { + predicate: ay, + children: [ + m.jsx(Y2, { + report: l == null ? void 0 : l.json(), + filteredStats: p, + metadataVisible: h, + toggleMetadataVisible: () => v((j) => !j), + }), + m.jsx(Pv, { + files: x.files, + expandedFiles: c, + setExpandedFiles: f, + projectNames: + (l == null ? void 0 : l.json().projectNames) || [], + }), + ], + }), + m.jsxs(Kf, { + predicate: iy, + children: [ + m.jsx(Y2, { + report: l == null ? void 0 : l.json(), + filteredStats: p, + metadataVisible: h, + toggleMetadataVisible: () => v((j) => !j), + }), + l && m.jsx(ty, { report: l, tests: x.tests }), + ], + }), + m.jsx(Kf, { + predicate: ly, + children: + l && + m.jsx(cy, { + report: l, + next: U, + prev: R, + testId: E, + testIdToFileIdMap: B, + }), + }), + ], + }), + }) + ); + }, + cy = ({ + report: l, + testIdToFileIdMap: u, + next: c, + prev: f, + testId: r, + }) => { + const [o, h] = ct.useState("loading"), + v = +(se().get("run") || "0"); + if ( + (ct.useEffect(() => { + (async () => { + if (!r || (typeof o == "object" && r === o.testId)) return; + const S = u.get(r); + if (!S) { + h("not-found"); + return; + } + const O = await l.entry(`${S}.json`); + h( + (O == null ? void 0 : O.tests.find((X) => X.testId === r)) || + "not-found", + ); + })(); + }, [o, l, r, u]), + o === "loading") + ) + return m.jsx("div", { className: "test-case-column" }); + if (o === "not-found") + return m.jsxs("div", { + className: "test-case-column", + children: [ + m.jsx(Or, { title: "Test not found" }), + m.jsxs("div", { + className: "test-case-location", + children: ["Test ID: ", r], + }), + ], + }); + const { projectNames: y, metadata: A, options: E } = l.json(); + return m.jsx("div", { + className: "test-case-column", + children: m.jsx(Vv, { + projectNames: y, + testRunMetadata: A, + options: E, + next: c, + prev: f, + test: o, + run: v, + }), + }); + }; + function sy(l, u) { + const c = { total: 0, duration: 0 }; + for (const f of l) { + const r = f.tests.filter((o) => u.matches(o)); + c.total += r.length; + for (const o of r) c.duration += o.duration; + } + return c; + } + function fy(l, u) { + const c = { files: [], tests: [] }; + for (const f of (l == null ? void 0 : l.json().files) || []) { + const r = f.tests.filter((o) => u.matches(o)); + (r.length && c.files.push({ ...f, tests: r }), c.tests.push(...r)); + } + return c; + } + function ry(l, u) { + const c = [], + f = new Map(); + for (const o of (l == null ? void 0 : l.json().files) || []) { + const h = o.tests.filter((v) => u.matches(v)); + for (const v of h) { + const y = v.path[0] ?? ""; + let A = f.get(y); + A || + ((A = { + fileId: y, + fileName: y, + tests: [], + stats: { + total: 0, + expected: 0, + unexpected: 0, + flaky: 0, + skipped: 0, + ok: !0, + }, + }), + f.set(y, A), + c.push(A)); + const E = { ...v, path: v.path.slice(1) }; + A.tests.push(E); + } + } + c.sort((o, h) => o.fileName.localeCompare(h.fileName)); + const r = { files: c, tests: [] }; + for (const o of c) r.tests.push(...o.tests); + return r; + } + function oy(l, u) { + const f = ((l == null ? void 0 : l.json().files) || []) + .flatMap((r) => r.tests) + .filter((r) => u.matches(r)); + return ( + f.sort((r, o) => o.duration - r.duration), + { files: [], tests: f } + ); + } + const dy = + "data:image/svg+xml,%3csvg%20width='400'%20height='400'%20viewBox='0%200%20400%20400'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M136.444%20221.556C123.558%20225.213%20115.104%20231.625%20109.535%20238.032C114.869%20233.364%20122.014%20229.08%20131.652%20226.348C141.51%20223.554%20149.92%20223.574%20156.869%20224.915V219.481C150.941%20218.939%20144.145%20219.371%20136.444%20221.556ZM108.946%20175.876L61.0895%20188.484C61.0895%20188.484%2061.9617%20189.716%2063.5767%20191.36L104.153%20180.668C104.153%20180.668%20103.578%20188.077%2098.5847%20194.705C108.03%20187.559%20108.946%20175.876%20108.946%20175.876ZM149.005%20288.347C81.6582%20306.486%2046.0272%20228.438%2035.2396%20187.928C30.2556%20169.229%2028.0799%20155.067%2027.5%20145.928C27.4377%20144.979%2027.4665%20144.179%2027.5336%20143.446C24.04%20143.657%2022.3674%20145.473%2022.7077%20150.721C23.2876%20159.855%2025.4633%20174.016%2030.4473%20192.721C41.2301%20233.225%2076.8659%20311.273%20144.213%20293.134C158.872%20289.185%20169.885%20281.992%20178.152%20272.81C170.532%20279.692%20160.995%20285.112%20149.005%20288.347ZM161.661%20128.11V132.903H188.077C187.535%20131.206%20186.989%20129.677%20186.447%20128.11H161.661Z'%20fill='%232D4552'/%3e%3cpath%20d='M193.981%20167.584C205.861%20170.958%20212.144%20179.287%20215.465%20186.658L228.711%20190.42C228.711%20190.42%20226.904%20164.623%20203.57%20157.995C181.741%20151.793%20168.308%20170.124%20166.674%20172.496C173.024%20167.972%20182.297%20164.268%20193.981%20167.584ZM299.422%20186.777C277.573%20180.547%20264.145%20198.916%20262.535%20201.255C268.89%20196.736%20278.158%20193.031%20289.837%20196.362C301.698%20199.741%20307.976%20208.06%20311.307%20215.436L324.572%20219.212C324.572%20219.212%20322.736%20193.41%20299.422%20186.777ZM286.262%20254.795L176.072%20223.99C176.072%20223.99%20177.265%20230.038%20181.842%20237.869L274.617%20263.805C282.255%20259.386%20286.262%20254.795%20286.262%20254.795ZM209.867%20321.102C122.618%20297.71%20133.166%20186.543%20147.284%20133.865C153.097%20112.156%20159.073%2096.0203%20164.029%2085.204C161.072%2084.5953%20158.623%2086.1529%20156.203%2091.0746C150.941%20101.747%20144.212%20119.124%20137.7%20143.45C123.586%20196.127%20113.038%20307.29%20200.283%20330.682C241.406%20341.699%20273.442%20324.955%20297.323%20298.659C274.655%20319.19%20245.714%20330.701%20209.867%20321.102Z'%20fill='%232D4552'/%3e%3cpath%20d='M161.661%20262.296V239.863L99.3324%20257.537C99.3324%20257.537%20103.938%20230.777%20136.444%20221.556C146.302%20218.762%20154.713%20218.781%20161.661%20220.123V128.11H192.869C189.471%20117.61%20186.184%20109.526%20183.423%20103.909C178.856%2094.612%20174.174%20100.775%20163.545%20109.665C156.059%20115.919%20137.139%20129.261%20108.668%20136.933C80.1966%20144.61%2057.179%20142.574%2047.5752%20140.911C33.9601%20138.562%2026.8387%20135.572%2027.5049%20145.928C28.0847%20155.062%2030.2605%20169.224%2035.2445%20187.928C46.0272%20228.433%2081.663%20306.481%20149.01%20288.342C166.602%20283.602%20179.019%20274.233%20187.626%20262.291H161.661V262.296ZM61.0848%20188.484L108.946%20175.876C108.946%20175.876%20107.551%20194.288%2089.6087%20199.018C71.6614%20203.743%2061.0848%20188.484%2061.0848%20188.484Z'%20fill='%23E2574C'/%3e%3cpath%20d='M341.786%20129.174C329.345%20131.355%20299.498%20134.072%20262.612%20124.185C225.716%20114.304%20201.236%2097.0224%20191.537%2088.8994C177.788%2077.3834%20171.74%2069.3802%20165.788%2081.4857C160.526%2092.163%20153.797%20109.54%20147.284%20133.866C133.171%20186.543%20122.623%20297.706%20209.867%20321.098C297.093%20344.47%20343.53%20242.92%20357.644%20190.238C364.157%20165.917%20367.013%20147.5%20367.799%20135.625C368.695%20122.173%20359.455%20126.078%20341.786%20129.174ZM166.497%20172.756C166.497%20172.756%20180.246%20151.372%20203.565%20158C226.899%20164.628%20228.706%20190.425%20228.706%20190.425L166.497%20172.756ZM223.42%20268.713C182.403%20256.698%20176.077%20223.99%20176.077%20223.99L286.262%20254.796C286.262%20254.791%20264.021%20280.578%20223.42%20268.713ZM262.377%20201.495C262.377%20201.495%20276.107%20180.126%20299.422%20186.773C322.736%20193.411%20324.572%20219.208%20324.572%20219.208L262.377%20201.495Z'%20fill='%232EAD33'/%3e%3cpath%20d='M139.88%20246.04L99.3324%20257.532C99.3324%20257.532%20103.737%20232.44%20133.607%20222.496L110.647%20136.33L108.663%20136.933C80.1918%20144.611%2057.1742%20142.574%2047.5704%20140.911C33.9554%20138.563%2026.834%20135.572%2027.5001%20145.929C28.08%20155.063%2030.2557%20169.224%2035.2397%20187.929C46.0225%20228.433%2081.6583%20306.481%20149.005%20288.342L150.989%20287.719L139.88%20246.04ZM61.0848%20188.485L108.946%20175.876C108.946%20175.876%20107.551%20194.288%2089.6087%20199.018C71.6615%20203.743%2061.0848%20188.485%2061.0848%20188.485Z'%20fill='%23D65348'/%3e%3cpath%20d='M225.27%20269.163L223.415%20268.712C182.398%20256.698%20176.072%20223.99%20176.072%20223.99L232.89%20239.872L262.971%20124.281L262.607%20124.185C225.711%20114.304%20201.232%2097.0224%20191.532%2088.8994C177.783%2077.3834%20171.735%2069.3802%20165.783%2081.4857C160.526%2092.163%20153.797%20109.54%20147.284%20133.866C133.171%20186.543%20122.623%20297.706%20209.867%20321.097L211.655%20321.5L225.27%20269.163ZM166.497%20172.756C166.497%20172.756%20180.246%20151.372%20203.565%20158C226.899%20164.628%20228.706%20190.425%20228.706%20190.425L166.497%20172.756Z'%20fill='%231D8D22'/%3e%3cpath%20d='M141.946%20245.451L131.072%20248.537C133.641%20263.019%20138.169%20276.917%20145.276%20289.195C146.513%20288.922%20147.74%20288.687%20149%20288.342C152.302%20287.451%20155.364%20286.348%20158.312%20285.145C150.371%20273.361%20145.118%20259.789%20141.946%20245.451ZM137.7%20143.451C132.112%20164.307%20127.113%20194.326%20128.489%20224.436C130.952%20223.367%20133.554%20222.371%20136.444%20221.551L138.457%20221.101C136.003%20188.939%20141.308%20156.165%20147.284%20133.866C148.799%20128.225%20150.318%20122.978%20151.832%20118.085C149.393%20119.637%20146.767%20121.228%20143.776%20122.867C141.759%20129.093%20139.722%20135.898%20137.7%20143.451Z'%20fill='%23C04B41'/%3e%3c/svg%3e", + Ff = N5, + Rr = document.createElement("link"); + Rr.rel = "shortcut icon"; + Rr.href = dy; + document.head.appendChild(Rr); + const hy = () => { + const [l, u] = ct.useState(); + return ( + ct.useEffect(() => { + const c = new my(); + c.load().then(() => { + var f; + ((f = document.getElementById("playwrightReportBase64")) == + null || f.remove(), + u(c)); + }); + }, []), + m.jsx(cv, { children: m.jsx(uy, { report: l }) }) + ); + }; + window.onload = () => { + (gv(), + X5.createRoot(document.querySelector("#root")).render(m.jsx(hy, {}))); + }; + class my { + constructor() { + yn(this, "_entries", new Map()); + yn(this, "_json"); + } + async load() { + const u = document.getElementById( + "playwrightReportBase64", + ).textContent, + c = new Ff.ZipReader(new Ff.Data64URIReader(u), { + useWebWorkers: !1, + }); + for (const f of await c.getEntries()) + this._entries.set(f.filename, f); + this._json = await this.entry("report.json"); + } + json() { + return this._json; + } + async entry(u) { + const c = this._entries.get(u), + f = new Ff.TextWriter(); + return (await c.getData(f), JSON.parse(await f.getData())); + } + } + + -
+
- \ No newline at end of file + diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 1c994bed..e45b966f 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -3,9 +3,18 @@ import AppLayout from "../components/layout/AppLayout"; import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; import AuditLogsPage from "../features/audit/AuditLogsPage"; +import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthPage from "../features/auth/AuthPage"; +import LoginPage from "../features/auth/LoginPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; +import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab"; +import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage"; +import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage"; +import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage"; +import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab"; +import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab"; +import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; @@ -17,6 +26,14 @@ import UserListPage from "../features/users/UserListPage"; export const router = createBrowserRouter( [ + { + path: "/login", + element: , + }, + { + path: "/auth/callback", + element: , + }, { path: "/", element: , @@ -30,11 +47,23 @@ export const router = createBrowserRouter( { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, + { path: "tenant-groups", element: }, + { path: "tenant-groups/new", element: }, + { + path: "tenant-groups/:id", + element: , + children: [ + { index: true, element: }, + { path: "tenants", element: }, + { path: "admins", element: }, + ], + }, { path: "tenants/:tenantId", element: , children: [ { index: true, element: }, + { path: "admins", element: }, { path: "schema", element: }, ], }, diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 14f68839..7b1cc989 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -29,7 +29,6 @@ const navItems = [ { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; - function AppLayout() { const [theme, setTheme] = useState<"light" | "dark">(() => { const stored = window.localStorage.getItem("admin_theme"); diff --git a/adminfront/src/components/ui/badge.tsx b/adminfront/src/components/ui/badge.tsx index 8ef586fd..8f9d0789 100644 --- a/adminfront/src/components/ui/badge.tsx +++ b/adminfront/src/components/ui/badge.tsx @@ -26,7 +26,8 @@ const badgeVariants = cva( ); export interface BadgeProps - extends React.HTMLAttributes, + extends + React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { diff --git a/adminfront/src/components/ui/button.tsx b/adminfront/src/components/ui/button.tsx index ee1a84b4..91d21e58 100644 --- a/adminfront/src/components/ui/button.tsx +++ b/adminfront/src/components/ui/button.tsx @@ -34,7 +34,8 @@ const buttonVariants = cva( ); export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } diff --git a/adminfront/src/components/ui/input.tsx b/adminfront/src/components/ui/input.tsx index 41955477..eb2a9c6e 100644 --- a/adminfront/src/components/ui/input.tsx +++ b/adminfront/src/components/ui/input.tsx @@ -1,8 +1,7 @@ import * as React from "react"; import { cn } from "../../lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} +export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( ({ className, type, ...props }, ref) => { diff --git a/adminfront/src/components/ui/textarea.tsx b/adminfront/src/components/ui/textarea.tsx index 80f0abc2..78dbae91 100644 --- a/adminfront/src/components/ui/textarea.tsx +++ b/adminfront/src/components/ui/textarea.tsx @@ -1,8 +1,7 @@ import * as React from "react"; import { cn } from "../../lib/utils"; -export interface TextareaProps - extends React.TextareaHTMLAttributes {} +export interface TextareaProps extends React.TextareaHTMLAttributes {} const Textarea = React.forwardRef( ({ className, ...props }, ref) => { diff --git a/adminfront/src/features/auth/AuthCallbackPage.tsx b/adminfront/src/features/auth/AuthCallbackPage.tsx new file mode 100644 index 00000000..bf8b5fda --- /dev/null +++ b/adminfront/src/features/auth/AuthCallbackPage.tsx @@ -0,0 +1,43 @@ +import { ShieldHalf } from "lucide-react"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +function AuthCallbackPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + useEffect(() => { + const token = searchParams.get("token"); + if (token) { + window.localStorage.setItem("admin_session", token); + + // 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기 + if (window.opener) { + window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*"); + window.close(); + } else { + // 일반 리다이렉트 방식인 경우 홈으로 이동 + navigate("/", { replace: true }); + } + } else { + console.error("No token found in callback URL"); + navigate("/login", { replace: true }); + } + }, [navigate, searchParams]); + + return ( +
+
+
+ +
+
인증 완료 중...
+

+ 세션을 동기화하고 있습니다. +

+
+
+ ); +} + +export default AuthCallbackPage; diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx new file mode 100644 index 00000000..9939f443 --- /dev/null +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -0,0 +1,132 @@ +import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; + +function LoginPage() { + const navigate = useNavigate(); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + useEffect(() => { + // Listen for login success message from the popup + const handleMessage = (event: MessageEvent) => { + // Security check: In production, verify event.origin + if (event.data?.type === "LOGIN_SUCCESS" && event.data?.token) { + window.localStorage.setItem("admin_session", event.data.token); + setIsLoggingIn(false); + navigate("/"); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [navigate]); + + const handleSSOLogin = () => { + const userfrontUrl = import.meta.env.USERFRONT_URL || "https://sso.hmac.kr"; + const callbackUrl = `${window.location.origin}/auth/callback`; + + // 항상 redirect_uri를 포함하여 로그인이 성공하면 콜백 페이지로 오도록 함 + const loginUrl = `${userfrontUrl}/signin?source=adminfront&redirect_uri=${encodeURIComponent(callbackUrl)}`; + + const width = 500; + const height = 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + loginUrl, + "BaronSSOLogin", + `width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`, + ); + + if (popup) { + setIsLoggingIn(true); + const timer = setInterval(() => { + if (popup.closed) { + clearInterval(timer); + setIsLoggingIn(false); + } + }, 1000); + } else { + alert("팝업 차단이 설정되어 있습니다. 팝업 허용 후 다시 시도해 주세요."); + } + }; + + return ( +
+
+
+
+ +
+
+

Baron SSO

+

+ Admin Control Plane +

+
+
+ + + + + + 관리자 로그인 + + + Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다. + + + + + +

+ 관리자 전역 세션은 보안을 위해 15분간 유지됩니다. +
+ 민감한 작업 시 재인증을 요구할 수 있습니다. +

+
+
+ +
+
+
+
+
+ +

+ 인증 정보가 없거나 로그인이 되지 않는 경우 +
+ 시스템 관리자에게 문의하세요. +

+
+
+ ); +} + +export default LoginPage; diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 61a0af2d..24e8f139 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -216,6 +216,8 @@ function GlobalOverviewPage() {
+ +
); } diff --git a/adminfront/src/features/overview/components/PermissionChecker.tsx b/adminfront/src/features/overview/components/PermissionChecker.tsx new file mode 100644 index 00000000..02e06691 --- /dev/null +++ b/adminfront/src/features/overview/components/PermissionChecker.tsx @@ -0,0 +1,142 @@ +import { useMutation } from "@tanstack/react-query"; +import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react"; +import { useState } from "react"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import apiClient from "../../../lib/apiClient"; + +type CheckPermissionResponse = { + allowed: boolean; + query: { + namespace: string; + object: string; + relation: string; + subject: string; + }; +}; + +function PermissionChecker() { + const [namespace, setNamespace] = useState("Tenant"); + const [object, setObject] = useState(""); + const [relation, setRelation] = useState("manage"); + const [subject, setSubject] = useState(""); + + const checkMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.get( + "/v1/admin/debug/check-permission", + { + params: { namespace, object, relation, subject }, + }, + ); + return data; + }, + }); + + const result = checkMutation.data; + + return ( + + + + + ReBAC 권한 검증 도구 + + + 특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory + Keto를 통해 실시간으로 확인합니다. + + + +
+
+ + +
+
+ + setRelation(e.target.value)} + /> +
+
+ + setObject(e.target.value)} + /> +
+
+ + setSubject(e.target.value)} + /> +
+
+ +
+ +
+ + {checkMutation.isSuccess && ( +
+ {result.allowed ? ( + <> + +
Access ALLOWED
+

+ 해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 + 포함) +

+ + ) : ( + <> + +
Access DENIED
+

+ 해당 사용자는 요청한 리소스에 대해 권한이 없습니다. +

+ + )} +
+ )} +
+
+ ); +} + +export default PermissionChecker; diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx new file mode 100644 index 00000000..794f2e55 --- /dev/null +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx @@ -0,0 +1,215 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { useOutletContext } from "react-router-dom"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { + type TenantGroupSummary, + addGroupAdmin, + fetchGroupAdmins, + fetchUsers, + removeGroupAdmin, +} from "../../../lib/adminApi"; + +function TenantGroupAdminsTab() { + const { group } = useOutletContext<{ + group: TenantGroupSummary; + }>(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + + // 현재 관리자 목록 + const adminsQuery = useQuery({ + queryKey: ["tenant-group-admins", group.id], + queryFn: () => fetchGroupAdmins(group.id), + enabled: !!group.id, + }); + + // 전체 사용자 목록 (관리자 추가용) + const usersQuery = useQuery({ + queryKey: ["users", { limit: 100, search: searchTerm }], + queryFn: () => fetchUsers(100, 0, searchTerm), + enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색 + }); + + const addMutation = useMutation({ + mutationFn: (userId: string) => addGroupAdmin(group.id, userId), + onSuccess: () => { + adminsQuery.refetch(); + setSearchTerm(""); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (userId: string) => removeGroupAdmin(group.id, userId), + onSuccess: () => { + adminsQuery.refetch(); + }, + }); + + const handleAddAdmin = (userId: string) => { + addMutation.mutate(userId); + }; + + const handleRemoveAdmin = (userId: string, userName: string) => { + if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) { + removeMutation.mutate(userId); + } + }; + + return ( +
+ {/* 현재 그룹 관리자 */} + + + + + 그룹 관리자 + + + 이 그룹과 소속 테넌트를 관리할 수 있는 권한을 가진 사용자들입니다. + + + + + + + 이름 + 이메일 + 회수 + + + + {adminsQuery.data?.length === 0 && ( + + + 등록된 관리자가 없습니다. + + + )} + {adminsQuery.data?.map((admin) => ( + + + {admin.name || "Unknown"} + + {admin.email} + + + + + ))} + +
+
+
+ + {/* 사용자 검색 및 추가 */} + + +
+ + + 관리자 추가 + +
+ + 관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일). + +
+ +
+ + setSearchTerm(e.target.value)} + /> +
+ + + + + 사용자 + 추가 + + + + {searchTerm.length < 2 && ( + + + 사용자 이름을 입력하여 검색하세요. + + + )} + {searchTerm.length >= 2 && + usersQuery.data?.items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {usersQuery.data?.items + .filter((u) => !adminsQuery.data?.some((a) => a.id === u.id)) + .map((user) => ( + + +
{user.name}
+
+ {user.email} +
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +export default TenantGroupAdminsTab; diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx new file mode 100644 index 00000000..b9cc1d71 --- /dev/null +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx @@ -0,0 +1,144 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { LayoutGrid, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { Textarea } from "../../../components/ui/textarea"; +import { createTenantGroup } from "../../../lib/adminApi"; + +function TenantGroupCreatePage() { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + + const mutation = useMutation({ + mutationFn: () => + createTenantGroup({ + name, + slug: slug || name.toLowerCase().replace(/ /g, "-"), + description: description || undefined, + }), + onSuccess: () => { + navigate("/tenant-groups"); + }, + }); + + const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + + return ( +
+
+
+ Tenants + / + Groups + / + Create +
+
+
+

테넌트 그룹 추가

+

+ 여러 테넌트를 논리적으로 묶어 관리하기 위한 그룹을 생성합니다. +

+
+ Super Admin only +
+
+ + + + + + Group Profile + + + 그룹 이름과 식별자(Slug)를 입력합니다. + + + +
+ + setName(e.target.value)} + placeholder="예: 바론소프트웨어 통합그룹" + /> +
+
+ + setSlug(e.target.value)} + placeholder="baron-group" + /> +

+ URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로 + 자동 생성됩니다. +

+
+
+ +