forked from baron/baron-sso
feat: integrate orgfront and expose internal ids
This commit is contained in:
@@ -106,6 +106,16 @@ jobs:
|
|||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
|
|
||||||
|
- name: Build and push orgfront RC image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./orgfront
|
||||||
|
file: ./orgfront/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
- name: Build and push userfront RC image
|
- name: Build and push userfront RC image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
run_lint:
|
run_lint:
|
||||||
description: "Run lint/format checks for Go, Flutter, adminfront, devfront"
|
description: "Run lint/format checks for Go, Flutter, adminfront, devfront, orgfront"
|
||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
@@ -39,6 +39,11 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
|
run_orgfront_tests:
|
||||||
|
description: "Run orgfront Playwright tests"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -56,6 +61,7 @@ jobs:
|
|||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
adminfront/package-lock.json
|
adminfront/package-lock.json
|
||||||
devfront/package-lock.json
|
devfront/package-lock.json
|
||||||
|
orgfront/package-lock.json
|
||||||
|
|
||||||
- name: i18n resource check
|
- name: i18n resource check
|
||||||
run: |
|
run: |
|
||||||
@@ -104,6 +110,17 @@ jobs:
|
|||||||
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||||
|
|
||||||
|
- name: Install orgfront dependencies
|
||||||
|
run: |
|
||||||
|
cd orgfront
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Biome check orgfront (lint + format)
|
||||||
|
run: |
|
||||||
|
cd orgfront
|
||||||
|
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||||
|
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||||
|
|
||||||
- name: Lint Go backend
|
- name: Lint Go backend
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
@@ -809,3 +826,186 @@ jobs:
|
|||||||
devfront/playwright-report
|
devfront/playwright-report
|
||||||
devfront/test-results
|
devfront/test-results
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
orgfront-tests:
|
||||||
|
needs: lint
|
||||||
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: orgfront/package-lock.json
|
||||||
|
|
||||||
|
- name: Get Playwright version
|
||||||
|
id: playwright-version
|
||||||
|
run: |
|
||||||
|
cd orgfront
|
||||||
|
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Playwright Browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-playwright-
|
||||||
|
|
||||||
|
- name: Install orgfront dependencies
|
||||||
|
run: |
|
||||||
|
mkdir -p reports
|
||||||
|
set +e
|
||||||
|
cd orgfront
|
||||||
|
npm ci 2>&1 | tee ../reports/orgfront-install.log
|
||||||
|
install_exit_code=${PIPESTATUS[0]}
|
||||||
|
cd ..
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$install_exit_code" -ne 0 ]; then
|
||||||
|
{
|
||||||
|
echo "# OrgFront Test Failure Report"
|
||||||
|
echo
|
||||||
|
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||||
|
echo "- Job: \`orgfront-tests\`"
|
||||||
|
echo "- Reason: \`Dependency install failed\`"
|
||||||
|
echo "- Exit Code: \`$install_exit_code\`"
|
||||||
|
echo
|
||||||
|
echo "## Command"
|
||||||
|
echo "\`cd orgfront && npm ci\`"
|
||||||
|
echo
|
||||||
|
echo "## Install Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-install.log
|
||||||
|
echo '```'
|
||||||
|
} > reports/orgfront-test-failure-report.md
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Provision browsers for orgfront tests
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
cd orgfront
|
||||||
|
npx playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log
|
||||||
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
|
cd ..
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$provision_exit_code" -ne 0 ]; then
|
||||||
|
{
|
||||||
|
echo "# OrgFront Test Failure Report"
|
||||||
|
echo
|
||||||
|
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||||
|
echo "- Job: \`orgfront-tests\`"
|
||||||
|
echo "- Reason: \`Browser provisioning failed\`"
|
||||||
|
echo "- Exit Code: \`$provision_exit_code\`"
|
||||||
|
echo
|
||||||
|
echo "## Command"
|
||||||
|
echo "\`cd orgfront && npx playwright install --with-deps\`"
|
||||||
|
echo
|
||||||
|
echo "## Provision Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-provision.log
|
||||||
|
echo '```'
|
||||||
|
} > reports/orgfront-test-failure-report.md
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run orgfront tests
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_WORKERS: 2
|
||||||
|
run: |
|
||||||
|
mkdir -p reports
|
||||||
|
set +e
|
||||||
|
cd orgfront
|
||||||
|
npm test 2>&1 | tee ../reports/orgfront-test.log
|
||||||
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
|
cd ..
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$test_exit_code" -ne 0 ]; then
|
||||||
|
{
|
||||||
|
echo "# OrgFront Test Failure Report"
|
||||||
|
echo
|
||||||
|
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||||
|
echo "- Job: \`orgfront-tests\`"
|
||||||
|
echo "- Exit Code: \`$test_exit_code\`"
|
||||||
|
echo
|
||||||
|
echo "## Commands"
|
||||||
|
echo "1. \`cd orgfront\`"
|
||||||
|
echo "2. \`npm ci\`"
|
||||||
|
echo "3. \`npx playwright install --with-deps\`"
|
||||||
|
echo "4. \`npm test\`"
|
||||||
|
echo
|
||||||
|
echo "## Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-test.log
|
||||||
|
echo '```'
|
||||||
|
} > reports/orgfront-test-failure-report.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$test_exit_code"
|
||||||
|
|
||||||
|
- name: Ensure orgfront failure report exists
|
||||||
|
if: ${{ failure() }}
|
||||||
|
run: |
|
||||||
|
mkdir -p reports
|
||||||
|
if [ -f reports/orgfront-test-failure-report.md ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# OrgFront Test Failure Report"
|
||||||
|
echo
|
||||||
|
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||||
|
echo "- Job: \`orgfront-tests\`"
|
||||||
|
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||||
|
echo
|
||||||
|
if [ -f reports/orgfront-install.log ]; then
|
||||||
|
echo "## Install Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-install.log
|
||||||
|
echo '```'
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
if [ -f reports/orgfront-provision.log ]; then
|
||||||
|
echo "## Provision Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-provision.log
|
||||||
|
echo '```'
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
if [ -f reports/orgfront-test.log ]; then
|
||||||
|
echo "## Test Log Tail (last 200 lines)"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 reports/orgfront-test.log
|
||||||
|
echo '```'
|
||||||
|
fi
|
||||||
|
} > reports/orgfront-test-failure-report.md
|
||||||
|
|
||||||
|
- name: Publish orgfront failure summary
|
||||||
|
if: ${{ failure() }}
|
||||||
|
run: |
|
||||||
|
if [ -f reports/orgfront-test-failure-report.md ]; then
|
||||||
|
cat reports/orgfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload orgfront failure report artifact
|
||||||
|
if: ${{ failure() }}
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: orgfront-test-failure-report
|
||||||
|
path: |
|
||||||
|
reports/orgfront-test-failure-report.md
|
||||||
|
reports/orgfront-install.log
|
||||||
|
reports/orgfront-provision.log
|
||||||
|
reports/orgfront-test.log
|
||||||
|
orgfront/playwright-report
|
||||||
|
orgfront/test-results
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ jobs:
|
|||||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||||
|
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||||
|
|
||||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||||
@@ -90,7 +91,7 @@ jobs:
|
|||||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||||
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||||
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||||
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
|
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ jobs:
|
|||||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||||
|
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||||
|
|
||||||
# Staging-specific variables
|
# Staging-specific variables
|
||||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
@@ -72,6 +73,7 @@ jobs:
|
|||||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||||
|
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||||
|
|
||||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||||
@@ -97,6 +99,7 @@ jobs:
|
|||||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||||
|
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||||
@@ -132,6 +135,7 @@ jobs:
|
|||||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||||
|
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||||
EOF
|
EOF
|
||||||
@@ -161,6 +165,7 @@ jobs:
|
|||||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||||
|
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -39,6 +39,11 @@ userfront/.env
|
|||||||
|
|
||||||
# Frontend test artifacts
|
# Frontend test artifacts
|
||||||
adminfront/test-results/
|
adminfront/test-results/
|
||||||
|
adminfront/test-results.nobody-backup/
|
||||||
devfront/test-results/
|
devfront/test-results/
|
||||||
|
orgfront/test-results/
|
||||||
adminfront/playwright-report/
|
adminfront/playwright-report/
|
||||||
devfront/playwright-report/
|
devfront/playwright-report/
|
||||||
|
orgfront/playwright-report/
|
||||||
|
orgfront/node_modules/
|
||||||
|
orgfront/dist/
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "../../lib/sessionSliding";
|
} from "../../lib/sessionSliding";
|
||||||
|
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
@@ -114,6 +115,9 @@ function AppLayout() {
|
|||||||
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
const isSuperAdmin = isTest || effectiveRole === "super_admin";
|
||||||
const isTenantAdmin = effectiveRole === "tenant_admin";
|
const isTenantAdmin = effectiveRole === "tenant_admin";
|
||||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||||
|
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||||
|
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||||
|
);
|
||||||
|
|
||||||
const filteredItems = items.filter((item) => {
|
const filteredItems = items.filter((item) => {
|
||||||
if (isTest) return true;
|
if (isTest) return true;
|
||||||
@@ -129,7 +133,7 @@ function AppLayout() {
|
|||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
filteredItems.splice(2, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
@@ -152,7 +156,7 @@ function AppLayout() {
|
|||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
},
|
},
|
||||||
@@ -161,7 +165,7 @@ function AppLayout() {
|
|||||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -439,6 +439,9 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[220px]">
|
||||||
|
{t("ui.admin.tenants.table.id", "ID")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.table.name", "NAME")}
|
{t("ui.admin.tenants.table.name", "NAME")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -465,7 +468,7 @@ function TenantListPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8}>
|
<TableCell colSpan={9}>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -473,7 +476,7 @@ function TenantListPage() {
|
|||||||
{!query.isLoading && tenants.length === 0 && (
|
{!query.isLoading && tenants.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={8}
|
colSpan={9}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -493,6 +496,12 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||||
|
data-testid={`tenant-internal-id-${tenant.id}`}
|
||||||
|
>
|
||||||
|
{tenant.id}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="font-semibold">
|
<TableCell className="font-semibold">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ function UserCreatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.VITE_ORGCHART_URL,
|
import.meta.env.ORGFRONT_URL,
|
||||||
{
|
{
|
||||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ import {
|
|||||||
type OrgChartTenantSelection,
|
type OrgChartTenantSelection,
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
|
isHanmacFamilyTenant,
|
||||||
|
isHanmacFamilyUser,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
|
||||||
@@ -369,7 +371,10 @@ function UserDetailPage() {
|
|||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchTenants(100, 0),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = React.useMemo(
|
||||||
|
() => tenantsData?.items ?? [],
|
||||||
|
[tenantsData?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const rpHistoryQuery = useQuery({
|
const rpHistoryQuery = useQuery({
|
||||||
queryKey: ["user-rp-history", userId],
|
queryKey: ["user-rp-history", userId],
|
||||||
@@ -498,7 +503,7 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.VITE_ORGCHART_URL,
|
import.meta.env.ORGFRONT_URL,
|
||||||
{
|
{
|
||||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||||
},
|
},
|
||||||
@@ -642,15 +647,33 @@ function UserDetailPage() {
|
|||||||
Record<string, string | number | boolean>
|
Record<string, string | number | boolean>
|
||||||
>) || {},
|
>) || {},
|
||||||
});
|
});
|
||||||
|
const isUserHanmacFamily = isHanmacFamilyUser(
|
||||||
|
user,
|
||||||
|
tenants,
|
||||||
|
hanmacFamilyTenantId,
|
||||||
|
);
|
||||||
const resolvedUserType =
|
const resolvedUserType =
|
||||||
metadata.userType === "personal" ||
|
metadata.userType === "personal" ||
|
||||||
user.companyCode === "personal"
|
user.companyCode === "personal"
|
||||||
? "personal"
|
? "personal"
|
||||||
: metadata.hanmacFamily === true
|
: isUserHanmacFamily
|
||||||
? "hanmac"
|
? "hanmac"
|
||||||
: "external";
|
: "external";
|
||||||
setUserType(resolvedUserType);
|
setUserType(resolvedUserType);
|
||||||
setIsHanmacFamily(resolvedUserType === "hanmac");
|
setIsHanmacFamily(resolvedUserType === "hanmac");
|
||||||
|
const familyFallbackTenants = [
|
||||||
|
...(user.joinedTenants ?? []),
|
||||||
|
...(user.tenant ? [user.tenant] : []),
|
||||||
|
].filter(
|
||||||
|
(tenant, index, allTenants) =>
|
||||||
|
allTenants.findIndex((item) => item.id === tenant.id) ===
|
||||||
|
index &&
|
||||||
|
isHanmacFamilyTenant(
|
||||||
|
tenant,
|
||||||
|
tenants,
|
||||||
|
hanmacFamilyTenantId,
|
||||||
|
),
|
||||||
|
);
|
||||||
setAdditionalAppointments(
|
setAdditionalAppointments(
|
||||||
Array.isArray(rawAppointments)
|
Array.isArray(rawAppointments)
|
||||||
? (rawAppointments as UserAppointment[]).map(
|
? (rawAppointments as UserAppointment[]).map(
|
||||||
@@ -659,22 +682,38 @@ function UserDetailPage() {
|
|||||||
draftId: createDraftId(),
|
draftId: createDraftId(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
: metadata.hanmacFamily === true && fallbackAppointment
|
: isUserHanmacFamily
|
||||||
? [
|
? familyFallbackTenants.length > 0
|
||||||
{
|
? familyFallbackTenants.map((tenant) => ({
|
||||||
draftId: createDraftId(),
|
draftId: createDraftId(),
|
||||||
tenantId: fallbackAppointment.id,
|
tenantId: tenant.id,
|
||||||
tenantName: fallbackAppointment.name,
|
tenantName: tenant.name,
|
||||||
tenantSlug: fallbackAppointment.slug,
|
tenantSlug: tenant.slug,
|
||||||
isOwner: metadata.primaryTenantIsOwner === true,
|
isOwner:
|
||||||
|
metadata.primaryTenantIsOwner === true &&
|
||||||
|
tenant.id === fallbackAppointment?.id,
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
},
|
}))
|
||||||
]
|
: fallbackAppointment
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
draftId: createDraftId(),
|
||||||
|
tenantId: fallbackAppointment.id,
|
||||||
|
tenantName: fallbackAppointment.name,
|
||||||
|
tenantSlug: fallbackAppointment.slug,
|
||||||
|
isOwner:
|
||||||
|
metadata.primaryTenantIsOwner ===
|
||||||
|
true,
|
||||||
|
jobTitle: user.jobTitle,
|
||||||
|
position: user.position,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [user, reset]);
|
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||||
|
|||||||
@@ -464,6 +464,9 @@ function UserListPage() {
|
|||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[220px]">
|
||||||
|
{t("ui.admin.users.list.table.id", "ID")}
|
||||||
|
</TableHead>
|
||||||
<TableHead className="min-w-[200px]">
|
<TableHead className="min-w-[200px]">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.list.table.name_email",
|
"ui.admin.users.list.table.name_email",
|
||||||
@@ -494,7 +497,7 @@ function UserListPage() {
|
|||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={5 + userSchema.length}
|
colSpan={6 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
@@ -504,7 +507,7 @@ function UserListPage() {
|
|||||||
{!query.isLoading && items.length === 0 && (
|
{!query.isLoading && items.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={5 + userSchema.length}
|
colSpan={6 + userSchema.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -538,6 +541,12 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||||
|
data-testid={`user-internal-id-${user.id}`}
|
||||||
|
>
|
||||||
|
{user.id}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
|
buildAuthenticatedOrgChartUrl,
|
||||||
buildOrgChartTenantPickerUrl,
|
buildOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
|
isHanmacFamilyUser,
|
||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
|
|
||||||
describe("orgChartPicker", () => {
|
describe("orgChartPicker", () => {
|
||||||
it("builds the tenant picker embed URL from VITE_ORGCHART_URL", () => {
|
it("builds the tenant picker embed URL from ORGFRONT_URL", () => {
|
||||||
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
|
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
|
||||||
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
|
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
|
||||||
);
|
);
|
||||||
@@ -36,6 +38,12 @@ describe("orgChartPicker", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds the chart navigation URL through the org-chart auto login entry", () => {
|
||||||
|
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
||||||
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
||||||
expect(
|
expect(
|
||||||
parseOrgChartTenantSelection({
|
parseOrgChartTenantSelection({
|
||||||
@@ -115,4 +123,50 @@ describe("orgChartPicker", () => {
|
|||||||
|
|
||||||
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
|
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects existing users as Hanmac family from tenant subtree without metadata flag", () => {
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "external-id",
|
||||||
|
slug: "external",
|
||||||
|
name: "External",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
name: "한맥가족",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
parentId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hanmac-company-id",
|
||||||
|
slug: "hanmac-company",
|
||||||
|
name: "한맥기술",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "hanmac-family-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hanmac-team-id",
|
||||||
|
slug: "hanmac-team",
|
||||||
|
name: "기술기획",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
parentId: "hanmac-company-id",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isHanmacFamilyUser(
|
||||||
|
{
|
||||||
|
companyCode: "external",
|
||||||
|
tenant: tenants[0],
|
||||||
|
joinedTenants: [tenants[3]],
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
tenants,
|
||||||
|
"hanmac-family-id",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type TenantFilterTarget = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HanmacFamilyUserTarget = {
|
||||||
|
companyCode?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenant?: TenantFilterTarget;
|
||||||
|
joinedTenants?: TenantFilterTarget[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrgChartPickerMessage = {
|
type OrgChartPickerMessage = {
|
||||||
@@ -25,6 +34,10 @@ type OrgChartTenantPickerOptions = {
|
|||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrgChartLoginOptions = {
|
||||||
|
returnTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function isSystemTenant(tenant: TenantFilterTarget) {
|
function isSystemTenant(tenant: TenantFilterTarget) {
|
||||||
const slug = tenant.slug?.trim().toLowerCase();
|
const slug = tenant.slug?.trim().toLowerCase();
|
||||||
const type = tenant.type?.trim().toUpperCase();
|
const type = tenant.type?.trim().toUpperCase();
|
||||||
@@ -66,11 +79,77 @@ function isInTenantSubtree<T extends TenantFilterTarget>(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHanmacFamilyTenantId<T extends TenantFilterTarget>(
|
||||||
|
tenants: T[],
|
||||||
|
hanmacFamilyTenantId?: string,
|
||||||
|
) {
|
||||||
|
const envTenantId = hanmacFamilyTenantId?.trim();
|
||||||
|
if (envTenantId) return envTenantId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
tenants.find((tenant) => tenant.slug?.toLowerCase() === "hanmac-family")
|
||||||
|
?.id ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
|
||||||
|
tenant: T | undefined,
|
||||||
|
tenants: T[],
|
||||||
|
hanmacFamilyTenantId?: string,
|
||||||
|
) {
|
||||||
|
if (!tenant || !tenant.id) return false;
|
||||||
|
|
||||||
|
const rootTenantId = resolveHanmacFamilyTenantId(
|
||||||
|
tenants,
|
||||||
|
hanmacFamilyTenantId,
|
||||||
|
);
|
||||||
|
if (!rootTenantId) return false;
|
||||||
|
|
||||||
|
const tenantById = new Map(
|
||||||
|
tenants
|
||||||
|
.filter((item) => item.id?.trim())
|
||||||
|
.map((item) => [item.id as string, item]),
|
||||||
|
);
|
||||||
|
const target = tenantById.get(tenant.id) ?? tenant;
|
||||||
|
|
||||||
|
return isInTenantSubtree(target, rootTenantId, tenantById);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHanmacFamilyUser<T extends TenantFilterTarget>(
|
||||||
|
user: HanmacFamilyUserTarget,
|
||||||
|
tenants: T[],
|
||||||
|
hanmacFamilyTenantId?: string,
|
||||||
|
) {
|
||||||
|
const metadata = user.metadata ?? {};
|
||||||
|
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantBySlug = new Map(
|
||||||
|
tenants
|
||||||
|
.filter((tenant) => tenant.slug?.trim())
|
||||||
|
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
||||||
|
);
|
||||||
|
const tenantCandidates = [
|
||||||
|
user.tenant,
|
||||||
|
...(user.joinedTenants ?? []),
|
||||||
|
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
||||||
|
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
return tenantCandidates.some((tenant) =>
|
||||||
|
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
|
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
|
||||||
tenants: T[],
|
tenants: T[],
|
||||||
hanmacFamilyTenantId?: string,
|
hanmacFamilyTenantId?: string,
|
||||||
) {
|
) {
|
||||||
const rootTenantId = hanmacFamilyTenantId?.trim() ?? "";
|
const rootTenantId = resolveHanmacFamilyTenantId(
|
||||||
|
tenants,
|
||||||
|
hanmacFamilyTenantId,
|
||||||
|
);
|
||||||
const tenantById = new Map(
|
const tenantById = new Map(
|
||||||
tenants
|
tenants
|
||||||
.filter((tenant) => tenant.id?.trim())
|
.filter((tenant) => tenant.id?.trim())
|
||||||
@@ -107,11 +186,19 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
|
|||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
options: OrgChartTenantPickerOptions = {},
|
options: OrgChartTenantPickerOptions = {},
|
||||||
) {
|
) {
|
||||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
|
||||||
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
|
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
|
||||||
|
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthenticatedOrgChartUrl(
|
||||||
|
baseUrl?: string,
|
||||||
|
options: OrgChartLoginOptions = {},
|
||||||
|
) {
|
||||||
|
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||||
|
const returnTo = options.returnTo?.trim() || "/chart";
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
auto: "1",
|
auto: "1",
|
||||||
returnTo: pickerUrl,
|
returnTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${normalizedBase}/login?${params.toString()}`;
|
return `${normalizedBase}/login?${params.toString()}`;
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ test.describe("Authentication", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should link org chart navigation through the auto login entry", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"http://localhost:5175/login?auto=1&returnTo=%2Fchart",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("should logout and redirect to login page", async ({ page }) => {
|
test("should logout and redirect to login page", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
page.on("dialog", (dialog) => dialog.accept());
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -57,13 +57,15 @@ test.describe("Tenants Management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should list tenants", async ({ page }) => {
|
test("should list tenants", async ({ page }) => {
|
||||||
|
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
if (route.request().method() === "GET") {
|
if (route.request().method() === "GET") {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: internalTenantId,
|
||||||
name: "Tenant A",
|
name: "Tenant A",
|
||||||
slug: "tenant-a",
|
slug: "tenant-a",
|
||||||
status: "active",
|
status: "active",
|
||||||
@@ -90,6 +92,7 @@ test.describe("Tenants Management", () => {
|
|||||||
await expect(page.locator("table")).toContainText("Tenant A", {
|
await expect(page.locator("table")).toContainText("Tenant A", {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
await expect(page.locator("table")).toContainText(internalTenantId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create a new tenant", async ({ page }) => {
|
test("should create a new tenant", async ({ page }) => {
|
||||||
|
|||||||
@@ -465,6 +465,40 @@ test.describe("User Management", () => {
|
|||||||
.toMatchObject({ status: "inactive" });
|
.toMatchObject({ status: "inactive" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should expose internal user uuid in the users table", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const internalUserId = "4d20c735-05d0-42d4-9479-0e9be74fd987";
|
||||||
|
|
||||||
|
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: internalUserId,
|
||||||
|
name: "UUID User",
|
||||||
|
email: "uuid-user@test.com",
|
||||||
|
phone: "010-2222-3333",
|
||||||
|
loginId: "uuid_login_id",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page.locator("table")).toContainText(internalUserId);
|
||||||
|
});
|
||||||
|
|
||||||
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
|
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const buildOutDir =
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
build: {
|
build: {
|
||||||
outDir: buildOutDir,
|
outDir: buildOutDir,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig } from "vite";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
// 인스턴스별 도메인을 자동으로 허용
|
// 인스턴스별 도메인을 자동으로 허용
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ services:
|
|||||||
command: npm run dev -- --host 0.0.0.0
|
command: npm run dev -- --host 0.0.0.0
|
||||||
networks: [app_net]
|
networks: [app_net]
|
||||||
|
|
||||||
|
orgfront:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}_orgfront
|
||||||
|
working_dir: /app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "${ORGFRONT_PORT}:5175"
|
||||||
|
volumes:
|
||||||
|
- ../../orgfront:/app
|
||||||
|
- ./orgfront/vite.config.ts:/app/vite.config.ts:ro
|
||||||
|
- ./orgfront/auth.ts:/app/src/lib/auth.ts:ro
|
||||||
|
command: npm run dev -- --host 0.0.0.0 --port 5175
|
||||||
|
networks: [app_net]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app_net:
|
app_net:
|
||||||
name: ${COMPOSE_PROJECT_NAME}_net
|
name: ${COMPOSE_PROJECT_NAME}_net
|
||||||
|
|||||||
23
deploy/templates/orgfront/auth.ts
Normal file
23
deploy/templates/orgfront/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
|
|
||||||
|
export const oidcConfig: AuthProviderProps = {
|
||||||
|
authority:
|
||||||
|
import.meta.env.VITE_OIDC_AUTHORITY ||
|
||||||
|
`${window.location.protocol}//${window.location.hostname}:{{USERFRONT_PORT}}/oidc`,
|
||||||
|
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||||
|
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid offline_access profile email",
|
||||||
|
post_logout_redirect_uri: window.location.origin,
|
||||||
|
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
automaticSilentRenew: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userManager = new UserManager({
|
||||||
|
...oidcConfig,
|
||||||
|
authority: oidcConfig.authority || "",
|
||||||
|
client_id: oidcConfig.client_id || "",
|
||||||
|
redirect_uri: oidcConfig.redirect_uri || "",
|
||||||
|
});
|
||||||
36
deploy/templates/orgfront/vite.config.ts
Normal file
36
deploy/templates/orgfront/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
const allowedHosts = [
|
||||||
|
"{{ORGFRONT_DOMAIN}}",
|
||||||
|
"baron-orgchart.hmac.kr",
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
envPrefix: ["VITE_", "USERFRONT_"],
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
// 인스턴스별 도메인을 자동으로 허용합니다.
|
||||||
|
allowedHosts,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 5175,
|
||||||
|
allowedHosts,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
|
|
||||||
orgfront:
|
orgfront:
|
||||||
build:
|
build:
|
||||||
context: ../baron-orgchart
|
context: ./orgfront
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: baron_orgfront
|
container_name: baron_orgfront
|
||||||
env_file:
|
env_file:
|
||||||
@@ -98,7 +98,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${ORGFRONT_PORT:-5175}:5175"
|
- "${ORGFRONT_PORT:-5175}:5175"
|
||||||
volumes:
|
volumes:
|
||||||
- ../baron-orgchart:/app
|
- ./orgfront:/app
|
||||||
- ./locales:/locales
|
- ./locales:/locales
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
|
||||||
|
orgfront:
|
||||||
|
image: ${ORGFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||||
|
container_name: baron_orgfront
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=stage
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
ports:
|
||||||
|
- "${ORGFRONT_PORT:-5175}:5175"
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
userfront:
|
userfront:
|
||||||
image: ${USERFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
image: ${USERFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||||
container_name: baron_userfront
|
container_name: baron_userfront
|
||||||
|
|||||||
@@ -414,6 +414,25 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
|
||||||
|
orgfront:
|
||||||
|
build:
|
||||||
|
context: ./orgfront
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_orgfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
ports:
|
||||||
|
- "${ORGFRONT_PORT:-5175}:5175"
|
||||||
|
volumes:
|
||||||
|
- ./orgfront:/app
|
||||||
|
- /app/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
userfront:
|
userfront:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Issue #663: 한맥가족 사용자 테넌트 동기화 및 직무/직급 표시 정리
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
adminfront 사용자 관리 화면과 orgfront 조직도/조직 선택기 사이의 한맥가족 사용자 표시 기준을 정리했다.
|
||||||
|
|
||||||
|
기존에는 사용자 상세 화면에서 `metadata.hanmacFamily` 값에 의존해 한맥가족 탭을 결정했다. 이 방식은 기존 사용자가 이미 한맥가족 테넌트 트리에 소속되어 있어도 metadata 플래그가 없으면 외부 기업 회원으로 분류될 수 있었다.
|
||||||
|
|
||||||
|
## 정책 기준
|
||||||
|
|
||||||
|
- `docs/organization-chart-policy.md`에 따라 한맥가족 조직은 `COMPANY_GROUP` 아래의 `COMPANY`/`USER_GROUP` 테넌트 계층으로 판단한다.
|
||||||
|
- 한맥가족 사용자의 직무/직급은 단일 사용자 필드보다 소속별 `additionalAppointments`를 우선한다.
|
||||||
|
- orgfront 표시명은 직무가 있으면 `이름(직무) 직급` 형태를 사용한다.
|
||||||
|
|
||||||
|
## 구현 요약
|
||||||
|
|
||||||
|
- adminfront
|
||||||
|
- 한맥가족 root tenant 및 subtree 판정 유틸을 추가했다.
|
||||||
|
- 기존 사용자의 `tenant`, `joinedTenants`, `companyCode`, `tenantSlug`를 기준으로 한맥가족 여부를 계산한다.
|
||||||
|
- 사용자 상세 초기화 시 한맥가족 subtree 소속이면 `metadata.hanmacFamily`가 없어도 한맥가족 탭을 표시한다.
|
||||||
|
- 한맥가족 저장 시 기존 단일 `position`/`jobTitle` payload를 비우고 `additionalAppointments`를 사용한다.
|
||||||
|
- orgfront
|
||||||
|
- 사용자 표시명 공통 유틸을 추가했다.
|
||||||
|
- 조직도와 조직 선택기 모두 동일한 표시명 로직을 사용한다.
|
||||||
|
- `metadata.additionalAppointments`에 현재 테넌트와 매칭되는 직무/직급이 있으면 이를 우선 사용한다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- RED 확인
|
||||||
|
- `adminfront`: 한맥가족 subtree 사용자 판정 유틸 부재로 unit test 실패 확인.
|
||||||
|
- `orgfront`: 조직도/조직 선택기에서 `이름(직무) 직급` 표시가 없어 Playwright test 실패 확인.
|
||||||
|
- GREEN 확인
|
||||||
|
- `adminfront`: `npm run test:unit -- src/features/users/orgChartPicker.test.ts`
|
||||||
|
- `adminfront`: `npm run test:unit`
|
||||||
|
- `adminfront`: `npm run build`
|
||||||
|
- `orgfront`: `npm run lint`
|
||||||
|
- `orgfront`: `npm run build`
|
||||||
|
- `orgfront`: `npx playwright test tests/orgchart-picker.spec.ts tests/orgchart-vector-render.spec.ts --project=chromium`
|
||||||
|
|
||||||
|
## 남은 정책/운영 메모
|
||||||
|
|
||||||
|
- 이슈는 `한맥가족사 조직도 반영` 마일스톤에 연결했다.
|
||||||
|
- 해당 마일스톤은 Due Date가 비어 있다. 목표 Due Date를 정해 마일스톤에 반영하는 것이 좋다.
|
||||||
1
orgfront
1
orgfront
Submodule orgfront deleted from c9878bf8d0
8
orgfront/.dockerignore
Normal file
8
orgfront/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
docker-compose.yml
|
||||||
|
.env*
|
||||||
|
npm-debug.log
|
||||||
24
orgfront/.gitignore
vendored
Normal file
24
orgfront/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
20
orgfront/Dockerfile
Normal file
20
orgfront/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 패키지 정보 복사 및 의존성 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite 기본 포트
|
||||||
|
EXPOSE 5175
|
||||||
|
|
||||||
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
|
RUN chmod +x ./scripts/runtime-mode.sh
|
||||||
|
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||||
20
orgfront/Dockerfile2
Normal file
20
orgfront/Dockerfile2
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 패키지 정보 복사 및 의존성 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite 기본 포트
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
|
RUN chmod +x ./scripts/runtime-mode.sh
|
||||||
|
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||||
106
orgfront/README.md
Normal file
106
orgfront/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Baron OrgChart (바론 조직도 서비스)
|
||||||
|
|
||||||
|
Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organization Chart) 웹 애플리케이션**입니다. 기존 관리자 시스템(Adminfront)에 종속되어 있던 기능을 별도의 서비스(RP: Relying Party)로 분리하여 구축했습니다.
|
||||||
|
|
||||||
|
## 🌟 주요 기능
|
||||||
|
|
||||||
|
* **독립된 SSO 인증 (Ory Hydra):** Baron SSO(Ory 기반)를 통한 안전한 OAuth2/OIDC 로그인 지원
|
||||||
|
* **딥링크(Deep Link) 지원:** 특정 부서의 조직도로 바로 접근 가능한 공유 링크 (`/chart/:tenantId` 또는 `/chart/:slug`) 지원
|
||||||
|
* **고급 트리뷰 디자인:** 밝은 테마 기반의 직관적인 계층형 트리뷰 제공
|
||||||
|
* **Keto ReBAC 권한 연동 (예정):** 사용자의 소속 부서 및 권한 레벨에 따라 열람 가능한 조직도가 동적으로 제어됩니다.
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
* **프레임워크:** React 19 + Vite + TypeScript
|
||||||
|
* **스타일링:** Tailwind CSS
|
||||||
|
* **인증 연동:** `react-oidc-context`, `oidc-client-ts`
|
||||||
|
* **상태 관리:** React Query (`@tanstack/react-query`)
|
||||||
|
* **아이콘:** Lucide React
|
||||||
|
|
||||||
|
## 🧩 조직도 데이터 연동 구조
|
||||||
|
|
||||||
|
조직도 화면은 Baron Admin에서 관리한 **테넌트(Tenant)**와 **사용자(User)** 정보를 Backend API로 받아와 프론트에서 트리 구조로 조립합니다. Adminfront 화면에 직접 의존하지 않고, 동일한 Admin API 데이터를 사용하는 독립 RP로 동작합니다.
|
||||||
|
|
||||||
|
### 인증 및 API 호출
|
||||||
|
|
||||||
|
* 모든 인증 화면은 `react-oidc-context`를 통해 OIDC Access Token을 얻고, API 요청 시 `Authorization: Bearer <access_token>` 헤더를 붙입니다.
|
||||||
|
* 사용자가 작업 테넌트를 선택한 경우 `localStorage.dev_tenant_id` 값을 `X-Tenant-ID` 헤더로 함께 전달합니다.
|
||||||
|
* API Base URL은 `VITE_DEV_API_BASE`, `VITE_ADMIN_API_BASE`, `/api` 순서로 결정됩니다.
|
||||||
|
|
||||||
|
### 일반 조직도 화면
|
||||||
|
|
||||||
|
`/chart`와 `/chart/:tenantId`는 로그인된 사용자를 대상으로 다음 API를 호출합니다.
|
||||||
|
|
||||||
|
| 용도 | API | 주요 사용 필드 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 테넌트 목록 | `GET /v1/admin/tenants?limit=10000&offset=0` | `id`, `type`, `name`, `slug`, `parentId`, `memberCount`, `status` |
|
||||||
|
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `position`, `jobTitle` |
|
||||||
|
|
||||||
|
테넌트는 Baron Admin에서 입력한 `parentId` 관계를 기준으로 트리로 변환합니다. 현재 루트 후보는 `type === "COMPANY_GROUP"`인 테넌트를 우선 사용하고, 없으면 최상위 테넌트를 사용합니다. 회사 필터는 루트 하위의 `type === "COMPANY"` 테넌트로 구성됩니다.
|
||||||
|
|
||||||
|
사용자는 다음 순서로 조직도 노드에 매핑됩니다.
|
||||||
|
|
||||||
|
1. `status === "active"`인 사용자만 사용합니다.
|
||||||
|
2. `@hanmac.kr` 이메일은 현재 조직도 표시 대상에서 제외합니다.
|
||||||
|
3. 사용자의 `companyCode`가 있으면 해당 값을 테넌트 `slug`와 매칭합니다.
|
||||||
|
4. `companyCode`가 없으면 `tenantSlug`를 사용합니다.
|
||||||
|
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
|
||||||
|
6. 같은 테넌트 노드 안에서 동일 사용자 `id`는 중복 추가하지 않습니다.
|
||||||
|
|
||||||
|
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 `position`/`jobTitle` 기준으로 정렬되며, 표시 직무는 `jobTitle || position || "사원"` 순서로 결정됩니다.
|
||||||
|
|
||||||
|
### 공유 조직도 화면
|
||||||
|
|
||||||
|
`/chart?token=<share-token>` 형태의 공유 링크는 인증 체크를 건너뛰고 다음 공개 API만 호출합니다.
|
||||||
|
|
||||||
|
| 용도 | API | 주요 사용 필드 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 공유 조직도 | `GET /v1/public/orgchart?token=<share-token>` | `tenants`, `users`, `sharedWith` |
|
||||||
|
|
||||||
|
공개 API 응답의 `tenants`와 `users`는 일반 조직도와 같은 방식으로 트리 및 구성원 목록에 매핑됩니다. 단, 공개 응답은 서버가 공유 범위에 맞게 이미 필터링한 데이터로 간주합니다.
|
||||||
|
|
||||||
|
### 조직 선택기
|
||||||
|
|
||||||
|
`/picker`와 `/embed/picker`도 Baron Admin의 테넌트/사용자 데이터를 사용합니다.
|
||||||
|
|
||||||
|
* `GET /v1/admin/tenants`
|
||||||
|
* `GET /v1/admin/users`
|
||||||
|
|
||||||
|
선택기는 테넌트 노드와 사용자 노드를 함께 보여주며, 사용자는 `companyCode || tenantSlug`와 테넌트 `slug`를 기준으로 배치합니다. 임베딩 모드에서는 선택 결과를 `postMessage`로 부모 화면에 전달합니다.
|
||||||
|
|
||||||
|
## 🚀 로컬 환경 실행 가이드
|
||||||
|
|
||||||
|
### 1. 저장소 복제 및 의존성 설치
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.hmac.kr/baron/baron-orgchart.git
|
||||||
|
cd baron-orgchart
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 환경 변수 설정
|
||||||
|
프로젝트 루트에 `.env.local` 파일을 생성하고 아래 환경 변수를 설정합니다. (개발 환경 기준)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Baron SSO(Gateway)의 OIDC 엔드포인트
|
||||||
|
VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
|
||||||
|
|
||||||
|
# Hydra에 등록된 Client ID
|
||||||
|
VITE_OIDC_CLIENT_ID=orgfront
|
||||||
|
|
||||||
|
# Backend API 주소
|
||||||
|
VITE_ADMIN_API_BASE=http://localhost:5000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 개발 서버 실행
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
기본적으로 `http://localhost:5175` 에서 실행됩니다.
|
||||||
|
|
||||||
|
## 🔗 관련 문서 및 이슈
|
||||||
|
* [조직도 임베딩 토큰 설계](docs/orgchart-embedding-token-design.md)
|
||||||
|
* [Issue #544: 조직도 탭 분리 및 스타일 변경](https://gitea.hmac.kr/baron/baron-sso/issues/544)
|
||||||
|
* [Issue #545: 조직도 권한 설정](https://gitea.hmac.kr/baron/baron-sso/issues/545)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Powered by Baron SSO*
|
||||||
29
orgfront/biome.json
Normal file
29
orgfront/biome.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useEnumInitializers": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": [
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"tsconfig*.json",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
orgfront/docker-compose.yml
Normal file
19
orgfront/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron-orgchart
|
||||||
|
ports:
|
||||||
|
- "5175:5175"
|
||||||
|
environment:
|
||||||
|
- APP_ENV=development
|
||||||
|
- VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
|
||||||
|
- VITE_OIDC_CLIENT_ID=orgfront
|
||||||
|
- VITE_API_URL=http://localhost:5000/api
|
||||||
|
- API_PROXY_TARGET=http://localhost:5000
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
125
orgfront/docs/orgchart-embedding-token-design.md
Normal file
125
orgfront/docs/orgchart-embedding-token-design.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 조직도 임베딩 토큰 설계
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
조직도 임베딩 검증 화면은 외부 서비스가 Baron OrgChart의 조직 선택기 또는 조직도 데이터를 안전하게 사용할 수 있는지를 확인하는 도구입니다. 토큰 설계는 다음 두 가지 요구를 동시에 다룹니다.
|
||||||
|
|
||||||
|
* 최상위 테넌트 권한 범위에 맞춰 조직도 접근 토큰을 발급한다.
|
||||||
|
* 특정 서비스가 장기간 안정적으로 임베딩할 수 있도록 서비스 전용 토큰을 생성하고 갱신한다.
|
||||||
|
|
||||||
|
## 방식 A: 최상위 테넌트 권한 기반 토큰
|
||||||
|
|
||||||
|
이 방식은 로그인한 사용자의 현재 권한을 기준으로 단기 공유 토큰을 발급합니다. 관리자가 `COMPANY_GROUP` 또는 최상위 테넌트 범위를 선택하면, 서버는 사용자가 해당 범위를 볼 수 있는지 확인한 뒤 제한된 수명의 토큰을 반환합니다.
|
||||||
|
|
||||||
|
### 권장 API
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/orgchart/embed-tokens
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
X-Tenant-ID: <current-tenant-id>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scopeType": "tenant_root",
|
||||||
|
"rootTenantId": "group-hmac",
|
||||||
|
"allowedModes": ["single", "multiple"],
|
||||||
|
"allowedSelectableTypes": ["tenant", "user", "both"],
|
||||||
|
"expiresInSeconds": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서버 검증
|
||||||
|
|
||||||
|
* 요청자는 `rootTenantId` 또는 그 상위 범위를 관리할 수 있어야 합니다.
|
||||||
|
* 토큰에는 `rootTenantId`, 허용 선택 모드, 허용 선택 대상, 만료 시각을 포함합니다.
|
||||||
|
* 토큰은 서버 저장형 opaque token을 우선 권장합니다. 즉시 폐기와 감사 로그 추적이 쉽기 때문입니다.
|
||||||
|
* JWT를 사용할 경우 `jti`를 저장해 폐기 목록을 운영해야 합니다.
|
||||||
|
|
||||||
|
### 장점
|
||||||
|
|
||||||
|
* 현재 관리자 권한 모델과 자연스럽게 연결됩니다.
|
||||||
|
* 단기 검증, 데모, 운영자 테스트에 적합합니다.
|
||||||
|
* 토큰 범위가 명확해 권한 사고 범위를 줄일 수 있습니다.
|
||||||
|
|
||||||
|
### 한계
|
||||||
|
|
||||||
|
* 서비스가 장기간 임베딩하려면 사용자가 주기적으로 재발급해야 합니다.
|
||||||
|
* 외부 서비스 단위의 소유자, 회전 주기, 폐기 정책을 별도로 관리하기 어렵습니다.
|
||||||
|
|
||||||
|
## 방식 B: 특정 서비스용 토큰 생성 및 갱신
|
||||||
|
|
||||||
|
이 방식은 Baron Admin 또는 OrgFront 관리 화면에서 외부 서비스별 임베딩 클라이언트를 등록하고, 각 서비스에 장기 토큰 또는 회전 가능한 토큰 세트를 발급합니다.
|
||||||
|
|
||||||
|
### 권장 모델
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "embed-client-001",
|
||||||
|
"serviceName": "crm-dashboard",
|
||||||
|
"ownerTenantId": "group-hmac",
|
||||||
|
"rootTenantId": "company-baron",
|
||||||
|
"allowedOrigins": ["https://crm.example.com"],
|
||||||
|
"allowedModes": ["single", "multiple"],
|
||||||
|
"allowedSelectableTypes": ["user"],
|
||||||
|
"status": "active",
|
||||||
|
"expiresAt": "2026-07-31T00:00:00.000Z",
|
||||||
|
"lastRotatedAt": "2026-04-29T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 권장 API
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/admin/orgchart/embed-clients
|
||||||
|
GET /v1/admin/orgchart/embed-clients
|
||||||
|
POST /v1/admin/orgchart/embed-clients/{clientId}/rotate-token
|
||||||
|
POST /v1/admin/orgchart/embed-clients/{clientId}/revoke
|
||||||
|
GET /v1/public/orgchart/embed-config?token=<embed-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서버 검증
|
||||||
|
|
||||||
|
* 서비스 토큰은 특정 `rootTenantId` 이하 데이터에만 접근할 수 있어야 합니다.
|
||||||
|
* `allowedOrigins`가 설정된 경우 `Origin` 헤더를 검증합니다.
|
||||||
|
* 토큰 회전 시 기존 토큰과 신규 토큰이 함께 유효한 grace period를 짧게 둘 수 있습니다.
|
||||||
|
* 모든 생성, 갱신, 폐기, 사용 이벤트는 감사 로그에 남깁니다.
|
||||||
|
* 토큰 원문은 최초 생성 또는 회전 직후에만 보여주고, 서버에는 해시만 저장합니다.
|
||||||
|
|
||||||
|
### 장점
|
||||||
|
|
||||||
|
* 외부 서비스별 접근 범위, 만료, 회전, 폐기 정책을 독립적으로 관리할 수 있습니다.
|
||||||
|
* 운영 환경의 장기 임베딩에 적합합니다.
|
||||||
|
* 감사 로그와 장애 대응이 명확합니다.
|
||||||
|
|
||||||
|
### 한계
|
||||||
|
|
||||||
|
* 별도 관리 화면과 백엔드 저장 모델이 필요합니다.
|
||||||
|
* 토큰 회전 정책, Origin 검증, 만료 알림 등 운영 기능의 설계 범위가 커집니다.
|
||||||
|
|
||||||
|
## 권장 적용 순서
|
||||||
|
|
||||||
|
1. 먼저 방식 A를 구현해 임베딩 검증 화면에서 최상위 테넌트 권한 기반 단기 토큰을 발급합니다.
|
||||||
|
2. 토큰 payload와 감사 로그 이벤트 스키마를 방식 B에서도 재사용할 수 있게 고정합니다.
|
||||||
|
3. 외부 서비스 운영 요구가 확정되면 방식 B의 embed client 관리 API와 화면을 추가합니다.
|
||||||
|
4. 방식 B 도입 후에도 방식 A는 관리자 테스트용 단기 토큰 발급 기능으로 유지합니다.
|
||||||
|
|
||||||
|
## 프론트엔드 반영 방향
|
||||||
|
|
||||||
|
임베딩 검증 화면은 현재 선택 모드와 선택 대상 조합을 바꿔 iframe을 갱신합니다. 토큰 기능이 추가되면 다음 값을 함께 표시해야 합니다.
|
||||||
|
|
||||||
|
* 발급 주체: 현재 사용자 또는 서비스 클라이언트
|
||||||
|
* 접근 루트: `rootTenantId`
|
||||||
|
* 허용 Origin
|
||||||
|
* 허용 선택 모드: `single`, `multiple`
|
||||||
|
* 허용 선택 대상: `tenant`, `user`, `both`
|
||||||
|
* 만료 시각과 갱신 가능 여부
|
||||||
|
|
||||||
|
iframe URL은 다음 형태를 기준으로 확장합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/embed/picker?token=<embed-token>&mode=multiple&select=both
|
||||||
|
```
|
||||||
|
|
||||||
|
서버는 토큰의 허용 범위와 URL query가 충돌할 경우 더 좁은 범위를 적용하거나 요청을 거절해야 합니다.
|
||||||
188
orgfront/hydra-rp-dummy.py
Normal file
188
orgfront/hydra-rp-dummy.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from http import cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
CLIENT_ID = os.environ["CLIENT_ID"]
|
||||||
|
SUBJECT = os.environ["SUBJECT"]
|
||||||
|
REDIRECT_URI = os.environ["REDIRECT_URI"]
|
||||||
|
SCOPE = os.environ["SCOPE"]
|
||||||
|
STATE = os.environ["STATE"]
|
||||||
|
NONCE = os.environ["NONCE"]
|
||||||
|
ADMIN_BASE = os.environ.get("HYDRA_ADMIN_URL", "http://127.0.0.1:4445")
|
||||||
|
PUBLIC_BASE = os.environ.get("HYDRA_PUBLIC_URL", "http://127.0.0.1:4444")
|
||||||
|
|
||||||
|
|
||||||
|
def _put_json(url: str, payload: dict) -> dict:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, method="PUT")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
|
||||||
|
|
||||||
|
def accept_login(challenge: str) -> str:
|
||||||
|
url = f"{ADMIN_BASE}/oauth2/auth/requests/login/accept?login_challenge={urllib.parse.quote(challenge)}"
|
||||||
|
payload = {"subject": SUBJECT, "remember": True, "remember_for": 3600}
|
||||||
|
data = _put_json(url, payload)
|
||||||
|
return data.get("redirect_to", "")
|
||||||
|
|
||||||
|
|
||||||
|
def accept_consent(challenge: str) -> str:
|
||||||
|
url = f"{ADMIN_BASE}/oauth2/auth/requests/consent/accept?consent_challenge={urllib.parse.quote(challenge)}"
|
||||||
|
payload = {"grant_scope": ["openid", "profile", "email"], "remember": True, "remember_for": 3600}
|
||||||
|
data = _put_json(url, payload)
|
||||||
|
return data.get("redirect_to", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _location_from_response(url: str, cookie_header: str | None) -> str:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
if cookie_header:
|
||||||
|
req.add_header("Cookie", cookie_header)
|
||||||
|
opener = urllib.request.build_opener(NoRedirect())
|
||||||
|
try:
|
||||||
|
opener.open(req, timeout=5)
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
return err.headers.get("Location", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
login_challenge = (params.get("login_challenge") or [""])[0]
|
||||||
|
consent_challenge = (params.get("consent_challenge") or [""])[0]
|
||||||
|
login_verifier = (params.get("login_verifier") or [""])[0]
|
||||||
|
consent_verifier = (params.get("consent_verifier") or [""])[0]
|
||||||
|
|
||||||
|
if parsed.path == "/oauth2/auth" and consent_verifier:
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"consent_verifier": consent_verifier,
|
||||||
|
"client_id": (params.get("client_id") or [""])[0],
|
||||||
|
"redirect_uri": (params.get("redirect_uri") or [""])[0],
|
||||||
|
"response_type": (params.get("response_type") or [""])[0],
|
||||||
|
"scope": (params.get("scope") or [""])[0],
|
||||||
|
"state": (params.get("state") or [""])[0],
|
||||||
|
"nonce": (params.get("nonce") or [""])[0],
|
||||||
|
})
|
||||||
|
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
|
||||||
|
location = _location_from_response(public_url, self.headers.get("Cookie"))
|
||||||
|
print(f"consent_verifier_location={location}")
|
||||||
|
if not location:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"missing redirect location")
|
||||||
|
return
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", location)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/oauth2/auth" and login_verifier:
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"login_verifier": login_verifier,
|
||||||
|
"client_id": (params.get("client_id") or [""])[0],
|
||||||
|
"redirect_uri": (params.get("redirect_uri") or [""])[0],
|
||||||
|
"response_type": (params.get("response_type") or [""])[0],
|
||||||
|
"scope": (params.get("scope") or [""])[0],
|
||||||
|
"state": (params.get("state") or [""])[0],
|
||||||
|
"nonce": (params.get("nonce") or [""])[0],
|
||||||
|
})
|
||||||
|
public_url = f"{PUBLIC_BASE}/oauth2/auth?{query}"
|
||||||
|
location = _location_from_response(public_url, self.headers.get("Cookie"))
|
||||||
|
print(f"login_verifier_location={location}")
|
||||||
|
if not location:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"missing redirect location")
|
||||||
|
return
|
||||||
|
consent_challenge = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get(
|
||||||
|
"consent_challenge",
|
||||||
|
[""],
|
||||||
|
)[0]
|
||||||
|
if not consent_challenge:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"missing consent_challenge location={location}".encode("utf-8"))
|
||||||
|
return
|
||||||
|
redirect_to = accept_consent(consent_challenge)
|
||||||
|
if not redirect_to:
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"consent accept failed")
|
||||||
|
return
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", redirect_to)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
if login_challenge:
|
||||||
|
redirect_to = accept_login(login_challenge)
|
||||||
|
elif consent_challenge:
|
||||||
|
redirect_to = accept_consent(consent_challenge)
|
||||||
|
else:
|
||||||
|
redirect_to = ""
|
||||||
|
|
||||||
|
if not redirect_to:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"missing challenge")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", redirect_to)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class StopAtRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
if newurl.startswith(REDIRECT_URI):
|
||||||
|
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server = HTTPServer(("127.0.0.1", 3000), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
encoded_redirect = urllib.parse.quote(REDIRECT_URI, safe="")
|
||||||
|
encoded_scope = urllib.parse.quote(SCOPE, safe="")
|
||||||
|
auth_url = (
|
||||||
|
f"{PUBLIC_BASE}/oauth2/auth?response_type=code"
|
||||||
|
f"&client_id={CLIENT_ID}"
|
||||||
|
f"&redirect_uri={encoded_redirect}"
|
||||||
|
f"&scope={encoded_scope}"
|
||||||
|
f"&state={STATE}"
|
||||||
|
f"&nonce={NONCE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
jar = cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), StopAtRedirect())
|
||||||
|
try:
|
||||||
|
opener.open(auth_url, timeout=10)
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
body = err.read().decode("utf-8") if hasattr(err, "read") else ""
|
||||||
|
print(f"error_url={err.geturl()}")
|
||||||
|
print(f"error_code={err.code}")
|
||||||
|
if body:
|
||||||
|
print(f"error_body={body}")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
13
orgfront/index.html
Normal file
13
orgfront/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>바론 조직도 서비스</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3220
orgfront/package-lock.json
generated
Normal file
3220
orgfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
orgfront/package.json
Normal file
52
orgfront/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "orgfront",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
|
||||||
|
"test:ui": "playwright test --ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@tanstack/react-query": "^5.66.8",
|
||||||
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-oidc-context": "^3.3.0",
|
||||||
|
"react-router-dom": "^6.28.2",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
orgfront/playwright.config.ts
Normal file
65
orgfront/playwright.config.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||||
|
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: "http://localhost:5175",
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: process.env.CI
|
||||||
|
? "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
|
||||||
|
: "npm run dev -- --host 0.0.0.0 --port 5175",
|
||||||
|
url: "http://localhost:5175",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
orgfront/postcss.config.js
Normal file
6
orgfront/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
orgfront/public/vite.svg
Normal file
1
orgfront/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
26
orgfront/scripts/runtime-mode.sh
Normal file
26
orgfront/scripts/runtime-mode.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$app_env" in
|
||||||
|
production|prod|stage|staging)
|
||||||
|
mode="production"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
mode="development"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--print-mode" ]; then
|
||||||
|
printf '%s\n' "$mode"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "production" ]; then
|
||||||
|
echo "Running in production mode with Vite preview..."
|
||||||
|
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running in development mode..."
|
||||||
|
exec npm run dev -- --host 0.0.0.0
|
||||||
11
orgfront/src/app/queryClient.ts
Normal file
11
orgfront/src/app/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
46
orgfront/src/app/routes.tsx
Normal file
46
orgfront/src/app/routes.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||||
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
|
import AuthGuard from "../features/auth/AuthGuard";
|
||||||
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
|
import { TenantOrgChartPage } from "../features/orgchart/routes/OrgChartPage";
|
||||||
|
import { OrgFrontLayout } from "../features/orgchart/routes/OrgFrontLayout";
|
||||||
|
import { OrgPickerEmbedPreviewPage } from "../features/orgchart/routes/OrgPickerEmbedPreviewPage";
|
||||||
|
import {
|
||||||
|
OrgPickerEmbedPage,
|
||||||
|
OrgPickerPage,
|
||||||
|
} from "../features/orgchart/routes/OrgPickerPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/auth/callback",
|
||||||
|
element: <AuthCallbackPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/chart" replace /> },
|
||||||
|
{
|
||||||
|
element: <OrgFrontLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: "chart", element: <TenantOrgChartPage /> },
|
||||||
|
{ path: "chart/:tenantId", element: <TenantOrgChartPage /> },
|
||||||
|
{ path: "picker", element: <OrgPickerPage /> },
|
||||||
|
{ path: "embed-preview", element: <OrgPickerEmbedPreviewPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "embed/picker", element: <OrgPickerEmbedPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
future: {
|
||||||
|
v7_startTransition: true,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||||
|
);
|
||||||
1
orgfront/src/assets/react.svg
Normal file
1
orgfront/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
51
orgfront/src/components/common/ForbiddenMessage.tsx
Normal file
51
orgfront/src/components/common/ForbiddenMessage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resourceToken: "audit" | "clients";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForbiddenMessage({ resourceToken }: Props) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const rawProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||||
|
const role = resolveProfileRole(rawProfile);
|
||||||
|
|
||||||
|
let explanation = t(
|
||||||
|
"msg.dev.forbidden.default",
|
||||||
|
"해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (role === "rp_admin") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.rp_admin",
|
||||||
|
"RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.",
|
||||||
|
);
|
||||||
|
} else if (role === "tenant_admin") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.tenant_admin",
|
||||||
|
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
|
||||||
|
);
|
||||||
|
} else if (role === "user" || role === "tenant_member") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.user",
|
||||||
|
"일반 사용자는 관리자 화면에 접근할 수 없습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
|
||||||
|
resource:
|
||||||
|
resourceToken === "audit"
|
||||||
|
? t("ui.dev.audit.title", "Audit Logs")
|
||||||
|
: t("ui.dev.clients.registry.subtitle", "연동 앱"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-12 text-center text-red-500/90 gap-3">
|
||||||
|
<ShieldAlert className="h-10 w-10 text-red-500/80 mb-2" />
|
||||||
|
<h3 className="text-xl font-bold text-foreground">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">{explanation}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
orgfront/src/components/common/LanguageSelector.tsx
Normal file
53
orgfront/src/components/common/LanguageSelector.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
const LOCALE_STORAGE_KEY = "locale";
|
||||||
|
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||||
|
|
||||||
|
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
function resolveLocale(): Locale {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "ko";
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored === "ko" || stored === "en") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLocale = window.location.pathname.split("/")[1];
|
||||||
|
if (pathLocale === "ko" || pathLocale === "en") {
|
||||||
|
return pathLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserLang = window.navigator.language.toLowerCase();
|
||||||
|
return browserLang.startsWith("ko") ? "ko" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function LanguageSelector() {
|
||||||
|
const [locale, setLocale] = useState<Locale>(resolveLocale());
|
||||||
|
|
||||||
|
const handleChange = (next: Locale) => {
|
||||||
|
if (next === locale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
|
setLocale(next);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => handleChange(event.target.value as Locale)}
|
||||||
|
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label={t("ui.common.language", "언어")}
|
||||||
|
>
|
||||||
|
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
|
||||||
|
<option value="en">{t("ui.common.language_en", "English")}</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
567
orgfront/src/components/layout/AppLayout.tsx
Normal file
567
orgfront/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
ChevronDown,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
NotebookTabs,
|
||||||
|
ShieldHalf,
|
||||||
|
Sun,
|
||||||
|
User as UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { fetchMe } from "../../features/auth/authApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
import {
|
||||||
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
|
} from "../../lib/sessionSliding";
|
||||||
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.clients",
|
||||||
|
labelFallback: "Clients",
|
||||||
|
to: "/clients",
|
||||||
|
icon: ShieldHalf,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.audit_logs",
|
||||||
|
labelFallback: "Audit Logs",
|
||||||
|
to: "/audit-logs",
|
||||||
|
icon: NotebookTabs,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isRenewInFlightRef = useRef(false);
|
||||||
|
const lastRenewAttemptAtRef = useRef(0);
|
||||||
|
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
||||||
|
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
||||||
|
return stored !== "false";
|
||||||
|
});
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ["userMe"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||||
|
auth.removeUser();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
if (theme === "light") {
|
||||||
|
root.classList.add("light");
|
||||||
|
} else {
|
||||||
|
root.classList.add("dark");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
profileMenuRef.current &&
|
||||||
|
!profileMenuRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maybeRenewSession = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!shouldAttemptSlidingSessionRenew({
|
||||||
|
expiresAtSec: auth.user?.expires_at,
|
||||||
|
nowMs: now,
|
||||||
|
isEnabled: isSessionExpiryEnabled,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
isLoading: auth.isLoading,
|
||||||
|
isRenewInFlight: isRenewInFlightRef.current,
|
||||||
|
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRenewInFlightRef.current = true;
|
||||||
|
lastRenewAttemptAtRef.current = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.signinSilent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||||
|
} finally {
|
||||||
|
isRenewInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserAction = () => {
|
||||||
|
void maybeRenewSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointerdown", handleUserAction);
|
||||||
|
window.addEventListener("keydown", handleUserAction);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", handleUserAction);
|
||||||
|
window.removeEventListener("keydown", handleUserAction);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
auth,
|
||||||
|
auth.isAuthenticated,
|
||||||
|
auth.isLoading,
|
||||||
|
auth.user?.expires_at,
|
||||||
|
isSessionExpiryEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maybeKeepSessionAlive = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!shouldAttemptUnlimitedSessionRenew({
|
||||||
|
expiresAtSec: auth.user?.expires_at,
|
||||||
|
nowMs: now,
|
||||||
|
isEnabled: isSessionExpiryEnabled,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
isLoading: auth.isLoading,
|
||||||
|
isRenewInFlight: isRenewInFlightRef.current,
|
||||||
|
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRenewInFlightRef.current = true;
|
||||||
|
lastRenewAttemptAtRef.current = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.signinSilent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
|
||||||
|
} finally {
|
||||||
|
isRenewInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void maybeKeepSessionAlive();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
void maybeKeepSessionAlive();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
auth,
|
||||||
|
auth.isAuthenticated,
|
||||||
|
auth.isLoading,
|
||||||
|
auth.user?.expires_at,
|
||||||
|
isSessionExpiryEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||||
|
if (lastVisitedRouteRef.current === null) {
|
||||||
|
lastVisitedRouteRef.current = routeKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastVisitedRouteRef.current === routeKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisitedRouteRef.current = routeKey;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!shouldAttemptSlidingSessionRenew({
|
||||||
|
expiresAtSec: auth.user?.expires_at,
|
||||||
|
nowMs: now,
|
||||||
|
isEnabled: isSessionExpiryEnabled,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
isLoading: auth.isLoading,
|
||||||
|
isRenewInFlight: isRenewInFlightRef.current,
|
||||||
|
lastAttemptAtMs: lastRenewAttemptAtRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRenewInFlightRef.current = true;
|
||||||
|
lastRenewAttemptAtRef.current = now;
|
||||||
|
|
||||||
|
void auth
|
||||||
|
.signinSilent()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("세션 자동 연장에 실패했습니다.", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRenewInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
auth,
|
||||||
|
auth.isAuthenticated,
|
||||||
|
auth.isLoading,
|
||||||
|
auth.user?.expires_at,
|
||||||
|
isSessionExpiryEnabled,
|
||||||
|
location.hash,
|
||||||
|
location.pathname,
|
||||||
|
location.search,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileName =
|
||||||
|
profile?.name?.trim() ||
|
||||||
|
auth.user?.profile?.name?.toString().trim() ||
|
||||||
|
auth.user?.profile?.preferred_username?.toString().trim() ||
|
||||||
|
auth.user?.profile?.nickname?.toString().trim() ||
|
||||||
|
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||||
|
const profileEmail =
|
||||||
|
profile?.email?.trim() ||
|
||||||
|
auth.user?.profile?.email?.toString().trim() ||
|
||||||
|
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||||
|
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||||
|
const currentRole = resolveProfileRole(
|
||||||
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
|
);
|
||||||
|
const displayRoleKey = profile?.role || currentRole;
|
||||||
|
|
||||||
|
const isDevConsoleAllowed = [
|
||||||
|
"super_admin",
|
||||||
|
"tenant_admin",
|
||||||
|
"rp_admin",
|
||||||
|
].includes(currentRole);
|
||||||
|
const expiresAtSec = auth.user?.expires_at;
|
||||||
|
const remainingMs =
|
||||||
|
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||||
|
const remainingTotalSec =
|
||||||
|
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
|
||||||
|
const remainingMinutes =
|
||||||
|
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
|
||||||
|
const remainingSeconds =
|
||||||
|
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
|
||||||
|
|
||||||
|
let sessionToneClass =
|
||||||
|
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||||
|
let sessionText = t("ui.dev.session.active", "세션 활성");
|
||||||
|
|
||||||
|
if (remainingMs === null) {
|
||||||
|
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||||
|
sessionText = t("ui.dev.session.unknown", "알 수 없음");
|
||||||
|
} else if (remainingMs <= 0) {
|
||||||
|
sessionToneClass =
|
||||||
|
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||||
|
sessionText = t("ui.dev.session.expired", "세션 만료");
|
||||||
|
} else if (
|
||||||
|
remainingMinutes !== null &&
|
||||||
|
remainingSeconds !== null &&
|
||||||
|
remainingMinutes <= 5
|
||||||
|
) {
|
||||||
|
sessionToneClass =
|
||||||
|
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||||
|
sessionText = t(
|
||||||
|
"ui.dev.session.expiring",
|
||||||
|
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||||
|
{
|
||||||
|
minutes: remainingMinutes,
|
||||||
|
seconds: remainingSeconds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sessionText = t(
|
||||||
|
"ui.dev.session.remaining",
|
||||||
|
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||||
|
{
|
||||||
|
minutes: remainingMinutes ?? 0,
|
||||||
|
seconds: remainingSeconds ?? 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionExpiryToggle = () => {
|
||||||
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||||
|
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||||
|
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||||
|
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||||
|
<ShieldHalf size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{t("ui.dev.brand", "Baron 로그인")}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{t("ui.dev.console_title", "Developer Console")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||||
|
<BadgeCheck size={14} />
|
||||||
|
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
|
{t("ui.dev.env_badge", "Env: dev")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{isDevConsoleAllowed &&
|
||||||
|
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{t(labelKey, labelFallback)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border-t border-border/50 px-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
|
||||||
|
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"msg.dev.sidebar.notice_detail",
|
||||||
|
"Register and manage client applications.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
{t("ui.dev.header.plane", "Dev Plane")}
|
||||||
|
</p>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LanguageSelector />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||||
|
>
|
||||||
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
|
{theme === "light"
|
||||||
|
? t("ui.common.theme_light", "Light")
|
||||||
|
: t("ui.common.theme_dark", "Dark")}
|
||||||
|
</button>
|
||||||
|
{isSessionExpiryEnabled ? (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||||
|
sessionToneClass,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{sessionText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="relative" ref={profileMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
|
||||||
|
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isProfileMenuOpen}
|
||||||
|
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||||
|
>
|
||||||
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||||
|
{profileInitial}
|
||||||
|
</div>
|
||||||
|
<div className="hidden min-w-0 text-left md:block">
|
||||||
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
|
{profileName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
|
{profileEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isProfileMenuOpen ? (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{t("ui.dev.profile.menu_title", "Account")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{profileName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{profileEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pt-1">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
|
||||||
|
{t(
|
||||||
|
`ui.admin.role.${displayRoleKey}`,
|
||||||
|
displayRoleKey.toUpperCase(),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isSessionExpiryEnabled
|
||||||
|
? sessionText
|
||||||
|
: t(
|
||||||
|
"ui.dev.session.disabled",
|
||||||
|
"세션 만료 비활성화",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isSessionExpiryEnabled}
|
||||||
|
onClick={handleSessionExpiryToggle}
|
||||||
|
className={[
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
|
||||||
|
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-block h-5 w-5 rounded-full bg-white transition",
|
||||||
|
isSessionExpiryEnabled
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-1",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/profile");
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserIcon size={16} className="text-muted-foreground" />
|
||||||
|
<span>{t("ui.dev.profile.title", "내 정보")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
47
orgfront/src/components/ui/avatar.tsx
Normal file
47
orgfront/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
39
orgfront/src/components/ui/badge.tsx
Normal file
39
orgfront/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||||
|
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
56
orgfront/src/components/ui/button.tsx
Normal file
56
orgfront/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-6 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
72
orgfront/src/components/ui/card.tsx
Normal file
72
orgfront/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
};
|
||||||
75
orgfront/src/components/ui/copy-button.tsx
Normal file
75
orgfront/src/components/ui/copy-button.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Button, type ButtonProps } from "./button";
|
||||||
|
|
||||||
|
interface CopyButtonProps extends ButtonProps {
|
||||||
|
value: string;
|
||||||
|
onCopy?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({
|
||||||
|
value,
|
||||||
|
onCopy,
|
||||||
|
className,
|
||||||
|
variant = "secondary",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: CopyButtonProps) {
|
||||||
|
const [hasCopied, setHasCopied] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasCopied) {
|
||||||
|
const timer = setTimeout(() => setHasCopied(false), 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [hasCopied]);
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} else {
|
||||||
|
// Fallback for non-secure contexts (HTTP) or missing navigator.clipboard
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = value;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
textArea.style.top = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand("copy");
|
||||||
|
if (!successful) throw new Error("execCommand copy failed");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fallback: Oops, unable to copy", err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHasCopied(true);
|
||||||
|
if (onCopy) onCopy();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy text: ", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn("relative z-10", className)}
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Copy</span>
|
||||||
|
{hasCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500 transition-all scale-110" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 transition-all" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
orgfront/src/components/ui/input.tsx
Normal file
24
orgfront/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
orgfront/src/components/ui/label.tsx
Normal file
19
orgfront/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
44
orgfront/src/components/ui/scroll-area.tsx
Normal file
44
orgfront/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
16
orgfront/src/components/ui/separator.tsx
Normal file
16
orgfront/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("shrink-0 bg-border", "h-px w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
26
orgfront/src/components/ui/switch.tsx
Normal file
26
orgfront/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
113
orgfront/src/components/ui/table.tsx
Normal file
113
orgfront/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-6 align-middle text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
23
orgfront/src/components/ui/textarea.tsx
Normal file
23
orgfront/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
35
orgfront/src/components/ui/toaster.tsx
Normal file
35
orgfront/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useToastState } from "./use-toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const toasts = useToastState();
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||||
|
t.type === "success" &&
|
||||||
|
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||||
|
t.type === "error" &&
|
||||||
|
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||||
|
t.type === "info" &&
|
||||||
|
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.type === "success" && (
|
||||||
|
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||||
|
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||||
|
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
orgfront/src/components/ui/use-toast.ts
Normal file
42
orgfront/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "info";
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscribers: ((toasts: Toast[]) => void)[] = [];
|
||||||
|
let toasts: Toast[] = [];
|
||||||
|
|
||||||
|
const notify = () => {
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
sub(toasts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toast = (message: string, type: ToastType = "success") => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
toasts = [...toasts, { id, message, type }];
|
||||||
|
notify();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts = toasts.filter((t) => t.id !== id);
|
||||||
|
notify();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useToastState = () => {
|
||||||
|
const [state, setState] = React.useState<Toast[]>(toasts);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
subscribers.push(setState);
|
||||||
|
return () => {
|
||||||
|
subscribers = subscribers.filter((sub) => sub !== setState);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
422
orgfront/src/features/audit/AuditLogsPage.tsx
Normal file
422
orgfront/src/features/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import type { DevAuditLog } from "../../lib/devApi";
|
||||||
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
type AuditDetails = {
|
||||||
|
request_id?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
action?: string;
|
||||||
|
target_id?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDetails(details?: string): AuditDetails {
|
||||||
|
if (!details) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as AuditDetails;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parsed.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsv(logs: DevAuditLog[]) {
|
||||||
|
const header = [
|
||||||
|
"timestamp",
|
||||||
|
"user_id",
|
||||||
|
"status",
|
||||||
|
"event_type",
|
||||||
|
"action",
|
||||||
|
"target_id",
|
||||||
|
"tenant_id",
|
||||||
|
"request_id",
|
||||||
|
];
|
||||||
|
const rows = logs.map((logItem) => {
|
||||||
|
const details = parseDetails(logItem.details);
|
||||||
|
return [
|
||||||
|
logItem.timestamp,
|
||||||
|
logItem.user_id || "",
|
||||||
|
logItem.status,
|
||||||
|
logItem.event_type,
|
||||||
|
details.action || "",
|
||||||
|
details.target_id || "",
|
||||||
|
details.tenant_id || "",
|
||||||
|
details.request_id || "",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return [header, ...rows]
|
||||||
|
.map((line) =>
|
||||||
|
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(content: string, filename: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogsPage() {
|
||||||
|
const [searchClientId, setSearchClientId] = React.useState("");
|
||||||
|
const [searchAction, setSearchAction] = React.useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||||
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
fetchDevAuditLogs(50, pageParam, {
|
||||||
|
client_id: searchClientId.trim() || undefined,
|
||||||
|
action: searchAction.trim() || undefined,
|
||||||
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||||
|
}),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs =
|
||||||
|
query.data?.pages.flatMap((page) =>
|
||||||
|
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const handleCopy = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const csv = toCsv(logs);
|
||||||
|
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||||
|
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.error) {
|
||||||
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||||
|
if (axiosError.response?.status === 403) {
|
||||||
|
return <ForbiddenMessage resourceToken="audit" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errMsg =
|
||||||
|
axiosError.response?.data?.error ?? (query.error as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
||||||
|
error: errMsg,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{t("ui.dev.audit.registry.title", "Audit registry")}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-3xl font-black tracking-tight">
|
||||||
|
{t("ui.dev.audit.title", "Audit Logs")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.audit.subtitle",
|
||||||
|
"Shows DevFront activity history within current tenant/app scope.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="muted">
|
||||||
|
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
||||||
|
count: logs.length,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
{t("ui.common.refresh", "새로고침")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="shadow-sm shadow-primary/30"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
value={searchClientId}
|
||||||
|
onChange={(e) => setSearchClientId(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.audit.filter.client_id",
|
||||||
|
"Filter by Client ID",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={searchAction}
|
||||||
|
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.audit.filter.action",
|
||||||
|
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||||
|
</option>
|
||||||
|
<option value="success">
|
||||||
|
{t("ui.common.status.success", "Success")}
|
||||||
|
</option>
|
||||||
|
<option value="failure">
|
||||||
|
{t("ui.common.status.failure", "Failure")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[190px]">
|
||||||
|
{t("ui.dev.audit.table.time", "Time")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px]">
|
||||||
|
{t("ui.dev.audit.table.actor", "Actor")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[180px]">
|
||||||
|
{t("ui.dev.audit.table.action", "Action")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[260px]">
|
||||||
|
{t("ui.dev.audit.table.target", "Target")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[120px]">
|
||||||
|
{t("ui.dev.audit.table.status", "Status")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{logs.map((row, index) => {
|
||||||
|
const details = parseDetails(row.details);
|
||||||
|
const actionLabel = details.action || row.event_type;
|
||||||
|
const targetValue = details.target_id || "-";
|
||||||
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
|
const expanded = Boolean(expandedRows[rowKey]);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rowKey}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(row.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{row.user_id || "-"}</span>
|
||||||
|
{row.user_id ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(row.user_id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">{targetValue}</span>
|
||||||
|
{targetValue !== "-" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => handleCopy(targetValue)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status === "success" ? "success" : "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]: !expanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expanded ? (
|
||||||
|
<TableRow className="bg-card/20">
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
Request ID: {formatValue(details.request_id)}
|
||||||
|
</div>
|
||||||
|
<div>Method: {formatValue(details.method)}</div>
|
||||||
|
<div>Path: {formatValue(details.path)}</div>
|
||||||
|
<div>
|
||||||
|
Tenant: {formatValue(details.tenant_id)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 break-all">
|
||||||
|
<div>Before: {formatValue(details.before)}</div>
|
||||||
|
<div>After: {formatValue(details.after)}</div>
|
||||||
|
<div>Error: {formatValue(details.error)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{query.hasNextPage ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.fetchNextPage()}
|
||||||
|
disabled={query.isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{query.isFetchingNextPage
|
||||||
|
? t("msg.common.loading", "Loading...")
|
||||||
|
: t("ui.dev.audit.load_more", "Load more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogsPage;
|
||||||
35
orgfront/src/features/auth/AuthCallbackPage.tsx
Normal file
35
orgfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { userManager } from "../../lib/auth";
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 팝업으로 열린 경우 signinPopupCallback 처리
|
||||||
|
if (window.opener) {
|
||||||
|
userManager.signinPopupCallback().catch((error) => {
|
||||||
|
console.error("Popup callback failed:", error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
const returnTo =
|
||||||
|
typeof auth.user?.state === "object" &&
|
||||||
|
auth.user?.state !== null &&
|
||||||
|
"returnTo" in auth.user.state &&
|
||||||
|
typeof auth.user.state.returnTo === "string"
|
||||||
|
? auth.user.state.returnTo
|
||||||
|
: "/chart";
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
} else if (auth.error) {
|
||||||
|
console.error("Auth Error:", auth.error);
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||||
|
|
||||||
|
return <div>Loading Auth...</div>;
|
||||||
|
}
|
||||||
60
orgfront/src/features/auth/AuthGuard.tsx
Normal file
60
orgfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
export default function AuthGuard() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const shareToken = searchParams.get("token");
|
||||||
|
|
||||||
|
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
|
||||||
|
if (shareToken) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.isLoading || auth.activeNavigator) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.error) {
|
||||||
|
return <div>Auth Error: {auth.error.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조직도 앱은 일반 사용자(user)도 볼 수 있어야 하므로 접근 제한을 해제합니다.
|
||||||
|
const isDenied = false; // normalizedRole === "guest";
|
||||||
|
|
||||||
|
if (isDenied) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
|
||||||
|
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.auth.access_denied_description",
|
||||||
|
"조직도를 볼 수 있는 권한이 없습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
|
onClick={() => {
|
||||||
|
auth.removeUser();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
111
orgfront/src/features/auth/AuthPage.tsx
Normal file
111
orgfront/src/features/auth/AuthPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
const flows = [
|
||||||
|
{
|
||||||
|
title: "Admin login",
|
||||||
|
description:
|
||||||
|
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||||
|
pill: "15m TTL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tenant pick",
|
||||||
|
description:
|
||||||
|
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||||
|
pill: "Header-ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Device approval",
|
||||||
|
description:
|
||||||
|
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||||
|
pill: "App session",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function AuthPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Admin auth
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Build the admin-only login flow first, keeping app login separate.
|
||||||
|
Respect the “fallback only when user chooses” rule for SMS/email
|
||||||
|
vs app approval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||||
|
IDP session placeholder
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
Connect auth layer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{flows.map((flow) => (
|
||||||
|
<div
|
||||||
|
key={flow.title}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||||
|
<span>{flow.pill}</span>
|
||||||
|
<Fingerprint size={14} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
{flow.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Smartphone size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em]">
|
||||||
|
App-based approvals
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
App session as MFA replacement
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
If the admin keeps the mobile app signed in and opts in, use
|
||||||
|
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||||
|
SMS/email based on user choice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em]">
|
||||||
|
TTL discipline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
Keep admin sessions short
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||||
|
with step-up MFA when critical actions (rotate secret, export logs)
|
||||||
|
happen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthPage;
|
||||||
126
orgfront/src/features/auth/LoginPage.tsx
Normal file
126
orgfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const autoStartedRef = useRef(false);
|
||||||
|
const returnTo = searchParams.get("returnTo") || "/chart";
|
||||||
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
}
|
||||||
|
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldAutoLogin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStartedRef.current = true;
|
||||||
|
void auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||||
|
|
||||||
|
const handleSSOLogin = async () => {
|
||||||
|
try {
|
||||||
|
await auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo: "/chart",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Redirect login failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||||
|
<ShieldHalf size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||||
|
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||||
|
Developer Control Plane
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
|
<LogIn size={20} className="text-primary" />
|
||||||
|
개발자 포털 로그인
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 pb-8 space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSSOLogin}
|
||||||
|
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
||||||
|
disabled={auth.isLoading}
|
||||||
|
>
|
||||||
|
{auth.isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
로그인 진행 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldHalf size={22} />
|
||||||
|
SSO 계정으로 로그인
|
||||||
|
<ExternalLink size={16} className="opacity-50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||||
|
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
|
||||||
|
<br />
|
||||||
|
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
|
<div className="h-1 w-1 rounded-full bg-primary/30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
|
인증 정보가 없거나 로그인이 되지 않는 경우
|
||||||
|
<br />
|
||||||
|
시스템 관리자에게 문의하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
24
orgfront/src/features/auth/authApi.ts
Normal file
24
orgfront/src/features/auth/authApi.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import apiClient from "../../lib/apiClient";
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role: string;
|
||||||
|
companyCode?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenant?: Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe() {
|
||||||
|
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
600
orgfront/src/features/clients/ClientConsentsPage.tsx
Normal file
600
orgfront/src/features/clients/ClientConsentsPage.tsx
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function ClientConsentsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const clientId = params.id ?? "";
|
||||||
|
const [subjectInput, setSubjectInput] = useState("");
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||||
|
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
|
||||||
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: clientData } = useQuery({
|
||||||
|
queryKey: ["client", clientId],
|
||||||
|
queryFn: () => fetchClient(clientId),
|
||||||
|
enabled: clientId.length > 0,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: consentsData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["consents", clientId, subject],
|
||||||
|
queryFn: () => fetchConsents(subject, clientId, "all"),
|
||||||
|
enabled: clientId.length > 0,
|
||||||
|
});
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (payload: { subject: string }) =>
|
||||||
|
revokeConsent(payload.subject, clientId),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRevoke = (sub: string) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.consents.revoke_confirm",
|
||||||
|
"정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
revokeMutation.mutate({ subject: sub });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = consentsData?.items ?? [];
|
||||||
|
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
|
||||||
|
const filteredRows = rows.filter((row) => {
|
||||||
|
const matchStatus =
|
||||||
|
statusFilter.length === 0 || statusFilter.includes(row.status);
|
||||||
|
const matchScope =
|
||||||
|
scopeFilter.length === 0 ||
|
||||||
|
scopeFilter.some((s) => row.grantedScopes.includes(s));
|
||||||
|
return matchStatus && matchScope;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (filteredRows.length === 0) return;
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
t("ui.dev.clients.consents.table.user", "User"),
|
||||||
|
t("ui.dev.clients.consents.table.tenant", "Tenant"),
|
||||||
|
t("ui.dev.clients.table.status", "Status"),
|
||||||
|
t("ui.dev.clients.consents.table.scopes", "Granted Scopes"),
|
||||||
|
t("ui.dev.clients.consents.table.first_granted", "First Granted"),
|
||||||
|
t(
|
||||||
|
"ui.dev.clients.consents.table.last_auth",
|
||||||
|
"Last Authenticated / Revoked",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(","),
|
||||||
|
...filteredRows.map((row) => {
|
||||||
|
const lastAuthRevoked =
|
||||||
|
row.status === "revoked" && row.deletedAt
|
||||||
|
? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}`
|
||||||
|
: row.authenticatedAt
|
||||||
|
? new Date(row.authenticatedAt).toLocaleString()
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return [
|
||||||
|
`"${row.subject} (${row.userName || ""})"`,
|
||||||
|
`"${row.tenantName || row.tenantId || ""}"`,
|
||||||
|
`"${row.status}"`,
|
||||||
|
`"${row.grantedScopes.join(", ")}"`,
|
||||||
|
`"${new Date(row.createdAt).toLocaleString()}"`,
|
||||||
|
`"${lastAuthRevoked}"`,
|
||||||
|
].join(",");
|
||||||
|
}),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([`\uFEFF${csvContent}`], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const date = new Date().toISOString().split("T")[0];
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `consents_${clientId}_${date}.csv`);
|
||||||
|
link.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (status: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setStatusFilter((prev) => [...prev, status]);
|
||||||
|
} else {
|
||||||
|
setStatusFilter((prev) => prev.filter((s) => s !== status));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeFilterChange = (scope: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setScopeFilter((prev) => [...prev, scope]);
|
||||||
|
} else {
|
||||||
|
setScopeFilter((prev) => prev.filter((s) => s !== scope));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllScopesChange = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setScopeFilter(allScopes);
|
||||||
|
} else {
|
||||||
|
setScopeFilter([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-4">
|
||||||
|
<div className="flex flex-wrap justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link to="/" className="hover:text-primary">
|
||||||
|
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/clients" className="hover:text-primary">
|
||||||
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{clientData?.client?.name || clientId}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.breadcrumb.current",
|
||||||
|
"User Consent Grants",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to={`/clients/${clientId}`}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-black leading-tight">
|
||||||
|
{t("ui.dev.clients.consents.title", "User Consent Grants")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.consents.subtitle",
|
||||||
|
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
clientData?.client?.status === "active" ? "info" : "muted"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{clientData?.client?.status === "active"
|
||||||
|
? t("ui.common.status.active", "Active")
|
||||||
|
: t("ui.common.status.inactive", "Inactive")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||||
|
<Link
|
||||||
|
to={`/clients/${clientId}`}
|
||||||
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||||
|
</Link>
|
||||||
|
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||||
|
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
to={`/clients/${clientId}/settings`}
|
||||||
|
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.clients.consents.search_placeholder",
|
||||||
|
"사용자 ID, 이름, 이메일로 검색",
|
||||||
|
)}
|
||||||
|
value={subjectInput}
|
||||||
|
onChange={(e) => setSubjectInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"gap-1 text-muted-foreground",
|
||||||
|
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.filters.advanced",
|
||||||
|
"Advanced Filters",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="shadow-sm shadow-primary/30"
|
||||||
|
onClick={() => setSubject(subjectInput.trim())}
|
||||||
|
>
|
||||||
|
{t("ui.common.search", "검색")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="shadow-sm shadow-primary/30"
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={filteredRows.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdvancedFilterOpen && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||||
|
checked={statusFilter.includes("active")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleStatusFilterChange("active", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||||
|
checked={statusFilter.includes("revoked")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleStatusFilterChange("revoked", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{allScopes.length > 0 && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||||
|
checked={
|
||||||
|
scopeFilter.length === allScopes.length &&
|
||||||
|
allScopes.length > 0
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAllScopesChange(e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
ALL
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{allScopes.map((scope) => (
|
||||||
|
<label
|
||||||
|
key={scope}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||||
|
checked={scopeFilter.includes(scope)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleScopeFilterChange(scope, e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{scope}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground p-0 h-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter([]);
|
||||||
|
setScopeFilter([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("ui.common.reset", "초기화")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
{error && (
|
||||||
|
<CardContent className="text-sm text-red-500">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.consents.load_error",
|
||||||
|
"Error loading consents: {{error}}",
|
||||||
|
{
|
||||||
|
error: (error as Error).message,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
{t("msg.dev.clients.consents.loading", "Loading consents...")}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.consents.table.user", "User")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.consents.table.status", "Status")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.table.first_granted",
|
||||||
|
"First Granted",
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.table.last_auth",
|
||||||
|
"Last Authenticated / Revoked",
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.dev.clients.consents.table.action", "Action")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRows.length === 0 && !isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${row.subject}-${row.clientId}`}
|
||||||
|
className={row.status === "revoked" ? "opacity-60" : ""}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
||||||
|
{(row.userName || row.subject)
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{row.userName ||
|
||||||
|
t("ui.dev.clients.consents.subject", "Subject")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{row.tenantName || t("ui.common.na", "N/A")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.tenantId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.status === "active" ? (
|
||||||
|
<Badge variant="success">
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="warning">
|
||||||
|
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.grantedScopes.map((scope) => (
|
||||||
|
<Badge
|
||||||
|
key={scope}
|
||||||
|
variant="muted"
|
||||||
|
className="border bg-muted/40 text-foreground"
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(row.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{row.status === "revoked" && row.deletedAt ? (
|
||||||
|
<span className="text-destructive font-medium">
|
||||||
|
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
|
||||||
|
{new Date(row.deletedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : row.authenticatedAt ? (
|
||||||
|
new Date(row.authenticatedAt).toLocaleString()
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.status === "active" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleRevoke(row.subject)}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.consents.showing",
|
||||||
|
"Showing {{from}} to {{to}} of {{total}} users",
|
||||||
|
{
|
||||||
|
from: filteredRows.length > 0 ? 1 : 0,
|
||||||
|
to: filteredRows.length,
|
||||||
|
total: rows.length,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" disabled>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={filteredRows.length === 0}>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" disabled>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.stats.active_grants",
|
||||||
|
"Active Grants",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl font-black">
|
||||||
|
{rows.filter((r) => r.status === "active").length}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.stats.total_scopes",
|
||||||
|
"Total Scopes Issued",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl font-black">
|
||||||
|
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.stats.avg_scopes",
|
||||||
|
"Avg. Scopes per User",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl font-black">
|
||||||
|
{rows.length > 0
|
||||||
|
? (
|
||||||
|
rows.reduce(
|
||||||
|
(acc, row) => acc + row.grantedScopes.length,
|
||||||
|
0,
|
||||||
|
) / rows.length
|
||||||
|
).toFixed(1)
|
||||||
|
: "0.0"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientConsentsPage;
|
||||||
540
orgfront/src/features/clients/ClientDetailsPage.tsx
Normal file
540
orgfront/src/features/clients/ClientDetailsPage.tsx
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useParams } 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 { CopyButton } from "../../components/ui/copy-button";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
|
import { Separator } from "../../components/ui/separator";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
|
import { toast } from "../../components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
fetchClient,
|
||||||
|
rotateClientSecret,
|
||||||
|
updateClient,
|
||||||
|
} from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function ClientDetailsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clientId = params.id ?? "";
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useQuery({
|
||||||
|
queryKey: ["client", clientId],
|
||||||
|
queryFn: () => fetchClient(clientId),
|
||||||
|
enabled: clientId.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
const redirectUrisHydratedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!redirectUrisHydratedRef.current &&
|
||||||
|
data?.client?.redirectUris &&
|
||||||
|
redirectUris === ""
|
||||||
|
) {
|
||||||
|
setRedirectUris(data.client.redirectUris.join(", "));
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [data, redirectUris]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const uriList = redirectUris
|
||||||
|
.split(",")
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return updateClient(clientId, { redirectUris: uriList });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.redirect_saved",
|
||||||
|
"Redirect URIs가 저장되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast(
|
||||||
|
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
|
||||||
|
error: (err as Error).message,
|
||||||
|
}),
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateMutation = useMutation({
|
||||||
|
mutationFn: () => rotateClientSecret(clientId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.secret_rotated",
|
||||||
|
"Client Secret이 재발급되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setShowSecret(true); // 재발급 후 바로 보여줌
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast(
|
||||||
|
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
|
||||||
|
error: (err as Error).message,
|
||||||
|
}),
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRotateSecret = () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.rotate_confirm",
|
||||||
|
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
rotateMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !data) {
|
||||||
|
const errMsg =
|
||||||
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
|
(error as Error)?.message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.details.load_error",
|
||||||
|
"Error loading app: {{error}}",
|
||||||
|
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.clients.details.loading", "Loading app details...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = data?.client;
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const endpointValues = data?.endpoints ?? {
|
||||||
|
discovery: "-",
|
||||||
|
issuer: "-",
|
||||||
|
authorization: "-",
|
||||||
|
token: "-",
|
||||||
|
userinfo: "-",
|
||||||
|
};
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||||
|
labelFallback: "Discovery Endpoint",
|
||||||
|
value: endpointValues.discovery,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
||||||
|
labelFallback: "Issuer URL",
|
||||||
|
value: endpointValues.issuer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
||||||
|
labelFallback: "Authorization Endpoint",
|
||||||
|
value: endpointValues.authorization,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.details.endpoint.token",
|
||||||
|
labelFallback: "Token Endpoint",
|
||||||
|
value: endpointValues.token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
||||||
|
labelFallback: "UserInfo Endpoint",
|
||||||
|
value: endpointValues.userinfo,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Client Secret from API
|
||||||
|
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||||
|
const clientSecret = client?.clientSecret || secretPlaceholder;
|
||||||
|
const displaySecret =
|
||||||
|
clientSecret === secretPlaceholder
|
||||||
|
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||||
|
: clientSecret;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link to="/" className="hover:text-primary">
|
||||||
|
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/clients" className="hover:text-primary">
|
||||||
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{client?.name || clientId}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-semibold">
|
||||||
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link to="/clients">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
||||||
|
{client?.name || client?.id || clientId}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.details.subtitle",
|
||||||
|
"Manage OIDC credentials and endpoints.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={client?.status === "active" ? "info" : "muted"}
|
||||||
|
className="px-3 py-1 text-xs uppercase"
|
||||||
|
>
|
||||||
|
{client?.status === "active"
|
||||||
|
? t("ui.common.status.active", "Active")
|
||||||
|
: client?.status === "inactive"
|
||||||
|
? t("ui.common.status.inactive", "Inactive")
|
||||||
|
: t("msg.common.loading", "Loading...")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 border-b border-border">
|
||||||
|
<Link
|
||||||
|
to={`/clients/${clientId}`}
|
||||||
|
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/clients/${clientId}/consents`}
|
||||||
|
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/clients/${clientId}/settings`}
|
||||||
|
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.details.credentials.title",
|
||||||
|
"Client Credentials",
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardContent className="flex flex-col gap-4 p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.details.credentials.client_id",
|
||||||
|
"Client ID",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-mono text-lg truncate">
|
||||||
|
{client?.id || clientId}
|
||||||
|
</p>
|
||||||
|
<CopyButton
|
||||||
|
value={client?.id || clientId}
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.copy_client_id",
|
||||||
|
"Client ID가 복사되었습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.details.credentials.client_secret",
|
||||||
|
"Client Secret",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-lg",
|
||||||
|
!showSecret && "tracking-widest",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showSecret ? displaySecret : "••••••••••••••••"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowSecret(!showSecret)}
|
||||||
|
aria-label={
|
||||||
|
showSecret
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.details.secret.hide",
|
||||||
|
"비밀키 숨기기",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"ui.dev.clients.details.secret.show",
|
||||||
|
"비밀키 보기",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showSecret ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRotateSecret}
|
||||||
|
disabled={rotateMutation.isPending}
|
||||||
|
title={t(
|
||||||
|
"ui.dev.clients.details.secret.rotate",
|
||||||
|
"비밀키 재발급 (Rotate)",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
rotateMutation.isPending && "animate-spin",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<CopyButton
|
||||||
|
value={clientSecret}
|
||||||
|
disabled={
|
||||||
|
!showSecret && clientSecret === secretPlaceholder
|
||||||
|
}
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.copy_client_secret",
|
||||||
|
"Client Secret이 복사되었습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="muted" className="gap-1">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{endpoints.map((endpoint) => (
|
||||||
|
<TableRow
|
||||||
|
key={endpoint.labelKey}
|
||||||
|
className="border-border/70"
|
||||||
|
>
|
||||||
|
<TableCell className="w-1/3">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
{t(endpoint.labelKey, endpoint.labelFallback)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="flex items-center justify-between gap-3">
|
||||||
|
<span className="break-all font-mono text-sm">
|
||||||
|
{endpoint.value}
|
||||||
|
</span>
|
||||||
|
<CopyButton
|
||||||
|
value={endpoint.value}
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onCopy={() =>
|
||||||
|
toast(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.details.copy_endpoint",
|
||||||
|
"{{label}}가 복사되었습니다.",
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
endpoint.labelKey,
|
||||||
|
endpoint.labelFallback,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
|
||||||
|
</h2>
|
||||||
|
<Card className="glass-panel border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.details.redirect.description",
|
||||||
|
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="redirect-uris"
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.details.redirect.callback_label",
|
||||||
|
"인증 콜백 URL",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="redirect-uris"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.clients.details.redirect.placeholder",
|
||||||
|
"https://your-app.com/callback, http://localhost:3000/auth/callback",
|
||||||
|
)}
|
||||||
|
rows={5}
|
||||||
|
value={redirectUris}
|
||||||
|
onChange={(e) => {
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
|
setRedirectUris(e.target.value);
|
||||||
|
}}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{mutation.isPending
|
||||||
|
? t("msg.common.saving", "저장 중...")
|
||||||
|
: t(
|
||||||
|
"ui.dev.clients.details.redirect.save",
|
||||||
|
"Redirect URIs 저장",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-6 opacity-80">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
|
<Shield className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{t("ui.dev.clients.details.security.title", "보안 메모")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.details.security.note",
|
||||||
|
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.details.security.footer",
|
||||||
|
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientDetailsPage;
|
||||||
1645
orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
1645
orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
527
orgfront/src/features/clients/ClientsPage.tsx
Normal file
527
orgfront/src/features/clients/ClientsPage.tsx
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
BookOpenText,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ServerCog,
|
||||||
|
ShieldHalf,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "../../components/ui/avatar";
|
||||||
|
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 { Separator } from "../../components/ui/separator";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function ClientsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isLoadingClients,
|
||||||
|
error: clientError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["clients"],
|
||||||
|
queryFn: fetchClients,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: isLoadingStats } = useQuery({
|
||||||
|
queryKey: ["dev-stats"],
|
||||||
|
queryFn: fetchDevStats,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
const clients = data?.items || [];
|
||||||
|
|
||||||
|
const filteredClients = clients.filter((client) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery ||
|
||||||
|
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" || client.status === statusFilter;
|
||||||
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalClients = statsData?.total_clients ?? clients.length;
|
||||||
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
|
const authFailures = statsData?.auth_failures_24h ?? 0;
|
||||||
|
|
||||||
|
type StatTone = "up" | "down" | "stable";
|
||||||
|
type StatItem = {
|
||||||
|
labelKey: string;
|
||||||
|
labelFallback: string;
|
||||||
|
value: string;
|
||||||
|
deltaKey: string;
|
||||||
|
deltaFallback: string;
|
||||||
|
tone: StatTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats: StatItem[] = [
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.stats.total",
|
||||||
|
labelFallback: "Total Applications",
|
||||||
|
value: totalClients.toString(),
|
||||||
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
|
deltaFallback: "Realtime",
|
||||||
|
tone: "up" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.stats.active_sessions",
|
||||||
|
labelFallback: "Active Sessions",
|
||||||
|
value: activeSessions.toString(),
|
||||||
|
deltaKey: "ui.dev.clients.stats.realtime",
|
||||||
|
deltaFallback: "Realtime",
|
||||||
|
tone: "up" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.clients.stats.auth_failures",
|
||||||
|
labelFallback: "Auth Failures (24h)",
|
||||||
|
value: authFailures.toString(),
|
||||||
|
deltaKey:
|
||||||
|
authFailures > 0
|
||||||
|
? "ui.dev.clients.stats.alert"
|
||||||
|
: "ui.dev.clients.stats.stable",
|
||||||
|
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
|
||||||
|
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading = isLoadingClients || isLoadingStats;
|
||||||
|
|
||||||
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientError) {
|
||||||
|
const axiosError = clientError as AxiosError<{ error?: string }>;
|
||||||
|
if (axiosError.response?.status === 403) {
|
||||||
|
return <ForbiddenMessage resourceToken="clients" />;
|
||||||
|
}
|
||||||
|
const errMsg =
|
||||||
|
axiosError.response?.data?.error ?? (clientError as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||||
|
error: errMsg,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-3xl font-black tracking-tight">
|
||||||
|
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.registry.description",
|
||||||
|
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="shadow-lg shadow-primary/30"
|
||||||
|
onClick={() => navigate("/clients/new")}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.clients.search_placeholder",
|
||||||
|
"클라이언트 이름/ID로 검색...",
|
||||||
|
)}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"gap-1 text-muted-foreground",
|
||||||
|
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.consents.filters.advanced",
|
||||||
|
"Advanced Filters",
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
|
<Badge variant="muted">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.badge.tenant_selected",
|
||||||
|
"테넌트: 선택됨",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="success">
|
||||||
|
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdvancedFilterOpen && (
|
||||||
|
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||||
|
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||||
|
</option>
|
||||||
|
<option value="private">
|
||||||
|
{t("ui.dev.clients.type.private", "Server side App")}
|
||||||
|
</option>
|
||||||
|
<option value="pkce">
|
||||||
|
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||||
|
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||||
|
</option>
|
||||||
|
<option value="active">
|
||||||
|
{t("ui.common.status.active", "Active")}
|
||||||
|
</option>
|
||||||
|
<option value="inactive">
|
||||||
|
{t("ui.common.status.inactive", "Inactive")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter("all");
|
||||||
|
setStatusFilter("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("ui.common.reset", "초기화")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{stats.map((item) => (
|
||||||
|
<Card key={item.labelKey} className="border border-border/60">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>
|
||||||
|
{t(item.labelKey, item.labelFallback)}
|
||||||
|
</CardDescription>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold">{item.value}</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
item.tone === "up"
|
||||||
|
? "success"
|
||||||
|
: item.tone === "down"
|
||||||
|
? "warning"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"px-2",
|
||||||
|
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(item.deltaKey, item.deltaFallback)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.table.status", "상태")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.dev.clients.table.actions", "액션")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredClients.map((client) => (
|
||||||
|
<TableRow key={client.id} className="bg-card/40">
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to={`/clients/${client.id}`}
|
||||||
|
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
{client.type === "private" ? (
|
||||||
|
<ServerCog className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ShieldHalf className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{client.name ||
|
||||||
|
t("ui.dev.clients.untitled", "Untitled")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{client.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={client.type === "private" ? "success" : "muted"}
|
||||||
|
>
|
||||||
|
{client.type === "private"
|
||||||
|
? t("ui.dev.clients.type.private", "Server side App")
|
||||||
|
: client.metadata?.headless_login_enabled
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.type.pkce_headless",
|
||||||
|
"PKCE (Headless Login)",
|
||||||
|
)
|
||||||
|
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={client.status === "active" ? "info" : "muted"}
|
||||||
|
className="px-3 py-1 text-xs uppercase"
|
||||||
|
>
|
||||||
|
{client.status === "active"
|
||||||
|
? t("ui.common.status.active", "Active")
|
||||||
|
: t("ui.common.status.inactive", "Inactive")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{client.createdAt
|
||||||
|
? new Date(client.createdAt).toLocaleDateString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/clients/${client.id}`}>
|
||||||
|
{t("ui.common.view", "View")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.showing",
|
||||||
|
"Showing {{shown}} of {{total}} clients",
|
||||||
|
{ shown: filteredClients.length, total: totalClients },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
{t("ui.common.previous", "Previous")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
{t("ui.common.next", "Next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.help.title",
|
||||||
|
"Need help with OIDC configuration?",
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.help.subtitle",
|
||||||
|
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
|
<BookOpenText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.help.docs_body",
|
||||||
|
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary">
|
||||||
|
{t("ui.dev.clients.help.view_guides", "View guides")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
{t("ui.dev.clients.owner.title", "Owner")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage
|
||||||
|
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
||||||
|
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>AR</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
||||||
|
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
||||||
|
<span>
|
||||||
|
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
|
||||||
|
</span>
|
||||||
|
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientsPage;
|
||||||
306
orgfront/src/features/clients/routes/ClientFederationPage.tsx
Normal file
306
orgfront/src/features/clients/routes/ClientFederationPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } 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 {
|
||||||
|
createIdpConfigForClient,
|
||||||
|
listIdpConfigsForClient,
|
||||||
|
} from "../../../lib/devApi";
|
||||||
|
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
// Proper Modal Component with Form
|
||||||
|
const CreateIdpModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
clientId,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
clientId: string;
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState<IdpConfigCreateRequest>({
|
||||||
|
client_id: clientId,
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "",
|
||||||
|
status: "active",
|
||||||
|
issuer_url: "",
|
||||||
|
oidc_client_id: "",
|
||||||
|
oidc_client_secret: "",
|
||||||
|
scopes: "openid email profile",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (newData: IdpConfigCreateRequest) =>
|
||||||
|
createIdpConfigForClient(newData),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
alert(`Failed to create configuration: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||||
|
<Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.federation.add_subtitle",
|
||||||
|
"Connect an external OIDC provider.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Display Name</label>
|
||||||
|
<Input
|
||||||
|
name="display_name"
|
||||||
|
value={formData.display_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g. Google Workspace"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Issuer URL</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
name="issuer_url"
|
||||||
|
value={formData.issuer_url}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="https://accounts.google.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Client ID</label>
|
||||||
|
<Input
|
||||||
|
name="oidc_client_id"
|
||||||
|
value={formData.oidc_client_id}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Client Secret</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="oidc_client_secret"
|
||||||
|
value={formData.oidc_client_secret}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">Scopes</label>
|
||||||
|
<Input
|
||||||
|
name="scopes"
|
||||||
|
value={formData.scopes}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t("ui.common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
mutation.isPending ||
|
||||||
|
formData.display_name.trim() === "" ||
|
||||||
|
(formData.issuer_url?.trim() ?? "") === ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
)}
|
||||||
|
{mutation.isPending
|
||||||
|
? t("msg.common.saving", "Saving...")
|
||||||
|
: t("ui.common.save", "Save Configuration")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientFederationPage() {
|
||||||
|
const { id: clientId } = useParams<{ id: string }>();
|
||||||
|
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-destructive">
|
||||||
|
Client ID is missing
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["idpConfigs", clientId],
|
||||||
|
queryFn: () => listIdpConfigsForClient(clientId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-1">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Globe className="h-6 w-6 text-primary" />
|
||||||
|
{t("ui.dev.clients.federation.title", "Identity Federation")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.federation.subtitle",
|
||||||
|
"Manage external identity providers for this application.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Display Name</TableHead>
|
||||||
|
<TableHead>Provider Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="h-24 text-center">
|
||||||
|
{t("msg.common.loading", "Loading...")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : error ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={4}
|
||||||
|
className="h-24 text-center text-destructive"
|
||||||
|
>
|
||||||
|
{(error as Error).message}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={4}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.federation.empty",
|
||||||
|
"No IdP configurations found.",
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.map((config: IdpConfig) => (
|
||||||
|
<tr
|
||||||
|
key={config.id}
|
||||||
|
className="border-b transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{config.display_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||||
|
config.status === "active"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CreateIdpModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setCreateModalOpen(false)}
|
||||||
|
clientId={clientId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
294
orgfront/src/features/dashboard/DashboardPage.tsx
Normal file
294
orgfront/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
Database,
|
||||||
|
KeyRound,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
const guardHighlights = [
|
||||||
|
{
|
||||||
|
titleKey: "ui.dev.dashboard.guard.policy.title",
|
||||||
|
titleFallback: "RP 정책 통제",
|
||||||
|
bodyKey: "msg.dev.dashboard.guard.policy.body",
|
||||||
|
bodyFallback:
|
||||||
|
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.policy.metric",
|
||||||
|
metricFallback: "Policy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "ui.dev.dashboard.guard.consent.title",
|
||||||
|
titleFallback: "Consent 흐름",
|
||||||
|
bodyKey: "msg.dev.dashboard.guard.consent.body",
|
||||||
|
bodyFallback:
|
||||||
|
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.consent.metric",
|
||||||
|
metricFallback: "Consent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: "ui.dev.dashboard.guard.hydra.title",
|
||||||
|
titleFallback: "Hydra Admin",
|
||||||
|
bodyKey: "msg.dev.dashboard.guard.hydra.body",
|
||||||
|
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
|
||||||
|
metricKey: "ui.dev.dashboard.guard.hydra.metric",
|
||||||
|
metricFallback: "Hydra",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackReadiness = [
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.stack.react",
|
||||||
|
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.stack.query",
|
||||||
|
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.stack.axios",
|
||||||
|
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.stack.tailwind",
|
||||||
|
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.stack.proxy",
|
||||||
|
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextSteps = [
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.next.rp_workflow",
|
||||||
|
fallback: "RP 등록/수정/삭제 워크플로우 추가",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.next.consent_filters",
|
||||||
|
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "msg.dev.dashboard.next.audit_guard",
|
||||||
|
fallback: "권한 가드 및 감사 로그 연동",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||||
|
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold leading-tight">
|
||||||
|
{t(
|
||||||
|
"msg.dev.dashboard.hero.title_prefix",
|
||||||
|
"RP 등록 현황과 Consent 상태를",
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--color-accent)]">
|
||||||
|
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
|
||||||
|
</span>
|
||||||
|
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
{t(
|
||||||
|
"msg.dev.dashboard.hero.body",
|
||||||
|
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||||
|
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||||
|
{t(
|
||||||
|
"ui.dev.dashboard.badge.consent_guard",
|
||||||
|
"Consent guard ready",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
{t(
|
||||||
|
"ui.dev.dashboard.badge.policy_toggle",
|
||||||
|
"Policy toggle enabled",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.dev_scope",
|
||||||
|
"RP 정책은 dev scope에서만 적용",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<KeyRound size={16} />
|
||||||
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.consent_audit",
|
||||||
|
"Consent 회수는 감사 로그와 연계",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<Database size={16} />
|
||||||
|
{t(
|
||||||
|
"msg.dev.dashboard.notice.hydra_health",
|
||||||
|
"Hydra Admin 상태 체크 준비",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{guardHighlights.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.titleKey}
|
||||||
|
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||||
|
<div className="relative flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{t(item.metricKey, item.metricFallback)}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||||
|
{t("ui.common.status.active", "active")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-3 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t(item.titleKey, item.titleFallback)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
{t(item.bodyKey, item.bodyFallback)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">
|
||||||
|
{t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
{stackReadiness.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
size={16}
|
||||||
|
className="text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
<p className="text-sm">{t(item.key, item.fallback)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{t("ui.dev.dashboard.next.title", "Next actions")}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{nextSteps.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text)]">
|
||||||
|
{t(item.key, item.fallback)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{t("ui.dev.dashboard.ops.title", "Ops board")}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">
|
||||||
|
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
{t("ui.dev.dashboard.ops.tag.rp_status", "RP status")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">
|
||||||
|
{t("ui.common.status.pending", "준비 중")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Activity size={16} />
|
||||||
|
{t(
|
||||||
|
"ui.dev.dashboard.ops.card.consent_revoked",
|
||||||
|
"Consent 회수 건수",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">
|
||||||
|
{t("ui.common.status.pending", "준비 중")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Database size={16} />
|
||||||
|
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">
|
||||||
|
{t("ui.common.status.ok", "정상")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
135
orgfront/src/features/orgchart/pickerTree.ts
Normal file
135
orgfront/src/features/orgchart/pickerTree.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||||
|
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
|
||||||
|
import type { OrgPickerTreeNode } from "./pickerTypes";
|
||||||
|
import { getOrgChartUserDisplayName } from "./userDisplay";
|
||||||
|
|
||||||
|
function getUserTenantSlug(user: UserSummary) {
|
||||||
|
return (
|
||||||
|
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
|
||||||
|
let cursor: TenantSummary | undefined = node;
|
||||||
|
const byId = new Map(allTenants.map((tenant) => [tenant.id, tenant]));
|
||||||
|
|
||||||
|
while (cursor?.parentId) {
|
||||||
|
const parent = byId.get(cursor.parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantToPickerNode(
|
||||||
|
tenant: TenantNode,
|
||||||
|
usersBySlug: Map<string, UserSummary[]>,
|
||||||
|
): OrgPickerTreeNode {
|
||||||
|
const tenantChildren = tenant.children.map((child) =>
|
||||||
|
tenantToPickerNode(child, usersBySlug),
|
||||||
|
);
|
||||||
|
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
|
||||||
|
(user) => ({
|
||||||
|
type: "user" as const,
|
||||||
|
id: user.id,
|
||||||
|
name: getOrgChartUserDisplayName(user, tenant),
|
||||||
|
parentId: tenant.id,
|
||||||
|
user,
|
||||||
|
children: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "tenant",
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
parentId: tenant.parentId ?? null,
|
||||||
|
tenant,
|
||||||
|
children: [...userChildren, ...tenantChildren],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTenantNode(
|
||||||
|
roots: TenantNode[],
|
||||||
|
tenantId: string,
|
||||||
|
): TenantNode | undefined {
|
||||||
|
for (const root of roots) {
|
||||||
|
if (root.id === tenantId) return root;
|
||||||
|
const child = findTenantNode(root.children, tenantId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrgPickerTree({
|
||||||
|
tenants,
|
||||||
|
users,
|
||||||
|
rootTenantId,
|
||||||
|
tenantId,
|
||||||
|
}: {
|
||||||
|
tenants: TenantSummary[];
|
||||||
|
users: UserSummary[];
|
||||||
|
rootTenantId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}) {
|
||||||
|
const usersBySlug = new Map<string, UserSummary[]>();
|
||||||
|
for (const user of users) {
|
||||||
|
if (user.status !== "active") continue;
|
||||||
|
const slug = getUserTenantSlug(user);
|
||||||
|
if (!slug) continue;
|
||||||
|
const list = usersBySlug.get(slug) || [];
|
||||||
|
list.push(user);
|
||||||
|
usersBySlug.set(slug, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyGroup =
|
||||||
|
tenants.find((tenant) => tenant.id === rootTenantId) ??
|
||||||
|
tenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
|
||||||
|
tenants.find((tenant) => !tenant.parentId);
|
||||||
|
|
||||||
|
if (!companyGroup) return { roots: [], companies: [], companyGroupId: "" };
|
||||||
|
|
||||||
|
const { currentBase } = buildTenantFullTree(tenants, companyGroup.id);
|
||||||
|
const groupNode =
|
||||||
|
currentBase ??
|
||||||
|
buildTenantFullTree(tenants).subTree.find(
|
||||||
|
(node) => node.id === companyGroup.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
|
||||||
|
|
||||||
|
const companies = groupNode.children.filter(
|
||||||
|
(node) => node.type === "COMPANY",
|
||||||
|
);
|
||||||
|
const scopedRoot = tenantId
|
||||||
|
? findTenantNode([groupNode], tenantId)
|
||||||
|
: groupNode;
|
||||||
|
const filteredRoots = scopedRoot ? [scopedRoot] : [];
|
||||||
|
const roots = filteredRoots.map((node) =>
|
||||||
|
tenantToPickerNode(node, usersBySlug),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roots,
|
||||||
|
companies: companies.map((company) => ({
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
companyGroupTenantId: getCompanyGroupId(company, tenants),
|
||||||
|
})),
|
||||||
|
companyGroupId: companyGroup.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenDescendants(node: OrgPickerTreeNode) {
|
||||||
|
const descendants: OrgPickerTreeNode[] = [];
|
||||||
|
const walk = (current: OrgPickerTreeNode) => {
|
||||||
|
for (const child of current.children) {
|
||||||
|
descendants.push(child);
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(node);
|
||||||
|
return descendants;
|
||||||
|
}
|
||||||
98
orgfront/src/features/orgchart/pickerTypes.ts
Normal file
98
orgfront/src/features/orgchart/pickerTypes.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
export type OrgPickerMode = "single" | "multiple";
|
||||||
|
export type OrgPickerSelectableType = "tenant" | "user" | "both";
|
||||||
|
export type OrgPickerObjectType = "tenant" | "user";
|
||||||
|
|
||||||
|
export type OrgPickerSelection = {
|
||||||
|
type: OrgPickerObjectType;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerResult = {
|
||||||
|
mode: OrgPickerMode;
|
||||||
|
selections: OrgPickerSelection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerEmbedOptions = {
|
||||||
|
mode: OrgPickerMode;
|
||||||
|
select: OrgPickerSelectableType;
|
||||||
|
includeDescendants: boolean;
|
||||||
|
showDescendantToggle: boolean;
|
||||||
|
tenantId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerTreeNode = {
|
||||||
|
type: OrgPickerObjectType;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
tenant?: TenantSummary;
|
||||||
|
user?: UserSummary;
|
||||||
|
children: OrgPickerTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function nodeKey(node: Pick<OrgPickerTreeNode, "type" | "id">) {
|
||||||
|
return `${node.type}:${node.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionKey(selection: OrgPickerSelection) {
|
||||||
|
return `${selection.type}:${selection.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgPickerMode(value: string | null): OrgPickerMode {
|
||||||
|
return value === "multiple" ? "multiple" : "single";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgPickerSelectableType(
|
||||||
|
value: string | null,
|
||||||
|
): OrgPickerSelectableType {
|
||||||
|
if (value === "tenant" || value === "user") return value;
|
||||||
|
return "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmbedDimension(value: string | null, fallback: number) {
|
||||||
|
const parsed = Number.parseInt(value ?? "", 10);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(1600, Math.max(240, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrgPickerEmbedOptions(search: string) {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return {
|
||||||
|
mode:
|
||||||
|
params.get("mode") === "single"
|
||||||
|
? ("single" as const)
|
||||||
|
: ("multiple" as const),
|
||||||
|
select: parseOrgPickerSelectableType(params.get("select")),
|
||||||
|
includeDescendants: params.get("includeDescendants") !== "false",
|
||||||
|
showDescendantToggle: params.get("showDescendantToggle") !== "false",
|
||||||
|
tenantId: params.get("tenantId") ?? params.get("companyTenantId") ?? "",
|
||||||
|
width: parseEmbedDimension(params.get("width"), 400),
|
||||||
|
height: parseEmbedDimension(params.get("height"), 600),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mode: options.mode,
|
||||||
|
select: options.select,
|
||||||
|
width: String(options.width),
|
||||||
|
height: String(options.height),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantId = options.tenantId.trim();
|
||||||
|
if (tenantId) {
|
||||||
|
params.set("tenantId", tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mode === "multiple") {
|
||||||
|
params.set("includeDescendants", String(options.includeDescendants));
|
||||||
|
params.set("showDescendantToggle", String(options.showDescendantToggle));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/embed/picker?${params.toString()}`;
|
||||||
|
}
|
||||||
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
type TenantSummary,
|
||||||
|
type UserSummary,
|
||||||
|
fetchPublicOrgChart,
|
||||||
|
fetchTenants,
|
||||||
|
fetchUsers,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||||
|
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
|
||||||
|
|
||||||
|
type OrgNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
members: UserSummary[];
|
||||||
|
children: OrgNode[];
|
||||||
|
totalCount: number;
|
||||||
|
totalMemberIds: Set<string>;
|
||||||
|
companyCode?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewBox = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisualNode = {
|
||||||
|
node: OrgNode;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
members: UserSummary[];
|
||||||
|
collapsed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisualEdge = {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartLayout = {
|
||||||
|
nodes: VisualNode[];
|
||||||
|
edges: VisualEdge[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_WIDTH = 340;
|
||||||
|
const HEADER_HEIGHT = 42;
|
||||||
|
const MEMBER_ROW_HEIGHT = 24;
|
||||||
|
const NODE_PADDING_Y = 12;
|
||||||
|
const ROOT_GAP_X = 120;
|
||||||
|
const CHILD_GAP_X = 80;
|
||||||
|
const CHILD_GAP_Y = 96;
|
||||||
|
const CHART_MARGIN = 72;
|
||||||
|
const MIN_SCALE = 0.45;
|
||||||
|
const MAX_SCALE = 2.4;
|
||||||
|
const ZOOM_SENSITIVITY = 0.0015;
|
||||||
|
const FAMILY_FILTER_ID = "hanmac-family";
|
||||||
|
|
||||||
|
const ROLE_ORDER = [
|
||||||
|
"사장",
|
||||||
|
"부사장",
|
||||||
|
"전무",
|
||||||
|
"상무",
|
||||||
|
"이사",
|
||||||
|
"수석",
|
||||||
|
"책임",
|
||||||
|
"선임",
|
||||||
|
"주임",
|
||||||
|
"사원",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRankWeight(
|
||||||
|
user: UserSummary,
|
||||||
|
tenant?: { id: string; slug: string },
|
||||||
|
) {
|
||||||
|
const profile = getUserOrgProfile(user, tenant);
|
||||||
|
const role = profile.position || "";
|
||||||
|
const order = ROLE_ORDER.indexOf(role);
|
||||||
|
const isLeader =
|
||||||
|
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
|
||||||
|
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeHeight(members: UserSummary[]) {
|
||||||
|
return (
|
||||||
|
HEADER_HEIGHT +
|
||||||
|
NODE_PADDING_Y * 2 +
|
||||||
|
Math.max(members.length, 1) * MEMBER_ROW_HEIGHT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScale(scale: number) {
|
||||||
|
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrgNode(
|
||||||
|
tenantNode: TenantNode,
|
||||||
|
usersMap: Map<string, UserSummary[]>,
|
||||||
|
depth: number,
|
||||||
|
): OrgNode {
|
||||||
|
const slug = tenantNode.slug.toLowerCase();
|
||||||
|
const members = usersMap.get(slug) || [];
|
||||||
|
const children = tenantNode.children.map((child) =>
|
||||||
|
buildOrgNode(child, usersMap, depth + 1),
|
||||||
|
);
|
||||||
|
const totalMemberIds = new Set(members.map((member) => member.id));
|
||||||
|
for (const child of children) {
|
||||||
|
for (const memberId of child.totalMemberIds) {
|
||||||
|
totalMemberIds.add(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tenantNode.id,
|
||||||
|
name: tenantNode.name,
|
||||||
|
level: depth,
|
||||||
|
members,
|
||||||
|
children,
|
||||||
|
totalCount: totalMemberIds.size,
|
||||||
|
totalMemberIds,
|
||||||
|
companyCode: slug,
|
||||||
|
type: tenantNode.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutTree(
|
||||||
|
node: OrgNode,
|
||||||
|
collapsedIds: Set<string>,
|
||||||
|
originX = 0,
|
||||||
|
originY = 0,
|
||||||
|
): ChartLayout {
|
||||||
|
const members = [...node.members].sort(
|
||||||
|
(a, b) =>
|
||||||
|
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
|
||||||
|
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
|
||||||
|
);
|
||||||
|
const nodeHeight = getNodeHeight(members);
|
||||||
|
const collapsed = collapsedIds.has(node.id);
|
||||||
|
const childLayouts = collapsed
|
||||||
|
? []
|
||||||
|
: node.children.map((child) => layoutTree(child, collapsedIds));
|
||||||
|
const childrenWidth =
|
||||||
|
childLayouts.length > 0
|
||||||
|
? childLayouts.reduce((sum, child) => sum + child.width, 0) +
|
||||||
|
CHILD_GAP_X * (childLayouts.length - 1)
|
||||||
|
: 0;
|
||||||
|
const subtreeWidth = Math.max(NODE_WIDTH, childrenWidth);
|
||||||
|
const nodeX = originX + (subtreeWidth - NODE_WIDTH) / 2;
|
||||||
|
const nodeY = originY;
|
||||||
|
const visualNode: VisualNode = {
|
||||||
|
node,
|
||||||
|
x: nodeX,
|
||||||
|
y: nodeY,
|
||||||
|
width: NODE_WIDTH,
|
||||||
|
height: nodeHeight,
|
||||||
|
members,
|
||||||
|
collapsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cursorX = originX;
|
||||||
|
let maxChildHeight = 0;
|
||||||
|
const nodes: VisualNode[] = [visualNode];
|
||||||
|
const edges: VisualEdge[] = [];
|
||||||
|
|
||||||
|
for (const childLayout of childLayouts) {
|
||||||
|
const childOffsetY = originY + nodeHeight + CHILD_GAP_Y;
|
||||||
|
const shiftedNodes = childLayout.nodes.map((childNode) => ({
|
||||||
|
...childNode,
|
||||||
|
x: childNode.x + cursorX,
|
||||||
|
y: childNode.y + childOffsetY,
|
||||||
|
}));
|
||||||
|
const shiftedEdges = childLayout.edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
path: offsetPath(edge.path, cursorX, childOffsetY),
|
||||||
|
}));
|
||||||
|
const childRoot = shiftedNodes[0];
|
||||||
|
const parentCenterX = nodeX + NODE_WIDTH / 2;
|
||||||
|
const parentBottomY = nodeY + nodeHeight;
|
||||||
|
const childCenterX = childRoot.x + childRoot.width / 2;
|
||||||
|
const childTopY = childRoot.y;
|
||||||
|
const midY = parentBottomY + CHILD_GAP_Y / 2;
|
||||||
|
|
||||||
|
nodes.push(...shiftedNodes);
|
||||||
|
edges.push({
|
||||||
|
key: `${node.id}->${childRoot.node.id}`,
|
||||||
|
path: `M ${parentCenterX} ${parentBottomY} L ${parentCenterX} ${midY} L ${childCenterX} ${midY} L ${childCenterX} ${childTopY}`,
|
||||||
|
});
|
||||||
|
edges.push(...shiftedEdges);
|
||||||
|
cursorX += childLayout.width + CHILD_GAP_X;
|
||||||
|
maxChildHeight = Math.max(maxChildHeight, childLayout.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const height =
|
||||||
|
childLayouts.length > 0
|
||||||
|
? nodeHeight + CHILD_GAP_Y + maxChildHeight
|
||||||
|
: nodeHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
width: subtreeWidth,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetPath(path: string, offsetX: number, offsetY: number) {
|
||||||
|
const numbers = path.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||||
|
let index = 0;
|
||||||
|
return path.replace(/-?\d+(\.\d+)?/g, () => {
|
||||||
|
const value = numbers[index] ?? 0;
|
||||||
|
const nextValue = index % 2 === 0 ? value + offsetX : value + offsetY;
|
||||||
|
index += 1;
|
||||||
|
return String(nextValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutForest(
|
||||||
|
nodes: OrgNode[],
|
||||||
|
collapsedIds: Set<string>,
|
||||||
|
): ChartLayout {
|
||||||
|
const layouts = nodes.map((node) => layoutTree(node, collapsedIds));
|
||||||
|
const contentWidth =
|
||||||
|
layouts.reduce((sum, layout) => sum + layout.width, 0) +
|
||||||
|
ROOT_GAP_X * Math.max(layouts.length - 1, 0);
|
||||||
|
const contentHeight = Math.max(1, ...layouts.map((layout) => layout.height));
|
||||||
|
const visualNodes: VisualNode[] = [];
|
||||||
|
const edges: VisualEdge[] = [];
|
||||||
|
let cursorX = CHART_MARGIN;
|
||||||
|
|
||||||
|
for (const layout of layouts) {
|
||||||
|
visualNodes.push(
|
||||||
|
...layout.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
x: node.x + cursorX,
|
||||||
|
y: node.y + CHART_MARGIN,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
edges.push(
|
||||||
|
...layout.edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
path: offsetPath(edge.path, cursorX, CHART_MARGIN),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
cursorX += layout.width + ROOT_GAP_X;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: visualNodes,
|
||||||
|
edges,
|
||||||
|
width: Math.max(contentWidth + CHART_MARGIN * 2, 960),
|
||||||
|
height: Math.max(contentHeight + CHART_MARGIN * 2, 640),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInitialViewBox(
|
||||||
|
layout: ChartLayout,
|
||||||
|
viewport: DOMRect | null,
|
||||||
|
): ViewBox {
|
||||||
|
const aspect =
|
||||||
|
viewport && viewport.height > 0 ? viewport.width / viewport.height : 16 / 9;
|
||||||
|
const width = Math.max(layout.width, 960);
|
||||||
|
const height = Math.max(width / aspect, layout.height);
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewBoxScale(layout: ChartLayout, viewBox: ViewBox) {
|
||||||
|
return layout.width / viewBox.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorForCompany(companyCode?: string) {
|
||||||
|
const code = (companyCode || "").toLowerCase();
|
||||||
|
if (code.includes("hanmac")) return "#ef4444";
|
||||||
|
if (code.includes("saman")) return "#ffb366";
|
||||||
|
if (code.includes("ptc")) return "#a855f7";
|
||||||
|
if (code.includes("baron")) return "#3b82f6";
|
||||||
|
return "#64748b";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleOrgChartUser(user: UserSummary) {
|
||||||
|
return (
|
||||||
|
!user.email.toLowerCase().endsWith("@hanmac.kr") &&
|
||||||
|
!isSystemGlobalUser(user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemGlobalTenant(
|
||||||
|
tenant?: Pick<TenantSummary, "id" | "slug" | "type" | "name">,
|
||||||
|
) {
|
||||||
|
if (!tenant) return false;
|
||||||
|
const values = [tenant.id, tenant.slug, tenant.type, tenant.name].map(
|
||||||
|
(value) => value.toLowerCase().replaceAll("_", "-"),
|
||||||
|
);
|
||||||
|
return values.some(
|
||||||
|
(value) =>
|
||||||
|
value === "system" ||
|
||||||
|
value === "global" ||
|
||||||
|
value === "system-global" ||
|
||||||
|
value === "tenant-global" ||
|
||||||
|
value === "시스템 전역",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemGlobalUser(user: UserSummary) {
|
||||||
|
const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedRole === "super-admin" ||
|
||||||
|
normalizedRole === "superadmin" ||
|
||||||
|
normalizedRole === "system-admin" ||
|
||||||
|
isSystemGlobalTenant(user.tenant) ||
|
||||||
|
isSystemGlobalTenant({
|
||||||
|
id: user.companyCode || user.tenantSlug || "",
|
||||||
|
slug: user.companyCode || user.tenantSlug || "",
|
||||||
|
type: user.role,
|
||||||
|
name: user.role,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodeByTenantId(
|
||||||
|
nodes: TenantNode[],
|
||||||
|
tenantId: string,
|
||||||
|
): TenantNode | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === tenantId) return node;
|
||||||
|
const child = findNodeByTenantId(node.children, tenantId);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSystemGlobalTenants(tenants: TenantSummary[]) {
|
||||||
|
const excludedIds = new Set(
|
||||||
|
tenants.filter(isSystemGlobalTenant).map((tenant) => tenant.id),
|
||||||
|
);
|
||||||
|
let changed = true;
|
||||||
|
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
if (
|
||||||
|
tenant.parentId &&
|
||||||
|
excludedIds.has(tenant.parentId) &&
|
||||||
|
!excludedIds.has(tenant.id)
|
||||||
|
) {
|
||||||
|
excludedIds.add(tenant.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
type TenantIndexes = {
|
||||||
|
byId: Map<string, TenantNode>;
|
||||||
|
bySlug: Map<string, TenantNode>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes {
|
||||||
|
const byId = new Map<string, TenantNode>();
|
||||||
|
const bySlug = new Map<string, TenantNode>();
|
||||||
|
const visit = (node: TenantNode) => {
|
||||||
|
byId.set(node.id, node);
|
||||||
|
bySlug.set(node.slug.toLowerCase(), node);
|
||||||
|
for (const child of node.children) visit(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const node of nodes) visit(node);
|
||||||
|
return { byId, bySlug };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDescendantTenant(
|
||||||
|
candidate: TenantNode,
|
||||||
|
ancestor: TenantNode,
|
||||||
|
byId: Map<string, TenantNode>,
|
||||||
|
) {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let currentParentId = candidate.parentId;
|
||||||
|
|
||||||
|
while (currentParentId) {
|
||||||
|
if (currentParentId === ancestor.id) return true;
|
||||||
|
if (visited.has(currentParentId)) return false;
|
||||||
|
visited.add(currentParentId);
|
||||||
|
currentParentId = byId.get(currentParentId)?.parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLeafMembershipSlugs(
|
||||||
|
slugs: Set<string>,
|
||||||
|
tenantIndexes: TenantIndexes,
|
||||||
|
) {
|
||||||
|
const memberships = Array.from(slugs);
|
||||||
|
|
||||||
|
return memberships.filter((slug) => {
|
||||||
|
const tenant = tenantIndexes.bySlug.get(slug);
|
||||||
|
if (!tenant) return true;
|
||||||
|
|
||||||
|
return !memberships.some((otherSlug) => {
|
||||||
|
if (otherSlug === slug) return false;
|
||||||
|
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
|
||||||
|
if (!otherTenant) return false;
|
||||||
|
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsersMap(
|
||||||
|
users: UserSummary[],
|
||||||
|
rootNodes: TenantNode[],
|
||||||
|
options: { activeOnly: boolean },
|
||||||
|
) {
|
||||||
|
const tenantIndexes = buildTenantIndexes(rootNodes);
|
||||||
|
const map = new Map<string, UserSummary[]>();
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (options.activeOnly && user.status !== "active") continue;
|
||||||
|
if (!isVisibleOrgChartUser(user)) continue;
|
||||||
|
|
||||||
|
const slugs = new Set<string>();
|
||||||
|
const primarySlug =
|
||||||
|
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || "";
|
||||||
|
if (
|
||||||
|
primarySlug &&
|
||||||
|
!isSystemGlobalTenant({
|
||||||
|
id: primarySlug,
|
||||||
|
slug: primarySlug,
|
||||||
|
type: primarySlug,
|
||||||
|
name: primarySlug,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
slugs.add(primarySlug);
|
||||||
|
}
|
||||||
|
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
|
||||||
|
slugs.add(user.tenant.slug.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const joinedTenant of user.joinedTenants || []) {
|
||||||
|
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
|
||||||
|
slugs.add(joinedTenant.slug.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
|
||||||
|
const list = map.get(slug) || [];
|
||||||
|
if (!list.some((existing) => existing.id === user.id)) list.push(user);
|
||||||
|
map.set(slug, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantOrgChartPage() {
|
||||||
|
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = React.useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startViewBox: ViewBox;
|
||||||
|
} | null>(null);
|
||||||
|
const { tenantId } = useParams<{ tenantId?: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const shareToken = searchParams.get("token");
|
||||||
|
const [selectedTenantFilter, setSelectedTenantFilter] =
|
||||||
|
React.useState(FAMILY_FILTER_ID);
|
||||||
|
const [collapsedIds, setCollapsedIds] = React.useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [viewBox, setViewBox] = React.useState<ViewBox>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
const [hasUserMovedCanvas, setHasUserMovedCanvas] = React.useState(false);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
|
const publicQuery = useQuery({
|
||||||
|
queryKey: ["public-orgchart", shareToken],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!shareToken) throw new Error("Missing share token");
|
||||||
|
return fetchPublicOrgChart(shareToken);
|
||||||
|
},
|
||||||
|
enabled: !!shareToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantsQuery = useQuery({
|
||||||
|
queryKey: ["tenants-full-tree-v2"],
|
||||||
|
queryFn: () => fetchTenants(10000, 0),
|
||||||
|
enabled: !shareToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { limit: 5000, offset: 0 }],
|
||||||
|
queryFn: () => fetchUsers(5000, 0),
|
||||||
|
enabled: !shareToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
|
||||||
|
if (shareToken) {
|
||||||
|
if (!publicQuery.data) {
|
||||||
|
return {
|
||||||
|
rootNodes: [],
|
||||||
|
usersMap: new Map<string, UserSummary[]>(),
|
||||||
|
sharedWith: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootNodes = buildTenantFullTree(
|
||||||
|
filterSystemGlobalTenants(publicQuery.data.tenants),
|
||||||
|
).subTree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootNodes,
|
||||||
|
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
|
||||||
|
activeOnly: false,
|
||||||
|
}),
|
||||||
|
sharedWith: publicQuery.data.sharedWith,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
|
||||||
|
return {
|
||||||
|
rootNodes: [],
|
||||||
|
usersMap: new Map<string, UserSummary[]>(),
|
||||||
|
sharedWith: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootNodes = buildTenantFullTree(
|
||||||
|
filterSystemGlobalTenants(tenantsQuery.data.items),
|
||||||
|
).subTree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootNodes,
|
||||||
|
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
|
||||||
|
activeOnly: true,
|
||||||
|
}),
|
||||||
|
sharedWith: "",
|
||||||
|
};
|
||||||
|
}, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]);
|
||||||
|
|
||||||
|
const familyRoot = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
|
||||||
|
rootNodes[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [rootNodes]);
|
||||||
|
|
||||||
|
const companyFilters = React.useMemo(() => {
|
||||||
|
return (familyRoot?.children ?? [])
|
||||||
|
.filter((node) => node.type === "COMPANY")
|
||||||
|
.map((node) => ({ id: node.id, label: node.name }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [familyRoot]);
|
||||||
|
|
||||||
|
const filterOptions = React.useMemo(
|
||||||
|
() => [{ id: FAMILY_FILTER_ID, label: "한맥가족" }, ...companyFilters],
|
||||||
|
[companyFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!tenantId) return;
|
||||||
|
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
|
||||||
|
const match = searchRoots
|
||||||
|
.flatMap((node) => [node, ...node.children])
|
||||||
|
.find(
|
||||||
|
(node) =>
|
||||||
|
node.id === tenantId ||
|
||||||
|
node.slug.toLowerCase() === tenantId.toLowerCase() ||
|
||||||
|
node.name === tenantId,
|
||||||
|
);
|
||||||
|
if (match?.type === "COMPANY") setSelectedTenantFilter(match.id);
|
||||||
|
}, [familyRoot, rootNodes, tenantId]);
|
||||||
|
|
||||||
|
const targetNodes = React.useMemo(() => {
|
||||||
|
if (!familyRoot) return [];
|
||||||
|
if (selectedTenantFilter === FAMILY_FILTER_ID) {
|
||||||
|
return [buildOrgNode(familyRoot, usersMap, 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyNode = findNodeByTenantId([familyRoot], selectedTenantFilter);
|
||||||
|
return companyNode ? [buildOrgNode(companyNode, usersMap, 0)] : [];
|
||||||
|
}, [familyRoot, selectedTenantFilter, usersMap]);
|
||||||
|
|
||||||
|
const layout = React.useMemo(
|
||||||
|
() => layoutForest(targetNodes, collapsedIds),
|
||||||
|
[collapsedIds, targetNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (hasUserMovedCanvas) return;
|
||||||
|
setViewBox(
|
||||||
|
makeInitialViewBox(
|
||||||
|
layout,
|
||||||
|
viewportRef.current?.getBoundingClientRect() ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [hasUserMovedCanvas, layout]);
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startViewBox: viewBox,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = dragRef.current;
|
||||||
|
const rect = viewportRef.current?.getBoundingClientRect();
|
||||||
|
if (!dragState || !rect || dragState.pointerId !== event.pointerId) return;
|
||||||
|
|
||||||
|
const dx =
|
||||||
|
((event.clientX - dragState.startX) / rect.width) *
|
||||||
|
dragState.startViewBox.width;
|
||||||
|
const dy =
|
||||||
|
((event.clientY - dragState.startY) / rect.height) *
|
||||||
|
dragState.startViewBox.height;
|
||||||
|
setHasUserMovedCanvas(true);
|
||||||
|
setViewBox({
|
||||||
|
...dragState.startViewBox,
|
||||||
|
x: dragState.startViewBox.x - dx,
|
||||||
|
y: dragState.startViewBox.y - dy,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = dragRef.current;
|
||||||
|
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
dragRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const currentScale = getViewBoxScale(layout, viewBox);
|
||||||
|
const nextScale = clampScale(
|
||||||
|
currentScale * Math.exp(-event.deltaY * ZOOM_SENSITIVITY),
|
||||||
|
);
|
||||||
|
const nextWidth = layout.width / nextScale;
|
||||||
|
const nextHeight = nextWidth / (rect.width / rect.height);
|
||||||
|
const pointX =
|
||||||
|
viewBox.x + ((event.clientX - rect.left) / rect.width) * viewBox.width;
|
||||||
|
const pointY =
|
||||||
|
viewBox.y + ((event.clientY - rect.top) / rect.height) * viewBox.height;
|
||||||
|
const ratioX = (pointX - viewBox.x) / viewBox.width;
|
||||||
|
const ratioY = (pointY - viewBox.y) / viewBox.height;
|
||||||
|
|
||||||
|
setHasUserMovedCanvas(true);
|
||||||
|
setViewBox({
|
||||||
|
x: pointX - nextWidth * ratioX,
|
||||||
|
y: pointY - nextHeight * ratioY,
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = shareToken
|
||||||
|
? publicQuery.isLoading
|
||||||
|
: tenantsQuery.isLoading || usersQuery.isLoading;
|
||||||
|
const isError = shareToken
|
||||||
|
? publicQuery.isError
|
||||||
|
: tenantsQuery.isError || usersQuery.isError;
|
||||||
|
|
||||||
|
const totalUsers = React.useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const node of targetNodes) {
|
||||||
|
for (const memberId of node.totalMemberIds) {
|
||||||
|
ids.add(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.size;
|
||||||
|
}, [targetNodes]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
조직도를 불러올 수 없거나 만료된 링크입니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-theme(spacing.32))] flex-col overflow-hidden rounded-xl border border-[#e0d5c1] bg-[#f6efe6] shadow-sm">
|
||||||
|
<header className="z-10 flex shrink-0 flex-col items-start justify-between border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="mb-4 flex flex-col gap-1 sm:mb-0">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-[#f2c484]">
|
||||||
|
{shareToken ? `공유된 조직도: ${sharedWith}` : "MH Dashboard"}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-xl font-black text-[#f7f0e4]">조직 현황</h2>
|
||||||
|
</div>
|
||||||
|
<div className="custom-scrollbar flex max-w-full items-center gap-2 overflow-x-auto">
|
||||||
|
{filterOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTenantFilter(option.id);
|
||||||
|
setCollapsedIds(new Set());
|
||||||
|
setHasUserMovedCanvas(false);
|
||||||
|
}}
|
||||||
|
className={`whitespace-nowrap rounded-full border px-4 py-2 text-xs font-bold transition-all ${
|
||||||
|
selectedTenantFilter === option.id
|
||||||
|
? "border-[#f2c484]/40 bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] shadow-sm"
|
||||||
|
: "border-[#f2c484]/30 bg-white/10 text-[#f7f0e4]/70 hover:border-[#f2c484]/50 hover:text-[#f7f0e4]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm">
|
||||||
|
총 {totalUsers}명
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative flex-1 touch-none select-none overflow-hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
|
data-testid="orgchart-viewport"
|
||||||
|
onPointerCancel={finishDrag}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={finishDrag}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
ref={viewportRef}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-full w-full"
|
||||||
|
data-scale={getViewBoxScale(layout, viewBox).toFixed(3)}
|
||||||
|
data-testid="orgchart-vector-svg"
|
||||||
|
role="img"
|
||||||
|
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
||||||
|
>
|
||||||
|
<title>조직도 벡터 렌더링</title>
|
||||||
|
<g data-testid="orgchart-canvas">
|
||||||
|
{layout.edges.map((edge) => (
|
||||||
|
<path
|
||||||
|
d={edge.path}
|
||||||
|
fill="none"
|
||||||
|
key={edge.key}
|
||||||
|
stroke="#bca58a"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{layout.nodes.map((visualNode) => (
|
||||||
|
<SvgOrgNode key={visualNode.node.id} visualNode={visualNode} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SvgOrgNode({
|
||||||
|
visualNode,
|
||||||
|
}: {
|
||||||
|
visualNode: VisualNode;
|
||||||
|
}) {
|
||||||
|
const { node, x, y, width, height, members, collapsed } = visualNode;
|
||||||
|
const headerFill = node.level === 0 ? "#0a2a22" : "#2f5547";
|
||||||
|
const accent = getColorForCompany(node.companyCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} ${y})`}>
|
||||||
|
<rect
|
||||||
|
fill="#ffffff"
|
||||||
|
height={height}
|
||||||
|
rx="10"
|
||||||
|
stroke="#e0d5c1"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
<rect fill={headerFill} height={HEADER_HEIGHT} rx="10" width={width} />
|
||||||
|
<rect
|
||||||
|
fill={headerFill}
|
||||||
|
height="18"
|
||||||
|
width={width}
|
||||||
|
y={HEADER_HEIGHT - 18}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill="#f7f0e4"
|
||||||
|
fontSize={node.level === 0 ? 17 : 15}
|
||||||
|
fontWeight="800"
|
||||||
|
textAnchor="middle"
|
||||||
|
x={width / 2}
|
||||||
|
y="27"
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</text>
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
fill="rgba(0,0,0,0.22)"
|
||||||
|
height="22"
|
||||||
|
rx="11"
|
||||||
|
width="52"
|
||||||
|
x={width - 66}
|
||||||
|
y="10"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill="#ffffff"
|
||||||
|
fontSize="12"
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
x={width - 40}
|
||||||
|
y="25"
|
||||||
|
>
|
||||||
|
{collapsed ? "+" : node.totalCount}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{members.length > 0 ? (
|
||||||
|
members.map((member, index) => (
|
||||||
|
<g
|
||||||
|
key={member.id}
|
||||||
|
transform={`translate(14 ${HEADER_HEIGHT + NODE_PADDING_Y + index * MEMBER_ROW_HEIGHT})`}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
fill="#ffffff"
|
||||||
|
height="20"
|
||||||
|
rx="4"
|
||||||
|
stroke="#e5e7eb"
|
||||||
|
width={width - 28}
|
||||||
|
/>
|
||||||
|
<rect fill={accent} height="20" rx="4" width="4" />
|
||||||
|
<text fill="#334155" fontSize="12" fontWeight="800" x="12" y="14">
|
||||||
|
{getOrgChartUserDisplayName(member, {
|
||||||
|
id: node.id,
|
||||||
|
slug: node.companyCode ?? "",
|
||||||
|
})}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<text fill="#94a3b8" fontSize="12" x="16" y={HEADER_HEIGHT + 28}>
|
||||||
|
구성원 없음
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx
Normal file
48
orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { GitBranch, Network, PanelTop } from "lucide-react";
|
||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/chart", label: "조직도", icon: Network },
|
||||||
|
{ to: "/picker", label: "조직 선택기", icon: GitBranch },
|
||||||
|
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OrgFrontLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Baron Orgfront
|
||||||
|
</p>
|
||||||
|
<h1 className="text-xl font-semibold">조직도 서비스</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
|
||||||
|
{navItems.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-5">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
type OrgPickerEmbedOptions,
|
||||||
|
type OrgPickerMode,
|
||||||
|
type OrgPickerSelectableType,
|
||||||
|
buildOrgPickerEmbedSrc,
|
||||||
|
parseOrgPickerEmbedOptions,
|
||||||
|
} from "../pickerTypes";
|
||||||
|
|
||||||
|
type PickerMessage = {
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PickerScenarioControls({
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: OrgPickerEmbedOptions;
|
||||||
|
onChange: (options: OrgPickerEmbedOptions) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">선택 모드</span>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
value={options.mode}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
mode: event.target.value as OrgPickerMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="multiple">복수 선택</option>
|
||||||
|
<option value="single">단일 선택</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">선택 대상</span>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
value={options.select}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
select: event.target.value as OrgPickerSelectableType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="both">조직&구성원</option>
|
||||||
|
<option value="tenant">조직</option>
|
||||||
|
<option value="user">구성원</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">tenant ID</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
tenantId: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="company-baron"
|
||||||
|
type="text"
|
||||||
|
value={options.tenantId}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||||
|
<input
|
||||||
|
checked={options.includeDescendants}
|
||||||
|
disabled={options.mode === "single"}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
includeDescendants: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>하위 포함</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||||
|
<input
|
||||||
|
checked={options.showDescendantToggle}
|
||||||
|
disabled={options.mode === "single"}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
showDescendantToggle: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>하위 선택 스위치 표시</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">임베딩 너비</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
min={240}
|
||||||
|
max={1600}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
width: Number.parseInt(event.target.value || "400", 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={options.width}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">임베딩 높이</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
min={240}
|
||||||
|
max={1600}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
height: Number.parseInt(event.target.value || "600", 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={options.height}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrgPickerEmbedPreviewPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
|
||||||
|
parseOrgPickerEmbedOptions(location.search),
|
||||||
|
);
|
||||||
|
const [lastMessage, setLastMessage] = React.useState<PickerMessage | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const pickerSrc = buildOrgPickerEmbedSrc(options);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent<PickerMessage>) => {
|
||||||
|
if (!event.data?.type?.startsWith("orgfront:picker:")) return;
|
||||||
|
setLastMessage(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Embed Preview
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold">임베딩 검증</h1>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="min-h-16 w-full overflow-x-auto rounded-md border border-border bg-card px-4 py-3 font-mono text-sm leading-6 text-foreground"
|
||||||
|
data-testid="embed-preview-src"
|
||||||
|
>
|
||||||
|
{pickerSrc}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PickerScenarioControls options={options} onChange={setOptions} />
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[1fr,360px]">
|
||||||
|
<div
|
||||||
|
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
|
||||||
|
data-testid="embed-preview-frame-shell"
|
||||||
|
style={{
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
className="h-full w-full bg-background"
|
||||||
|
src={pickerSrc}
|
||||||
|
title="조직 선택기 임베딩 검증"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-3 rounded-md border border-border bg-card p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
postMessage
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">수신 결과</h2>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className="min-h-[280px] overflow-auto rounded-md border border-border bg-background p-3 text-xs"
|
||||||
|
data-testid="embed-preview-output"
|
||||||
|
>
|
||||||
|
{lastMessage
|
||||||
|
? JSON.stringify(lastMessage, null, 2)
|
||||||
|
: "아직 수신된 메시지가 없습니다."}
|
||||||
|
</pre>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal file
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
|
||||||
|
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
|
||||||
|
import {
|
||||||
|
type OrgPickerEmbedOptions,
|
||||||
|
type OrgPickerMode,
|
||||||
|
type OrgPickerResult,
|
||||||
|
type OrgPickerSelectableType,
|
||||||
|
type OrgPickerSelection,
|
||||||
|
type OrgPickerTreeNode,
|
||||||
|
buildOrgPickerEmbedSrc,
|
||||||
|
nodeKey,
|
||||||
|
parseOrgPickerEmbedOptions,
|
||||||
|
parseOrgPickerMode,
|
||||||
|
parseOrgPickerSelectableType,
|
||||||
|
} from "../pickerTypes";
|
||||||
|
|
||||||
|
function canSelectNode(
|
||||||
|
node: OrgPickerTreeNode,
|
||||||
|
select: OrgPickerSelectableType,
|
||||||
|
) {
|
||||||
|
return select === "both" || select === node.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSelectedNodes({
|
||||||
|
roots,
|
||||||
|
selectedKeys,
|
||||||
|
includeDescendants,
|
||||||
|
select,
|
||||||
|
}: {
|
||||||
|
roots: OrgPickerTreeNode[];
|
||||||
|
selectedKeys: Set<string>;
|
||||||
|
includeDescendants: boolean;
|
||||||
|
select: OrgPickerSelectableType;
|
||||||
|
}) {
|
||||||
|
const selected = new Map<string, OrgPickerTreeNode>();
|
||||||
|
const visit = (node: OrgPickerTreeNode) => {
|
||||||
|
const key = nodeKey(node);
|
||||||
|
if (selectedKeys.has(key) && canSelectNode(node, select)) {
|
||||||
|
selected.set(key, node);
|
||||||
|
if (includeDescendants && node.type === "tenant") {
|
||||||
|
for (const descendant of flattenDescendants(node)) {
|
||||||
|
if (canSelectNode(descendant, select)) {
|
||||||
|
selected.set(nodeKey(descendant), descendant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.children) visit(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const root of roots) visit(root);
|
||||||
|
return Array.from(selected.values()).map(toSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCheckedKeys({
|
||||||
|
roots,
|
||||||
|
selectedKeys,
|
||||||
|
includeDescendants,
|
||||||
|
select,
|
||||||
|
}: {
|
||||||
|
roots: OrgPickerTreeNode[];
|
||||||
|
selectedKeys: Set<string>;
|
||||||
|
includeDescendants: boolean;
|
||||||
|
select: OrgPickerSelectableType;
|
||||||
|
}) {
|
||||||
|
const checkedKeys = new Set(selectedKeys);
|
||||||
|
if (!includeDescendants) return checkedKeys;
|
||||||
|
|
||||||
|
const visit = (node: OrgPickerTreeNode) => {
|
||||||
|
const key = nodeKey(node);
|
||||||
|
if (selectedKeys.has(key) && node.type === "tenant") {
|
||||||
|
for (const descendant of flattenDescendants(node)) {
|
||||||
|
if (canSelectNode(descendant, select)) {
|
||||||
|
checkedKeys.add(nodeKey(descendant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.children) visit(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const root of roots) visit(root);
|
||||||
|
return checkedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postPickerMessage(message: unknown) {
|
||||||
|
window.parent.postMessage(message, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSearchValues(value: unknown, depth = 0): string[] {
|
||||||
|
if (value == null || depth > 4) return [];
|
||||||
|
if (
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "boolean"
|
||||||
|
) {
|
||||||
|
return [String(value)];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap((item) => collectSearchValues(item, depth + 1));
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return Object.entries(value as Record<string, unknown>).flatMap(
|
||||||
|
([key, item]) => [key, ...collectSearchValues(item, depth + 1)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeSearchValues(node: OrgPickerTreeNode) {
|
||||||
|
const tenantSearchValues = node.tenant
|
||||||
|
? collectSearchValues({
|
||||||
|
id: node.tenant.id,
|
||||||
|
type: node.tenant.type,
|
||||||
|
name: node.tenant.name,
|
||||||
|
slug: node.tenant.slug,
|
||||||
|
description: node.tenant.description,
|
||||||
|
status: node.tenant.status,
|
||||||
|
domains: node.tenant.domains,
|
||||||
|
parentId: node.tenant.parentId,
|
||||||
|
config: node.tenant.config,
|
||||||
|
memberCount: node.tenant.memberCount,
|
||||||
|
createdAt: node.tenant.createdAt,
|
||||||
|
updatedAt: node.tenant.updatedAt,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
node.type,
|
||||||
|
node.id,
|
||||||
|
node.name,
|
||||||
|
node.parentId ?? "",
|
||||||
|
...tenantSearchValues,
|
||||||
|
...collectSearchValues(node.user),
|
||||||
|
].map((value) => value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) {
|
||||||
|
return getNodeSearchValues(node).some((value) => value.includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPickerTree(
|
||||||
|
roots: OrgPickerTreeNode[],
|
||||||
|
rawQuery: string,
|
||||||
|
): OrgPickerTreeNode[] {
|
||||||
|
const query = rawQuery.trim().toLowerCase();
|
||||||
|
if (!query) return roots;
|
||||||
|
|
||||||
|
const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => {
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map(filterNode)
|
||||||
|
.filter((child): child is OrgPickerTreeNode => Boolean(child));
|
||||||
|
|
||||||
|
if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: filteredChildren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return roots
|
||||||
|
.map(filterNode)
|
||||||
|
.filter((node): node is OrgPickerTreeNode => Boolean(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrgPickerTree({
|
||||||
|
roots,
|
||||||
|
mode,
|
||||||
|
select,
|
||||||
|
selectedKeys,
|
||||||
|
onSingleSelect,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
roots: OrgPickerTreeNode[];
|
||||||
|
mode: OrgPickerMode;
|
||||||
|
select: OrgPickerSelectableType;
|
||||||
|
selectedKeys: Set<string>;
|
||||||
|
onSingleSelect: (node: OrgPickerTreeNode) => void;
|
||||||
|
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1" data-testid="org-picker-tree">
|
||||||
|
{roots.map((node) => (
|
||||||
|
<OrgPickerTreeItem
|
||||||
|
key={nodeKey(node)}
|
||||||
|
mode={mode}
|
||||||
|
node={node}
|
||||||
|
onSingleSelect={onSingleSelect}
|
||||||
|
onToggle={onToggle}
|
||||||
|
select={select}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrgPickerTreeItem({
|
||||||
|
node,
|
||||||
|
mode,
|
||||||
|
select,
|
||||||
|
selectedKeys,
|
||||||
|
onSingleSelect,
|
||||||
|
onToggle,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
node: OrgPickerTreeNode;
|
||||||
|
mode: OrgPickerMode;
|
||||||
|
select: OrgPickerSelectableType;
|
||||||
|
selectedKeys: Set<string>;
|
||||||
|
onSingleSelect: (node: OrgPickerTreeNode) => void;
|
||||||
|
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(true);
|
||||||
|
const selectable = canSelectNode(node, select);
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const key = nodeKey(node);
|
||||||
|
const checked = selectedKeys.has(key);
|
||||||
|
const label = `${node.name} 선택`;
|
||||||
|
const email = node.type === "user" ? node.user?.email : undefined;
|
||||||
|
const nameTestId =
|
||||||
|
node.type === "tenant"
|
||||||
|
? "org-picker-node-name-tenant"
|
||||||
|
: "org-picker-node-name-user";
|
||||||
|
const content = (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span
|
||||||
|
className={`truncate font-semibold leading-5 ${
|
||||||
|
node.type === "tenant" ? "text-[#0a2114]" : ""
|
||||||
|
}`}
|
||||||
|
data-testid={nameTestId}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
{email ? (
|
||||||
|
<span className="truncate text-xs leading-5 text-muted-foreground">
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`group flex min-h-7 items-center gap-1.5 rounded-sm py-0.5 pr-1.5 transition ${
|
||||||
|
mode === "single" && checked
|
||||||
|
? "bg-primary/15 text-foreground ring-2 ring-primary/60 shadow-sm"
|
||||||
|
: "hover:bg-secondary/50"
|
||||||
|
} ${depth > 0 ? "pl-4" : "pl-1"}`}
|
||||||
|
data-selected={mode === "single" && checked ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition hover:bg-secondary"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
aria-label={`${node.name} ${isOpen ? "접기" : "펼치기"}`}
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "multiple" && selectable ? (
|
||||||
|
<input
|
||||||
|
aria-label={label}
|
||||||
|
checked={checked}
|
||||||
|
className="h-3.5 w-3.5 rounded border-border"
|
||||||
|
onChange={(event) => onToggle(node, event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode === "single" && selectable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={checked}
|
||||||
|
className={`min-w-0 flex-1 rounded-sm px-1 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
|
checked ? "text-primary" : ""
|
||||||
|
}`}
|
||||||
|
data-selected={checked ? "true" : undefined}
|
||||||
|
onClick={() => onSingleSelect(node)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex-1">{content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && hasChildren ? (
|
||||||
|
<div className="ml-4">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<OrgPickerTreeItem
|
||||||
|
depth={depth + 1}
|
||||||
|
key={nodeKey(child)}
|
||||||
|
mode={mode}
|
||||||
|
node={child}
|
||||||
|
onSingleSelect={onSingleSelect}
|
||||||
|
onToggle={onToggle}
|
||||||
|
select={select}
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrgPickerEmbedPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const mode = parseOrgPickerMode(searchParams.get("mode"));
|
||||||
|
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||||
|
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||||
|
const tenantId =
|
||||||
|
searchParams.get("tenantId") ||
|
||||||
|
searchParams.get("companyTenantId") ||
|
||||||
|
undefined;
|
||||||
|
const [includeDescendants, setIncludeDescendants] = React.useState(
|
||||||
|
searchParams.get("includeDescendants") !== "false",
|
||||||
|
);
|
||||||
|
const showDescendantToggle =
|
||||||
|
searchParams.get("showDescendantToggle") !== "false";
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
|
const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantsQuery = useQuery({
|
||||||
|
queryKey: ["org-picker-tenants"],
|
||||||
|
queryFn: () => fetchTenants(10000, 0),
|
||||||
|
});
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["org-picker-users"],
|
||||||
|
queryFn: () => fetchUsers(5000, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
postPickerMessage({ type: "orgfront:picker:ready" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tree = React.useMemo(() => {
|
||||||
|
return buildOrgPickerTree({
|
||||||
|
tenants: tenantsQuery.data?.items ?? [],
|
||||||
|
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
|
||||||
|
rootTenantId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
}, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]);
|
||||||
|
|
||||||
|
const selectedItems = React.useMemo(
|
||||||
|
() =>
|
||||||
|
collectSelectedNodes({
|
||||||
|
roots: tree.roots,
|
||||||
|
selectedKeys,
|
||||||
|
includeDescendants: mode === "multiple" && includeDescendants,
|
||||||
|
select,
|
||||||
|
}),
|
||||||
|
[includeDescendants, mode, select, selectedKeys, tree.roots],
|
||||||
|
);
|
||||||
|
const checkedKeys = React.useMemo(
|
||||||
|
() =>
|
||||||
|
collectCheckedKeys({
|
||||||
|
roots: tree.roots,
|
||||||
|
selectedKeys,
|
||||||
|
includeDescendants: mode === "multiple" && includeDescendants,
|
||||||
|
select,
|
||||||
|
}),
|
||||||
|
[includeDescendants, mode, select, selectedKeys, tree.roots],
|
||||||
|
);
|
||||||
|
const filteredRoots = React.useMemo(
|
||||||
|
() => filterPickerTree(tree.roots, searchQuery),
|
||||||
|
[searchQuery, tree.roots],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSingleSelect = (node: OrgPickerTreeNode) => {
|
||||||
|
setSelectedKeys(new Set([nodeKey(node)]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => {
|
||||||
|
setSelectedKeys((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
const key = nodeKey(node);
|
||||||
|
if (checked) next.add(key);
|
||||||
|
else next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
const payload: OrgPickerResult = {
|
||||||
|
mode,
|
||||||
|
selections: selectedItems,
|
||||||
|
};
|
||||||
|
postPickerMessage({ type: "orgfront:picker:confirm", payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSelection = () => {
|
||||||
|
postPickerMessage({ type: "orgfront:picker:cancel" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
|
||||||
|
const isError = tenantsQuery.isError || usersQuery.isError;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const htmlOverflow = document.documentElement.style.overflow;
|
||||||
|
const bodyOverflow = document.body.style.overflow;
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.documentElement.style.overflow = htmlOverflow;
|
||||||
|
document.body.style.overflow = bodyOverflow;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isError) return;
|
||||||
|
postPickerMessage({
|
||||||
|
type: "orgfront:picker:error",
|
||||||
|
error: "org_picker_load_failed",
|
||||||
|
});
|
||||||
|
}, [isError]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-background p-6 text-muted-foreground">
|
||||||
|
조직 선택기를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-background p-6 text-destructive">
|
||||||
|
조직 선택기를 불러올 수 없습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||||
|
<main className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-b border-border bg-background p-2"
|
||||||
|
data-testid="org-picker-search-section"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr),auto] items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="sr-only" htmlFor="org-picker-search">
|
||||||
|
조직/구성원 검색
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="org-picker-search"
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm"
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="ID, 이름, 이메일, 메타데이터"
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "multiple" && showDescendantToggle ? (
|
||||||
|
<label
|
||||||
|
className="inline-flex h-9 items-center gap-2 whitespace-nowrap text-sm"
|
||||||
|
data-testid="org-picker-descendant-toggle"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked={includeDescendants}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
onChange={(event) =>
|
||||||
|
setIncludeDescendants(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>하위 선택</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto p-3"
|
||||||
|
data-testid="org-picker-tree-scroll"
|
||||||
|
>
|
||||||
|
{filteredRoots.length > 0 ? (
|
||||||
|
<OrgPickerTree
|
||||||
|
mode={mode}
|
||||||
|
onSingleSelect={handleSingleSelect}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
roots={filteredRoots}
|
||||||
|
select={select}
|
||||||
|
selectedKeys={checkedKeys}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid min-h-40 place-items-center rounded-md border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-background px-3 py-2">
|
||||||
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
|
{selectedItems.length > 0
|
||||||
|
? `${selectedItems.length}개 항목 선택됨`
|
||||||
|
: "선택된 항목이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={cancelSelection} type="button" variant="outline">
|
||||||
|
<X size={16} />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selectedItems.length === 0}
|
||||||
|
onClick={confirmSelection}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
선택 완료
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrgPickerPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
|
||||||
|
parseOrgPickerEmbedOptions(location.search),
|
||||||
|
);
|
||||||
|
const pickerSrc = buildOrgPickerEmbedSrc(options);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Picker Workbench
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold">조직 선택기</h1>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{pickerSrc}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">선택 모드</span>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
value={options.mode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
mode: event.target.value as OrgPickerMode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="multiple">복수 선택</option>
|
||||||
|
<option value="single">단일 선택</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">선택 대상</span>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
value={options.select}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
select: event.target.value as OrgPickerSelectableType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="both">조직&구성원</option>
|
||||||
|
<option value="tenant">조직</option>
|
||||||
|
<option value="user">구성원</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">tenant ID</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
tenantId: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="company-baron"
|
||||||
|
type="text"
|
||||||
|
value={options.tenantId}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||||
|
<input
|
||||||
|
checked={options.includeDescendants}
|
||||||
|
disabled={options.mode === "single"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
includeDescendants: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>하위 포함</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||||
|
<input
|
||||||
|
checked={options.showDescendantToggle}
|
||||||
|
disabled={options.mode === "single"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
showDescendantToggle: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>하위 선택 스위치 표시</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">임베딩 너비</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
max={1600}
|
||||||
|
min={240}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
width: Number.parseInt(event.target.value || "400", 10),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={options.width}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1 text-sm font-medium">
|
||||||
|
<span className="block text-muted-foreground">임베딩 높이</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||||
|
max={1600}
|
||||||
|
min={240}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((current) => ({
|
||||||
|
...current,
|
||||||
|
height: Number.parseInt(event.target.value || "600", 10),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={options.height}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
|
||||||
|
style={{
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
className="h-full w-full bg-background"
|
||||||
|
src={pickerSrc}
|
||||||
|
title="조직 선택기 테스트"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
orgfront/src/features/orgchart/userDisplay.ts
Normal file
63
orgfront/src/features/orgchart/userDisplay.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
type UserAppointment = {
|
||||||
|
tenantId?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
position?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TenantIdentity = Pick<TenantSummary, "id" | "slug">;
|
||||||
|
|
||||||
|
function normalizeText(value: unknown) {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAppointments(user: UserSummary): UserAppointment[] {
|
||||||
|
const rawAppointments = user.metadata?.additionalAppointments;
|
||||||
|
if (!Array.isArray(rawAppointments)) return [];
|
||||||
|
|
||||||
|
return rawAppointments
|
||||||
|
.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
typeof item === "object" && item !== null,
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
tenantId: normalizeText(item.tenantId),
|
||||||
|
tenantSlug: normalizeText(item.tenantSlug),
|
||||||
|
jobTitle: normalizeText(item.jobTitle),
|
||||||
|
position: normalizeText(item.position),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
||||||
|
const appointment = getUserAppointments(user).find((item) => {
|
||||||
|
if (tenant?.id && item.tenantId === tenant.id) return true;
|
||||||
|
if (
|
||||||
|
tenant?.slug &&
|
||||||
|
item.tenantSlug &&
|
||||||
|
item.tenantSlug.toLowerCase() === tenant.slug.toLowerCase()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||||
|
position: appointment?.position || normalizeText(user.position),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrgChartUserDisplayName(
|
||||||
|
user: UserSummary,
|
||||||
|
tenant?: TenantIdentity,
|
||||||
|
) {
|
||||||
|
const { jobTitle, position } = getUserOrgProfile(user, tenant);
|
||||||
|
const baseName = user.name.trim();
|
||||||
|
|
||||||
|
if (jobTitle && position) return `${baseName} ${position}[${jobTitle}]`;
|
||||||
|
if (jobTitle) return `${baseName}[${jobTitle}]`;
|
||||||
|
if (position) return `${baseName} ${position}`;
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
219
orgfront/src/features/profile/ProfilePage.tsx
Normal file
219
orgfront/src/features/profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
Fingerprint,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profile,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["userMe"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("ui.dev.profile.loading", "Loading profile...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
{t("ui.dev.profile.error", "Failed to load profile information.")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to token information if API data is incomplete
|
||||||
|
const displayTenant =
|
||||||
|
profile.tenant?.name ||
|
||||||
|
profile.tenantId ||
|
||||||
|
auth.user?.profile?.tenant_id?.toString() ||
|
||||||
|
"-";
|
||||||
|
const displayCompanyCode =
|
||||||
|
profile.companyCode || auth.user?.profile?.companyCode?.toString() || "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight">
|
||||||
|
{t("ui.dev.profile.title", "내 정보")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{t(
|
||||||
|
"ui.dev.profile.subtitle",
|
||||||
|
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-1 border-b border-border pb-px">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("basic")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === "basic"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.dev.profile.tab.basic", "기본 정보")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("role")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === "role"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.dev.profile.tab.role", "권한 및 역할")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
{activeTab === "basic" && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.basic.title", "사용자 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.id", "User ID")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
|
||||||
|
{profile.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.name", "Name")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{profile.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.email", "Email")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Briefcase className="h-4 w-4" />
|
||||||
|
{t("ui.dev.profile.basic.phone", "Phone")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{profile.phone || "-"}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.org.title", "조직 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("ui.dev.profile.org.tenant", "테넌트")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{displayTenant}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("ui.dev.profile.org.company_code", "회사 코드")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{displayCompanyCode}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ProfileTenantSwitcher />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "role" && (
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
{t("ui.dev.profile.role.title", "시스템 역할")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"ui.dev.profile.role.description",
|
||||||
|
"현재 계정에 부여된 권한 등급입니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
||||||
|
<Briefcase className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
|
||||||
|
{t("ui.dev.profile.role.current", "Current Role")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold mt-1">
|
||||||
|
{t(
|
||||||
|
`ui.common.role.${profile.role}`,
|
||||||
|
profile.role.toUpperCase(),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
`ui.dev.profile.role.desc_${profile.role}`,
|
||||||
|
"시스템 역할에 대한 설명이 제공되지 않았습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
92
orgfront/src/features/profile/ProfileTenantSwitcher.tsx
Normal file
92
orgfront/src/features/profile/ProfileTenantSwitcher.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Building2, Save } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { toast } from "../../components/ui/use-toast";
|
||||||
|
import { fetchMyTenants } from "../../lib/devApi";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
export default function ProfileTenantSwitcher() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: tenants, isLoading } = useQuery({
|
||||||
|
queryKey: ["myTenants"],
|
||||||
|
queryFn: fetchMyTenants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedTenantId, setSelectedTenantId] = useState<string>(() => {
|
||||||
|
return window.localStorage.getItem("dev_tenant_id") || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
window.localStorage.setItem("dev_tenant_id", selectedTenantId);
|
||||||
|
|
||||||
|
// Invalidate queries to refresh data with new tenant context
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] !== "userMe" && query.queryKey[0] !== "myTenants",
|
||||||
|
});
|
||||||
|
|
||||||
|
toast(t("ui.dev.tenant.switch_success", "테넌트 전환 완료"), "success");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !tenants || tenants.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's only one tenant, the user doesn't need to switch.
|
||||||
|
// Still show it as read-only or hidden. Let's just show it as disabled.
|
||||||
|
const isSingleTenant = tenants.length <= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-6 p-4 rounded-lg border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground -mt-2 mb-2">
|
||||||
|
{t(
|
||||||
|
"ui.dev.tenant.workspace_desc",
|
||||||
|
"현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
aria-label={t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
|
||||||
|
value={selectedTenantId}
|
||||||
|
onChange={(e) => setSelectedTenantId(e.target.value)}
|
||||||
|
disabled={isSingleTenant}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{tenants.map((tenant) => (
|
||||||
|
<option key={tenant.id} value={tenant.id}>
|
||||||
|
{tenant.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSingleTenant}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
{t("ui.common.save", "저장")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSingleTenant && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t(
|
||||||
|
"ui.dev.tenant.single_notice",
|
||||||
|
"단일 테넌트에 소속되어 전환할 필요가 없습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
orgfront/src/index.css
Normal file
95
orgfront/src/index.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 223 25% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 223 25% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 223 25% 12%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 17% 94%;
|
||||||
|
--secondary-foreground: 223 25% 20%;
|
||||||
|
--muted: 223 15% 45%;
|
||||||
|
--muted-foreground: 223 15% 45%;
|
||||||
|
--accent: 40 96% 62%;
|
||||||
|
--accent-foreground: 223 25% 12%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 17% 90%;
|
||||||
|
--input: 220 17% 90%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 223 25% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 223 25% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 223 25% 12%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 17% 94%;
|
||||||
|
--secondary-foreground: 223 25% 20%;
|
||||||
|
--muted: 223 15% 45%;
|
||||||
|
--muted-foreground: 223 15% 45%;
|
||||||
|
--accent: 40 96% 62%;
|
||||||
|
--accent-foreground: 223 25% 12%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 17% 90%;
|
||||||
|
--input: 220 17% 90%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 210 25% 6%;
|
||||||
|
--foreground: 210 35% 96%;
|
||||||
|
--card: 215 32% 9%;
|
||||||
|
--card-foreground: 210 35% 96%;
|
||||||
|
--popover: 215 32% 9%;
|
||||||
|
--popover-foreground: 210 35% 96%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 210 35% 96%;
|
||||||
|
--secondary: 215 25% 16%;
|
||||||
|
--secondary-foreground: 210 35% 96%;
|
||||||
|
--muted: 215 15% 65%;
|
||||||
|
--muted-foreground: 215 15% 65%;
|
||||||
|
--accent: 42 95% 57%;
|
||||||
|
--accent-foreground: 215 25% 10%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 35% 96%;
|
||||||
|
--border: 215 25% 24%;
|
||||||
|
--input: 215 25% 24%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--background)) 0%,
|
||||||
|
hsl(var(--secondary) / 0.35) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-inherit no-underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-panel {
|
||||||
|
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||||
|
}
|
||||||
|
}
|
||||||
724
orgfront/src/lib/adminApi.ts
Normal file
724
orgfront/src/lib/adminApi.ts
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
|
export type AuditLog = {
|
||||||
|
event_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogListResponse = {
|
||||||
|
items: AuditLog[];
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantSummary = {
|
||||||
|
id: string;
|
||||||
|
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
domains?: string[];
|
||||||
|
parentId?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
memberCount: number; // Added member count
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
slug?: string;
|
||||||
|
parentId?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantListResponse = {
|
||||||
|
items: TenantSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantUpdateRequest = {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
slug?: string;
|
||||||
|
parentId?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client_id: string;
|
||||||
|
scopes: string[];
|
||||||
|
status: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyListResponse = {
|
||||||
|
items: ApiKeySummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleListResponse = {
|
||||||
|
items: RoleSummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
|
params: { limit, cursor },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
||||||
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
|
"/v1/admin/tenants",
|
||||||
|
{
|
||||||
|
params: { limit, offset, parentId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenant(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(payload: TenantCreateRequest) {
|
||||||
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
|
"/v1/admin/tenants",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(
|
||||||
|
tenantId: string,
|
||||||
|
payload: TenantUpdateRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(tenantId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenantsBulk(ids: string[]) {
|
||||||
|
await apiClient.delete("/v1/admin/tenants/bulk", {
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveTenant(tenantId: string) {
|
||||||
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/approve`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantAdmin = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTenantAdmins(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/admins`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantAdmin(tenantId: string, userId: string) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantAdmin(tenantId: string, userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantOwners(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/owners`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantOwner(tenantId: string, userId: string) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group Management
|
||||||
|
export type GroupMember = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupSummary = {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
parentId?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
unitType?: string;
|
||||||
|
members?: GroupMember[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
description?: string;
|
||||||
|
unitType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchGroups(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<GroupSummary[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGroup(tenantId: string, groupId: string) {
|
||||||
|
const { data } = await apiClient.get<GroupSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(
|
||||||
|
tenantId: string,
|
||||||
|
payload: GroupCreateRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<GroupSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(tenantId: string, groupId: string) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupMember(
|
||||||
|
tenantId: string,
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await apiClient.post(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupMember(
|
||||||
|
tenantId: string,
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
totalRows: number;
|
||||||
|
processed: number;
|
||||||
|
userCreated: number;
|
||||||
|
userUpdated: number;
|
||||||
|
tenantCreated: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImportProgress(
|
||||||
|
tenantId: string,
|
||||||
|
progressId: string,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<{ current: number; total: number }>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importOrgChart(
|
||||||
|
tenantId: string,
|
||||||
|
file: File,
|
||||||
|
progressId?: string,
|
||||||
|
) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const url = progressId
|
||||||
|
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
|
||||||
|
: `/v1/admin/tenants/${tenantId}/organization/import`;
|
||||||
|
|
||||||
|
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupRole = {
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
relation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchGroupRoles(tenantId: string, groupId: string) {
|
||||||
|
const { data } = await apiClient.get<GroupRole[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignGroupRole(
|
||||||
|
tenantId: string,
|
||||||
|
groupId: string,
|
||||||
|
targetTenantId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.post(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
|
||||||
|
{ tenantId: targetTenantId, relation },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupRole(
|
||||||
|
tenantId: string,
|
||||||
|
groupId: string,
|
||||||
|
targetTenantId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key Management (M2M)
|
||||||
|
export type ApiKeyCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyCreateResponse = {
|
||||||
|
apiKey: ApiKeySummary;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
{
|
||||||
|
params: { limit, offset },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||||
|
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
export type UserSummary = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
tenant?: TenantSummary;
|
||||||
|
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
department?: string;
|
||||||
|
position?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserListResponse = {
|
||||||
|
items: UserSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCreateRequest = {
|
||||||
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
|
password?: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
department?: string;
|
||||||
|
position?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCreateResponse = UserSummary & {
|
||||||
|
initialPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserUpdateRequest = {
|
||||||
|
loginId?: string;
|
||||||
|
password?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
department?: string;
|
||||||
|
position?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkUserItem = {
|
||||||
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
department?: string;
|
||||||
|
position?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkUserResult = {
|
||||||
|
email: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkUserResponse = {
|
||||||
|
results: BulkUserResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchUsers(
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
search?: string,
|
||||||
|
tenantSlug?: string,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||||
|
params: { limit, offset, search, tenantSlug },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUser(userId: string) {
|
||||||
|
const { data } = await apiClient.get<UserSummary>(
|
||||||
|
`/v1/admin/users/${userId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload: UserCreateRequest) {
|
||||||
|
// Map tenantSlug to companyCode for backend compatibility
|
||||||
|
const requestPayload: UserCreateRequest & { companyCode?: string } = {
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
if (payload.tenantSlug !== undefined) {
|
||||||
|
requestPayload.companyCode = payload.tenantSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await apiClient.post<UserCreateResponse>(
|
||||||
|
"/v1/admin/users",
|
||||||
|
requestPayload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.append("search", search);
|
||||||
|
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
||||||
|
|
||||||
|
// Get mock role from storage if exists for dev environment
|
||||||
|
const isMockRoleEnabled =
|
||||||
|
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||||
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
|
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
||||||
|
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||||
|
const mappedUsers = users.map((u) => {
|
||||||
|
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
|
||||||
|
if (u.tenantSlug !== undefined) {
|
||||||
|
mapped.companyCode = u.tenantSlug;
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
const { data } = await apiClient.post<BulkUserResponse>(
|
||||||
|
"/v1/admin/users/bulk",
|
||||||
|
{ users: mappedUsers },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkUpdateUsers(payload: {
|
||||||
|
userIds: string[];
|
||||||
|
status?: string;
|
||||||
|
role?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
department?: string;
|
||||||
|
}) {
|
||||||
|
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
if (payload.tenantSlug !== undefined) {
|
||||||
|
requestPayload.companyCode = payload.tenantSlug;
|
||||||
|
}
|
||||||
|
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteUsers(userIds: string[]) {
|
||||||
|
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
|
||||||
|
data: { userIds },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||||
|
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
if (payload.tenantSlug !== undefined) {
|
||||||
|
requestPayload.companyCode = payload.tenantSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await apiClient.put<UserSummary>(
|
||||||
|
`/v1/admin/users/${userId}`,
|
||||||
|
requestPayload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordPolicyResponse = {
|
||||||
|
minLength?: number;
|
||||||
|
lowercase?: boolean;
|
||||||
|
uppercase?: boolean;
|
||||||
|
number?: boolean;
|
||||||
|
nonAlphanumeric?: boolean;
|
||||||
|
minCharacterTypes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPasswordPolicy() {
|
||||||
|
const { data } = await apiClient.get<PasswordPolicyResponse>(
|
||||||
|
"/v1/auth/password/policy",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRpHistoryItem = {
|
||||||
|
client_id: string;
|
||||||
|
client_name: string;
|
||||||
|
lastLoginAt: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchUserRpHistory(userId: string) {
|
||||||
|
const { data } = await apiClient.get<UserRpHistoryItem[]>(
|
||||||
|
`/v1/admin/users/${userId}/rp-history`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserProfileResponse = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
department: string;
|
||||||
|
affiliationType: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
tenant?: TenantSummary;
|
||||||
|
manageableTenants?: TenantSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMe() {
|
||||||
|
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relying Party Management
|
||||||
|
export type RelyingParty = {
|
||||||
|
clientId: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydraClientReq = {
|
||||||
|
client_id?: string;
|
||||||
|
client_name: string;
|
||||||
|
client_secret?: string;
|
||||||
|
redirect_uris: string[];
|
||||||
|
scope?: string;
|
||||||
|
token_endpoint_auth_method?: string;
|
||||||
|
grant_types?: string[];
|
||||||
|
response_types?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRelyingParties(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllRelyingParties() {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
"/v1/admin/relying-parties",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRelyingParty(
|
||||||
|
tenantId: string,
|
||||||
|
payload: HydraClientReq,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<RelyingParty>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelyingParty(id: string) {
|
||||||
|
const { data } = await apiClient.get<{
|
||||||
|
relyingParty: RelyingParty;
|
||||||
|
oauth2Config: HydraClientReq;
|
||||||
|
}>(`/v1/admin/relying-parties/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||||
|
const { data } = await apiClient.put<RelyingParty>(
|
||||||
|
`/v1/admin/relying-parties/${id}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRelyingParty(id: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPOwner = {
|
||||||
|
subject: string;
|
||||||
|
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRPOwners(clientId: string) {
|
||||||
|
const { data } = await apiClient.get<RPOwner[]>(
|
||||||
|
`/v1/admin/relying-parties/${clientId}/owners`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRPOwner(clientId: string, subject: string) {
|
||||||
|
await apiClient.post(
|
||||||
|
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRPOwner(clientId: string, subject: string) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicOrgChart(token: string) {
|
||||||
|
const { data } = await apiClient.get<{
|
||||||
|
tenants: TenantSummary[];
|
||||||
|
users: UserSummary[];
|
||||||
|
sharedWith: string;
|
||||||
|
}>("/v1/public/orgchart", {
|
||||||
|
params: { token },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
52
orgfront/src/lib/apiClient.ts
Normal file
52
orgfront/src/lib/apiClient.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL:
|
||||||
|
import.meta.env.VITE_DEV_API_BASE ??
|
||||||
|
import.meta.env.VITE_ADMIN_API_BASE ??
|
||||||
|
"/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
|
// OIDC Access Token 주입
|
||||||
|
const user = await userManager.getUser();
|
||||||
|
if (user?.access_token) {
|
||||||
|
config.headers.Authorization = `Bearer ${user.access_token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||||
|
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
|
||||||
|
if (tenantId) {
|
||||||
|
config.headers["X-Tenant-ID"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message =
|
||||||
|
error.response?.data?.error?.toString().toLowerCase() ??
|
||||||
|
error.response?.data?.message?.toString().toLowerCase() ??
|
||||||
|
"";
|
||||||
|
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
||||||
|
const isLoginPath = window.location.pathname === "/login";
|
||||||
|
const shouldRedirectToLogin =
|
||||||
|
status === 401 ||
|
||||||
|
(status === 403 &&
|
||||||
|
(message.includes("authentication required") ||
|
||||||
|
message.includes("invalid session") ||
|
||||||
|
message.includes("token is not active")));
|
||||||
|
|
||||||
|
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
|
||||||
|
await userManager.removeUser();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
22
orgfront/src/lib/auth.ts
Normal file
22
orgfront/src/lib/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
|
|
||||||
|
export const oidcConfig: AuthProviderProps = {
|
||||||
|
authority:
|
||||||
|
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||||
|
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||||
|
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||||
|
post_logout_redirect_uri: window.location.origin,
|
||||||
|
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
automaticSilentRenew: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userManager = new UserManager({
|
||||||
|
...oidcConfig,
|
||||||
|
authority: oidcConfig.authority || "",
|
||||||
|
client_id: oidcConfig.client_id || "",
|
||||||
|
redirect_uri: oidcConfig.redirect_uri || "",
|
||||||
|
});
|
||||||
315
orgfront/src/lib/devApi.ts
Normal file
315
orgfront/src/lib/devApi.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
|
export type ClientStatus = "active" | "inactive";
|
||||||
|
export type ClientType = "private" | "pkce";
|
||||||
|
|
||||||
|
export type ClientSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ClientType;
|
||||||
|
status: ClientStatus;
|
||||||
|
createdAt?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
tokenEndpointAuthMethod?: string;
|
||||||
|
jwksUri?: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
scopes: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientListResponse = {
|
||||||
|
items: ClientSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevStats = {
|
||||||
|
total_clients: number;
|
||||||
|
active_sessions: number;
|
||||||
|
auth_failures_24h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevAuditLog = {
|
||||||
|
event_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevAuditLogListResponse = {
|
||||||
|
items: DevAuditLog[];
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientEndpoints = {
|
||||||
|
discovery: string;
|
||||||
|
issuer: string;
|
||||||
|
authorization: string;
|
||||||
|
token: string;
|
||||||
|
userinfo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientDetailResponse = {
|
||||||
|
client: ClientSummary & {
|
||||||
|
clientSecret?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
endpoints: ClientEndpoints;
|
||||||
|
headlessJwksCache?: {
|
||||||
|
clientId: string;
|
||||||
|
jwksUri: string;
|
||||||
|
cachedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
lastCheckedAt?: string;
|
||||||
|
lastSuccessfulVerificationAt?: string;
|
||||||
|
lastRefreshStatus?: "success" | "failure" | "pending";
|
||||||
|
lastError?: string;
|
||||||
|
consecutiveFailures?: number;
|
||||||
|
cachedKids?: string[];
|
||||||
|
etag?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
parsedKeys?: Array<{
|
||||||
|
kid?: string;
|
||||||
|
kty?: string;
|
||||||
|
use?: string;
|
||||||
|
alg?: string;
|
||||||
|
n?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientUpsertRequest = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: ClientType;
|
||||||
|
status?: ClientStatus;
|
||||||
|
redirectUris?: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
grantTypes?: string[];
|
||||||
|
responseTypes?: string[];
|
||||||
|
tokenEndpointAuthMethod?: string;
|
||||||
|
jwksUri?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsentSummary = {
|
||||||
|
subject: string;
|
||||||
|
userName?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName?: string;
|
||||||
|
grantedScopes: string[];
|
||||||
|
authenticatedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
status: "active" | "revoked";
|
||||||
|
tenantId?: string;
|
||||||
|
tenantName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsentListResponse = {
|
||||||
|
items: ConsentSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Federation / IdP Config Types ---
|
||||||
|
export type ProviderType = "oidc" | "saml";
|
||||||
|
|
||||||
|
export type IdpConfig = {
|
||||||
|
id: string;
|
||||||
|
client_id: string; // Changed from tenant_id
|
||||||
|
provider_type: ProviderType;
|
||||||
|
display_name: string;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
issuer_url?: string;
|
||||||
|
// OIDC specific fields
|
||||||
|
oidc_client_id?: string;
|
||||||
|
oidc_client_secret?: string;
|
||||||
|
scopes?: string;
|
||||||
|
// SAML specific fields
|
||||||
|
metadata_url?: string;
|
||||||
|
metadata_xml?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
acs_url?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdpConfigCreateRequest = Omit<
|
||||||
|
IdpConfig,
|
||||||
|
"id" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
|
||||||
|
// --- End Federation Types ---
|
||||||
|
|
||||||
|
export async function fetchClients() {
|
||||||
|
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDevStats() {
|
||||||
|
const { data } = await apiClient.get<DevStats>("/dev/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClient(clientId: string) {
|
||||||
|
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||||
|
`/dev/clients/${clientId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClientStatus(
|
||||||
|
clientId: string,
|
||||||
|
status: ClientStatus,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.patch<ClientDetailResponse>(
|
||||||
|
`/dev/clients/${clientId}/status`,
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClient(payload: ClientUpsertRequest) {
|
||||||
|
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||||
|
"/dev/clients",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClient(
|
||||||
|
clientId: string,
|
||||||
|
payload: ClientUpsertRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<ClientDetailResponse>(
|
||||||
|
`/dev/clients/${clientId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateClientSecret(clientId: string) {
|
||||||
|
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||||
|
`/dev/clients/${clientId}/secret/rotate`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshHeadlessJwksCache(clientId: string) {
|
||||||
|
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||||
|
`/dev/clients/${clientId}/headless-jwks/refresh`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeHeadlessJwksCache(clientId: string) {
|
||||||
|
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClient(clientId: string) {
|
||||||
|
await apiClient.delete(`/dev/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConsents(
|
||||||
|
subject: string,
|
||||||
|
clientId?: string,
|
||||||
|
status?: string,
|
||||||
|
) {
|
||||||
|
const params: Record<string, string> = { subject };
|
||||||
|
if (clientId) {
|
||||||
|
params.client_id = clientId;
|
||||||
|
}
|
||||||
|
if (status && status !== "all") {
|
||||||
|
params.status = status;
|
||||||
|
}
|
||||||
|
const { data } = await apiClient.get<ConsentListResponse>("/dev/consents", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeConsent(subject: string, clientId?: string) {
|
||||||
|
const params: Record<string, string> = { subject };
|
||||||
|
if (clientId) {
|
||||||
|
params.client_id = clientId;
|
||||||
|
}
|
||||||
|
await apiClient.delete("/dev/consents", { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Federation / IdP Config API Calls ---
|
||||||
|
|
||||||
|
export async function listIdpConfigsForClient(clientId: string) {
|
||||||
|
const { data } = await apiClient.get<IdpConfig[]>(
|
||||||
|
`/dev/clients/${clientId}/idps`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIdpConfigForClient(
|
||||||
|
payload: IdpConfigCreateRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<IdpConfig>(
|
||||||
|
`/dev/clients/${payload.client_id}/idps`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIdpConfig(
|
||||||
|
clientId: string,
|
||||||
|
idpId: string,
|
||||||
|
payload: IdpConfigUpdateRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<IdpConfig>(
|
||||||
|
`/dev/clients/${clientId}/idps/${idpId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIdpConfig(clientId: string, idpId: string) {
|
||||||
|
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDevAuditLogs(
|
||||||
|
limit = 50,
|
||||||
|
cursor?: string,
|
||||||
|
filters?: {
|
||||||
|
action?: string;
|
||||||
|
client_id?: string;
|
||||||
|
status?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<DevAuditLogListResponse>(
|
||||||
|
"/dev/audit-logs",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
action: filters?.action,
|
||||||
|
client_id: filters?.client_id,
|
||||||
|
status: filters?.status,
|
||||||
|
tenant_id: filters?.tenant_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMyTenants() {
|
||||||
|
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
148
orgfront/src/lib/i18n.ts
Normal file
148
orgfront/src/lib/i18n.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
const LOCALE_STORAGE_KEY = "locale";
|
||||||
|
const DEFAULT_LOCALE = "ko";
|
||||||
|
const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||||
|
|
||||||
|
type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
type TomlValue = string | TomlObject;
|
||||||
|
|
||||||
|
interface TomlObject {
|
||||||
|
[key: string]: TomlValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(value: string): value is Locale {
|
||||||
|
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToml(raw: string): TomlObject {
|
||||||
|
const lines = raw.split(/\r?\n/);
|
||||||
|
const root: TomlObject = {};
|
||||||
|
let currentPath: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("[") && line.endsWith("]")) {
|
||||||
|
const sectionName = line.slice(1, -1).trim();
|
||||||
|
currentPath = sectionName
|
||||||
|
? sectionName
|
||||||
|
.split(".")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqIndex = line.indexOf("=");
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, eqIndex).trim();
|
||||||
|
const valueRaw = line.slice(eqIndex + 1).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = valueRaw;
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor: TomlObject = root;
|
||||||
|
for (const section of currentPath) {
|
||||||
|
if (!cursor[section] || typeof cursor[section] === "string") {
|
||||||
|
cursor[section] = {};
|
||||||
|
}
|
||||||
|
cursor = cursor[section] as TomlObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValue(target: TomlObject, key: string): string | undefined {
|
||||||
|
const parts = key.split(".");
|
||||||
|
let cursor: TomlValue = target;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof cursor !== "object" || cursor === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
cursor = (cursor as TomlObject)[part];
|
||||||
|
if (cursor === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof cursor === "string" ? cursor : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored && isSupportedLocale(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathLocale = window.location.pathname.split("/")[1];
|
||||||
|
if (pathLocale && isSupportedLocale(pathLocale)) {
|
||||||
|
return pathLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserLang = window.navigator.language.toLowerCase();
|
||||||
|
if (browserLang.startsWith("ko")) {
|
||||||
|
return "ko";
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import enRaw from "../locales/en.toml?raw";
|
||||||
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
|
const translations: Record<Locale, TomlObject> = {
|
||||||
|
ko: parseToml(koRaw),
|
||||||
|
en: parseToml(enRaw),
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTemplate(
|
||||||
|
template: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
if (!vars) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||||
|
const value = vars[key];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(
|
||||||
|
key: string,
|
||||||
|
fallback?: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const locale = detectLocale();
|
||||||
|
const value = getValue(translations[locale], key);
|
||||||
|
if (value && value.length > 0) {
|
||||||
|
return formatTemplate(value, vars);
|
||||||
|
}
|
||||||
|
return formatTemplate(fallback ?? key, vars);
|
||||||
|
}
|
||||||
27
orgfront/src/lib/role.ts
Normal file
27
orgfront/src/lib/role.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function normalizeRole(rawRole: unknown): string {
|
||||||
|
if (typeof rawRole !== "string") return "";
|
||||||
|
const role = rawRole.trim().toLowerCase();
|
||||||
|
if (role === "tenant_member") return "user";
|
||||||
|
if (role === "admin") return "tenant_admin";
|
||||||
|
if (role === "superadmin") return "super_admin";
|
||||||
|
if (role === "tenantadmin") return "tenant_admin";
|
||||||
|
if (role === "rpadmin") return "rp_admin";
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileRole(
|
||||||
|
profile: Record<string, unknown> | undefined,
|
||||||
|
) {
|
||||||
|
if (!profile) return "";
|
||||||
|
const candidates = [
|
||||||
|
profile.role,
|
||||||
|
profile.grade,
|
||||||
|
profile["custom:role"],
|
||||||
|
profile["custom:grade"],
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeRole(candidate);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
76
orgfront/src/lib/sessionSliding.ts
Normal file
76
orgfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
|
||||||
|
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||||
|
|
||||||
|
type SlidingSessionRenewDecisionParams = {
|
||||||
|
expiresAtSec?: number | null;
|
||||||
|
nowMs: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isRenewInFlight: boolean;
|
||||||
|
lastAttemptAtMs: number;
|
||||||
|
thresholdMs?: number;
|
||||||
|
throttleMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldAttemptSlidingSessionRenew({
|
||||||
|
expiresAtSec,
|
||||||
|
nowMs,
|
||||||
|
isEnabled,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
isRenewInFlight,
|
||||||
|
lastAttemptAtMs,
|
||||||
|
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||||
|
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||||
|
}: SlidingSessionRenewDecisionParams) {
|
||||||
|
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expiresAtSec !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||||
|
if (remainingMs <= 0 || remainingMs > thresholdMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAttemptUnlimitedSessionRenew({
|
||||||
|
expiresAtSec,
|
||||||
|
nowMs,
|
||||||
|
isEnabled,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
isRenewInFlight,
|
||||||
|
lastAttemptAtMs,
|
||||||
|
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
|
||||||
|
throttleMs = SESSION_RENEW_THROTTLE_MS,
|
||||||
|
}: SlidingSessionRenewDecisionParams) {
|
||||||
|
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expiresAtSec !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||||
|
if (remainingMs <= 0 || remainingMs > thresholdMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
86
orgfront/src/lib/tenantTree.ts
Normal file
86
orgfront/src/lib/tenantTree.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { TenantSummary } from "./adminApi";
|
||||||
|
|
||||||
|
export type TenantNode = TenantSummary & {
|
||||||
|
children: TenantNode[];
|
||||||
|
recursiveMemberCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a hierarchical tree from a flat list of tenants and calculates
|
||||||
|
* direct and recursive member counts for each node.
|
||||||
|
*/
|
||||||
|
export function buildTenantFullTree(
|
||||||
|
allTenants: TenantSummary[],
|
||||||
|
rootId?: string,
|
||||||
|
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||||
|
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||||
|
|
||||||
|
const tenantMap = new Map<string, TenantNode>();
|
||||||
|
for (const t of allTenants) {
|
||||||
|
tenantMap.set(t.id, {
|
||||||
|
...t,
|
||||||
|
children: [],
|
||||||
|
recursiveMemberCount: t.memberCount || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build initial children relations and prevent simple cycles
|
||||||
|
for (const t of allTenants) {
|
||||||
|
if (t.parentId && t.parentId !== t.id) {
|
||||||
|
const parent = tenantMap.get(t.parentId);
|
||||||
|
const child = tenantMap.get(t.id);
|
||||||
|
if (parent && child) {
|
||||||
|
// Simple cycle prevention during build: don't add if it creates an immediate loop
|
||||||
|
parent.children.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitedForCalc = new Set<string>();
|
||||||
|
// Function to calculate recursive counts with cycle protection
|
||||||
|
const calculateRecursive = (node: TenantNode): number => {
|
||||||
|
if (visitedForCalc.has(node.id)) {
|
||||||
|
console.warn(
|
||||||
|
`Circular dependency detected in tenant tree for ID: ${node.id}`,
|
||||||
|
);
|
||||||
|
return 0; // Prevent infinite loop
|
||||||
|
}
|
||||||
|
visitedForCalc.add(node.id);
|
||||||
|
|
||||||
|
let total = node.memberCount || 0;
|
||||||
|
for (const child of node.children) {
|
||||||
|
total += calculateRecursive(child);
|
||||||
|
}
|
||||||
|
node.recursiveMemberCount = total;
|
||||||
|
|
||||||
|
// We don't remove from visitedForCalc here because a tree shouldn't have
|
||||||
|
// multiple paths to the same node anyway (it's a tree, not a graph).
|
||||||
|
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
||||||
|
// a node should only be visited once.
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate for all top-level nodes (those without parent)
|
||||||
|
for (const node of tenantMap.values()) {
|
||||||
|
if (!node.parentId) {
|
||||||
|
visitedForCalc.clear();
|
||||||
|
calculateRecursive(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific rootId is provided, find and return its subtree
|
||||||
|
if (rootId) {
|
||||||
|
const base = tenantMap.get(rootId);
|
||||||
|
if (base) {
|
||||||
|
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||||
|
visitedForCalc.clear();
|
||||||
|
calculateRecursive(base);
|
||||||
|
return { currentBase: base, subTree: base.children };
|
||||||
|
}
|
||||||
|
return { currentBase: null, subTree: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no rootId, return all top-level roots as subTree
|
||||||
|
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||||
|
return { currentBase: null, subTree: roots };
|
||||||
|
}
|
||||||
6
orgfront/src/lib/utils.ts
Normal file
6
orgfront/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
1793
orgfront/src/locales/en.toml
Normal file
1793
orgfront/src/locales/en.toml
Normal file
File diff suppressed because it is too large
Load Diff
1789
orgfront/src/locales/ko.toml
Normal file
1789
orgfront/src/locales/ko.toml
Normal file
File diff suppressed because it is too large
Load Diff
1778
orgfront/src/locales/template.toml
Normal file
1778
orgfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
27
orgfront/src/main.tsx
Normal file
27
orgfront/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { AuthProvider } from "react-oidc-context";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { queryClient } from "./app/queryClient";
|
||||||
|
import { router } from "./app/routes";
|
||||||
|
import { Toaster } from "./components/ui/toaster";
|
||||||
|
import "./index.css";
|
||||||
|
import { oidcConfig, userManager } from "./lib/auth";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider {...oidcConfig} userManager={userManager}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
67
orgfront/tailwind.config.ts
Normal file
67
orgfront/tailwind.config.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1.5rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Space Grotesk", "Pretendard Variable", ...fontFamily.sans],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 12px 40px rgba(7, 15, 26, 0.25)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
39
orgfront/tests/clients.spec.ts
Normal file
39
orgfront/tests/clients.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
type Consent,
|
||||||
|
installDevApiMock,
|
||||||
|
makeClient,
|
||||||
|
seedAuth,
|
||||||
|
} from "./helpers/devfront-fixtures";
|
||||||
|
|
||||||
|
test("clients page loads correctly", async ({ page }) => {
|
||||||
|
await seedAuth(page);
|
||||||
|
await installDevApiMock(page, {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-playwright", {
|
||||||
|
name: "Playwright Client",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
redirectUris: ["http://localhost:5174/callback"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/clients");
|
||||||
|
await expect(page).toHaveURL(/\/clients$/);
|
||||||
|
|
||||||
|
// 타이틀 확인
|
||||||
|
await expect(page).toHaveTitle(/바론 개발자 서비스/);
|
||||||
|
|
||||||
|
// 페이지 내 주요 텍스트 확인
|
||||||
|
await expect(page.getByText("연동 앱 목록")).toBeVisible();
|
||||||
|
|
||||||
|
// 테이블 헤더 확인
|
||||||
|
await expect(
|
||||||
|
page.locator("th").filter({ hasText: "애플리케이션" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
120
orgfront/tests/devfront-audit.spec.ts
Normal file
120
orgfront/tests/devfront-audit.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
type AuditLog,
|
||||||
|
type Consent,
|
||||||
|
installDevApiMock,
|
||||||
|
makeClient,
|
||||||
|
seedAuth,
|
||||||
|
} from "./helpers/devfront-fixtures";
|
||||||
|
|
||||||
|
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||||
|
|
||||||
|
test.describe("DevFront audit logs", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
await dialog.accept().catch(() => {});
|
||||||
|
});
|
||||||
|
await seedAuth(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filtering and cursor pagination", async ({ page }) => {
|
||||||
|
const state = {
|
||||||
|
clients: [makeClient("client-audit", { name: "Audit app" })],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: {
|
||||||
|
"": {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event_id: "evt-1",
|
||||||
|
timestamp: "2026-03-03T09:00:00.000Z",
|
||||||
|
user_id: "actor-a",
|
||||||
|
event_type: "CLIENT_UPDATE",
|
||||||
|
status: "success",
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "playwright",
|
||||||
|
details: JSON.stringify({
|
||||||
|
action: "UPDATE_CLIENT",
|
||||||
|
target_id: "client-audit",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next_cursor: "cursor-2",
|
||||||
|
},
|
||||||
|
"cursor-2": {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event_id: "evt-2",
|
||||||
|
timestamp: "2026-03-03T09:01:00.000Z",
|
||||||
|
user_id: "actor-b",
|
||||||
|
event_type: "CLIENT_ROTATE_SECRET",
|
||||||
|
status: "success",
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "playwright",
|
||||||
|
details: JSON.stringify({
|
||||||
|
action: "ROTATE_SECRET",
|
||||||
|
target_id: "client-audit",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/audit-logs");
|
||||||
|
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/Client ID로 필터|Filter by Client ID/i)
|
||||||
|
.fill("client-audit");
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/액션으로 필터|Filter by Action/i)
|
||||||
|
.fill("ROTATE_SECRET");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /더 보기|Load more/i }).click();
|
||||||
|
await expect(page.getByText("ROTATE_SECRET")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("realtime create/update actions should be recorded", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [makeClient("client-realtime", { name: "Realtime app" })],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogs: [] as AuditLog[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/new");
|
||||||
|
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime New App");
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
|
||||||
|
.fill("https://realtime.example.com/callback");
|
||||||
|
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
||||||
|
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
await expect.poll(() => state.clients.at(-1)?.id).toMatch(/^client-/);
|
||||||
|
const createdClientId = state.clients.at(-1)?.id;
|
||||||
|
expect(createdClientId).toBeTruthy();
|
||||||
|
|
||||||
|
await page.goto(`/clients/${createdClientId}/settings`);
|
||||||
|
await page.getByPlaceholder(appNamePlaceholder).fill("Realtime Updated");
|
||||||
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
await expect.poll(() => state.auditLogs.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const actions = state.auditLogs
|
||||||
|
.map((item) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(item.details)?.action as string | undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
|
expect(actions).toContain("CREATE_CLIENT");
|
||||||
|
expect(actions).toContain("UPDATE_CLIENT");
|
||||||
|
});
|
||||||
|
});
|
||||||
374
orgfront/tests/devfront-clients-lifecycle.spec.ts
Normal file
374
orgfront/tests/devfront-clients-lifecycle.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
type ClientStatus,
|
||||||
|
type Consent,
|
||||||
|
installDevApiMock,
|
||||||
|
makeClient,
|
||||||
|
seedAuth,
|
||||||
|
} from "./helpers/devfront-fixtures";
|
||||||
|
|
||||||
|
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||||
|
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
|
||||||
|
|
||||||
|
test.describe("DevFront clients lifecycle", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await seedAuth(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create, update status, and delete", async ({ page }) => {
|
||||||
|
const state = {
|
||||||
|
clients: [makeClient("existing-client", { name: "Existing app" })],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
updatedStatus: "active" as ClientStatus,
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
onUpdateStatus(status: ClientStatus) {
|
||||||
|
this.updatedStatus = status;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients");
|
||||||
|
await expect(page.getByText("Existing app")).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/clients\/new$/);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(appNamePlaceholder)
|
||||||
|
.fill("Playwright Created App");
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/app\.example\.com\/callback/i)
|
||||||
|
.fill("https://playwright.example.com/callback");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /앱 생성|클라이언트 생성|Create/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", {
|
||||||
|
name: /연동 앱 설정|클라이언트 설정|Client Settings/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /비활성|Inactive/i }).click();
|
||||||
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
await expect.poll(() => state.updatedStatus).toBe("inactive");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /삭제|Delete/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/clients$/);
|
||||||
|
await expect(page.getByText("Playwright Created App")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rotate secret shows new value", async ({ page }) => {
|
||||||
|
let rotatedSecret = "";
|
||||||
|
const state = {
|
||||||
|
clients: [makeClient("client-rotate", { name: "Rotate app" })],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
onRotateSecret(newSecret: string) {
|
||||||
|
rotatedSecret = newSecret;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-rotate");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Rotate app", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTitle(/비밀키 재발급|Rotate/i).click();
|
||||||
|
await expect.poll(() => rotatedSecret).toBe("client-rotate-rotated-secret");
|
||||||
|
await expect(page.getByText("client-rotate-rotated-secret")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update name and redirect URI should be persisted", async ({ page }) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-edit", {
|
||||||
|
name: "Before Name",
|
||||||
|
redirectUris: ["https://before.example.com/callback"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-edit/settings");
|
||||||
|
await page.getByPlaceholder(appNamePlaceholder).fill("After Name");
|
||||||
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
await expect.poll(() => state.clients[0]?.name).toBe("After Name");
|
||||||
|
|
||||||
|
await page.goto("/clients/client-edit");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: /인증 콜백 URL|Callback/i })
|
||||||
|
.fill("https://after.example.com/callback");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /Redirect URIs 저장|Save/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.redirectUris[0])
|
||||||
|
.toBe("https://after.example.com/callback");
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }),
|
||||||
|
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pkce headless login uses jwks uri only and shows cache actions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-headless-login", {
|
||||||
|
name: "Headless Login App",
|
||||||
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
request_object_signing_alg: "RS256",
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-headless-login",
|
||||||
|
jwksUri,
|
||||||
|
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastError: "",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
cachedKids: ["kid-1"],
|
||||||
|
etag: 'W/"cache-etag"',
|
||||||
|
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
|
||||||
|
parsedKeys: [
|
||||||
|
{
|
||||||
|
kid: "kid-1",
|
||||||
|
kty: "RSA",
|
||||||
|
use: "sig",
|
||||||
|
alg: "RS256",
|
||||||
|
n: "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
onRefreshHeadlessJwks(clientId: string) {
|
||||||
|
if (this.clients[0].headlessJwksCache) {
|
||||||
|
this.clients[0].headlessJwksCache = {
|
||||||
|
...this.clients[0].headlessJwksCache,
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastCheckedAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
expect(clientId).toBe("client-headless-login");
|
||||||
|
},
|
||||||
|
onRevokeHeadlessJwksCache(clientId: string) {
|
||||||
|
expect(clientId).toBe("client-headless-login");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-headless-login/settings");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("switch", {
|
||||||
|
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", {
|
||||||
|
name: /공개키 등록|Public Key Registration/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Request Object Signing Algorithm/i),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Allowed algorithms|허용 알고리즘/i),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||||
|
.fill(jwksUri);
|
||||||
|
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
||||||
|
.toBe("none");
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
||||||
|
.toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
|
||||||
|
)
|
||||||
|
.toBe("private_key_jwt");
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
|
||||||
|
.toBe(jwksUri);
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.metadata?.request_object_signing_alg)
|
||||||
|
.toBeUndefined();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/^KID$/i)).toBeVisible();
|
||||||
|
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
"voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ",
|
||||||
|
{ exact: true },
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /refresh|새로고침/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
|
||||||
|
.toBe("2026-04-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
page.removeAllListeners("dialog");
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => state.clients[0]?.headlessJwksCache)
|
||||||
|
.toBeUndefined();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", {
|
||||||
|
name: /공개키 등록|Public Key Registration/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i }),
|
||||||
|
).toHaveValue(jwksUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pkce headless login blocks save when parsed jwks algorithm is unsupported", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-headless-unsupported", {
|
||||||
|
name: "Unsupported Headless Login App",
|
||||||
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
request_object_signing_alg: "RS256",
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-headless-unsupported",
|
||||||
|
jwksUri,
|
||||||
|
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastError: "",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
cachedKids: ["kid-unsupported"],
|
||||||
|
parsedKeys: [
|
||||||
|
{
|
||||||
|
kid: "kid-unsupported",
|
||||||
|
kty: "RSA",
|
||||||
|
use: "sig",
|
||||||
|
alg: "HS256",
|
||||||
|
n: "unsupported-n-value",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-headless-unsupported/settings");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||||
|
.fill(jwksUri);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", {
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pkce headless login blocks save when parsed jwks algorithm is missing", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = {
|
||||||
|
clients: [
|
||||||
|
makeClient("client-headless-missing-alg", {
|
||||||
|
name: "Missing Alg Headless Login App",
|
||||||
|
type: "pkce",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
headless_jwks_uri: jwksUri,
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-headless-missing-alg",
|
||||||
|
jwksUri,
|
||||||
|
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||||
|
lastRefreshStatus: "success",
|
||||||
|
lastError: "",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
cachedKids: ["kid-missing-alg"],
|
||||||
|
parsedKeys: [
|
||||||
|
{
|
||||||
|
kid: "kid-missing-alg",
|
||||||
|
kty: "RSA",
|
||||||
|
use: "sig",
|
||||||
|
alg: "",
|
||||||
|
n: "missing-alg-n-value",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.goto("/clients/client-headless-missing-alg/settings");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /^저장$|^Save$/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user