첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
24
baron-sso/devfront/.gitignore
vendored
Normal file
24
baron-sso/devfront/.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?
|
||||
38
baron-sso/devfront/Dockerfile
Normal file
38
baron-sso/devfront/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
ENV CI=true
|
||||
ENV DEVFRONT_BUILD_OUT_DIR=/workspace/devfront/dist
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY devfront ./devfront
|
||||
|
||||
ARG VITE_DEVFRONT_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /workspace/devfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5173
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/devfront/dist ./dist
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
29
baron-sso/devfront/README.md
Normal file
29
baron-sso/devfront/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Dev Front (React 19 + Vite)
|
||||
|
||||
RP 등록 현황과 Consent 관리를 담당하는 개발자 포털용 React/Vite 기반 SPA입니다. adminfront와 동일한 스택으로 구성하고, Ory Hydra Admin API 연동을 위한 훅 포인트를 남겨두었습니다.
|
||||
|
||||
## 주요 스택
|
||||
- React 19, Vite 8, TypeScript(strict)
|
||||
- React Router v6 (data router)
|
||||
- TanStack Query v5
|
||||
- Tailwind CSS v3 + shadcn/ui 컴포넌트(seed: Button/Card/Badge/Input/Table/Avatar)
|
||||
- Axios 클라이언트 스텁: Bearer + `X-Tenant-ID` 헤더 주입 준비
|
||||
- React Hook Form + Zod (추가 예정)
|
||||
- Biome (formatter/linter)
|
||||
|
||||
## 실행
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 구조
|
||||
- `src/app`: 라우터, QueryClient 등 전역 설정
|
||||
- `src/components/layout`: App 레이아웃/네비게이션
|
||||
- `src/features`: dashboard, clients, audit, auth 등 화면 스캐폴딩
|
||||
- `src/lib/apiClient.ts`: Axios 인스턴스(토큰/테넌트 헤더 주입 스텁)
|
||||
|
||||
## 다음 작업 가이드
|
||||
- Devfront 전용 인증/권한 가드 추가 (RP 관리 권한 검증)
|
||||
- 테넌트 선택 UI 추가 → `X-Tenant-ID` 헤더에 반영
|
||||
- Hydra Admin API 기반 RP/Consent 실데이터 연동
|
||||
7
baron-sso/devfront/biome.json
Normal file
7
baron-sso/devfront/biome.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 802 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 810 KiB |
188
baron-sso/devfront/hydra-rp-dummy.py
Normal file
188
baron-sso/devfront/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
baron-sso/devfront/index.html
Normal file
13
baron-sso/devfront/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>
|
||||
5455
baron-sso/devfront/package-lock.json
generated
Normal file
5455
baron-sso/devfront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
baron-sso/devfront/package.json
Normal file
60
baron-sso/devfront/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "devfront",
|
||||
"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:coverage": "vitest run --coverage --bail 1",
|
||||
"test:unit": "vitest run --bail 1",
|
||||
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"axios": "^1.16.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
81
baron-sso/devfront/playwright.config.ts
Normal file
81
baron-sso/devfront/playwright.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { shouldIncludeWebKit } =
|
||||
require("../scripts/playwrightHostDeps.cjs") as {
|
||||
shouldIncludeWebKit: () => boolean;
|
||||
};
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const skipWebServer =
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
|
||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
/* 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"] },
|
||||
},
|
||||
|
||||
...(shouldIncludeWebKit()
|
||||
? [
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: skipWebServer
|
||||
? undefined
|
||||
: {
|
||||
command:
|
||||
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174",
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
});
|
||||
3474
baron-sso/devfront/pnpm-lock.yaml
generated
Normal file
3474
baron-sso/devfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
baron-sso/devfront/postcss.config.js
Normal file
6
baron-sso/devfront/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
baron-sso/devfront/public/vite.svg
Normal file
1
baron-sso/devfront/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 |
143
baron-sso/devfront/scripts/runtime-mode.sh
Normal file
143
baron-sso/devfront/scripts/runtime-mode.sh
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ -z "${VITE_DEVFRONT_PUBLIC_URL:-}" ] && [ -n "${DEVFRONT_URL:-}" ]; then
|
||||
export VITE_DEVFRONT_PUBLIC_URL="$DEVFRONT_URL"
|
||||
fi
|
||||
|
||||
if [ -z "${VITE_DEVFRONT_PUBLIC_URL:-}" ] && [ -n "${DEVFRONT_CALLBACK_URLS:-}" ]; then
|
||||
first_devfront_callback="${DEVFRONT_CALLBACK_URLS%%,*}"
|
||||
case "$first_devfront_callback" in
|
||||
http://*/auth/callback | https://*/auth/callback)
|
||||
export VITE_DEVFRONT_PUBLIC_URL="${first_devfront_callback%/auth/callback}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$app_env" in
|
||||
production|prod|stage|staging)
|
||||
mode="production"
|
||||
;;
|
||||
*)
|
||||
mode="development"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = "--print-public-url" ]; then
|
||||
printf '%s\n' "${VITE_DEVFRONT_PUBLIC_URL:-}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--print-mode" ]; then
|
||||
printf '%s\n' "$mode"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
APP_PACKAGE_NAME="devfront"
|
||||
|
||||
# Detect workspace root
|
||||
if [ -f "/workspace/pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="/workspace"
|
||||
elif [ -f "../../pnpm-workspace.yaml" ]; then
|
||||
WORKSPACE_ROOT="../.."
|
||||
else
|
||||
WORKSPACE_ROOT=""
|
||||
fi
|
||||
|
||||
# Manage dependencies from the real workspace tree if possible, otherwise use current dir.
|
||||
if [ -n "$WORKSPACE_ROOT" ]; then
|
||||
WORKSPACE_DIR="$WORKSPACE_ROOT"
|
||||
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
|
||||
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="npm ci"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode=""
|
||||
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
|
||||
|
||||
acquire_install_lock() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
lock_mode="flock"
|
||||
exec 9>"$lock_file"
|
||||
flock 9
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
return 0
|
||||
fi
|
||||
|
||||
lock_mode="mkdir"
|
||||
while ! mkdir "$lock_file" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
trap 'release_install_lock' EXIT INT TERM
|
||||
}
|
||||
|
||||
release_install_lock() {
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [ "$lock_mode" = "flock" ]; then
|
||||
flock -u 9 || true
|
||||
exec 9>&-
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$lock_mode" = "mkdir" ]; then
|
||||
rmdir "$lock_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
|
||||
if [ "$installed_hash" != "$deps_hash" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||
release_install_lock
|
||||
return 0
|
||||
fi
|
||||
|
||||
eval "$INSTALL_CMD"
|
||||
|
||||
mkdir -p node_modules
|
||||
printf '%s\n' "$deps_hash" > "$deps_stamp"
|
||||
release_install_lock
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_frontend_dependencies
|
||||
|
||||
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
|
||||
7
baron-sso/devfront/src/app/queryClient.ts
Normal file
7
baron-sso/devfront/src/app/queryClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: queryClientDefaultOptions,
|
||||
});
|
||||
13
baron-sso/devfront/src/app/routes.test.tsx
Normal file
13
baron-sso/devfront/src/app/routes.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { matchRoutes } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
import { devFrontRoutes } from "./routes";
|
||||
|
||||
describe("devfront routes", () => {
|
||||
it("accepts the auth callback path used by the OIDC redirect URI", () => {
|
||||
const matches = matchRoutes(devFrontRoutes, DEVFRONT_AUTH_CALLBACK_PATH);
|
||||
|
||||
expect(matches).not.toBeNull();
|
||||
expect(matches?.at(-1)?.route.path).toBe(DEVFRONT_AUTH_CALLBACK_PATH);
|
||||
});
|
||||
});
|
||||
66
baron-sso/devfront/src/app/routes.tsx
Normal file
66
baron-sso/devfront/src/app/routes.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createBrowserRouter, type RouteObject } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthGuard from "../features/auth/AuthGuard";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
|
||||
const devFrontAppChildren: RouteObject[] = [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{
|
||||
path: "clients/:id/relationships",
|
||||
element: <ClientRelationsPage />,
|
||||
},
|
||||
{ path: "developer-requests", element: <DeveloperRequestPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "profile", element: <ProfilePage /> },
|
||||
];
|
||||
|
||||
export const devFrontRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: DEVFRONT_AUTH_CALLBACK_PATH,
|
||||
element: <AuthCallbackPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element:
|
||||
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
|
||||
children:
|
||||
import.meta.env.MODE === "development"
|
||||
? devFrontAppChildren
|
||||
: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: devFrontAppChildren,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
devFrontRoutes,
|
||||
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
||||
{
|
||||
future: {
|
||||
v7_startTransition: true,
|
||||
},
|
||||
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||
);
|
||||
1
baron-sso/devfront/src/assets/react.svg
Normal file
1
baron-sso/devfront/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 |
@@ -0,0 +1,66 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DeveloperAccessRequestCard } from "./DeveloperAccessRequestCard";
|
||||
|
||||
describe("DeveloperAccessRequestCard", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders the request CTA for pending and denied states", () => {
|
||||
const onAction = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<DeveloperAccessRequestCard
|
||||
title="운영 현황"
|
||||
isPending={true}
|
||||
canRequest={false}
|
||||
pendingMessage="검토 중"
|
||||
deniedMessage="거부됨"
|
||||
pendingDetailMessage="승인 대기"
|
||||
deniedDetailMessage="신청 필요"
|
||||
actionLabel="개발자 권한 신청"
|
||||
onAction={onAction}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector("h2")?.textContent).toBe("운영 현황");
|
||||
expect(container.textContent).toContain("검토 중");
|
||||
expect(container.textContent).toContain("승인 대기");
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button?.textContent).toBe("개발자 권한 신청");
|
||||
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<DeveloperAccessRequestCard
|
||||
title="감사 로그"
|
||||
isPending={false}
|
||||
canRequest={true}
|
||||
pendingMessage="검토 중"
|
||||
deniedMessage="거부됨"
|
||||
pendingDetailMessage="승인 대기"
|
||||
deniedDetailMessage="신청 필요"
|
||||
actionLabel="개발자 권한 신청"
|
||||
onAction={onAction}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector("h2")?.textContent).toBe("감사 로그");
|
||||
expect(container.textContent).toContain("거부됨");
|
||||
expect(container.textContent).toContain("신청 필요");
|
||||
expect(container.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
interface DeveloperAccessRequestCardProps {
|
||||
title: string;
|
||||
isPending: boolean;
|
||||
canRequest: boolean;
|
||||
pendingMessage: string;
|
||||
deniedMessage: string;
|
||||
pendingDetailMessage: string;
|
||||
deniedDetailMessage: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
export function DeveloperAccessRequestCard({
|
||||
title,
|
||||
isPending,
|
||||
canRequest,
|
||||
pendingMessage,
|
||||
deniedMessage,
|
||||
pendingDetailMessage,
|
||||
deniedDetailMessage,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: DeveloperAccessRequestCardProps) {
|
||||
const showAction = isPending || canRequest;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="font-medium text-foreground">
|
||||
{isPending ? pendingMessage : deniedMessage}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPending ? pendingDetailMessage : deniedDetailMessage}
|
||||
</p>
|
||||
{showAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold text-primary hover:underline"
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ForbiddenMessage } from "./ForbiddenMessage";
|
||||
|
||||
const authState = {
|
||||
user: {
|
||||
profile: {
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderMessage(resourceToken: "audit" | "clients" | "consents") {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ForbiddenMessage resourceToken={resourceToken} />);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ForbiddenMessage", () => {
|
||||
it("renders resource-specific user guidance", async () => {
|
||||
authState.user.profile.role = "user";
|
||||
|
||||
const audit = await renderMessage("audit");
|
||||
expect(audit.textContent).toContain("Audit Logs");
|
||||
expect(audit.textContent).toContain("audit read relationship");
|
||||
|
||||
const consents = await renderMessage("consents");
|
||||
expect(consents.textContent).toContain("User Consent Grants");
|
||||
expect(consents.textContent).toContain("operational relationship");
|
||||
|
||||
const clients = await renderMessage("clients");
|
||||
expect(clients.textContent).toContain("Connected Applications");
|
||||
expect(clients.textContent).toContain("target application");
|
||||
});
|
||||
|
||||
it("renders specific guidance for privileged admin roles", async () => {
|
||||
authState.user.profile.role = "rp_admin";
|
||||
const rpAdmin = await renderMessage("clients");
|
||||
expect(rpAdmin.textContent).toContain(
|
||||
"RP administrators can only access resources for their assigned applications.",
|
||||
);
|
||||
|
||||
authState.user.profile.role = "tenant_admin";
|
||||
const tenantAdmin = await renderMessage("clients");
|
||||
expect(tenantAdmin.textContent).toContain(
|
||||
"Tenant administrator permissions are not configured correctly or have expired.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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" | "consents";
|
||||
}
|
||||
|
||||
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",
|
||||
"You do not have permission to access this resource. Contact your administrator.",
|
||||
);
|
||||
|
||||
if (role === "user") {
|
||||
if (resourceToken === "consents") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.consents",
|
||||
"Viewing consent records for this application requires an operational relationship. Request access from an administrator if needed.",
|
||||
);
|
||||
} else if (resourceToken === "audit") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.audit",
|
||||
"Viewing audit logs for this application requires an audit read relationship. Request access from an administrator if needed.",
|
||||
);
|
||||
} else {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.user.clients",
|
||||
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target application. Request access from an administrator if needed.",
|
||||
);
|
||||
}
|
||||
} else if (role === "rp_admin") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.rp_admin",
|
||||
"RP administrators can only access resources for their assigned applications.",
|
||||
);
|
||||
} else if (role === "tenant_admin") {
|
||||
explanation = t(
|
||||
"msg.dev.forbidden.tenant_admin",
|
||||
"Tenant administrator permissions are not configured correctly or have expired.",
|
||||
);
|
||||
}
|
||||
|
||||
const resourceLabel =
|
||||
resourceToken === "audit"
|
||||
? t("ui.dev.audit.title", "Audit Logs")
|
||||
: resourceToken === "consents"
|
||||
? t("ui.dev.clients.consents.title", "User Consent Grants")
|
||||
: t("ui.dev.clients.registry.subtitle", "Connected Applications");
|
||||
|
||||
const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", {
|
||||
resource: resourceLabel,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}));
|
||||
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderSelector() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(<LanguageSelector />);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("LanguageSelector", () => {
|
||||
it("prefers the locale stored in localStorage", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
|
||||
const container = renderSelector();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
|
||||
expect(select.value).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to the path locale when storage is empty", () => {
|
||||
window.history.replaceState({}, "", "/ko");
|
||||
|
||||
const container = renderSelector();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
|
||||
expect(select.value).toBe("ko");
|
||||
});
|
||||
|
||||
it("saves the selected locale and dispatches a development event", () => {
|
||||
vi.stubEnv("MODE", "development");
|
||||
const dispatchEvent = vi.spyOn(window, "dispatchEvent");
|
||||
window.history.replaceState({}, "", "/ko");
|
||||
|
||||
const container = renderSelector();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
|
||||
act(() => {
|
||||
select.value = "en";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("locale")).toBe("en");
|
||||
expect(dispatchEvent).toHaveBeenCalled();
|
||||
expect(select.value).toBe("en");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const LOCALE_STORAGE_KEY = "locale";
|
||||
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
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);
|
||||
if (import.meta.env.MODE === "development") {
|
||||
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
|
||||
return;
|
||||
}
|
||||
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;
|
||||
184
baron-sso/devfront/src/components/layout/AppLayout.test.tsx
Normal file
184
baron-sso/devfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppLayout from "./AppLayout";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
profile: {
|
||||
sub: "user-1",
|
||||
name: "Dev Admin",
|
||||
email: "dev@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
},
|
||||
signinSilent: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../features/auth/authApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
name: "Fetched Dev Admin",
|
||||
email: "fetched@example.com",
|
||||
role: "super_admin",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||
authState.signinSilent.mockReset();
|
||||
authState.signinSilent.mockResolvedValue(undefined);
|
||||
authState.removeUser.mockReset();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderLayout(initialEntry = "/clients") {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route path="clients" element={<div>Client outlet</div>} />
|
||||
<Route path="profile" element={<div>Profile outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("devfront AppLayout", () => {
|
||||
it("renders shell navigation, profile summary, and outlet content", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
expect(container.textContent).toContain("Developer Console");
|
||||
expect(container.textContent).toContain("Clients");
|
||||
expect(container.textContent).toContain("Client outlet");
|
||||
expect(container.textContent).toContain("Fetched Dev Admin");
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="사이드바 접기"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
collapseButton.click();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="사이드바 펼치기"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
const themeButton = container.querySelector(
|
||||
'button[aria-label="Toggle theme"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
themeButton.click();
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
const profileButton = container.querySelector(
|
||||
'button[aria-label="Open account menu"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
profileButton.click();
|
||||
});
|
||||
expect(container.textContent).toContain("My Profile");
|
||||
|
||||
const profileMenuItem = Array.from(
|
||||
container.querySelectorAll('button[role="menuitem"]'),
|
||||
).find((button) => button.textContent?.includes("My Profile"));
|
||||
await act(async () => {
|
||||
(profileMenuItem as HTMLButtonElement).click();
|
||||
});
|
||||
expect(container.textContent).toContain("Profile outlet");
|
||||
|
||||
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Logout"),
|
||||
);
|
||||
await act(async () => {
|
||||
(logoutButton as HTMLButtonElement).click();
|
||||
});
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts silent renewal after user action when the session is expiring", async () => {
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||
await renderLayout();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
||||
});
|
||||
|
||||
expect(authState.signinSilent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
601
baron-sso/devfront/src/components/layout/AppLayout.tsx
Normal file
601
baron-sso/devfront/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
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 {
|
||||
AppSidebar,
|
||||
applyShellTheme,
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellSidebarCollapsed,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
writeShellSidebarCollapsed,
|
||||
} from "../../../../common/shell";
|
||||
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 LOCALE_CHANGED_EVENT = "baron_locale_changed";
|
||||
|
||||
const navItems: ShellSidebarNavItem[] = [
|
||||
{
|
||||
labelKey: "ui.dev.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.developer_request",
|
||||
labelFallback: "Developer Access Request",
|
||||
to: "/developer-requests",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
];
|
||||
|
||||
type SessionStatusProps = {
|
||||
expiresAtSec?: number | null;
|
||||
t: ShellTranslator;
|
||||
};
|
||||
|
||||
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
|
||||
}
|
||||
|
||||
function SessionStatusBadge(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionStatusText(props: SessionStatusProps) {
|
||||
const sessionStatus = useSessionStatus(props);
|
||||
|
||||
return <>{sessionStatus.text}</>;
|
||||
}
|
||||
|
||||
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 isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||
readShellSidebarCollapsed(false),
|
||||
);
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
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", "Are you sure you want to log out?"),
|
||||
)
|
||||
) {
|
||||
auth.removeUser();
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyShellTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rerenderDevelopmentShell = () => {
|
||||
setDevelopmentRenderRevision((value) => value + 1);
|
||||
};
|
||||
|
||||
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
LOCALE_CHANGED_EVENT,
|
||||
rerenderDevelopmentShell,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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("Silent session renewal failed.", 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(() => {
|
||||
if (isDevelopmentRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
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("Unlimited session keepalive renewal failed.", 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("Silent session renewal failed.", 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 profileSummary = buildShellProfileSummary({
|
||||
profileName:
|
||||
profile?.name ||
|
||||
auth.user?.profile?.name?.toString() ||
|
||||
auth.user?.profile?.preferred_username?.toString() ||
|
||||
auth.user?.profile?.nickname?.toString(),
|
||||
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
|
||||
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
|
||||
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
|
||||
});
|
||||
const currentRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const displayRoleKey = profile?.role || currentRole;
|
||||
const handleSessionExpiryToggle = () => {
|
||||
setIsSessionExpiryEnabled((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSessionExpiryEnabled(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
|
||||
const label = t(labelKey, labelFallback);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
<div className="border-t border-border/50 px-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
||||
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.content}>
|
||||
<header className={shellLayoutClasses.header}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<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={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{theme === "light"
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusBadge
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : 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.shell.profile.menu_aria",
|
||||
"Open account menu",
|
||||
)}
|
||||
>
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.shell.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileSummary.email}
|
||||
</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.shell.role.${displayRoleKey}`,
|
||||
displayRoleKey.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t(
|
||||
"ui.shell.session.auto_extend",
|
||||
"Session expiry",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled ? (
|
||||
<SessionStatusText
|
||||
expiresAtSec={auth.user?.expires_at}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
t(
|
||||
"ui.shell.session.disabled",
|
||||
"Session expiry 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.shell.nav.profile", "My Profile")}</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.shell.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
47
baron-sso/devfront/src/components/ui/avatar.tsx
Normal file
47
baron-sso/devfront/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, AvatarFallback, AvatarImage };
|
||||
21
baron-sso/devfront/src/components/ui/badge.tsx
Normal file
21
baron-sso/devfront/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
31
baron-sso/devfront/src/components/ui/button.tsx
Normal file
31
baron-sso/devfront/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type CommonButtonSize,
|
||||
type CommonButtonVariant,
|
||||
getCommonButtonClasses,
|
||||
} from "../../../../common/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
58
baron-sso/devfront/src/components/ui/card.tsx
Normal file
58
baron-sso/devfront/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
commonCardClass,
|
||||
commonCardContentClass,
|
||||
commonCardDescriptionClass,
|
||||
commonCardFooterClass,
|
||||
commonCardHeaderClass,
|
||||
commonCardTitleClass,
|
||||
} from "../../../../common/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
107
baron-sso/devfront/src/components/ui/copy-button.test.tsx
Normal file
107
baron-sso/devfront/src/components/ui/copy-button.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopyButton } from "./copy-button";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderCopyButton(value: string, onCopy = vi.fn()) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(<CopyButton value={value} onCopy={onCopy} />);
|
||||
});
|
||||
|
||||
return { container, onCopy };
|
||||
}
|
||||
|
||||
describe("CopyButton", () => {
|
||||
it("copies with the clipboard API when secure context is available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { container, onCopy } = renderCopyButton("client-secret");
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("client-secret");
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to execCommand when clipboard API is unavailable", async () => {
|
||||
const execCommand = vi.fn(() => true);
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: execCommand,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { container, onCopy } = renderCopyButton("client-secret");
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps running when the fallback copy flow fails", async () => {
|
||||
const execCommand = vi.fn(() => false);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: execCommand,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { container, onCopy } = renderCopyButton("client-secret");
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(onCopy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
75
baron-sso/devfront/src/components/ui/copy-button.tsx
Normal file
75
baron-sso/devfront/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>
|
||||
);
|
||||
}
|
||||
22
baron-sso/devfront/src/components/ui/input.tsx
Normal file
22
baron-sso/devfront/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { commonInputClass } from "../../../../common/ui/input";
|
||||
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(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
19
baron-sso/devfront/src/components/ui/label.tsx
Normal file
19
baron-sso/devfront/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
baron-sso/devfront/src/components/ui/scroll-area.tsx
Normal file
44
baron-sso/devfront/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
baron-sso/devfront/src/components/ui/separator.tsx
Normal file
16
baron-sso/devfront/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
baron-sso/devfront/src/components/ui/switch.tsx
Normal file
26
baron-sso/devfront/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 };
|
||||
102
baron-sso/devfront/src/components/ui/table.tsx
Normal file
102
baron-sso/devfront/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
commonTableBodyClass,
|
||||
commonTableCaptionClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableFooterClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={commonTableWrapperClass}>
|
||||
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(commonTableHeaderClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(commonTableFooterClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn(commonTableCaptionClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
23
baron-sso/devfront/src/components/ui/textarea.tsx
Normal file
23
baron-sso/devfront/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
baron-sso/devfront/src/components/ui/toaster.tsx
Normal file
35
baron-sso/devfront/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
baron-sso/devfront/src/components/ui/use-toast.ts
Normal file
42
baron-sso/devfront/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;
|
||||
};
|
||||
212
baron-sso/devfront/src/features/audit/AuditLogsPage.test.tsx
Normal file
212
baron-sso/devfront/src/features/audit/AuditLogsPage.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuditLogsPage from "./AuditLogsPage";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const fetchMeMock = vi.fn();
|
||||
const fetchDevAuditLogsMock = vi.fn();
|
||||
let gateState = {
|
||||
hasDeveloperAccess: true,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: false,
|
||||
isLoadingDeveloperAccessGate: false,
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../developer-access/developerAccessGate", () => ({
|
||||
useDeveloperAccessGate: () => gateState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchDevAuditLogs: (...args: unknown[]) => fetchDevAuditLogsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: (...args: unknown[]) => fetchMeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../common/core/components/audit", () => ({
|
||||
AuditLogTable: ({
|
||||
logs,
|
||||
onLoadMore,
|
||||
}: {
|
||||
logs: Array<{ event_id: string }>;
|
||||
onLoadMore: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div>table:{logs.length}</div>
|
||||
<button type="button" onClick={onLoadMore}>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/common/ForbiddenMessage", () => ({
|
||||
ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => (
|
||||
<div>Forbidden:{resourceToken}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
gateState = {
|
||||
hasDeveloperAccess: true,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: false,
|
||||
isLoadingDeveloperAccessGate: false,
|
||||
};
|
||||
fetchMeMock.mockResolvedValue({
|
||||
id: "user-1",
|
||||
role: "super_admin",
|
||||
});
|
||||
fetchDevAuditLogsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
event_id: "evt-1",
|
||||
timestamp: "2026-05-28T06:07:18.000Z",
|
||||
user_id: "user-1",
|
||||
event_type: "CLIENT_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: JSON.stringify({
|
||||
action: "업데이트",
|
||||
target_id: "client-a",
|
||||
}),
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuditLogsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AuditLogsPage", () => {
|
||||
it("shows the loading gate state", async () => {
|
||||
gateState = {
|
||||
hasDeveloperAccess: false,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: false,
|
||||
isLoadingDeveloperAccessGate: true,
|
||||
};
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("로딩 중...");
|
||||
});
|
||||
|
||||
it("renders the access request card when access is denied", async () => {
|
||||
gateState = {
|
||||
hasDeveloperAccess: false,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: true,
|
||||
isLoadingDeveloperAccessGate: false,
|
||||
};
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain(
|
||||
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||
);
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(item) => item.textContent?.includes("개발자 권한 신청"),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
|
||||
});
|
||||
|
||||
it("exports the fetched logs as CSV", async () => {
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, "createObjectURL")
|
||||
.mockReturnValue("blob:csv");
|
||||
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue();
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("table:1");
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(item) => item.textContent === "CSV 내보내기",
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv");
|
||||
});
|
||||
|
||||
it("renders the forbidden state on 403 errors", async () => {
|
||||
fetchDevAuditLogsMock.mockRejectedValueOnce({
|
||||
response: { status: 403 },
|
||||
message: "Forbidden",
|
||||
});
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("Forbidden:audit");
|
||||
});
|
||||
});
|
||||
311
baron-sso/devfront/src/features/audit/AuditLogsPage.tsx
Normal file
311
baron-sso/devfront/src/features/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { parseAuditDetails } from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||||
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 type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
|
||||
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 = parseAuditDetails(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 navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
|
||||
const [searchClientId, setSearchClientId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
|
||||
// Use deferred values to avoid UI lag during rapid typing
|
||||
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
||||
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
||||
|
||||
const { data: me, isLoading: isLoadingMe } = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
const profileRole = me?.role?.trim() || role;
|
||||
const {
|
||||
hasDeveloperAccess,
|
||||
isDeveloperRequestPending,
|
||||
canRequestDeveloperAccess,
|
||||
isLoadingDeveloperAccessGate,
|
||||
} = useDeveloperAccessGate({
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"dev-audit-logs",
|
||||
deferredSearchClientId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchDevAuditLogs(50, pageParam, {
|
||||
client_id: deferredSearchClientId || undefined,
|
||||
action: deferredSearchAction || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: hasDeveloperAccess,
|
||||
});
|
||||
|
||||
const logs =
|
||||
query.data?.pages.flatMap((page) =>
|
||||
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||
) ?? [];
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const csv = toCsv(logs);
|
||||
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||
};
|
||||
|
||||
if (isLoadingDeveloperAccessGate) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasDeveloperAccess) {
|
||||
return (
|
||||
<DeveloperAccessRequestCard
|
||||
title={t("ui.common.audit.title", "Audit Logs")}
|
||||
isPending={isDeveloperRequestPending}
|
||||
canRequest={canRequestDeveloperAccess}
|
||||
pendingMessage={t(
|
||||
"msg.dev.dashboard.access_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)}
|
||||
deniedMessage={t(
|
||||
"msg.dev.audit.access_denied",
|
||||
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||
)}
|
||||
pendingDetailMessage={t(
|
||||
"msg.dev.dashboard.access_pending_detail",
|
||||
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
||||
)}
|
||||
deniedDetailMessage={t(
|
||||
"msg.dev.audit.access_denied_detail",
|
||||
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||
)}
|
||||
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
onAction={() => navigate("/developer-requests")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
icon={<NotebookTabs size={20} />}
|
||||
title={t("ui.common.audit.title", "Audit Logs")}
|
||||
description={t(
|
||||
"msg.dev.audit.subtitle",
|
||||
"현재 앱 범위의 개발자 작업 이력을 조회합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
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.common.export_csv", "CSV 내보내기")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.audit.registry_description",
|
||||
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 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.common.audit.filters.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.common.audit.filters.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.common.audit.filters.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>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
query.isFetching && !query.isFetchingNextPage
|
||||
? "opacity-50 transition-opacity"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<AuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={query.isLoading}
|
||||
hasNextPage={Boolean(query.hasNextPage)}
|
||||
isFetchingNextPage={query.isFetchingNextPage}
|
||||
onLoadMore={() => query.fetchNextPage()}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuditLogsPage;
|
||||
35
baron-sso/devfront/src/features/auth/AuthCallbackPage.tsx
Normal file
35
baron-sso/devfront/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
|
||||
: "/clients";
|
||||
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>;
|
||||
}
|
||||
84
baron-sso/devfront/src/features/auth/AuthGuard.tsx
Normal file
84
baron-sso/devfront/src/features/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
import { findPersistedOidcUser } from "../../lib/oidcStorage";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
|
||||
findPersistedOidcUser() ? true : null,
|
||||
);
|
||||
const isDevelopmentMode = import.meta.env.MODE === "development";
|
||||
const isTestMode =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true || navigator.webdriver === true;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
setHasStoredUser(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const persistedUser = findPersistedOidcUser();
|
||||
if (persistedUser) {
|
||||
setHasStoredUser(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void userManager
|
||||
.getUser()
|
||||
.then((user) => {
|
||||
if (!cancelled) {
|
||||
setHasStoredUser(Boolean(user && !user.expired));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHasStoredUser(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isTestMode]);
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator || hasStoredUser === null) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-4 text-red-500">
|
||||
<h2 className="text-xl font-bold">Authentication Error</h2>
|
||||
<p>{auth.error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.signinRedirect()}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
>
|
||||
Start Login Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated && !hasStoredUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
111
baron-sso/devfront/src/features/auth/AuthPage.tsx
Normal file
111
baron-sso/devfront/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;
|
||||
177
baron-sso/devfront/src/features/auth/LoginPage.tsx
Normal file
177
baron-sso/devfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
||||
|
||||
const insecurePkceMessage =
|
||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||
|
||||
function isPkceSetupFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const returnTo = searchParams.get("returnTo") || "/clients";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
const authErrorMessage = useMemo(() => {
|
||||
const message = auth.error?.message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (message.includes("Crypto.subtle")) {
|
||||
return insecurePkceMessage;
|
||||
}
|
||||
return message;
|
||||
}, [auth.error?.message]);
|
||||
const visibleLoginError = loginError || authErrorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoLogin) {
|
||||
return;
|
||||
}
|
||||
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||
return;
|
||||
}
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth
|
||||
.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Auto login redirect failed", error);
|
||||
});
|
||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
setLoginError(null);
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
await auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/clients",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
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>
|
||||
|
||||
{visibleLoginError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{visibleLoginError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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;
|
||||
19
baron-sso/devfront/src/features/auth/authApi.test.ts
Normal file
19
baron-sso/devfront/src/features/auth/authApi.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchMe } from "./authApi";
|
||||
|
||||
const getMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/apiClient", () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => getMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("fetchMe", () => {
|
||||
it("returns the response payload from the API client", async () => {
|
||||
getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } });
|
||||
|
||||
await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" });
|
||||
expect(getMock).toHaveBeenCalledWith("/user/me");
|
||||
});
|
||||
});
|
||||
24
baron-sso/devfront/src/features/auth/authApi.ts
Normal file
24
baron-sso/devfront/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;
|
||||
}
|
||||
162
baron-sso/devfront/src/features/auth/authPages.test.tsx
Normal file
162
baron-sso/devfront/src/features/auth/authPages.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthCallbackPage from "./AuthCallbackPage";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
import AuthPage from "./AuthPage";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: undefined as
|
||||
| {
|
||||
state?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
signinRedirect: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/auth", () => ({
|
||||
userManager: {
|
||||
getUser: vi.fn(async () => undefined),
|
||||
signinPopupCallback: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = undefined;
|
||||
authState.signinRedirect.mockReset();
|
||||
authState.signinRedirect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderWithRouter(
|
||||
element: React.ReactElement,
|
||||
{
|
||||
entry = "/",
|
||||
path = "*",
|
||||
}: {
|
||||
entry?: string;
|
||||
path?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path={path} element={element}>
|
||||
<Route index element={<div>Protected outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login route</div>} />
|
||||
<Route path="/clients" element={<div>Clients route</div>} />
|
||||
<Route path="/profile" element={<div>Profile route</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("devfront auth pages", () => {
|
||||
it("renders the static auth planning page", async () => {
|
||||
const container = await renderWithRouter(<AuthPage />);
|
||||
|
||||
expect(container.textContent).toContain("Admin auth guardrails");
|
||||
expect(container.textContent).toContain("Device approval");
|
||||
});
|
||||
|
||||
it("renders login page and starts SSO redirect from the action button", async () => {
|
||||
const container = await renderWithRouter(<LoginPage />, {
|
||||
entry: "/login?returnTo=/profile",
|
||||
path: "/login",
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("개발자 포털 로그인");
|
||||
|
||||
const loginButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
|
||||
);
|
||||
await act(async () => {
|
||||
(loginButton as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(authState.signinRedirect).toHaveBeenCalledWith({
|
||||
state: { returnTo: "/clients" },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
const loading = await renderWithRouter(<AuthGuard />);
|
||||
expect(loading.textContent).toContain("Loading...");
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
const error = await renderWithRouter(<AuthGuard />);
|
||||
expect(error.textContent).toContain("Authentication Error");
|
||||
|
||||
const retryButton = error.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
retryButton.click();
|
||||
});
|
||||
expect(authState.signinRedirect).toHaveBeenCalled();
|
||||
|
||||
authState.error = null;
|
||||
const redirected = await renderWithRouter(<AuthGuard />);
|
||||
expect(redirected.textContent).toContain("Login route");
|
||||
|
||||
authState.isAuthenticated = true;
|
||||
const protectedPage = await renderWithRouter(<AuthGuard />);
|
||||
expect(protectedPage.textContent).toContain("Protected outlet");
|
||||
});
|
||||
|
||||
it("navigates from callback by auth result and stored return target", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = { state: { returnTo: "/profile" } };
|
||||
|
||||
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(authenticated.textContent).toContain("Profile route");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
const failed = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(failed.textContent).toContain("Login route");
|
||||
});
|
||||
});
|
||||
1140
baron-sso/devfront/src/features/clients/ClientConsentsPage.tsx
Normal file
1140
baron-sso/devfront/src/features/clients/ClientConsentsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string) =>
|
||||
({
|
||||
"ui.dev.clients.details.tab.connection": "연동 설정",
|
||||
"ui.dev.clients.details.tab.user_claims": "사용자 Claim",
|
||||
"ui.dev.clients.details.tab.settings": "설정",
|
||||
"ui.dev.clients.details.tab.relationships": "관계",
|
||||
})[key] ??
|
||||
fallback ??
|
||||
key,
|
||||
}));
|
||||
|
||||
describe("ClientDetailTabs", () => {
|
||||
it("exposes the RP user custom claim screen as a first-class tab", () => {
|
||||
const html = renderToString(
|
||||
<MemoryRouter>
|
||||
<ClientDetailTabs activeTab="connection" clientId="client-a" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(html).toContain("사용자 Claim");
|
||||
expect(html).toContain('href="/clients/client-a/consents"');
|
||||
});
|
||||
});
|
||||
61
baron-sso/devfront/src/features/clients/ClientDetailTabs.tsx
Normal file
61
baron-sso/devfront/src/features/clients/ClientDetailTabs.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type ClientDetailTab = "connection" | "consents" | "settings" | "relationships";
|
||||
|
||||
interface ClientDetailTabsProps {
|
||||
activeTab: ClientDetailTab;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
const tabOrder: Array<{
|
||||
key: ClientDetailTab;
|
||||
href: (clientId: string) => string;
|
||||
labelKey?: string;
|
||||
}> = [
|
||||
{ key: "connection", href: (clientId) => `/clients/${clientId}` },
|
||||
{
|
||||
key: "consents",
|
||||
href: (clientId) => `/clients/${clientId}/consents`,
|
||||
labelKey: "ui.dev.clients.details.tab.user_claims",
|
||||
},
|
||||
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
|
||||
{
|
||||
key: "relationships",
|
||||
href: (clientId) => `/clients/${clientId}/relationships`,
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientDetailTabs({
|
||||
activeTab,
|
||||
clientId,
|
||||
}: ClientDetailTabsProps) {
|
||||
return (
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{tabOrder.map((tab) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
const labelKey =
|
||||
tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
|
||||
return isActive ? (
|
||||
<span
|
||||
key={tab.key}
|
||||
className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary"
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.href(clientId)}
|
||||
className={cn(
|
||||
"whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
547
baron-sso/devfront/src/features/clients/ClientDetailsPage.tsx
Normal file
547
baron-sso/devfront/src/features/clients/ClientDetailsPage.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Shield,
|
||||
ShieldHalf,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
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";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import { canDisplayClientSecret } from "./clientSecretPolicy";
|
||||
|
||||
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) => {
|
||||
const axiosError = err as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.details.save_forbidden",
|
||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast(
|
||||
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
|
||||
error:
|
||||
axiosError.response?.data?.error ??
|
||||
(err as Error).message ??
|
||||
t("msg.common.unknown_error", "unknown error"),
|
||||
}),
|
||||
"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,
|
||||
},
|
||||
];
|
||||
|
||||
const hasClientSecret = canDisplayClientSecret(client);
|
||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||
const clientSecret = hasClientSecret
|
||||
? client?.clientSecret || secretPlaceholder
|
||||
: t("ui.common.na", "N/A");
|
||||
const displaySecret = !hasClientSecret
|
||||
? t(
|
||||
"msg.dev.clients.details.secret_not_applicable",
|
||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||
)
|
||||
: 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>
|
||||
<PageHeader
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={client?.name || client?.id || clientId}
|
||||
description={t(
|
||||
"msg.dev.clients.details.subtitle",
|
||||
"Manage OIDC credentials and endpoints.",
|
||||
)}
|
||||
actions={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<ClientDetailTabs activeTab="connection" clientId={clientId} />
|
||||
</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">
|
||||
{hasClientSecret ? (
|
||||
<>
|
||||
<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이 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!hasClientSecret ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.details.secret_not_applicable",
|
||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</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;
|
||||
2928
baron-sso/devfront/src/features/clients/ClientGeneralPage.tsx
Normal file
2928
baron-sso/devfront/src/features/clients/ClientGeneralPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
766
baron-sso/devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
766
baron-sso/devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Info, Link2, Plus, ShieldHalf, Trash2, X } from "lucide-react";
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
addClientRelation,
|
||||
type DevAssignableUser,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchDevUsers,
|
||||
removeClientRelation,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
const relationOptions = [
|
||||
"admins",
|
||||
"config_editor",
|
||||
"secret_viewer",
|
||||
"secret_rotator",
|
||||
"jwks_viewer",
|
||||
"jwks_operator",
|
||||
"consent_viewer",
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"audit_viewer",
|
||||
] as const;
|
||||
|
||||
type RelationOption = (typeof relationOptions)[number];
|
||||
|
||||
function relationLabel(relation: RelationOption) {
|
||||
return t(`ui.dev.clients.relationships.option.${relation}.label`, relation);
|
||||
}
|
||||
|
||||
function relationDescription(relation: RelationOption) {
|
||||
return t(
|
||||
`ui.dev.clients.relationships.option.${relation}.description`,
|
||||
relation,
|
||||
);
|
||||
}
|
||||
|
||||
function relationPermitsInfo(relation: RelationOption) {
|
||||
return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
|
||||
}
|
||||
|
||||
function formatUserLabel(user: DevAssignableUser) {
|
||||
const primary = user.name.trim() || user.email.trim();
|
||||
return `${primary} (${user.email.trim()})`;
|
||||
}
|
||||
|
||||
function ClientRelationsPage() {
|
||||
const params = useParams();
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id ?? "";
|
||||
const [selectedRelations, setSelectedRelations] = useState<RelationOption[]>(
|
||||
[],
|
||||
);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const deferredUserSearch = useDeferredValue(userSearch.trim());
|
||||
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
|
||||
null,
|
||||
);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
|
||||
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
const resolvedSystemRole = me?.role?.trim() || systemRole;
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: relationData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["client-relations", clientId],
|
||||
queryFn: () => fetchClientRelations(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
// Calculate permissions for UI hints and button states
|
||||
const isSuperAdmin = resolvedSystemRole === "super_admin";
|
||||
const myUserId = auth.user?.profile.sub;
|
||||
const isRpAdmin = useMemo(() => {
|
||||
if (isSuperAdmin) return true;
|
||||
if (!relationData?.items || !myUserId) return false;
|
||||
return relationData.items.some(
|
||||
(item) =>
|
||||
item.subject === `User:${myUserId}` && item.relation === "admins",
|
||||
);
|
||||
}, [relationData?.items, myUserId, isSuperAdmin]);
|
||||
|
||||
const canManageRelations = isRpAdmin || isSuperAdmin;
|
||||
|
||||
const isRelationshipViewForbidden =
|
||||
(error as AxiosError | null)?.response?.status === 403;
|
||||
const relationshipViewForbiddenMessage = t(
|
||||
"msg.dev.clients.relationships.view_forbidden",
|
||||
"이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
);
|
||||
|
||||
const {
|
||||
data: userSearchData,
|
||||
isFetching: isUserSearchLoading,
|
||||
error: userSearchError,
|
||||
} = useQuery({
|
||||
queryKey: ["dev-users", deferredUserSearch],
|
||||
queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId),
|
||||
enabled:
|
||||
clientId.length > 0 &&
|
||||
deferredUserSearch.length > 0 &&
|
||||
selectedUser == null &&
|
||||
!isRelationshipViewForbidden,
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...(relationData?.items ?? [])].sort((a, b) => {
|
||||
const relationCompare = a.relation.localeCompare(b.relation);
|
||||
if (relationCompare !== 0) {
|
||||
return relationCompare;
|
||||
}
|
||||
return a.subject.localeCompare(b.subject);
|
||||
});
|
||||
}, [relationData?.items]);
|
||||
|
||||
const selectedUserExistingRelations = useMemo(() => {
|
||||
if (!selectedUser) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
sortedItems
|
||||
.filter((item) => item.subjectId === selectedUser.id)
|
||||
.map((item) => item.relation),
|
||||
);
|
||||
}, [selectedUser, sortedItems]);
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedUser) {
|
||||
throw new Error(
|
||||
t(
|
||||
"msg.dev.clients.relationships.user_required",
|
||||
"추가할 사용자를 선택하세요.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRelations = selectedRelations.filter(
|
||||
(relation) => !selectedUserExistingRelations.has(relation),
|
||||
);
|
||||
for (const relation of pendingRelations) {
|
||||
await addClientRelation(clientId, {
|
||||
relation,
|
||||
userId: selectedUser.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["client-relations", clientId],
|
||||
});
|
||||
setSelectedRelations([]);
|
||||
setSelectedUser(null);
|
||||
setUserSearch("");
|
||||
setIsSearchOpen(false);
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.added",
|
||||
"Relationship가 추가되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.add_error",
|
||||
"Relationship 추가 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (payload: { relation: string; subject: string }) =>
|
||||
removeClientRelation(clientId, payload.relation, payload.subject),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["client-relations", clientId],
|
||||
});
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.removed",
|
||||
"Relationship가 제거되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_error",
|
||||
"Relationship 제거 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!canManageRelations) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.add_forbidden_viewer",
|
||||
"'관계 조회' 권한만으로는 새로운 관계를 추가하거나 사용자를 검색할 수 없습니다. 'RP 관리자' 권한이 필요합니다.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!selectedUser) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.user_required",
|
||||
"추가할 사용자를 선택하세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRelations = selectedRelations.filter(
|
||||
(relation) => !selectedUserExistingRelations.has(relation),
|
||||
);
|
||||
if (pendingRelations.length === 0) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.relation_required",
|
||||
"추가할 관계를 하나 이상 선택하세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRelationToggle = (relation: RelationOption) => {
|
||||
setSelectedRelations((current) =>
|
||||
current.includes(relation)
|
||||
? current.filter((item) => item !== relation)
|
||||
: [...current, relation],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectUser = (user: DevAssignableUser) => {
|
||||
setSelectedUser(user);
|
||||
setUserSearch(formatUserLabel(user));
|
||||
setIsSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = (targetRelation: string, subject: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_confirm",
|
||||
"이 relationship를 제거하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMutation.mutate({ relation: targetRelation, subject });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfoToggle = (
|
||||
event: React.MouseEvent,
|
||||
relation: RelationOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setInfoRelation((prev) => (prev === relation ? null : relation));
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUserSearchForbidden =
|
||||
(userSearchError as AxiosError | null)?.response?.status === 403;
|
||||
|
||||
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.details.tab.relationships", "Relationships")}
|
||||
</span>
|
||||
</nav>
|
||||
<PageHeader
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t(
|
||||
"ui.dev.clients.relationships.title",
|
||||
"Client Relationships",
|
||||
)}
|
||||
description={t(
|
||||
"msg.dev.clients.relationships.subtitle",
|
||||
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<ClientDetailTabs activeTab="relationships" clientId={clientId} />
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("ui.dev.clients.relationships.add_title", "Add Relationship")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.add_description",
|
||||
"사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Loading relationships...",
|
||||
)}
|
||||
</div>
|
||||
) : isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
{relationshipViewForbiddenMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-search-input">
|
||||
{t("ui.dev.clients.relationships.user_search", "사용자")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="user-search-input"
|
||||
value={userSearch}
|
||||
onFocus={() => {
|
||||
if (!selectedUser && userSearch.trim() !== "") {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setSelectedUser(null);
|
||||
setUserSearch(event.target.value);
|
||||
setIsSearchOpen(true);
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.relationships.user_search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
/>
|
||||
{isSearchOpen &&
|
||||
selectedUser == null &&
|
||||
userSearch.trim() !== "" && (
|
||||
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-md border border-border bg-background shadow-lg">
|
||||
{isUserSearchLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_loading",
|
||||
"사용자를 찾는 중입니다...",
|
||||
)}
|
||||
</div>
|
||||
) : isUserSearchForbidden ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-destructive font-medium border-b border-border/40 bg-destructive/5 flex flex-col gap-2">
|
||||
<p>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_forbidden_user",
|
||||
"일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_forbidden_user_hint",
|
||||
"'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (userSearchData?.items ?? []).length > 0 ? (
|
||||
(userSearchData?.items ?? []).map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className="flex w-full flex-col gap-1 px-3 py-2 text-left hover:bg-muted/40 border-b border-border/40 last:border-b-0"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
handleSelectUser(user);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold">
|
||||
{user.name || user.email}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
{user.loginId ? ` · ${user.loginId}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.search_empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.selected_user",
|
||||
"선택된 사용자: {{user}}",
|
||||
{ user: formatUserLabel(selectedUser) },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</Label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{relationOptions.map((relation) => {
|
||||
const disabled =
|
||||
selectedUserExistingRelations.has(relation);
|
||||
const isSelected = selectedRelations.includes(relation);
|
||||
const isInfoVisible = infoRelation === relation;
|
||||
|
||||
return (
|
||||
<div key={relation} className="relative">
|
||||
<label
|
||||
className={`flex gap-3 rounded-xl border p-4 transition-all ${
|
||||
disabled
|
||||
? "border-border/60 bg-muted/30 opacity-60"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
|
||||
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary"
|
||||
checked={isSelected || disabled}
|
||||
disabled={disabled}
|
||||
onChange={() => handleRelationToggle(relation)}
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
isSelected && !disabled ? "text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{relationLabel(relation)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
isInfoVisible
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={(e) => handleInfoToggle(e, relation)}
|
||||
>
|
||||
{isInfoVisible ? (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(relation)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] uppercase tracking-wide ${
|
||||
isSelected && !disabled
|
||||
? "text-primary/80"
|
||||
: "text-muted-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{relation}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{isInfoVisible && (
|
||||
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{t("ui.common.info", "상세 권한 안내")}
|
||||
</div>
|
||||
{relationPermitsInfo(relation)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addMutation.isPending || !canManageRelations}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{addMutation.isPending
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.clients.relationships.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
{t(
|
||||
"ui.dev.clients.relationships.list_title",
|
||||
"Assigned Relationships",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.list_description",
|
||||
"현재 RP에 직접 부여된 operator relation 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
{relationshipViewForbiddenMessage}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.load_error",
|
||||
"Relationship 조회 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Relationship를 불러오는 중입니다...",
|
||||
)}
|
||||
</div>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.empty",
|
||||
"직접 부여된 relationship가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject", "Subject")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject_type", "Type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedItems.map((item) => (
|
||||
<TableRow key={`${item.relation}:${item.subject}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<span>
|
||||
{relationLabel(item.relation as RelationOption)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
infoRelation === item.relation
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={(e) =>
|
||||
handleInfoToggle(
|
||||
e,
|
||||
item.relation as RelationOption,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{infoRelation === item.relation && (
|
||||
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
|
||||
{relationPermitsInfo(
|
||||
item.relation as RelationOption,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{relationDescription(item.relation as RelationOption)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{item.userName || item.userEmail || item.subject}
|
||||
</div>
|
||||
{(item.userEmail || item.userLoginId) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[item.userEmail, item.userLoginId]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{item.subject}
|
||||
</div>
|
||||
{item.subjectId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
ID: {item.subjectId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.subjectType || "-"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
disabled={
|
||||
removeMutation.isPending || !canManageRelations
|
||||
}
|
||||
onClick={() =>
|
||||
handleRemove(item.relation, item.subject)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("ui.common.delete", "Delete")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientRelationsPage;
|
||||
280
baron-sso/devfront/src/features/clients/ClientsPage.test.tsx
Normal file
280
baron-sso/devfront/src/features/clients/ClientsPage.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ClientsPage from "./ClientsPage";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const fetchClientsMock = vi.fn();
|
||||
const fetchMeMock = vi.fn();
|
||||
const fetchDeveloperRequestStatusMock = vi.fn();
|
||||
const fetchMyTenantsMock = vi.fn();
|
||||
const requestDeveloperAccessMock = vi.fn();
|
||||
|
||||
let authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
name: "Dev Admin",
|
||||
email: "dev@example.com",
|
||||
phone: "010-0000-0000",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router-dom")>(
|
||||
"react-router-dom",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchClients: () => fetchClientsMock(),
|
||||
fetchMe: () => fetchMeMock(),
|
||||
fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(),
|
||||
fetchMyTenants: () => fetchMyTenantsMock(),
|
||||
requestDeveloperAccess: (...args: unknown[]) =>
|
||||
requestDeveloperAccessMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
name: "Dev Admin",
|
||||
email: "dev@example.com",
|
||||
phone: "010-0000-0000",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchClientsMock.mockResolvedValue({
|
||||
items: [],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
fetchMeMock.mockResolvedValue({
|
||||
role: "super_admin",
|
||||
name: "Dev Admin",
|
||||
email: "dev@example.com",
|
||||
phone: "010-0000-0000",
|
||||
});
|
||||
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
|
||||
fetchMyTenantsMock.mockResolvedValue([
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "Hanmac",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
parentId: null,
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 10,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
function makeClients(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `client-${index + 1}`,
|
||||
name: `App ${index + 1}`,
|
||||
type: index % 2 === 0 ? "private" : "pkce",
|
||||
status: index % 2 === 0 ? "active" : "inactive",
|
||||
createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`,
|
||||
redirectUris: [],
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<ClientsPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClientsPage", () => {
|
||||
it("expands the list and applies search filters", async () => {
|
||||
fetchClientsMock.mockResolvedValue({
|
||||
items: makeClients(6),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain(
|
||||
"총 6개의 애플리케이션이 등록되어 있습니다.",
|
||||
);
|
||||
expect(container.textContent).toContain("App 6");
|
||||
expect(container.textContent).toContain("App 2");
|
||||
expect(container.textContent).not.toContain("App 1");
|
||||
|
||||
const moreButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "더보기",
|
||||
);
|
||||
expect(moreButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("App 6");
|
||||
expect(container.textContent).toContain("접기");
|
||||
|
||||
const advancedButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent === "Advanced Filters");
|
||||
expect(advancedButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const searchInput = Array.from(container.querySelectorAll("input")).find(
|
||||
(input) =>
|
||||
input
|
||||
.getAttribute("placeholder")
|
||||
?.includes("클라이언트 이름/ID로 검색"),
|
||||
) as HTMLInputElement | undefined;
|
||||
if (!searchInput) {
|
||||
throw new Error("Expected search input to be rendered");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(searchInput, "missing-client");
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다.");
|
||||
|
||||
const resetButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "초기화",
|
||||
);
|
||||
expect(resetButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(searchInput, "");
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("App 1");
|
||||
});
|
||||
|
||||
it("navigates to the developer request page from empty states", async () => {
|
||||
authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "user",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
name: "Requester",
|
||||
email: "requester@example.com",
|
||||
phone: "010-1234-5678",
|
||||
},
|
||||
},
|
||||
};
|
||||
fetchClientsMock.mockResolvedValue({
|
||||
items: [],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
|
||||
fetchMeMock.mockResolvedValue({
|
||||
role: "user",
|
||||
name: "Requester",
|
||||
email: "requester@example.com",
|
||||
phone: "010-1234-5678",
|
||||
});
|
||||
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("개발자 등록 신청하기");
|
||||
|
||||
const requestButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "개발자 등록 신청하기",
|
||||
);
|
||||
expect(requestButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
|
||||
});
|
||||
});
|
||||
828
baron-sso/devfront/src/features/clients/ClientsPage.tsx
Normal file
828
baron-sso/devfront/src/features/clients/ClientsPage.tsx
Normal file
@@ -0,0 +1,828 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
SortableTableHead,
|
||||
sortableTableHeadBaseClassName,
|
||||
sortableTableHeaderClassName,
|
||||
} from "../../../../common/core/components/sort";
|
||||
import {
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../common/core/utils";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
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 { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
type ClientSummary,
|
||||
fetchClients,
|
||||
fetchDeveloperRequestStatus,
|
||||
fetchMyTenants,
|
||||
requestDeveloperAccess,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
||||
import { ClientLogo } from "./components/ClientLogo";
|
||||
|
||||
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
|
||||
const clientListPreviewCount = 5;
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
const companyCode = userProfile?.companyCode as string | undefined;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingClients,
|
||||
error: clientError,
|
||||
} = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: fetchClients,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const { data: me, isLoading: isLoadingMe } = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const profileRole = me?.role?.trim() || role;
|
||||
|
||||
const {
|
||||
data: requestStatus,
|
||||
isLoading: isLoadingRequest,
|
||||
refetch: refetchRequest,
|
||||
} = useQuery({
|
||||
queryKey: ["developer-request", tenantId],
|
||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||
enabled:
|
||||
hasAccessToken &&
|
||||
(profileRole === "user" || profileRole === "tenant_member"),
|
||||
});
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ["myTenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const createAccessState = resolveClientCreateAccess({
|
||||
role: profileRole,
|
||||
requestStatus: requestStatus?.status,
|
||||
});
|
||||
const canCreateClient = createAccessState === "can_create";
|
||||
const isDeveloperRequestPending = createAccessState === "pending";
|
||||
const canRequestDeveloperAccess =
|
||||
createAccessState === "request_required" && !isLoadingRequest;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
const [isClientListExpanded, setIsClientListExpanded] = useState(false);
|
||||
const [sortConfig, setSortConfig] =
|
||||
useState<SortConfig<ClientSortKey> | null>({
|
||||
key: "createdAt",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const clients = data?.items || [];
|
||||
|
||||
const clientSortResolvers = useMemo<
|
||||
SortResolverMap<ClientSummary, ClientSortKey>
|
||||
>(
|
||||
() => ({
|
||||
application: (client) => client.name || client.id,
|
||||
id: (client) => client.id,
|
||||
type: (client) =>
|
||||
client.metadata?.headless_login_enabled
|
||||
? "private-headless"
|
||||
: client.type,
|
||||
status: (client) => client.status,
|
||||
createdAt: (client) =>
|
||||
client.createdAt ? new Date(client.createdAt) : null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
const nextClients = 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;
|
||||
});
|
||||
|
||||
return sortItems(nextClients, sortConfig, clientSortResolvers);
|
||||
}, [
|
||||
clientSortResolvers,
|
||||
clients,
|
||||
searchQuery,
|
||||
sortConfig,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
]);
|
||||
|
||||
const hasFilterResult = filteredClients.length > 0;
|
||||
const isFilteredOut = clients.length > 0 && !hasFilterResult;
|
||||
const visibleClients = useMemo(() => {
|
||||
if (isClientListExpanded) {
|
||||
return filteredClients;
|
||||
}
|
||||
|
||||
return filteredClients.slice(0, clientListPreviewCount);
|
||||
}, [filteredClients, isClientListExpanded]);
|
||||
const canToggleClientList = filteredClients.length > clientListPreviewCount;
|
||||
const currentTenant = tenants?.find(
|
||||
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
|
||||
);
|
||||
const organizationName = currentTenant?.name || companyCode || "";
|
||||
const profileName = me?.name || (userProfile?.name as string) || "";
|
||||
const profileEmail = me?.email || (userProfile?.email as string) || "";
|
||||
const profilePhone =
|
||||
me?.phone ||
|
||||
(userProfile?.phone as string | undefined) ||
|
||||
(userProfile?.phone_number as string | undefined) ||
|
||||
"";
|
||||
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
||||
|
||||
const isLoading =
|
||||
isLoadingClients ||
|
||||
isLoadingRequest ||
|
||||
(hasAccessToken && !profileRole && isLoadingMe);
|
||||
|
||||
const requestSort = (key: ClientSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
|
||||
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">
|
||||
<PageHeader
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
description={t(
|
||||
"msg.dev.clients.registry.description",
|
||||
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
canCreateClient ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-1 shadow-lg shadow-primary/30"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
) : isDeveloperRequestPending ? (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<p className="max-w-xs text-right text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.create_pending_detail",
|
||||
"개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/developer-requests")}
|
||||
>
|
||||
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
</Button>
|
||||
</div>
|
||||
) : canRequestDeveloperAccess ? (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.create_requires_request",
|
||||
"연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요.",
|
||||
).replaceAll("\\n", "\n")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/developer-requests")}
|
||||
>
|
||||
{t("ui.dev.welcome.btn_request", "개발자 권한 신청")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.create_forbidden_detail",
|
||||
"연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" disabled>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="space-y-4 pb-4 pt-6">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.showing",
|
||||
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
|
||||
{ shown: clients.length },
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<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>
|
||||
}
|
||||
actions={
|
||||
<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>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader className={sortableTableHeaderClassName}>
|
||||
<TableRow>
|
||||
<SortableTableHead
|
||||
label={t(
|
||||
"ui.dev.clients.table.application",
|
||||
"애플리케이션",
|
||||
)}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey="application"
|
||||
/>
|
||||
<SortableTableHead
|
||||
label={t("ui.dev.clients.table.client_id", "Client ID")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey="id"
|
||||
/>
|
||||
<SortableTableHead
|
||||
label={t("ui.dev.clients.table.type", "유형")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey="type"
|
||||
/>
|
||||
<SortableTableHead
|
||||
label={t("ui.dev.clients.table.status", "상태")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey="status"
|
||||
/>
|
||||
<SortableTableHead
|
||||
label={t("ui.dev.clients.table.created_at", "생성일")}
|
||||
onSort={requestSort}
|
||||
sortConfig={sortConfig}
|
||||
sortKey="createdAt"
|
||||
/>
|
||||
<TableHead
|
||||
className={`${sortableTableHeadBaseClassName} text-right`}
|
||||
>
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!hasFilterResult && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{isFilteredOut
|
||||
? t(
|
||||
"msg.dev.clients.empty_filtered",
|
||||
"조건에 맞는 연동 앱이 없습니다.",
|
||||
)
|
||||
: canCreateClient
|
||||
? t(
|
||||
"msg.dev.clients.empty_can_create",
|
||||
"아직 등록된 연동 앱이 없습니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.empty",
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{isFilteredOut
|
||||
? t(
|
||||
"msg.dev.clients.empty_filtered_detail",
|
||||
"검색어나 필터 조건을 변경해 보세요.",
|
||||
)
|
||||
: canCreateClient
|
||||
? t(
|
||||
"msg.dev.clients.empty_can_create_detail",
|
||||
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending_detail",
|
||||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
)}
|
||||
</p>
|
||||
{!isFilteredOut && canCreateClient && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
{t("ui.dev.clients.new", "연동 앱 추가")}
|
||||
</button>
|
||||
)}
|
||||
{!isFilteredOut && canRequestDeveloperAccess && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => navigate("/developer-requests")}
|
||||
>
|
||||
{t(
|
||||
"ui.dev.welcome.btn_request",
|
||||
"개발자 등록 신청하기",
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{visibleClients.map((client) => (
|
||||
<TableRow key={client.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/clients/${client.id}`}
|
||||
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||
>
|
||||
<ClientLogo client={client} />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{client.name ||
|
||||
t("ui.dev.clients.untitled", "Untitled")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span aria-hidden="true">
|
||||
{t(
|
||||
"ui.dev.clients.tenant_scoped",
|
||||
"Tenant-scoped",
|
||||
)}
|
||||
</span>
|
||||
</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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
client.type === "private" ||
|
||||
client.metadata?.headless_login_enabled
|
||||
? "success"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{client.metadata?.headless_login_enabled
|
||||
? t(
|
||||
"ui.dev.clients.type.private_headless",
|
||||
"Server side App (Headless Login)",
|
||||
)
|
||||
: client.type === "private"
|
||||
? t(
|
||||
"ui.dev.clients.type.private",
|
||||
"Server side App",
|
||||
)
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</Badge>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
{canToggleClientList ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={
|
||||
isClientListExpanded
|
||||
? t(
|
||||
"ui.dev.clients.list.collapse_aria",
|
||||
"연동 앱 목록 접기",
|
||||
)
|
||||
: t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기")
|
||||
}
|
||||
onClick={() => setIsClientListExpanded((current) => !current)}
|
||||
>
|
||||
{isClientListExpanded
|
||||
? t("ui.common.collapse", "접기")
|
||||
: t("ui.common.load_more", "더보기")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RequestAccessModal
|
||||
isOpen={isRequestModalOpen}
|
||||
onClose={() => setIsRequestModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetchRequest();
|
||||
setIsRequestModalOpen(false);
|
||||
}}
|
||||
tenantId={tenantId || ""}
|
||||
initialName={profileName}
|
||||
initialOrg={organizationName}
|
||||
initialEmail={profileEmail}
|
||||
initialPhone={profilePhone}
|
||||
initialRole={profileRoleLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestAccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
tenantId: string;
|
||||
initialName: string;
|
||||
initialOrg: string;
|
||||
initialEmail: string;
|
||||
initialPhone: string;
|
||||
initialRole: string;
|
||||
}
|
||||
|
||||
function RequestAccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
tenantId,
|
||||
initialName,
|
||||
initialOrg,
|
||||
initialEmail,
|
||||
initialPhone,
|
||||
initialRole,
|
||||
}: RequestAccessModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [organization, setOrganization] = useState(initialOrg);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName(initialName);
|
||||
setOrganization(initialOrg);
|
||||
}, [initialName, initialOrg, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: requestDeveloperAccess,
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({
|
||||
name,
|
||||
organization,
|
||||
reason,
|
||||
tenantId,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(
|
||||
"msg.dev.request.modal.desc",
|
||||
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.dev.request.modal.name", "성함")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="org">
|
||||
{t("ui.dev.request.modal.org", "소속")}
|
||||
</Label>
|
||||
<Input
|
||||
id="org"
|
||||
value={organization}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
{t("ui.dev.request.modal.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={initialEmail}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">
|
||||
{t("ui.dev.request.modal.phone", "전화번호")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={initialPhone}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">
|
||||
{t("ui.dev.request.modal.role", "역할")}
|
||||
</Label>
|
||||
<Input
|
||||
id="role"
|
||||
value={initialRole}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason">
|
||||
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.request.modal.reason_placeholder",
|
||||
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
|
||||
)}
|
||||
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-8 font-bold"
|
||||
>
|
||||
{mutation.isPending
|
||||
? t("ui.common.submitting", "제출 중...")
|
||||
: t("ui.common.submit", "신청하기")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientsPage;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
||||
|
||||
describe("client create access", () => {
|
||||
it("allows privileged roles to create clients without developer request approval", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "rp_admin",
|
||||
}),
|
||||
).toBe("can_create");
|
||||
});
|
||||
|
||||
it("requires a developer request for basic users without approval", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "user",
|
||||
requestStatus: "none",
|
||||
}),
|
||||
).toBe("request_required");
|
||||
});
|
||||
|
||||
it("treats unresolved roles as request required instead of allowing creation", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "",
|
||||
requestStatus: undefined,
|
||||
}),
|
||||
).toBe("request_required");
|
||||
});
|
||||
|
||||
it("shows pending state while a developer request is under review", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "tenant_member",
|
||||
requestStatus: "pending",
|
||||
}),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows client creation after developer request approval", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "user",
|
||||
requestStatus: "approved",
|
||||
}),
|
||||
).toBe("can_create");
|
||||
});
|
||||
|
||||
it("routes cancelled or rejected requests back to requestable state", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "user",
|
||||
requestStatus: "cancelled",
|
||||
}),
|
||||
).toBe("request_required");
|
||||
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "user",
|
||||
requestStatus: "rejected",
|
||||
}),
|
||||
).toBe("request_required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DeveloperRequestStatus } from "../../lib/devApi";
|
||||
|
||||
export type ClientCreateAccessState =
|
||||
| "can_create"
|
||||
| "pending"
|
||||
| "request_required"
|
||||
| "forbidden";
|
||||
|
||||
type ResolveClientCreateAccessParams = {
|
||||
role: string;
|
||||
requestStatus?: DeveloperRequestStatus;
|
||||
};
|
||||
|
||||
function canSelfRequestDeveloperAccess(role: string) {
|
||||
return role === "user" || role === "tenant_member";
|
||||
}
|
||||
|
||||
export function resolveClientCreateAccess({
|
||||
role,
|
||||
requestStatus,
|
||||
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
|
||||
if (!role.trim()) {
|
||||
return "request_required";
|
||||
}
|
||||
|
||||
if (!canSelfRequestDeveloperAccess(role)) {
|
||||
return "can_create";
|
||||
}
|
||||
|
||||
if (requestStatus === "approved") {
|
||||
return "can_create";
|
||||
}
|
||||
|
||||
if (requestStatus === "pending") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (
|
||||
requestStatus === "none" ||
|
||||
requestStatus === "rejected" ||
|
||||
requestStatus === "cancelled" ||
|
||||
typeof requestStatus === "undefined"
|
||||
) {
|
||||
return "request_required";
|
||||
}
|
||||
|
||||
return "forbidden";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDisplayClientSecret } from "./clientSecretPolicy";
|
||||
|
||||
describe("client secret policy", () => {
|
||||
it("allows client secret display for server-side apps", () => {
|
||||
expect(
|
||||
canDisplayClientSecret({
|
||||
type: "private",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still allows client secret display for server-side apps even when headless login is enabled in metadata", () => {
|
||||
expect(
|
||||
canDisplayClientSecret({
|
||||
type: "private",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow client secret display for PKCE apps", () => {
|
||||
expect(
|
||||
canDisplayClientSecret({
|
||||
type: "pkce",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
type ClientSecretPolicyTarget = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export function canDisplayClientSecret(client: ClientSecretPolicyTarget) {
|
||||
return client.type === "private";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AllowedTenantBadge } from "./AllowedTenantBadge";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderAllowedTenantBadge() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<AllowedTenantBadge
|
||||
tenant={{
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
}}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AllowedTenantBadge", () => {
|
||||
it("renders tenant name, slug, full UUID, and copies the UUID", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const container = renderAllowedTenantBadge();
|
||||
const badge = container.querySelector(
|
||||
'[data-testid="allowed-tenant-11111111-2222-3333-4444-555555555555"]',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("한맥기술");
|
||||
expect(container.textContent).toContain("hanmac");
|
||||
expect(container.textContent).toContain(
|
||||
"11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
const copyButton = container.querySelector(
|
||||
'[data-testid="allowed-tenant-copy-11111111-2222-3333-4444-555555555555"]',
|
||||
);
|
||||
expect(copyButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { CopyButton } from "../../../components/ui/copy-button";
|
||||
import type { MyTenantSummary, TenantSummary } from "../../../lib/devApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type AllowedTenant = Pick<TenantSummary | MyTenantSummary, "id" | "name"> & {
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
type AllowedTenantBadgeProps = {
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
tenant: AllowedTenant;
|
||||
};
|
||||
|
||||
export function AllowedTenantBadge({
|
||||
disabled = false,
|
||||
onRemove,
|
||||
tenant,
|
||||
}: AllowedTenantBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex max-w-full items-center gap-2 px-3 py-1.5"
|
||||
data-testid={`allowed-tenant-${tenant.id}`}
|
||||
data-tenant-id={tenant.id}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||||
{tenant.slug ? (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="max-w-64 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{tenant.id}
|
||||
</span>
|
||||
<CopyButton
|
||||
aria-label="테넌트 UUID 복사"
|
||||
className="h-6 w-6 shrink-0"
|
||||
data-testid={`allowed-tenant-copy-${tenant.id}`}
|
||||
size="icon"
|
||||
value={tenant.id}
|
||||
variant="ghost"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground transition hover:text-destructive"
|
||||
data-testid={`allowed-tenant-remove-${tenant.id}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClientLogo } from "./ClientLogo";
|
||||
|
||||
vi.mock("../../../components/ui/avatar", () => ({
|
||||
Avatar: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="avatar">{children}</div>
|
||||
),
|
||||
AvatarImage: (props: ComponentProps<"img">) => <img alt="" {...props} />,
|
||||
AvatarFallback: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="fallback">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderLogo(client: Parameters<typeof ClientLogo>[0]["client"]) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(<ClientLogo client={client} />);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClientLogo", () => {
|
||||
it("renders the fallback icon when no logo url exists", () => {
|
||||
const container = renderLogo({
|
||||
name: "",
|
||||
type: "private",
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll("svg").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses the logo image when a trimmed url is provided", () => {
|
||||
const container = renderLogo({
|
||||
name: "Gitea",
|
||||
type: "pkce",
|
||||
metadata: { logo_url: " https://example.com/logo.png " },
|
||||
});
|
||||
|
||||
const image = container.querySelector("img");
|
||||
expect(image).not.toBeNull();
|
||||
expect(container.querySelector("[data-testid='fallback']")).not.toBeNull();
|
||||
expect(image?.getAttribute("alt")).toContain("Gitea");
|
||||
expect(image?.getAttribute("src")).toBe("https://example.com/logo.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ServerCog, ShieldHalf } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../../components/ui/avatar";
|
||||
import type { ClientSummary, ClientType } from "../../../lib/devApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type ClientLogoProps = {
|
||||
client: Pick<ClientSummary, "name" | "type" | "metadata">;
|
||||
};
|
||||
|
||||
function readLogoUrl(metadata?: Record<string, unknown>): string | undefined {
|
||||
const logoUrl = metadata?.logo_url;
|
||||
if (typeof logoUrl !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmedLogoUrl = logoUrl.trim();
|
||||
return trimmedLogoUrl.length > 0 ? trimmedLogoUrl : undefined;
|
||||
}
|
||||
|
||||
function TypeFallbackIcon({ type }: { type: ClientType }) {
|
||||
if (type === "private") {
|
||||
return <ServerCog className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <ShieldHalf className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function ClientLogo({ client }: ClientLogoProps) {
|
||||
const [didImageFail, setDidImageFail] = useState(false);
|
||||
const logoUrl = useMemo(
|
||||
() => readLogoUrl(client.metadata),
|
||||
[client.metadata],
|
||||
);
|
||||
const showImage = Boolean(logoUrl) && !didImageFail;
|
||||
const clientName = client.name || t("ui.dev.clients.untitled", "Untitled");
|
||||
|
||||
return (
|
||||
<Avatar className="h-9 w-9 rounded-lg border border-border/60 bg-primary/10 text-primary">
|
||||
{showImage ? (
|
||||
<AvatarImage
|
||||
src={logoUrl}
|
||||
alt={t("ui.dev.clients.logo_alt", "{{name}} 로고", {
|
||||
name: clientName,
|
||||
})}
|
||||
className="object-contain p-1"
|
||||
onError={() => setDidImageFail(true)}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
|
||||
<TypeFallbackIcon type={client.type} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClientFederationPage } from "./ClientFederationPage";
|
||||
|
||||
let params: { id?: string } = { id: "client-a" };
|
||||
const listIdpConfigsMock = vi.fn();
|
||||
const createIdpConfigMock = vi.fn();
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router-dom")>(
|
||||
"react-router-dom",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => params,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../lib/devApi", () => ({
|
||||
listIdpConfigsForClient: (clientId: string) => listIdpConfigsMock(clientId),
|
||||
createIdpConfigForClient: (payload: unknown) => createIdpConfigMock(payload),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
params = { id: "client-a" };
|
||||
listIdpConfigsMock.mockResolvedValue([
|
||||
{
|
||||
id: "idp-1",
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Workspace OIDC",
|
||||
status: "active",
|
||||
issuer_url: "https://accounts.example",
|
||||
oidc_client_id: "oidc-client",
|
||||
scopes: "openid email profile",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
createIdpConfigMock.mockResolvedValue({
|
||||
id: "idp-2",
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "New Provider",
|
||||
status: "active",
|
||||
createdAt: "2026-05-02T00:00:00Z",
|
||||
updatedAt: "2026-05-02T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientFederationPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ClientFederationPage", () => {
|
||||
it("shows a missing client id message when no route param exists", async () => {
|
||||
params = {};
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("Client ID is missing");
|
||||
});
|
||||
|
||||
it("opens the create modal and submits a new IdP config", async () => {
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("Workspace OIDC");
|
||||
|
||||
const addButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Add Provider",
|
||||
);
|
||||
expect(addButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Add Identity Provider");
|
||||
|
||||
const displayName = container.querySelector(
|
||||
'input[name="display_name"]',
|
||||
) as HTMLInputElement | null;
|
||||
const issuerUrl = container.querySelector(
|
||||
'input[name="issuer_url"]',
|
||||
) as HTMLInputElement | null;
|
||||
const clientId = container.querySelector(
|
||||
'input[name="oidc_client_id"]',
|
||||
) as HTMLInputElement | null;
|
||||
const clientSecret = container.querySelector(
|
||||
'input[name="oidc_client_secret"]',
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
if (!displayName || !issuerUrl || !clientId || !clientSecret) {
|
||||
throw new Error("Expected federation form inputs to be rendered");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(displayName, "New Provider");
|
||||
await setInputValue(issuerUrl, "https://login.example");
|
||||
await setInputValue(clientId, "client-oidc");
|
||||
await setInputValue(clientSecret, "secret-value");
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Save Configuration",
|
||||
);
|
||||
expect(submitButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(createIdpConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
client_id: "client-a",
|
||||
display_name: "New Provider",
|
||||
issuer_url: "https://login.example",
|
||||
oidc_client_id: "client-oidc",
|
||||
oidc_client_secret: "secret-value",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit, Plus, Save, ShieldHalf, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../../common/core/components/page";
|
||||
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 { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
|
||||
import {
|
||||
createIdpConfigForClient,
|
||||
listIdpConfigsForClient,
|
||||
} 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: clientIdParam } = useParams<{ id: string }>();
|
||||
const clientId = clientIdParam ?? "";
|
||||
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["idpConfigs", clientId],
|
||||
queryFn: () => listIdpConfigsForClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
Client ID is missing
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-1">
|
||||
<PageHeader
|
||||
icon={<ShieldHalf size={20} />}
|
||||
title={t("ui.dev.clients.federation.title", "Identity Federation")}
|
||||
description={t(
|
||||
"msg.dev.clients.federation.subtitle",
|
||||
"Manage external identity providers for this application.",
|
||||
)}
|
||||
actions={
|
||||
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
123
baron-sso/devfront/src/features/coverage/AuditLogTable.test.tsx
Normal file
123
baron-sso/devfront/src/features/coverage/AuditLogTable.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { act } from "../../../../common/node_modules/react-dom/test-utils";
|
||||
import { createRoot, type Root } from "../../../../common/node_modules/react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommonAuditLog } from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(<AuditLogTable {...props} />);
|
||||
});
|
||||
|
||||
return { container };
|
||||
}
|
||||
|
||||
const logs: CommonAuditLog[] = [
|
||||
{
|
||||
event_id: "evt-1",
|
||||
timestamp: "2026-05-28T06:07:18.000Z",
|
||||
user_id: "user-1",
|
||||
event_type: "CLIENT_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
device_id: "device-1",
|
||||
details: JSON.stringify({
|
||||
request_id: "req-1",
|
||||
method: "POST",
|
||||
path: "/api/v1/clients",
|
||||
latency_ms: 120,
|
||||
tenant_id: "tenant-1",
|
||||
actor_id: "user-1",
|
||||
action: "업데이트",
|
||||
target_id: "client-a",
|
||||
before: { status: "inactive" },
|
||||
after: { status: "active" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("AuditLogTable", () => {
|
||||
it("renders rows, expands details, copies fields, and loads more", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const onLoadMore = vi.fn();
|
||||
const { container } = renderTable({
|
||||
logs,
|
||||
t: (key, fallback, vars) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
loading: false,
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("user-1");
|
||||
expect(container.textContent).toContain("업데이트");
|
||||
expect(container.textContent).toContain("client-a");
|
||||
expect(container.textContent).toContain("success");
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const actorCopyButton = buttons.find(
|
||||
(button) => button.getAttribute("aria-label") === "Copy User ID",
|
||||
);
|
||||
const targetCopyButton = buttons.find(
|
||||
(button) => button.getAttribute("aria-label") === "Copy Client ID",
|
||||
);
|
||||
const expandButton = buttons.find(
|
||||
(button) => !button.getAttribute("aria-label") && !button.textContent,
|
||||
);
|
||||
const loadMoreButton = buttons.find(
|
||||
(button) => button.textContent === "Load more",
|
||||
);
|
||||
|
||||
expect(actorCopyButton).toBeTruthy();
|
||||
expect(targetCopyButton).toBeTruthy();
|
||||
expect(expandButton).toBeTruthy();
|
||||
expect(loadMoreButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("user-1");
|
||||
expect(writeText).toHaveBeenCalledWith("client-a");
|
||||
expect(container.textContent).toContain("Request ID · req-1");
|
||||
expect(container.textContent).toContain("Actor");
|
||||
expect(container.textContent).toContain("Result");
|
||||
|
||||
await act(async () => {
|
||||
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
72
baron-sso/devfront/src/features/coverage/commonAudit.test.ts
Normal file
72
baron-sso/devfront/src/features/coverage/commonAudit.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../../../common/core/audit";
|
||||
|
||||
describe("common audit helpers", () => {
|
||||
it("parses audit details and falls back on invalid payloads", () => {
|
||||
expect(parseAuditDetails()).toEqual({});
|
||||
expect(parseAuditDetails("not-json")).toEqual({});
|
||||
expect(parseAuditDetails('{"action":"ADD_RELATION"}')).toEqual({
|
||||
action: "ADD_RELATION",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats audit values and dates", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
expect(formatAuditValue(null)).toBe("-");
|
||||
expect(formatAuditValue("hello")).toBe("hello");
|
||||
expect(formatAuditValue({ a: 1 })).toBe('{"a":1}');
|
||||
expect(formatAuditValue(circular)).toBe("[object Object]");
|
||||
|
||||
expect(formatAuditDateParts("")).toEqual({ date: "-", time: "-" });
|
||||
expect(formatAuditDateParts("invalid")).toEqual({
|
||||
date: "invalid",
|
||||
time: "-",
|
||||
});
|
||||
|
||||
const parsed = formatAuditDateParts("2026-05-27T07:43:39.000Z");
|
||||
expect(parsed.date).toBe("2026-05-27");
|
||||
expect(parsed.time).not.toBe("-");
|
||||
});
|
||||
|
||||
it("resolves audit actor, action, and target consistently", () => {
|
||||
expect(
|
||||
resolveAuditActor(
|
||||
{ user_id: "actor-1" },
|
||||
{ actor_id: "actor-2" },
|
||||
),
|
||||
).toBe("actor-1");
|
||||
expect(
|
||||
resolveAuditActor({ user_id: "" }, { actor_id: "actor-2" }),
|
||||
).toBe("actor-2");
|
||||
expect(resolveAuditActor({ user_id: "" }, {})).toBe("-");
|
||||
|
||||
expect(
|
||||
resolveAuditAction(
|
||||
{ event_type: "UPDATE_CLIENT" },
|
||||
{ action: "ADD_RELATION" },
|
||||
),
|
||||
).toBe("ADD_RELATION");
|
||||
expect(
|
||||
resolveAuditAction(
|
||||
{ event_type: "UPDATE_CLIENT" },
|
||||
{ method: "POST", path: "/dev/clients" },
|
||||
),
|
||||
).toBe("POST /dev/clients");
|
||||
expect(resolveAuditAction({ event_type: "UPDATE_CLIENT" }, {})).toBe(
|
||||
"UPDATE_CLIENT",
|
||||
);
|
||||
|
||||
expect(resolveAuditTarget({ target: "target-1" })).toBe("target-1");
|
||||
expect(resolveAuditTarget({ target_id: "target-2" })).toBe("target-2");
|
||||
expect(resolveAuditTarget({})).toBe("-");
|
||||
});
|
||||
});
|
||||
72
baron-sso/devfront/src/features/coverage/commonAuth.test.ts
Normal file
72
baron-sso/devfront/src/features/coverage/commonAuth.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_OIDC_REDIRECT_PATH,
|
||||
DEFAULT_OIDC_SCOPE,
|
||||
buildCommonOidcRuntimeConfig,
|
||||
buildCommonUserManagerSettings,
|
||||
shouldStartLoginRedirect,
|
||||
} from "../../../../common/core/auth";
|
||||
|
||||
describe("common auth helpers", () => {
|
||||
it("builds the runtime OIDC config with sensible defaults", () => {
|
||||
const config = buildCommonOidcRuntimeConfig({
|
||||
authority: "https://issuer.example.com",
|
||||
clientId: "client-1",
|
||||
userStore: { kind: "store" },
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
authority: "https://issuer.example.com",
|
||||
client_id: "client-1",
|
||||
redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
|
||||
response_type: "code",
|
||||
scope: DEFAULT_OIDC_SCOPE,
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
popup_redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
|
||||
userStore: { kind: "store" },
|
||||
automaticSilentRenew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies user manager config and fills missing string fields", () => {
|
||||
expect(
|
||||
buildCommonUserManagerSettings({
|
||||
authority: "https://issuer.example.com",
|
||||
}),
|
||||
).toEqual({
|
||||
authority: "https://issuer.example.com",
|
||||
client_id: "",
|
||||
redirect_uri: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("decides when to start login redirects", () => {
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/clients",
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/login",
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: `${DEFAULT_OIDC_REDIRECT_PATH}/callback`,
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/clients",
|
||||
isRedirecting: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
54
baron-sso/devfront/src/features/coverage/commonSort.test.ts
Normal file
54
baron-sso/devfront/src/features/coverage/commonSort.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNullableValues,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../common/core/utils/sort";
|
||||
|
||||
describe("common sort utilities in devfront coverage", () => {
|
||||
it("keeps nullish values last and compares normalized primitive values", () => {
|
||||
expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
|
||||
expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
|
||||
expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
|
||||
expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
|
||||
expect(compareNullableValues(true, false, "asc")).toBe(1);
|
||||
expect(
|
||||
compareNullableValues(
|
||||
new Date("2026-05-02T00:00:00Z"),
|
||||
new Date("2026-05-01T00:00:00Z"),
|
||||
"asc",
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("toggles sort direction and sorts with default and custom resolvers", () => {
|
||||
const firstSort = toggleSort(null, "name");
|
||||
expect(firstSort).toEqual({ key: "name", direction: "asc" });
|
||||
expect(toggleSort(firstSort, "name")).toEqual({
|
||||
key: "name",
|
||||
direction: "desc",
|
||||
});
|
||||
expect(toggleSort(firstSort, "createdAt")).toEqual({
|
||||
key: "createdAt",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ name: "charlie", rank: 3, nested: { score: 20 } },
|
||||
{ name: "Alpha", rank: 1, nested: { score: 30 } },
|
||||
{ name: "bravo", rank: 2, nested: { score: 10 } },
|
||||
];
|
||||
|
||||
expect(
|
||||
sortItems(rows, { key: "name", direction: "asc" }).map(
|
||||
(row) => row.name,
|
||||
),
|
||||
).toEqual(["Alpha", "bravo", "charlie"]);
|
||||
expect(
|
||||
sortItems(rows, { key: "score", direction: "desc" }, {
|
||||
score: (row) => row.nested.score,
|
||||
}).map((row) => row.name),
|
||||
).toEqual(["Alpha", "charlie", "bravo"]);
|
||||
expect(sortItems(rows, null)).not.toBe(rows);
|
||||
});
|
||||
});
|
||||
513
baron-sso/devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
513
baron-sso/devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import ClientConsentsPage from "../clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../clients/ClientRelationsPage";
|
||||
import ClientsPage from "../clients/ClientsPage";
|
||||
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
||||
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
|
||||
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
|
||||
import ProfilePage from "../profile/ProfilePage";
|
||||
import {
|
||||
approveDeveloperRequest,
|
||||
cancelDeveloperRequestApproval,
|
||||
rejectDeveloperRequest,
|
||||
} from "../../lib/devApi";
|
||||
|
||||
const authProfile = {
|
||||
sub: "user-1",
|
||||
role: "super_admin",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: authProfile,
|
||||
},
|
||||
signinRedirect: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../common/core/components/audit", () => ({
|
||||
AuditLogTable: ({
|
||||
logs,
|
||||
}: {
|
||||
logs: Array<{ user_id: string; event_type: string }>;
|
||||
}) => (
|
||||
<div>
|
||||
{logs.map((log) => (
|
||||
<div key={`${log.user_id}-${log.event_type}`}>
|
||||
<span>{log.user_id}</span>
|
||||
<span>{log.event_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const clientSummary = {
|
||||
id: "client-a",
|
||||
name: "Console App",
|
||||
type: "private" as const,
|
||||
status: "active" as const,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
redirectUris: ["https://app.example/callback"],
|
||||
scopes: ["openid", "profile"],
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
metadata: {
|
||||
headless_login_enabled: true,
|
||||
headless_login_jwks_uri: "https://app.example/jwks.json",
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "employee_id",
|
||||
value: "E001",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const clientDetail = {
|
||||
client: {
|
||||
...clientSummary,
|
||||
clientSecret: "secret-value",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
backchannelLogoutUri: "https://app.example/logout",
|
||||
backchannelLogoutSessionRequired: true,
|
||||
grantTypes: ["authorization_code"],
|
||||
responseTypes: ["code"],
|
||||
},
|
||||
endpoints: {
|
||||
discovery: "https://sso.example/.well-known/openid-configuration",
|
||||
issuer: "https://sso.example",
|
||||
authorization: "https://sso.example/oauth2/auth",
|
||||
token: "https://sso.example/oauth2/token",
|
||||
userinfo: "https://sso.example/userinfo",
|
||||
},
|
||||
headlessJwksCache: {
|
||||
clientId: "client-a",
|
||||
jwksUri: "https://app.example/jwks.json",
|
||||
cachedAt: "2026-05-01T00:00:00Z",
|
||||
expiresAt: "2026-05-02T00:00:00Z",
|
||||
lastCheckedAt: "2026-05-01T01:00:00Z",
|
||||
lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
|
||||
lastRefreshStatus: "success" as const,
|
||||
cachedKids: ["kid-1"],
|
||||
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchClients: vi.fn(async () => ({
|
||||
items: [clientSummary],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})),
|
||||
fetchDevStats: vi.fn(async () => ({
|
||||
total_clients: 1,
|
||||
active_sessions: 12,
|
||||
auth_failures_24h: 2,
|
||||
})),
|
||||
fetchDevRPUsageDaily: vi.fn(async () => ({
|
||||
days: 14,
|
||||
period: "day",
|
||||
items: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
tenantId: "tenant-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "Hanmac",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
loginRequests: 10,
|
||||
otherRequests: 4,
|
||||
uniqueSubjects: 3,
|
||||
},
|
||||
{
|
||||
date: "2026-05-08",
|
||||
tenantId: "tenant-1",
|
||||
tenantType: "COMPANY",
|
||||
tenantName: "Hanmac",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
loginRequests: 8,
|
||||
otherRequests: 5,
|
||||
uniqueSubjects: 4,
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchClient: vi.fn(async () => clientDetail),
|
||||
fetchClientRelations: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
relation: "admins",
|
||||
subject: "User:user-1",
|
||||
subjectType: "User",
|
||||
subjectId: "user-1",
|
||||
userName: "Dev Admin",
|
||||
userEmail: "dev@example.com",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchRPUserMetadata: vi.fn(async () => ({
|
||||
clientId: "client-a",
|
||||
userId: "user-1",
|
||||
metadata: {
|
||||
approvalLevel: "A",
|
||||
},
|
||||
})),
|
||||
updateRPUserMetadata: vi.fn(async (_clientId: string, userId: string, metadata: Record<string, unknown>) => ({
|
||||
clientId: "client-a",
|
||||
userId,
|
||||
metadata,
|
||||
})),
|
||||
fetchDevUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Editor User",
|
||||
email: "editor@example.com",
|
||||
loginId: "editor",
|
||||
},
|
||||
],
|
||||
})),
|
||||
addClientRelation: vi.fn(async () => ({
|
||||
relation: "admins",
|
||||
subject: "User:user-2",
|
||||
subjectType: "User",
|
||||
subjectId: "user-2",
|
||||
})),
|
||||
removeClientRelation: vi.fn(async () => undefined),
|
||||
updateClientStatus: vi.fn(async () => clientDetail),
|
||||
createClient: vi.fn(async () => clientDetail),
|
||||
updateClient: vi.fn(async () => clientDetail),
|
||||
rotateClientSecret: vi.fn(async () => clientDetail),
|
||||
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
|
||||
revokeHeadlessJwksCache: vi.fn(async () => undefined),
|
||||
deleteClient: vi.fn(async () => undefined),
|
||||
fetchConsents: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
subject: "user-1",
|
||||
userName: "Consent User",
|
||||
clientId: "client-a",
|
||||
clientName: "Console App",
|
||||
grantedScopes: ["openid", "profile"],
|
||||
authenticatedAt: "2026-05-01T02:00:00Z",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
status: "active",
|
||||
tenantId: "tenant-1",
|
||||
tenantName: "Hanmac",
|
||||
rpMetadata: {
|
||||
approvalLevel: "A",
|
||||
reviewedAt: "2026-06-09T09:30:00+09:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
revokeConsent: vi.fn(async () => undefined),
|
||||
listIdpConfigsForClient: vi.fn(async () => [
|
||||
{
|
||||
id: "idp-1",
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Workspace OIDC",
|
||||
status: "active",
|
||||
issuer_url: "https://accounts.example",
|
||||
oidc_client_id: "oidc-client",
|
||||
scopes: "openid email profile",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createIdpConfigForClient: vi.fn(async (payload) => ({
|
||||
id: "idp-1",
|
||||
...payload,
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
})),
|
||||
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
|
||||
id: idpId,
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "Provider",
|
||||
status: "active",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
...payload,
|
||||
})),
|
||||
deleteIdpConfig: vi.fn(async () => undefined),
|
||||
fetchDevAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "user-1",
|
||||
event_type: "client.update",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: "{\"client_id\":\"client-a\"}",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
fetchMyTenants: vi.fn(async () => [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "Hanmac",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
status: "active",
|
||||
description: "",
|
||||
memberCount: 10,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
|
||||
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
|
||||
fetchDeveloperRequests: vi.fn(async () => [
|
||||
{
|
||||
id: 1,
|
||||
userId: "user-3",
|
||||
tenantId: "tenant-1",
|
||||
name: "Requester",
|
||||
organization: "Hanmac",
|
||||
email: "requester@example.com",
|
||||
reason: "Need RP access",
|
||||
status: "pending",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userId: "user-4",
|
||||
tenantId: "tenant-1",
|
||||
name: "Approved Requester",
|
||||
organization: "Hanmac",
|
||||
email: "approved@example.com",
|
||||
reason: "Need elevated access",
|
||||
status: "approved",
|
||||
adminNotes: "Reviewed and approved",
|
||||
createdAt: "2026-05-02T00:00:00Z",
|
||||
updatedAt: "2026-05-02T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
|
||||
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
|
||||
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "dev@example.com",
|
||||
name: "Dev Admin",
|
||||
role: "super_admin",
|
||||
tenantId: "tenant-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderPage(
|
||||
element: React.ReactElement,
|
||||
{
|
||||
path = "/",
|
||||
entry = path,
|
||||
}: {
|
||||
path?: string;
|
||||
entry?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path={path} element={element} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("devfront coverage smoke pages", () => {
|
||||
it("renders overview, client list, audit, developer request, and profile pages", async () => {
|
||||
const overview = await renderPage(<GlobalOverviewPage />);
|
||||
expect(overview.textContent).toContain("Console App");
|
||||
|
||||
const clients = await renderPage(<ClientsPage />);
|
||||
expect(clients.textContent).toContain("Console App");
|
||||
|
||||
const audit = await renderPage(<AuditLogsPage />);
|
||||
expect(audit.textContent).toContain("client.update");
|
||||
|
||||
const requests = await renderPage(<DeveloperRequestPage />);
|
||||
expect(requests.textContent).toContain("Requester");
|
||||
|
||||
const profile = await renderPage(<ProfilePage />);
|
||||
expect(profile.textContent).toContain("Dev Admin");
|
||||
});
|
||||
|
||||
it("renders client detail, settings, consent, federation, and relationship pages", async () => {
|
||||
const details = await renderPage(<ClientDetailsPage />, {
|
||||
path: "/clients/:id",
|
||||
entry: "/clients/client-a",
|
||||
});
|
||||
expect(details.textContent).toContain("Console App");
|
||||
|
||||
const settings = await renderPage(<ClientGeneralPage />, {
|
||||
path: "/clients/:id/settings",
|
||||
entry: "/clients/client-a/settings",
|
||||
});
|
||||
expect(settings.textContent).toContain("Console App");
|
||||
expect(settings.textContent).not.toContain("top-level");
|
||||
expect(settings.textContent).toContain("Date");
|
||||
expect(settings.textContent).toContain("Datetime");
|
||||
expect(settings.textContent).toContain("관리자만 가능");
|
||||
|
||||
const consents = await renderPage(<ClientConsentsPage />, {
|
||||
path: "/clients/:id/consents",
|
||||
entry: "/clients/client-a/consents",
|
||||
});
|
||||
expect(consents.textContent).toContain("Consent User");
|
||||
expect(consents.textContent).toContain("approvalLevel");
|
||||
expect(consents.textContent).toContain("A");
|
||||
|
||||
const federation = await renderPage(<ClientFederationPage />, {
|
||||
path: "/clients/:id/federation",
|
||||
entry: "/clients/client-a/federation",
|
||||
});
|
||||
expect(federation.textContent).toContain("Workspace OIDC");
|
||||
|
||||
const relations = await renderPage(<ClientRelationsPage />, {
|
||||
path: "/clients/:id/relationships",
|
||||
entry: "/clients/client-a/relationships",
|
||||
});
|
||||
expect(relations.textContent).toContain("Dev Admin");
|
||||
});
|
||||
|
||||
it("covers developer request actions", async () => {
|
||||
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||
const requests = await renderPage(<DeveloperRequestPage />);
|
||||
|
||||
expect(requests.textContent).toContain("Requester");
|
||||
expect(requests.textContent).toContain("Approved Requester");
|
||||
|
||||
const pendingNote = Array.from(
|
||||
requests.querySelectorAll("input"),
|
||||
).find((input) => input.getAttribute("placeholder") === "메모 입력 (선택)...") as HTMLInputElement | undefined;
|
||||
const cancelNote = Array.from(
|
||||
requests.querySelectorAll("input"),
|
||||
).find(
|
||||
(input) => input.getAttribute("placeholder") === "승인 취소 사유 입력...",
|
||||
) as HTMLInputElement | undefined;
|
||||
|
||||
expect(pendingNote).toBeTruthy();
|
||||
expect(cancelNote).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(pendingNote!, "");
|
||||
});
|
||||
|
||||
const buttons = Array.from(requests.querySelectorAll("button"));
|
||||
const rejectButton = buttons.find((button) => button.textContent === "반려");
|
||||
const approveButton = buttons.find((button) => button.textContent === "승인");
|
||||
const cancelButton = buttons.find(
|
||||
(button) => button.textContent === "승인 취소",
|
||||
);
|
||||
|
||||
expect(rejectButton).toBeTruthy();
|
||||
expect(approveButton).toBeTruthy();
|
||||
expect(cancelButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(alertSpy).toHaveBeenCalledWith("반려 사유를 입력해주세요.");
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(pendingNote!, "Need more context");
|
||||
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await setInputValue(cancelNote!, "Approve needs revision");
|
||||
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(approveDeveloperRequest).toHaveBeenCalledWith(1, "Need more context");
|
||||
expect(rejectDeveloperRequest).not.toHaveBeenCalled();
|
||||
expect(cancelDeveloperRequestApproval).toHaveBeenCalledWith(
|
||||
2,
|
||||
"Approve needs revision",
|
||||
);
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDeveloperAccessGate,
|
||||
shouldFetchDeveloperRequestStatus,
|
||||
shouldShowDeveloperAccessLoading,
|
||||
} from "./developerAccessGate";
|
||||
|
||||
describe("developer access gate", () => {
|
||||
it("fetches request status only for user roles", () => {
|
||||
expect(shouldFetchDeveloperRequestStatus("user")).toBe(true);
|
||||
expect(shouldFetchDeveloperRequestStatus("tenant_admin")).toBe(false);
|
||||
expect(shouldFetchDeveloperRequestStatus("rp_admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves access and request states from the request status", () => {
|
||||
expect(resolveDeveloperAccessGate("super_admin", "pending")).toEqual({
|
||||
hasDeveloperAccess: true,
|
||||
isDeveloperRequestPending: true,
|
||||
canRequestDeveloperAccess: false,
|
||||
});
|
||||
|
||||
expect(resolveDeveloperAccessGate("user", "approved")).toEqual({
|
||||
hasDeveloperAccess: true,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: false,
|
||||
});
|
||||
|
||||
expect(resolveDeveloperAccessGate("user", "pending")).toEqual({
|
||||
hasDeveloperAccess: false,
|
||||
isDeveloperRequestPending: true,
|
||||
canRequestDeveloperAccess: false,
|
||||
});
|
||||
|
||||
expect(resolveDeveloperAccessGate("user", "none")).toEqual({
|
||||
hasDeveloperAccess: false,
|
||||
isDeveloperRequestPending: false,
|
||||
canRequestDeveloperAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the loading gate only for user requests", () => {
|
||||
expect(shouldShowDeveloperAccessLoading("user", true, false)).toBe(true);
|
||||
expect(shouldShowDeveloperAccessLoading("user", false, true)).toBe(true);
|
||||
expect(shouldShowDeveloperAccessLoading("tenant_admin", true, true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type DeveloperRequestStatus,
|
||||
fetchDeveloperRequestStatus,
|
||||
} from "../../lib/devApi";
|
||||
|
||||
export type DeveloperAccessGateState = {
|
||||
hasDeveloperAccess: boolean;
|
||||
isDeveloperRequestPending: boolean;
|
||||
canRequestDeveloperAccess: boolean;
|
||||
isLoadingDeveloperAccessGate: boolean;
|
||||
};
|
||||
|
||||
function isPrivilegedDeveloperRole(profileRole: string) {
|
||||
return (
|
||||
profileRole === "super_admin" ||
|
||||
profileRole === "rp_admin" ||
|
||||
profileRole === "tenant_admin"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDeveloperAccessGate(
|
||||
profileRole: string,
|
||||
requestStatus?: DeveloperRequestStatus,
|
||||
): Omit<DeveloperAccessGateState, "isLoadingDeveloperAccessGate"> {
|
||||
const hasDeveloperAccess =
|
||||
isPrivilegedDeveloperRole(profileRole) || requestStatus === "approved";
|
||||
const isDeveloperRequestPending = requestStatus === "pending";
|
||||
const canRequestDeveloperAccess =
|
||||
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending;
|
||||
|
||||
return {
|
||||
hasDeveloperAccess,
|
||||
isDeveloperRequestPending,
|
||||
canRequestDeveloperAccess,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldFetchDeveloperRequestStatus(profileRole: string) {
|
||||
return profileRole === "user";
|
||||
}
|
||||
|
||||
export function shouldShowDeveloperAccessLoading(
|
||||
profileRole: string,
|
||||
isLoadingIdentity: boolean,
|
||||
isLoadingRequestStatus: boolean,
|
||||
) {
|
||||
return (
|
||||
profileRole === "user" && (isLoadingIdentity || isLoadingRequestStatus)
|
||||
);
|
||||
}
|
||||
|
||||
export function useDeveloperAccessGate({
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
isLoadingIdentity = false,
|
||||
}: {
|
||||
hasAccessToken: boolean;
|
||||
profileRole: string;
|
||||
tenantId?: string;
|
||||
isLoadingIdentity?: boolean;
|
||||
}) {
|
||||
const shouldFetchRequestStatus =
|
||||
shouldFetchDeveloperRequestStatus(profileRole);
|
||||
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
|
||||
queryKey: ["developer-request", tenantId],
|
||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||
enabled: hasAccessToken && shouldFetchRequestStatus,
|
||||
});
|
||||
|
||||
const resolvedGate = resolveDeveloperAccessGate(
|
||||
profileRole,
|
||||
requestStatus?.status,
|
||||
);
|
||||
|
||||
return {
|
||||
...resolvedGate,
|
||||
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
|
||||
profileRole,
|
||||
isLoadingIdentity,
|
||||
isLoadingRequestStatus,
|
||||
),
|
||||
} satisfies DeveloperAccessGateState;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import DeveloperRequestPage from "./DeveloperRequestPage";
|
||||
|
||||
const fetchDeveloperRequestsMock = vi.fn();
|
||||
const fetchMyTenantsMock = vi.fn();
|
||||
const fetchMeMock = vi.fn();
|
||||
const requestDeveloperAccessMock = vi.fn();
|
||||
|
||||
let authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "user",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
name: "Requester",
|
||||
email: "requester@example.com",
|
||||
phone: "010-1234-5678",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: () => fetchMeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
fetchDeveloperRequests: () => fetchDeveloperRequestsMock(),
|
||||
fetchMyTenants: () => fetchMyTenantsMock(),
|
||||
requestDeveloperAccess: (...args: unknown[]) =>
|
||||
requestDeveloperAccessMock(...args),
|
||||
approveDeveloperRequest: vi.fn(),
|
||||
rejectDeveloperRequest: vi.fn(),
|
||||
cancelDeveloperRequestApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
role: "user",
|
||||
tenant_id: "tenant-1",
|
||||
companyCode: "HANMAC",
|
||||
name: "Requester",
|
||||
email: "requester@example.com",
|
||||
phone: "010-1234-5678",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchDeveloperRequestsMock.mockResolvedValue([]);
|
||||
fetchMyTenantsMock.mockResolvedValue([
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "Hanmac",
|
||||
slug: "hanmac",
|
||||
type: "COMPANY",
|
||||
parentId: null,
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 10,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
fetchMeMock.mockResolvedValue({
|
||||
id: "user-1",
|
||||
name: "Requester",
|
||||
email: "requester@example.com",
|
||||
phone: "010-1234-5678",
|
||||
role: "user",
|
||||
});
|
||||
requestDeveloperAccessMock.mockResolvedValue({ status: "pending" });
|
||||
});
|
||||
|
||||
async function setTextAreaValue(input: HTMLTextAreaElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DeveloperRequestPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("DeveloperRequestPage", () => {
|
||||
it("opens the request modal and submits a request", async () => {
|
||||
const container = await renderPage();
|
||||
expect(container.textContent).toContain("신규 신청하기");
|
||||
|
||||
const actionButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("신규 신청하기"),
|
||||
);
|
||||
expect(actionButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("개발자 등록 신청");
|
||||
|
||||
const reasonField = container.querySelector(
|
||||
"textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (!reasonField) {
|
||||
throw new Error("Expected reason textarea to be rendered");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await setTextAreaValue(reasonField, "Need RP access");
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "신청하기",
|
||||
);
|
||||
expect(submitButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(requestDeveloperAccessMock).toHaveBeenCalled();
|
||||
expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({
|
||||
name: "Requester",
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
approveDeveloperRequest,
|
||||
cancelDeveloperRequestApproval,
|
||||
fetchDeveloperRequests,
|
||||
fetchMyTenants,
|
||||
rejectDeveloperRequest,
|
||||
requestDeveloperAccess,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
|
||||
export default function DeveloperRequestPage() {
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
const companyCode = userProfile?.companyCode as string | undefined;
|
||||
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
|
||||
|
||||
const { data: requests, isLoading } = useQuery({
|
||||
queryKey: ["developer-requests"],
|
||||
queryFn: () => fetchDeveloperRequests(),
|
||||
enabled: !!auth.user?.access_token,
|
||||
});
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ["myTenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
enabled: !!auth.user?.access_token,
|
||||
});
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const currentTenant = tenants?.find(
|
||||
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
|
||||
);
|
||||
const organizationName = currentTenant?.name || companyCode || "";
|
||||
const profileName = me?.name || (userProfile?.name as string) || "";
|
||||
const profileEmail = me?.email || (userProfile?.email as string) || "";
|
||||
const profilePhone =
|
||||
me?.phone ||
|
||||
(userProfile?.phone as string | undefined) ||
|
||||
(userProfile?.phone_number as string | undefined) ||
|
||||
"";
|
||||
const profileRole = me?.role?.trim() || role;
|
||||
const isSuperAdmin = profileRole === "super_admin";
|
||||
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
||||
approveDeveloperRequest(id, adminNotes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
alert(t("msg.dev.request.approved", "승인되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
||||
rejectDeveloperRequest(id, adminNotes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
alert(t("msg.dev.request.rejected", "반려되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelApprovalMutation = useMutation({
|
||||
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
||||
cancelDeveloperRequestApproval(id, adminNotes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-request"] });
|
||||
alert(t("msg.dev.request.cancelled", "승인이 취소되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const handleApprove = (id: number) => {
|
||||
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
|
||||
};
|
||||
|
||||
const handleReject = (id: number) => {
|
||||
if (!adminNotes[id]) {
|
||||
alert(t("msg.dev.request.need_notes", "반려 사유를 입력해주세요."));
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({ id, adminNotes: adminNotes[id] });
|
||||
};
|
||||
|
||||
const handleCancelApproval = (id: number) => {
|
||||
if (!adminNotes[id]) {
|
||||
alert(
|
||||
t(
|
||||
"msg.dev.request.need_cancel_notes",
|
||||
"승인 취소 사유를 입력해주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
cancelApprovalMutation.mutate({ id, adminNotes: adminNotes[id] });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasActiveRequest = requests?.some(
|
||||
(r) => r.status === "pending" || r.status === "approved",
|
||||
);
|
||||
const approvedRequestCount =
|
||||
requests?.filter((request) => request.status === "approved").length ?? 0;
|
||||
const isActionPending =
|
||||
approveMutation.isPending ||
|
||||
rejectMutation.isPending ||
|
||||
cancelApprovalMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
icon={<ClipboardCheck size={20} />}
|
||||
title={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
description={
|
||||
isSuperAdmin
|
||||
? t(
|
||||
"msg.dev.request.admin_desc",
|
||||
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.request.user_desc",
|
||||
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
|
||||
)
|
||||
}
|
||||
actions={
|
||||
!isSuperAdmin && !hasActiveRequest ? (
|
||||
<Button onClick={() => setIsRequestModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
{t("ui.dev.request.list.title", "신청 내역")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.request.list.approved_count",
|
||||
"총 {{count}}명의 사용자가 승인되었습니다.",
|
||||
{ count: approvedRequestCount },
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table>
|
||||
<TableHeader className={commonStickyTableHeaderClass}>
|
||||
<TableRow>
|
||||
{isSuperAdmin && (
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.user", "사용자")}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.org", "소속")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.reason", "신청 사유")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.date", "신청일")}
|
||||
</TableHead>
|
||||
{isSuperAdmin && (
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.request.table.actions", "관리")}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!requests || requests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isSuperAdmin ? 6 : 4}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<TableRow key={req.id}>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="font-medium">
|
||||
<div>{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.email || req.userId}
|
||||
</div>
|
||||
{(req.phone || req.role) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{[req.phone, req.role]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{req.organization}</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="truncate" title={req.reason}>
|
||||
{req.reason}
|
||||
</div>
|
||||
{req.adminNotes && (
|
||||
<div className="mt-1 rounded bg-amber-50 p-1.5 text-xs text-amber-600 dark:bg-amber-900/20">
|
||||
<strong>Admin:</strong> {req.adminNotes}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={req.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{req.status === "pending" ? (
|
||||
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.admin_notes_placeholder",
|
||||
"메모 입력 (선택)...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleReject(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.reject", "반려")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.approve", "승인")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : req.status === "approved" ? (
|
||||
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.cancel_notes_placeholder",
|
||||
"승인 취소 사유 입력...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleCancelApproval(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t(
|
||||
"ui.dev.request.cancel_approval",
|
||||
"승인 취소",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs italic text-muted-foreground">
|
||||
{req.status === "cancelled"
|
||||
? t(
|
||||
"ui.dev.request.status.cancelled",
|
||||
"승인 취소됨",
|
||||
)
|
||||
: t("ui.common.rejected", "반려됨")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RequestAccessModal
|
||||
isOpen={isRequestModalOpen}
|
||||
onClose={() => setIsRequestModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
setIsRequestModalOpen(false);
|
||||
}}
|
||||
tenantId={tenantId || ""}
|
||||
initialName={profileName}
|
||||
initialOrg={organizationName}
|
||||
initialEmail={profileEmail}
|
||||
initialPhone={profilePhone}
|
||||
initialRole={profileRoleLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.pending", "대기 중")}
|
||||
</Badge>
|
||||
);
|
||||
case "approved":
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.approved", "승인됨")}
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="muted" className="gap-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.rejected", "반려됨")}
|
||||
</Badge>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<Badge variant="muted" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="muted">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestAccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
tenantId: string;
|
||||
initialName: string;
|
||||
initialOrg: string;
|
||||
initialEmail: string;
|
||||
initialPhone: string;
|
||||
initialRole: string;
|
||||
}
|
||||
|
||||
function RequestAccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
tenantId,
|
||||
initialName,
|
||||
initialOrg,
|
||||
initialEmail,
|
||||
initialPhone,
|
||||
initialRole,
|
||||
}: RequestAccessModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [organization, setOrganization] = useState(initialOrg);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName(initialName);
|
||||
setOrganization(initialOrg);
|
||||
}, [initialName, initialOrg, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: requestDeveloperAccess,
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate({
|
||||
name,
|
||||
organization,
|
||||
reason,
|
||||
tenantId,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(
|
||||
"msg.dev.request.modal.desc",
|
||||
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.dev.request.modal.name", "성함")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="org">
|
||||
{t("ui.dev.request.modal.org", "소속")}
|
||||
</Label>
|
||||
<Input
|
||||
id="org"
|
||||
value={organization}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
{t("ui.dev.request.modal.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={initialEmail}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">
|
||||
{t("ui.dev.request.modal.phone", "전화번호")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={initialPhone}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">
|
||||
{t("ui.dev.request.modal.role", "역할")}
|
||||
</Label>
|
||||
<Input
|
||||
id="role"
|
||||
value={initialRole}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason">
|
||||
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.request.modal.reason_placeholder",
|
||||
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
|
||||
)}
|
||||
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-8 font-bold"
|
||||
>
|
||||
{mutation.isPending
|
||||
? t("ui.common.submitting", "제출 중...")
|
||||
: t("ui.common.submit", "신청하기")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1609
baron-sso/devfront/src/features/overview/GlobalOverviewPage.tsx
Normal file
1609
baron-sso/devfront/src/features/overview/GlobalOverviewPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
|
||||
import {
|
||||
buildRecentClientChangeDetails,
|
||||
buildRecentClientChanges,
|
||||
getRecentClientActionLabel,
|
||||
} from "./recentClientChanges";
|
||||
|
||||
function makeClient(id: string, name = id): ClientSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: "private",
|
||||
status: "active",
|
||||
createdAt: "2026-05-27T00:00:00.000Z",
|
||||
redirectUris: [],
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuditLog(
|
||||
eventId: string,
|
||||
timestamp: string,
|
||||
action: string,
|
||||
targetId: string,
|
||||
details: Record<string, unknown>,
|
||||
): DevAuditLog {
|
||||
return {
|
||||
event_id: eventId,
|
||||
timestamp,
|
||||
user_id: "actor-1",
|
||||
event_type: "AUDIT",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "vitest",
|
||||
details: JSON.stringify({
|
||||
action,
|
||||
target_id: targetId,
|
||||
...details,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("recent client changes", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
function mockLocale(locale: "ko" | "en") {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", `/${locale}`);
|
||||
}
|
||||
|
||||
it("translates action labels and relation details by locale", () => {
|
||||
mockLocale("en");
|
||||
|
||||
expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation");
|
||||
expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe(
|
||||
"Settings changes",
|
||||
);
|
||||
expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe(
|
||||
"Status changes",
|
||||
);
|
||||
expect(getRecentClientActionLabel("ROTATE_SECRET")).toBe(
|
||||
"Client secret rotation",
|
||||
);
|
||||
expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship");
|
||||
expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe(
|
||||
"Remove Relationship",
|
||||
);
|
||||
expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion");
|
||||
expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION");
|
||||
|
||||
expect(
|
||||
buildRecentClientChangeDetails("ROTATE_SECRET", {
|
||||
after: {},
|
||||
}),
|
||||
).toEqual([{ label: "Client Secret", value: "Secret Rotated" }]);
|
||||
|
||||
expect(
|
||||
buildRecentClientChangeDetails("ADD_RELATION", {
|
||||
after: {
|
||||
relation: "admins",
|
||||
subject: "User:1",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ label: "Relation", value: "admins" },
|
||||
{ label: "Subject", value: "User:1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
|
||||
mockLocale("ko");
|
||||
|
||||
const clients = [
|
||||
makeClient("client-a", "Alpha"),
|
||||
makeClient("client-b", ""),
|
||||
];
|
||||
const auditLogs = [
|
||||
makeAuditLog(
|
||||
"evt-1",
|
||||
"2026-05-27T07:00:00.000Z",
|
||||
"CREATE_CLIENT",
|
||||
"client-a",
|
||||
{
|
||||
after: { name: "Alpha", type: "private", status: "active" },
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-2",
|
||||
"2026-05-27T08:00:00.000Z",
|
||||
"UPDATE_CLIENT",
|
||||
"client-a",
|
||||
{
|
||||
before: {
|
||||
name: "Alpha old",
|
||||
status: "inactive",
|
||||
sameField: "same",
|
||||
oldField: "old-value",
|
||||
},
|
||||
after: {
|
||||
name: "Alpha new",
|
||||
status: "active",
|
||||
sameField: "same",
|
||||
newField: "new-value",
|
||||
},
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-3",
|
||||
"2026-05-27T09:00:00.000Z",
|
||||
"UPDATE_CLIENT_STATUS",
|
||||
"client-a",
|
||||
{
|
||||
before: { status: "inactive" },
|
||||
after: { status: "active" },
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-4",
|
||||
"2026-05-27T10:00:00.000Z",
|
||||
"ADD_RELATION",
|
||||
"client-b",
|
||||
{
|
||||
after: {
|
||||
relation: "audit_viewer",
|
||||
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
|
||||
},
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-5",
|
||||
"2026-05-27T11:00:00.000Z",
|
||||
"REMOVE_RELATION",
|
||||
"client-b",
|
||||
{
|
||||
before: {
|
||||
relation: "admins",
|
||||
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
|
||||
},
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-6",
|
||||
"2026-05-27T12:00:00.000Z",
|
||||
"ROTATE_SECRET",
|
||||
"client-a",
|
||||
{
|
||||
after: {},
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-7",
|
||||
"2026-05-27T13:00:00.000Z",
|
||||
"DELETE_CLIENT",
|
||||
"client-a",
|
||||
{
|
||||
before: {
|
||||
name: "Alpha",
|
||||
status: "inactive",
|
||||
},
|
||||
},
|
||||
),
|
||||
makeAuditLog(
|
||||
"evt-8",
|
||||
"2026-05-27T14:00:00.000Z",
|
||||
"UNSUPPORTED_ACTION",
|
||||
"client-a",
|
||||
{
|
||||
after: { name: "Ignored" },
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const changes = buildRecentClientChanges(auditLogs, clients);
|
||||
|
||||
expect(changes).toHaveLength(7);
|
||||
expect(changes[0]).toMatchObject({
|
||||
eventId: "evt-7",
|
||||
clientName: "Alpha",
|
||||
actionLabel: "앱 삭제",
|
||||
});
|
||||
expect(changes[1]).toMatchObject({
|
||||
eventId: "evt-6",
|
||||
clientName: "Alpha",
|
||||
actionLabel: "클라이언트 시크릿 재발급",
|
||||
detailLabels: [
|
||||
{
|
||||
label: "클라이언트 시크릿",
|
||||
value: "Client Secret이 재발급되었습니다.",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(changes[2]).toMatchObject({
|
||||
eventId: "evt-5",
|
||||
clientName: "client-b",
|
||||
actionLabel: "관계 삭제",
|
||||
detailLabels: [
|
||||
{ label: "관계", value: "admins" },
|
||||
{
|
||||
label: "주체",
|
||||
value: "User:89692983-f512-4d96-845d-ac6123d08b95",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(changes[4]).toMatchObject({
|
||||
eventId: "evt-3",
|
||||
actionLabel: "상태 변경",
|
||||
clientName: "Alpha",
|
||||
detailLabels: [{ value: "inactive → active" }],
|
||||
});
|
||||
expect(changes[5]).toMatchObject({
|
||||
eventId: "evt-2",
|
||||
actionLabel: "설정 변경",
|
||||
detailLabels: [
|
||||
{ label: "애플리케이션", value: "Alpha old → Alpha new" },
|
||||
{ label: "상태", value: "inactive → active" },
|
||||
{ label: "oldField", value: "old-value" },
|
||||
],
|
||||
});
|
||||
expect(changes[6]).toMatchObject({
|
||||
eventId: "evt-1",
|
||||
actionLabel: "앱 생성",
|
||||
detailLabels: [
|
||||
{ label: "애플리케이션", value: "Alpha" },
|
||||
{ label: "유형", value: "private" },
|
||||
{ label: "상태", value: "active" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
203
baron-sso/devfront/src/features/overview/recentClientChanges.ts
Normal file
203
baron-sso/devfront/src/features/overview/recentClientChanges.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
type AuditDetails,
|
||||
type CommonAuditLog,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditActor,
|
||||
} from "../../../../common/core/audit";
|
||||
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
export type RecentClientChange = {
|
||||
eventId: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
actionLabel: string;
|
||||
timestamp: string;
|
||||
detailLabels: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
const recentClientActions = new Set([
|
||||
"CREATE_CLIENT",
|
||||
"UPDATE_CLIENT",
|
||||
"UPDATE_CLIENT_STATUS",
|
||||
"ROTATE_SECRET",
|
||||
"ADD_RELATION",
|
||||
"REMOVE_RELATION",
|
||||
"DELETE_CLIENT",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getRecentClientActionLabel(action: string) {
|
||||
switch (action) {
|
||||
case "CREATE_CLIENT":
|
||||
return t("ui.dev.clients.recent_changes.guide.create", "앱 생성");
|
||||
case "UPDATE_CLIENT":
|
||||
return t("ui.dev.clients.recent_changes.guide.settings", "설정 변경");
|
||||
case "UPDATE_CLIENT_STATUS":
|
||||
return t("ui.dev.clients.recent_changes.guide.status", "상태 변경");
|
||||
case "ROTATE_SECRET":
|
||||
return t(
|
||||
"ui.dev.clients.recent_changes.guide.secret",
|
||||
"클라이언트 시크릿 재발급",
|
||||
);
|
||||
case "ADD_RELATION":
|
||||
return t("ui.dev.clients.relationships.add_title", "관계 추가");
|
||||
case "REMOVE_RELATION":
|
||||
return t("ui.dev.clients.relationships.remove_title", "관계 삭제");
|
||||
case "DELETE_CLIENT":
|
||||
return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제");
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentClientFieldLabel(key: string) {
|
||||
switch (key) {
|
||||
case "name":
|
||||
return t("ui.dev.clients.table.application", "Application");
|
||||
case "type":
|
||||
return t("ui.dev.clients.table.type", "Type");
|
||||
case "status":
|
||||
return t("ui.dev.clients.table.status", "Status");
|
||||
case "relation":
|
||||
return t("ui.dev.clients.relationships.relation", "관계");
|
||||
case "subject":
|
||||
return t("ui.dev.clients.relationships.subject", "주체");
|
||||
case "client_secret":
|
||||
return t(
|
||||
"ui.dev.clients.details.credentials.client_secret",
|
||||
"클라이언트 시크릿",
|
||||
);
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRecentClientChangeDetails(
|
||||
action: string,
|
||||
details: AuditDetails,
|
||||
) {
|
||||
const before = isRecord(details.before) ? details.before : {};
|
||||
const after = isRecord(details.after) ? details.after : {};
|
||||
|
||||
if (action === "ROTATE_SECRET") {
|
||||
return [
|
||||
{
|
||||
label: getRecentClientFieldLabel("client_secret"),
|
||||
value: t("msg.dev.clients.details.secret_rotated", "재발급"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
|
||||
const source = action === "ADD_RELATION" ? after : before;
|
||||
return [
|
||||
...(source.relation
|
||||
? [
|
||||
{
|
||||
label: getRecentClientFieldLabel("relation"),
|
||||
value: formatAuditValue(source.relation),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(source.subject
|
||||
? [
|
||||
{
|
||||
label: getRecentClientFieldLabel("subject"),
|
||||
value: formatAuditValue(source.subject),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
const keys = Array.from(
|
||||
new Set([...Object.keys(before), ...Object.keys(after)]),
|
||||
);
|
||||
|
||||
const changes = keys
|
||||
.map((key) => {
|
||||
const beforeValue = before[key];
|
||||
const afterValue = after[key];
|
||||
|
||||
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
|
||||
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const label = getRecentClientFieldLabel(key);
|
||||
if (action === "CREATE_CLIENT") {
|
||||
if (afterValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { label, value: formatAuditValue(afterValue) };
|
||||
}
|
||||
if (action === "DELETE_CLIENT") {
|
||||
if (beforeValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { label, value: formatAuditValue(beforeValue) };
|
||||
}
|
||||
if (beforeValue === undefined && afterValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (beforeValue === undefined) {
|
||||
return { label, value: formatAuditValue(afterValue) };
|
||||
}
|
||||
if (afterValue === undefined) {
|
||||
return { label, value: formatAuditValue(beforeValue) };
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`,
|
||||
};
|
||||
})
|
||||
.filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
|
||||
return changes.slice(0, 3);
|
||||
}
|
||||
|
||||
export function buildRecentClientChanges(
|
||||
auditLogs: DevAuditLog[],
|
||||
clients: ClientSummary[],
|
||||
) {
|
||||
const clientNameById = new Map(
|
||||
clients.map((client) => [client.id, client.name || client.id]),
|
||||
);
|
||||
|
||||
return auditLogs
|
||||
.map((item) => {
|
||||
const details = parseAuditDetails(item.details);
|
||||
const action = details.action || "";
|
||||
const clientId = String(details.target_id || "");
|
||||
if (!recentClientActions.has(action) || !clientId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
eventId: item.event_id,
|
||||
clientId,
|
||||
clientName: clientNameById.get(clientId) || clientId,
|
||||
actorId: resolveAuditActor(
|
||||
item as Pick<CommonAuditLog, "user_id">,
|
||||
details,
|
||||
),
|
||||
action,
|
||||
actionLabel: getRecentClientActionLabel(action),
|
||||
timestamp: item.timestamp,
|
||||
detailLabels: buildRecentClientChangeDetails(action, details),
|
||||
} satisfies RecentClientChange;
|
||||
})
|
||||
.filter((item): item is RecentClientChange => Boolean(item))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.timestamp).getTime() -
|
||||
new Date(left.timestamp).getTime(),
|
||||
);
|
||||
}
|
||||
224
baron-sso/devfront/src/features/profile/ProfilePage.tsx
Normal file
224
baron-sso/devfront/src/features/profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{t("ui.dev.profile.title", "내 정보")}
|
||||
</h1>
|
||||
</div>
|
||||
<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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
46
baron-sso/devfront/src/index.css
Normal file
46
baron-sso/devfront/src/index.css
Normal file
@@ -0,0 +1,46 @@
|
||||
@import "../../common/theme/base.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--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%;
|
||||
--radius: 0.75rem;
|
||||
--app-background-image:
|
||||
radial-gradient(
|
||||
circle at 10% 18%,
|
||||
rgba(54, 211, 153, 0.16),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 78% 4%,
|
||||
rgba(249, 168, 38, 0.14),
|
||||
transparent 24%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 90%,
|
||||
rgba(54, 211, 153, 0.08),
|
||||
transparent 30%
|
||||
);
|
||||
}
|
||||
}
|
||||
88
baron-sso/devfront/src/lib/apiClient.test.ts
Normal file
88
baron-sso/devfront/src/lib/apiClient.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getUserMock = vi.fn();
|
||||
const findPersistedOidcUserMock = vi.fn();
|
||||
const removeUserMock = vi.fn();
|
||||
const shouldStartLoginRedirectMock = vi.fn();
|
||||
const shouldSuppressDevelopmentSessionRedirectMock = vi.fn();
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
userManager: {
|
||||
getUser: (...args: unknown[]) => getUserMock(...args),
|
||||
removeUser: (...args: unknown[]) => removeUserMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./oidcStorage", () => ({
|
||||
findPersistedOidcUser: (...args: unknown[]) =>
|
||||
findPersistedOidcUserMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../common/core/auth", () => ({
|
||||
shouldStartLoginRedirect: (...args: unknown[]) =>
|
||||
shouldStartLoginRedirectMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../common/core/session", () => ({
|
||||
shouldSuppressDevelopmentSessionRedirect: (...args: unknown[]) =>
|
||||
shouldSuppressDevelopmentSessionRedirectMock(...args),
|
||||
}));
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("MODE", "test");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
window.localStorage.clear();
|
||||
getUserMock.mockResolvedValue(null);
|
||||
findPersistedOidcUserMock.mockReturnValue(undefined);
|
||||
removeUserMock.mockResolvedValue(undefined);
|
||||
shouldStartLoginRedirectMock.mockReturnValue(true);
|
||||
shouldSuppressDevelopmentSessionRedirectMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("injects authorization and tenant headers into requests", async () => {
|
||||
getUserMock.mockResolvedValueOnce({ access_token: "live-token" });
|
||||
window.localStorage.setItem("dev_tenant_id", "tenant-1");
|
||||
|
||||
const { default: apiClient } = await import("./apiClient");
|
||||
const requestHandler =
|
||||
apiClient.interceptors.request.handlers[0]?.fulfilled;
|
||||
|
||||
const result = await requestHandler?.({ headers: {} });
|
||||
|
||||
expect(result.headers.Authorization).toBe("Bearer live-token");
|
||||
expect(result.headers["X-Tenant-ID"]).toBe("tenant-1");
|
||||
});
|
||||
|
||||
it("rejects non-auth response errors without redirecting", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { default: apiClient } = await import("./apiClient");
|
||||
const responseHandler =
|
||||
apiClient.interceptors.response.handlers[0]?.rejected;
|
||||
const error = { response: { status: 500, data: { error: "boom" } } };
|
||||
|
||||
await expect(responseHandler?.(error)).rejects.toBe(error);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(removeUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and rejects auth failures in test mode", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { default: apiClient } = await import("./apiClient");
|
||||
const responseHandler =
|
||||
apiClient.interceptors.response.handlers[0]?.rejected;
|
||||
const error = {
|
||||
response: {
|
||||
status: 403,
|
||||
data: { error: "authentication required" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(responseHandler?.(error)).rejects.toBe(error);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(removeUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
88
baron-sso/devfront/src/lib/apiClient.ts
Normal file
88
baron-sso/devfront/src/lib/apiClient.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from "axios";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||
import { userManager } from "./auth";
|
||||
import { findPersistedOidcUser } from "./oidcStorage";
|
||||
|
||||
let isRedirectingToLogin = false;
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL:
|
||||
import.meta.env.VITE_DEV_API_BASE ??
|
||||
import.meta.env.VITE_ADMIN_API_BASE ??
|
||||
"/api/v1",
|
||||
});
|
||||
|
||||
const isDevelopmentMode = import.meta.env.MODE === "development";
|
||||
const isTestMode =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true || navigator.webdriver === true;
|
||||
|
||||
apiClient.interceptors.request.use(async (config) => {
|
||||
// OIDC Access Token 주입
|
||||
const user = (await userManager.getUser()) ?? findPersistedOidcUser();
|
||||
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 shouldRedirectToLogin =
|
||||
status === 401 ||
|
||||
(status === 403 &&
|
||||
(message.includes("authentication required") ||
|
||||
message.includes("invalid session") ||
|
||||
message.includes("token is not active")));
|
||||
|
||||
if (!shouldRedirectToLogin) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
console.warn(
|
||||
"[apiClient] Auth failure detected, but local redirects are disabled.",
|
||||
);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (
|
||||
shouldSuppressDevelopmentSessionRedirect({
|
||||
appMode: import.meta.env.MODE,
|
||||
})
|
||||
) {
|
||||
console.warn(
|
||||
"[apiClient] Auth failure detected, but development session redirects are disabled.",
|
||||
);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (
|
||||
shouldStartLoginRedirect({
|
||||
pathname: window.location.pathname,
|
||||
isRedirecting: isRedirectingToLogin,
|
||||
})
|
||||
) {
|
||||
isRedirectingToLogin = true;
|
||||
await userManager.removeUser();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
23
baron-sso/devfront/src/lib/auth.ts
Normal file
23
baron-sso/devfront/src/lib/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
import {
|
||||
buildCommonOidcRuntimeConfig,
|
||||
buildCommonUserManagerSettings,
|
||||
} from "../../../common/core/auth";
|
||||
import { resolveDevFrontPublicOrigin } from "./authConfig";
|
||||
|
||||
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
|
||||
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
|
||||
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
|
||||
origin: devFrontPublicOrigin,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
});
|
||||
|
||||
export const userManager = new UserManager(
|
||||
buildCommonUserManagerSettings(oidcConfig),
|
||||
);
|
||||
102
baron-sso/devfront/src/lib/authConfig.test.ts
Normal file
102
baron-sso/devfront/src/lib/authConfig.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDevFrontAuthRedirectUris,
|
||||
canStartBrowserPkceLogin,
|
||||
DEVFRONT_AUTH_CALLBACK_PATH,
|
||||
resolveDevFrontPublicOrigin,
|
||||
} from "./authConfig";
|
||||
|
||||
describe("devfront auth config", () => {
|
||||
it("builds callback URLs from the public origin", () => {
|
||||
expect(buildDevFrontAuthRedirectUris("https://sdev.hmac.kr")).toEqual({
|
||||
redirectUri: "https://sdev.hmac.kr/auth/callback",
|
||||
postLogoutRedirectUri: "https://sdev.hmac.kr",
|
||||
popupRedirectUri: "https://sdev.hmac.kr/auth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the browser origin when the configured origin is empty or invalid", () => {
|
||||
expect(resolveDevFrontPublicOrigin("", "http://localhost:5173")).toBe(
|
||||
"http://localhost:5173",
|
||||
);
|
||||
expect(
|
||||
resolveDevFrontPublicOrigin("not a url", "http://localhost:5173"),
|
||||
).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
it("keeps the callback path aligned with the registered redirect path", () => {
|
||||
expect(DEVFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("blocks browser PKCE login in an insecure context", () => {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin: "http://localhost:5174",
|
||||
cryptoSubtleAvailable: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin: "http://172.16.9.189:5174",
|
||||
cryptoSubtleAvailable: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: true,
|
||||
origin: "http://172.16.9.189:5174",
|
||||
cryptoSubtleAvailable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows host.docker.internal when WebCrypto is enabled by the browser", () => {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin: "http://host.docker.internal:5000",
|
||||
cryptoSubtleAvailable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin: "http://host.docker.internal:5000",
|
||||
cryptoSubtleAvailable: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows private network IPv4 origins when WebCrypto is enabled by the browser", () => {
|
||||
for (const origin of [
|
||||
"http://10.0.0.10:5000",
|
||||
"http://172.16.9.189:5000",
|
||||
"http://172.31.255.255:5000",
|
||||
"http://192.168.0.20:5000",
|
||||
]) {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin,
|
||||
cryptoSubtleAvailable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
for (const origin of [
|
||||
"http://172.15.255.255:5000",
|
||||
"http://172.32.0.1:5000",
|
||||
"http://8.8.8.8:5000",
|
||||
]) {
|
||||
expect(
|
||||
canStartBrowserPkceLogin({
|
||||
isSecureContext: false,
|
||||
origin,
|
||||
cryptoSubtleAvailable: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
88
baron-sso/devfront/src/lib/authConfig.ts
Normal file
88
baron-sso/devfront/src/lib/authConfig.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface DevFrontAuthRedirectUris {
|
||||
redirectUri: string;
|
||||
postLogoutRedirectUri: string;
|
||||
popupRedirectUri: string;
|
||||
}
|
||||
|
||||
export const DEVFRONT_AUTH_CALLBACK_PATH = "/auth/callback";
|
||||
|
||||
export function resolveDevFrontPublicOrigin(
|
||||
configuredOrigin: string | undefined,
|
||||
browserOrigin: string,
|
||||
) {
|
||||
const trimmed = configuredOrigin?.trim();
|
||||
if (!trimmed) {
|
||||
return browserOrigin;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed).origin;
|
||||
} catch {
|
||||
return browserOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDevFrontAuthRedirectUris(
|
||||
publicOrigin: string,
|
||||
): DevFrontAuthRedirectUris {
|
||||
return {
|
||||
redirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
|
||||
postLogoutRedirectUri: publicOrigin,
|
||||
popupRedirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserPkceLoginCheck = {
|
||||
isSecureContext?: boolean;
|
||||
origin?: string;
|
||||
cryptoSubtleAvailable?: boolean;
|
||||
};
|
||||
|
||||
const devTrustedPkceHosts = new Set([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
]);
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [first, second] = parts;
|
||||
return (
|
||||
first === 10 ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isDevTrustedPkceOrigin(origin: string) {
|
||||
try {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canStartBrowserPkceLogin({
|
||||
isSecureContext = window.isSecureContext,
|
||||
origin = window.location.origin,
|
||||
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
|
||||
}: BrowserPkceLoginCheck = {}) {
|
||||
if (!cryptoSubtleAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSecureContext) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDevTrustedPkceOrigin(origin);
|
||||
}
|
||||
261
baron-sso/devfront/src/lib/devApi.test.ts
Normal file
261
baron-sso/devfront/src/lib/devApi.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiClient = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("./apiClient", () => ({
|
||||
default: apiClient,
|
||||
}));
|
||||
|
||||
describe("devApi", () => {
|
||||
beforeEach(() => {
|
||||
apiClient.get.mockReset();
|
||||
apiClient.post.mockReset();
|
||||
apiClient.put.mockReset();
|
||||
apiClient.patch.mockReset();
|
||||
apiClient.delete.mockReset();
|
||||
});
|
||||
|
||||
it("fetches list and detail resources with expected query parameters", async () => {
|
||||
const {
|
||||
fetchClients,
|
||||
fetchDevStats,
|
||||
fetchDevRPUsageDaily,
|
||||
fetchTenants,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchRPUserMetadata,
|
||||
fetchDevUsers,
|
||||
fetchConsents,
|
||||
fetchDevAuditLogs,
|
||||
fetchMyTenants,
|
||||
fetchDeveloperRequestStatus,
|
||||
fetchDeveloperRequests,
|
||||
listIdpConfigsForClient,
|
||||
} = await import("./devApi");
|
||||
apiClient.get.mockResolvedValue({ data: { ok: true } });
|
||||
|
||||
await fetchClients();
|
||||
await fetchDevStats();
|
||||
await fetchDevRPUsageDaily({ days: 30, period: "week" });
|
||||
await fetchTenants(25, 50, "tenant-parent");
|
||||
await fetchClient("client-a");
|
||||
await fetchClientRelations("client-a");
|
||||
await fetchRPUserMetadata("client-a", "user-a");
|
||||
await fetchDevUsers("admin", 5, "client-a");
|
||||
await fetchConsents("user-a", "client-a", "active");
|
||||
await fetchDevAuditLogs(10, "cursor-a", {
|
||||
action: "client.update",
|
||||
client_id: "client-a",
|
||||
status: "success",
|
||||
tenant_id: "tenant-a",
|
||||
});
|
||||
await fetchMyTenants();
|
||||
await fetchDeveloperRequestStatus("tenant-a");
|
||||
await fetchDeveloperRequests("pending");
|
||||
await listIdpConfigsForClient("client-a");
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
|
||||
params: { days: 30, period: "week" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
|
||||
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/relations",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/users/user-a/metadata",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
|
||||
params: { search: "admin", limit: 5, clientId: "client-a" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||
params: { subject: "user-a", client_id: "client-a", status: "active" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
|
||||
params: {
|
||||
limit: 10,
|
||||
cursor: "cursor-a",
|
||||
action: "client.update",
|
||||
client_id: "client-a",
|
||||
status: "success",
|
||||
tenant_id: "tenant-a",
|
||||
},
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/dev/developer-request/status",
|
||||
{
|
||||
params: { tenantId: "tenant-a" },
|
||||
},
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/developer-request/list", {
|
||||
params: { status: "pending" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
|
||||
});
|
||||
|
||||
it("omits optional consent filters when they are empty or all", async () => {
|
||||
const { fetchConsents, revokeConsent } = await import("./devApi");
|
||||
apiClient.get.mockResolvedValue({ data: { items: [] } });
|
||||
apiClient.delete.mockResolvedValue({ data: {} });
|
||||
|
||||
await fetchConsents("user-a", undefined, "all");
|
||||
await revokeConsent("user-a");
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||
params: { subject: "user-a" },
|
||||
});
|
||||
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||
params: { subject: "user-a" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends mutation requests to the documented dev endpoints", async () => {
|
||||
const {
|
||||
addClientRelation,
|
||||
removeClientRelation,
|
||||
updateRPUserMetadata,
|
||||
updateClientStatus,
|
||||
createClient,
|
||||
updateClient,
|
||||
rotateClientSecret,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
deleteClient,
|
||||
revokeConsent,
|
||||
createIdpConfigForClient,
|
||||
updateIdpConfig,
|
||||
deleteIdpConfig,
|
||||
requestDeveloperAccess,
|
||||
approveDeveloperRequest,
|
||||
rejectDeveloperRequest,
|
||||
cancelDeveloperRequestApproval,
|
||||
} = await import("./devApi");
|
||||
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.delete.mockResolvedValue({ data: {} });
|
||||
|
||||
await addClientRelation("client-a", {
|
||||
relation: "admins",
|
||||
userId: "user-a",
|
||||
});
|
||||
await removeClientRelation("client-a", "admins", "User:user-a");
|
||||
await updateRPUserMetadata("client-a", "user-a", { approvalLevel: "A" });
|
||||
await updateClientStatus("client-a", "inactive");
|
||||
await createClient({ id: "client-a", name: "Console App" });
|
||||
await updateClient("client-a", { name: "Console App Updated" });
|
||||
await rotateClientSecret("client-a");
|
||||
await refreshHeadlessJwksCache("client-a");
|
||||
await revokeHeadlessJwksCache("client-a");
|
||||
await deleteClient("client-a");
|
||||
await revokeConsent("user-a", "client-a");
|
||||
await createIdpConfigForClient({
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "OIDC Provider",
|
||||
status: "active",
|
||||
});
|
||||
await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
|
||||
await deleteIdpConfig("client-a", "idp-a");
|
||||
await requestDeveloperAccess({
|
||||
name: "Dev User",
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-a",
|
||||
});
|
||||
await approveDeveloperRequest(1, "approved");
|
||||
await rejectDeveloperRequest(2, "rejected");
|
||||
await cancelDeveloperRequestApproval(3, "cancelled");
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/relations",
|
||||
{ relation: "admins", userId: "user-a" },
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/relations",
|
||||
{
|
||||
params: { relation: "admins", subject: "User:user-a" },
|
||||
},
|
||||
);
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/users/user-a/metadata",
|
||||
{ metadata: { approvalLevel: "A" } },
|
||||
);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/status",
|
||||
{
|
||||
status: "inactive",
|
||||
},
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
|
||||
id: "client-a",
|
||||
name: "Console App",
|
||||
});
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
|
||||
name: "Console App Updated",
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/secret/rotate",
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/headless-jwks/refresh",
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/headless-jwks/cache",
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||
params: { subject: "user-a", client_id: "client-a" },
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients/client-a/idps", {
|
||||
client_id: "client-a",
|
||||
provider_type: "oidc",
|
||||
display_name: "OIDC Provider",
|
||||
status: "active",
|
||||
});
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/idps/idp-a",
|
||||
{
|
||||
status: "inactive",
|
||||
},
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/idps/idp-a",
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/dev/developer-request", {
|
||||
name: "Dev User",
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-a",
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/developer-request/1/approve",
|
||||
{
|
||||
adminNotes: "approved",
|
||||
},
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/developer-request/2/reject",
|
||||
{
|
||||
adminNotes: "rejected",
|
||||
},
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/developer-request/3/cancel-approval",
|
||||
{
|
||||
adminNotes: "cancelled",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
597
baron-sso/devfront/src/lib/devApi.ts
Normal file
597
baron-sso/devfront/src/lib/devApi.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
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;
|
||||
backchannelLogoutUri?: string;
|
||||
backchannelLogoutSessionRequired?: boolean;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientListResponse = {
|
||||
items: ClientSummary[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type TenantSummary = {
|
||||
id: string;
|
||||
type: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
status: string;
|
||||
domains?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TenantListResponse = {
|
||||
items: TenantSummary[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DevStats = {
|
||||
total_clients: number;
|
||||
active_sessions: number;
|
||||
auth_failures_24h: number;
|
||||
};
|
||||
|
||||
export type RPUsageDailyMetric = {
|
||||
date: string;
|
||||
tenantId: string;
|
||||
tenantType: string;
|
||||
tenantName?: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
loginRequests: number;
|
||||
otherRequests: number;
|
||||
uniqueSubjects: number;
|
||||
};
|
||||
|
||||
export type RPUsagePeriod = "day" | "week" | "month";
|
||||
|
||||
export type RPUsageDailyResponse = {
|
||||
items: RPUsageDailyMetric[];
|
||||
days: number;
|
||||
period: RPUsagePeriod;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
backchannelLogoutUri?: string;
|
||||
backchannelLogoutSessionRequired?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientRelation = {
|
||||
relation: string;
|
||||
subject: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userLoginId?: string;
|
||||
};
|
||||
|
||||
export type ClientRelationListResponse = {
|
||||
items: ClientRelation[];
|
||||
};
|
||||
|
||||
export type ClientRelationUpsertRequest = {
|
||||
relation: string;
|
||||
subject?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type DevAssignableUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
};
|
||||
|
||||
export type DevAssignableUserListResponse = {
|
||||
items: DevAssignableUser[];
|
||||
};
|
||||
|
||||
export type DevUserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
tenantSlug?: string;
|
||||
companyCode?: string;
|
||||
tenant?: TenantSummary;
|
||||
joinedTenants?: TenantSummary[];
|
||||
metadata?: Record<string, unknown>;
|
||||
department?: string;
|
||||
grade?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
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;
|
||||
rpMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ConsentListResponse = {
|
||||
items: ConsentSummary[];
|
||||
};
|
||||
|
||||
export type RPUserMetadataResponse = {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// --- 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 fetchDevRPUsageDaily({
|
||||
days = 14,
|
||||
period = "day",
|
||||
}: {
|
||||
days?: number;
|
||||
period?: RPUsagePeriod;
|
||||
} = {}) {
|
||||
const { data } = await apiClient.get<RPUsageDailyResponse>(
|
||||
"/dev/rp-usage/daily",
|
||||
{
|
||||
params: { days, period },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenants(
|
||||
limit = 1000,
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
|
||||
params: { limit, offset, parentId },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClient(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClientRelations(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientRelationListResponse>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRPUserMetadata(clientId: string, userId: string) {
|
||||
const { data } = await apiClient.get<RPUserMetadataResponse>(
|
||||
`/dev/clients/${clientId}/users/${userId}/metadata`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRPUserMetadata(
|
||||
clientId: string,
|
||||
userId: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) {
|
||||
const { data } = await apiClient.put<RPUserMetadataResponse>(
|
||||
`/dev/clients/${clientId}/users/${userId}/metadata`,
|
||||
{ metadata },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDevUsers(
|
||||
search: string,
|
||||
limit = 10,
|
||||
clientId?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<DevAssignableUserListResponse>(
|
||||
"/dev/users",
|
||||
{
|
||||
params: { search, limit, clientId },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDevUser(userId: string) {
|
||||
const { data } = await apiClient.get<DevUserSummary>(
|
||||
`/admin/users/${userId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addClientRelation(
|
||||
clientId: string,
|
||||
payload: ClientRelationUpsertRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<ClientRelation>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeClientRelation(
|
||||
clientId: string,
|
||||
relation: string,
|
||||
subject: string,
|
||||
) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}/relations`, {
|
||||
params: { relation, subject },
|
||||
});
|
||||
}
|
||||
|
||||
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 MyTenantSummary = Pick<TenantSummary, "id" | "name" | "slug"> &
|
||||
Partial<TenantSummary>;
|
||||
|
||||
export async function fetchMyTenants() {
|
||||
const { data } = await apiClient.get<MyTenantSummary[]>("/dev/my-tenants");
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Developer Request API ---
|
||||
export type DeveloperRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "none";
|
||||
|
||||
export type DeveloperRequest = {
|
||||
id: number;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
organization: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
reason: string;
|
||||
status: DeveloperRequestStatus;
|
||||
adminNotes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchDeveloperRequestStatus(tenantId?: string) {
|
||||
const { data } = await apiClient.get<DeveloperRequest | { status: "none" }>(
|
||||
"/dev/developer-request/status",
|
||||
{
|
||||
params: { tenantId },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function requestDeveloperAccess(payload: {
|
||||
name: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
tenantId: string;
|
||||
}) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
"/dev/developer-request",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDeveloperRequests(status?: string) {
|
||||
const { data } = await apiClient.get<DeveloperRequest[]>(
|
||||
"/dev/developer-request/list",
|
||||
{
|
||||
params: { status },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function approveDeveloperRequest(id: number, adminNotes: string) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/approve`,
|
||||
{ adminNotes },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function rejectDeveloperRequest(id: number, adminNotes: string) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/reject`,
|
||||
{ adminNotes },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelDeveloperRequestApproval(
|
||||
id: number,
|
||||
adminNotes: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/cancel-approval`,
|
||||
{ adminNotes },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
16
baron-sso/devfront/src/lib/i18n.ts
Normal file
16
baron-sso/devfront/src/lib/i18n.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createTomlTranslator } from "../../../common/core/i18n";
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import commonEnRaw from "../../../common/locales/en.toml?raw";
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import commonKoRaw from "../../../common/locales/ko.toml?raw";
|
||||
// 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";
|
||||
|
||||
export const t = createTomlTranslator({
|
||||
ko: [commonKoRaw, koRaw],
|
||||
en: [commonEnRaw, enRaw],
|
||||
});
|
||||
76
baron-sso/devfront/src/lib/oidcStorage.test.ts
Normal file
76
baron-sso/devfront/src/lib/oidcStorage.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findPersistedOidcUser } from "./oidcStorage";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private data = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.data.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.data.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.data.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("findPersistedOidcUser", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the first valid, unexpired devfront user entry", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem("oidc.user:issuer:other-client", JSON.stringify({}));
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
storage.setItem(
|
||||
"oidc.user:issuer:devfront",
|
||||
JSON.stringify({
|
||||
access_token: "token-1",
|
||||
expires_at: expiresAt,
|
||||
profile: { name: "Dev Admin" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findPersistedOidcUser(storage)).toEqual({
|
||||
access_token: "token-1",
|
||||
expires_at: expiresAt,
|
||||
profile: { name: "Dev Admin" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips malformed, empty, and expired entries", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem("random", "value");
|
||||
storage.setItem("oidc.user:issuer:devfront", "not-json");
|
||||
storage.setItem(
|
||||
"oidc.user:issuer:devfront",
|
||||
JSON.stringify({
|
||||
access_token: "expired",
|
||||
expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findPersistedOidcUser(storage)).toBeNull();
|
||||
});
|
||||
});
|
||||
42
baron-sso/devfront/src/lib/oidcStorage.ts
Normal file
42
baron-sso/devfront/src/lib/oidcStorage.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type PersistedOidcUser = {
|
||||
access_token?: string;
|
||||
expires_at?: number;
|
||||
profile?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const OIDC_USER_KEY_PREFIX = "oidc.user:";
|
||||
const OIDC_CLIENT_ID = "devfront";
|
||||
|
||||
export function findPersistedOidcUser(
|
||||
storage: Storage = window.localStorage,
|
||||
): PersistedOidcUser | null {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (
|
||||
key === null ||
|
||||
!key.startsWith(OIDC_USER_KEY_PREFIX) ||
|
||||
!key.endsWith(`:${OIDC_CLIENT_ID}`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValue = storage.getItem(key);
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
|
||||
if (
|
||||
typeof parsed.expires_at === "number" &&
|
||||
parsed.expires_at * 1000 > Date.now()
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed storage entries and keep scanning.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
33
baron-sso/devfront/src/lib/role.test.ts
Normal file
33
baron-sso/devfront/src/lib/role.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeRole, resolveProfileRole } from "./role";
|
||||
|
||||
describe("normalizeRole", () => {
|
||||
it("normalizes known role aliases", () => {
|
||||
expect(normalizeRole("tenant_member")).toBe("user");
|
||||
expect(normalizeRole("admin")).toBe("user");
|
||||
expect(normalizeRole("superadmin")).toBe("super_admin");
|
||||
expect(normalizeRole("tenantadmin")).toBe("tenant_admin");
|
||||
expect(normalizeRole("rpadmin")).toBe("rp_admin");
|
||||
});
|
||||
|
||||
it("returns 'user' for unknown string values and empty string for non-strings", () => {
|
||||
expect(normalizeRole(" custom_role ")).toBe("user");
|
||||
expect(normalizeRole(123)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProfileRole", () => {
|
||||
it("prefers the first non-empty normalized role candidate", () => {
|
||||
expect(
|
||||
resolveProfileRole({
|
||||
role: " ",
|
||||
grade: "tenant_member",
|
||||
"custom:role": "admin",
|
||||
}),
|
||||
).toBe("user");
|
||||
});
|
||||
|
||||
it("returns an empty string when no role is present", () => {
|
||||
expect(resolveProfileRole(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
38
baron-sso/devfront/src/lib/role.ts
Normal file
38
baron-sso/devfront/src/lib/role.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function normalizeRole(rawRole: unknown): string {
|
||||
if (typeof rawRole !== "string") return "";
|
||||
const role = rawRole.trim().toLowerCase();
|
||||
|
||||
switch (role) {
|
||||
case "super_admin":
|
||||
case "superadmin":
|
||||
case "super-admin":
|
||||
return "super_admin";
|
||||
case "rp_admin":
|
||||
case "rpadmin":
|
||||
case "rp-admin":
|
||||
return "rp_admin";
|
||||
case "tenant_admin":
|
||||
case "tenantadmin":
|
||||
case "tenant-admin":
|
||||
return "tenant_admin";
|
||||
default:
|
||||
return "user";
|
||||
}
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
6
baron-sso/devfront/src/lib/sessionSliding.ts
Normal file
6
baron-sso/devfront/src/lib/sessionSliding.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
|
||||
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
|
||||
shouldAttemptSlidingSessionRenew,
|
||||
shouldAttemptUnlimitedSessionRenew,
|
||||
} from "../../../common/core/session";
|
||||
8
baron-sso/devfront/src/lib/utils.ts
Normal file
8
baron-sso/devfront/src/lib/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { mergeClassNames } from "../../../common/core/utils";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return mergeClassNames(twMerge, [clsx(inputs)]);
|
||||
}
|
||||
2076
baron-sso/devfront/src/locales/en.toml
Normal file
2076
baron-sso/devfront/src/locales/en.toml
Normal file
File diff suppressed because it is too large
Load Diff
2072
baron-sso/devfront/src/locales/ko.toml
Normal file
2072
baron-sso/devfront/src/locales/ko.toml
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user