forked from baron/baron-sso
Compare commits
340
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df95c8a13 | ||
|
|
798d37bed9 | ||
|
|
28477aafc6 | ||
|
|
eec583b0a1 | ||
|
|
90fcd99b2f | ||
|
|
3a019f8e23 | ||
|
|
7003c0ada7 | ||
|
|
93699bb1b3 | ||
|
|
3a05ba94e0 | ||
|
|
b3371f48bb | ||
|
|
a1ece93a5c | ||
|
|
07aae642a7 | ||
|
|
f0cd60bf56 | ||
|
|
6569faee76 | ||
|
|
0d3aac0a58 | ||
|
|
93f6182cce | ||
|
|
27248cfa53 | ||
|
|
0741baf60b | ||
|
|
68459151c3 | ||
|
|
dd9e6394ad | ||
|
|
15a27a6620 | ||
|
|
f16cb9a344 | ||
|
|
9cdd89c1c1 | ||
|
|
abba85af75 | ||
|
|
c5dc531f1b | ||
|
|
440785dfe8 | ||
|
|
2ca612da7f | ||
|
|
0e88bc3986 | ||
|
|
c5f9445d67 | ||
|
|
c7c4eb1b82 | ||
|
|
880287088f | ||
|
|
561ae56cbb | ||
|
|
5d66e983cd | ||
|
|
8e45422606 | ||
|
|
eb34d387ad | ||
|
|
9b7b2da497 | ||
|
|
7f0a44e4ee | ||
|
|
05b6ff6d9e | ||
|
|
bb78c8e572 | ||
|
|
3d3e6a0c9c | ||
|
|
b49a39f9b2 | ||
|
|
658113d779 | ||
|
|
4b7264217d | ||
|
|
02e5fd2254 | ||
|
|
564ad29fc0 | ||
|
|
126462ce10 | ||
|
|
cfcb8f023f | ||
|
|
29c5c3f0cf | ||
|
|
226de652e3 | ||
|
|
3a6ae4948a | ||
|
|
d3072aceca | ||
|
|
804dc412fb | ||
|
|
22b132eb51 | ||
|
|
8bb8017e9b | ||
|
|
d06edd03c0 | ||
|
|
f0541368b2 | ||
|
|
89f3e09967 | ||
|
|
27a69db023 | ||
|
|
168fc5d991 | ||
|
|
a4345b3fb4 | ||
|
|
8333210a7d | ||
|
|
253e15202f | ||
|
|
5294066de6 | ||
|
|
568af8f90e | ||
|
|
6b85f001e2 | ||
|
|
b0899d8db3 | ||
|
|
a443ab3e72 | ||
|
|
20590b67d9 | ||
|
|
e57bf270eb | ||
|
|
e0ea0c7048 | ||
|
|
cf521f848f | ||
|
|
a87f1d2ce9 | ||
|
|
ce5255346c | ||
|
|
56fecb7e89 | ||
|
|
0b33ca0af6 | ||
|
|
051f446adc | ||
|
|
b2fd09fc43 | ||
|
|
9ddb4d6aff | ||
|
|
0725608f13 | ||
|
|
81824bd2de | ||
|
|
93a11c80a5 | ||
|
|
0e0d8c1ebc | ||
|
|
8168c47ad7 | ||
|
|
033ba64997 | ||
|
|
7f52479c5c | ||
|
|
7a057fa5db | ||
|
|
94c2ce9f31 | ||
|
|
5af39085e7 | ||
|
|
beebb3f7e6 | ||
|
|
ea66671d44 | ||
|
|
66e1ed1e72 | ||
|
|
62b5bdba76 | ||
|
|
d3d9f7bea6 | ||
|
|
8831208c08 | ||
|
|
a4e5ee78d1 | ||
|
|
044f5cf122 | ||
|
|
c8510fee4e | ||
|
|
b50f7b7436 | ||
|
|
5710bea4f9 | ||
|
|
f362e21774 | ||
|
|
7c3295fdc7 | ||
|
|
86613fac46 | ||
|
|
f719563ba9 | ||
|
|
d8f133b1e5 | ||
|
|
5633d75bd7 | ||
|
|
f4c38755ff | ||
|
|
3627d70ad9 | ||
|
|
974bfe7b9a | ||
|
|
c811b7e283 | ||
|
|
a4c5ab547d | ||
|
|
b577568d07 | ||
|
|
7144c1176c | ||
|
|
9f7c6bfd27 | ||
|
|
41c1672afc | ||
|
|
f5d519feaf | ||
|
|
63c9172ebc | ||
|
|
f88b1c37cb | ||
|
|
b4241cb98b | ||
|
|
5cf784a2c2 | ||
|
|
336eabf492 | ||
|
|
3de77ef681 | ||
|
|
12b3d21da6 | ||
|
|
3989e30bc2 | ||
|
|
1a02c15e78 | ||
|
|
1f1e7b6ce7 | ||
|
|
0eda7bde13 | ||
|
|
c5cec11c03 | ||
|
|
edfe47b356 | ||
|
|
69b1257ad7 | ||
|
|
5bb48191e8 | ||
|
|
4e068f76a7 | ||
|
|
a6836cad09 | ||
|
|
ee9f91e9f8 | ||
|
|
a6e37b97c6 | ||
|
|
40a4765dd6 | ||
|
|
0ac5b9ee0a | ||
|
|
19862c6188 | ||
|
|
50b9de5a64 | ||
|
|
1a97483bea | ||
|
|
981643df38 | ||
|
|
663bc7c8a8 | ||
|
|
764c833ad3 | ||
|
|
7e09764ad9 | ||
|
|
066ea86f46 | ||
|
|
d3facfbe77 | ||
|
|
bf469b1eb4 | ||
|
|
b908d71666 | ||
|
|
1c8a599d46 | ||
|
|
f14eb57ef8 | ||
|
|
3868f5967e | ||
|
|
d09abab5a2 | ||
|
|
1406c20959 | ||
|
|
4f3d0759c3 | ||
|
|
e20b61189c | ||
|
|
c94a369d1d | ||
|
|
6dbdd5d483 | ||
|
|
12ae5929e3 | ||
|
|
e345570210 | ||
|
|
f756959bbe | ||
|
|
fb2541cb09 | ||
|
|
a01ebfd143 | ||
|
|
3a3ea4879e | ||
|
|
849424f030 | ||
|
|
fa1f37dc90 | ||
|
|
8a9bbeeb88 | ||
|
|
39296ca522 | ||
|
|
a54c2ab138 | ||
|
|
9600845551 | ||
|
|
5dd425050c | ||
|
|
1c0a5ed272 | ||
|
|
7e662c9878 | ||
|
|
9d1fe0fb1f | ||
|
|
a92a47ad35 | ||
|
|
a0ebf7a48d | ||
|
|
521e519c4b | ||
|
|
f6e05596f2 | ||
|
|
10b3d93538 | ||
|
|
c6c6bd2092 | ||
|
|
109f138c8d | ||
|
|
c8f2127360 | ||
|
|
9e9c622600 | ||
|
|
03a78d75ae | ||
|
|
b39789dbe2 | ||
|
|
1db7ce8f10 | ||
|
|
35552943d7 | ||
|
|
1cb5115f2a | ||
|
|
83b5359405 | ||
|
|
f8b17f79cb | ||
|
|
91e724a3a9 | ||
|
|
765bf67cab | ||
|
|
3e2ceff692 | ||
|
|
59a5f99fb9 | ||
|
|
c82934b7cd | ||
|
|
968e16422d | ||
|
|
a8ac66b318 | ||
|
|
ec90853fe3 | ||
|
|
a52ec3b9f8 | ||
|
|
f6c7021fda | ||
|
|
a5e61df4ae | ||
|
|
aa24471012 | ||
|
|
60df7ba904 | ||
|
|
c58572b7cd | ||
|
|
3b0e471471 | ||
|
|
ab9d5e123b | ||
|
|
502efc7cbb | ||
|
|
84b645ba7b | ||
|
|
5bdcb67200 | ||
|
|
a27026fa2a | ||
|
|
ff655dc7c7 | ||
|
|
ee4c07f66d | ||
|
|
77d4e9fd77 | ||
|
|
209314fea7 | ||
|
|
8faa08e377 | ||
|
|
742964cf71 | ||
|
|
b88de7ec91 | ||
|
|
ff17259117 | ||
|
|
39594f8e21 | ||
|
|
df03771121 | ||
|
|
3e95650024 | ||
|
|
f33f417045 | ||
|
|
e573f4ca50 | ||
|
|
b7a0397ef9 | ||
|
|
1883753414 | ||
|
|
b55cb2c4bf | ||
|
|
93cab064fc | ||
|
|
1aaa772907 | ||
|
|
6d88c81217 | ||
|
|
c3f7b18afc | ||
|
|
492919e104 | ||
|
|
41f0549435 | ||
|
|
5c19b88230 | ||
|
|
5fa0af8d2e | ||
|
|
1a0dc74cb5 | ||
|
|
26e6430aa7 | ||
|
|
5c24e6eb4e | ||
|
|
1f7835f5a9 | ||
|
|
920c98a8f8 | ||
|
|
c914fad405 | ||
|
|
ce86aee5fb | ||
|
|
22a608f07a | ||
|
|
36cef14274 | ||
|
|
1c97731c52 | ||
|
|
1c1616de09 | ||
|
|
36e91771f8 | ||
|
|
28c6d6447a | ||
|
|
4a49c702b3 | ||
|
|
60035aad53 | ||
|
|
4ab6476f0f | ||
|
|
164203ffa1 | ||
|
|
8ba4af4945 | ||
|
|
72a36701da | ||
|
|
739da39a61 | ||
|
|
d922de5df6 | ||
|
|
b013184bf7 | ||
|
|
b42a4698e6 | ||
|
|
5d136d0542 | ||
|
|
939d8ee911 | ||
|
|
4c608c6c3c | ||
|
|
99b9e6bd48 | ||
|
|
dec66c136c | ||
|
|
4919cb2f8b | ||
|
|
3725eac1a8 | ||
|
|
98ba4c5b30 | ||
|
|
a2e9f87d33 | ||
|
|
f261618dc6 | ||
|
|
eb41d6cc2b | ||
|
|
29ed12c3bb | ||
|
|
78f11e1e63 | ||
|
|
bf5943bc93 | ||
|
|
6d4b0d360e | ||
|
|
7a00d84043 | ||
|
|
baab657ef0 | ||
|
|
89cf9b58a7 | ||
|
|
8894341f5a | ||
|
|
493a9e4ecc | ||
|
|
25b1dcfdaa | ||
|
|
be722068b5 | ||
|
|
e2aa3c5301 | ||
|
|
0e71f8b4e7 | ||
|
|
8e89c6c1c4 | ||
|
|
801c21a907 | ||
|
|
c39857c66c | ||
|
|
96bfcf0847 | ||
|
|
167b27bf2e | ||
|
|
a2af07f10e | ||
|
|
639072c0d5 | ||
|
|
03c8c14aa1 | ||
|
|
20e848deb6 | ||
|
|
4856801756 | ||
|
|
85d3044ace | ||
|
|
8ea9f06a9a | ||
|
|
c0083c1b69 | ||
|
|
431e3a7e05 | ||
|
|
8c5d87a5d2 | ||
|
|
16552dac54 | ||
|
|
ffe96c8c65 | ||
|
|
79e5a22a97 | ||
|
|
3c41c0dc62 | ||
|
|
27b8ff2ac1 | ||
|
|
ebfd60f81a | ||
|
|
b65ecc1b24 | ||
|
|
50385d510b | ||
|
|
e558de56c1 | ||
|
|
ca97bc84c9 | ||
|
|
290d5c6c86 | ||
|
|
1277fa8e46 | ||
|
|
c6ef9b4393 | ||
|
|
bea549c899 | ||
|
|
ffd2a95903 | ||
|
|
5c79626518 | ||
|
|
c51abd12dc | ||
|
|
4526f08e5f | ||
|
|
5dd2c94555 | ||
|
|
2aff11bc5d | ||
|
|
2317eb857c | ||
|
|
da5a5bd25a | ||
|
|
8b894cd90b | ||
|
|
dcf827b9ee | ||
|
|
e02a9b2153 | ||
|
|
ce901f3d49 | ||
|
|
caae0a6815 | ||
|
|
7a6ffc038a | ||
|
|
530694d613 | ||
|
|
59b5f8f7e9 | ||
|
|
ee9f835ceb | ||
|
|
b5aed4fedc | ||
|
|
4813ec2f6d | ||
|
|
33444c00cd | ||
|
|
b43635111c | ||
|
|
160527b6ec | ||
|
|
c4b86f77f1 | ||
|
|
659ccfbe53 | ||
|
|
362b6b60d4 | ||
|
|
8a25f143e7 | ||
|
|
cd0ca7a421 | ||
|
|
fec4a1cd55 | ||
|
|
c512f0f4e6 | ||
|
|
c56368d1cb | ||
|
|
904b35e6e6 | ||
|
|
48589dca5d |
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Baron SSO - Unified Environment Configuration
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# --- General System ---
|
||||||
|
APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
|
|
||||||
|
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
|
||||||
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
# --- Infrastructure Ports ---
|
||||||
|
DB_PORT=5432
|
||||||
|
CLICKHOUSE_PORT_HTTP=8123
|
||||||
|
CLICKHOUSE_PORT_NATIVE=9000
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
ADMINFRONT_PORT=5173
|
||||||
|
DEVFRONT_PORT=5174
|
||||||
|
USERFRONT_PORT=5000
|
||||||
|
|
||||||
|
# --- Database Credentials (PostgreSQL) ---
|
||||||
|
DB_USER=baron
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_NAME=baron_sso
|
||||||
|
|
||||||
|
# --- Backend Configuration ---
|
||||||
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
|
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
JWT_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||||
|
|
||||||
|
# Audit System Configuration
|
||||||
|
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||||
|
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||||
|
|
||||||
|
# Redis Cache Configuration
|
||||||
|
PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간
|
||||||
|
|
||||||
|
# --- Naver Cloud Services ---
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=...
|
||||||
|
|
||||||
|
# --- AWS SES (이메일 발송용) ---
|
||||||
|
AWS_REGION=ap-northeast-2
|
||||||
|
AWS_ACCESS_KEY_ID=...
|
||||||
|
AWS_SECRET_ACCESS_KEY=...
|
||||||
|
AWS_SES_SENDER=no-reply@baron.co.kr
|
||||||
|
|
||||||
|
# --- 관리자 page pw ---
|
||||||
|
ADMIN_EMAIL=admin@baron.co.kr
|
||||||
|
ADMIN_PASSWORD=adminPasswordIsNotSimple
|
||||||
|
|
||||||
|
# --- URLs for Proxy/Handoff ---
|
||||||
|
# Project Public Base URL (Served by UserFront Nginx)
|
||||||
|
USERFRONT_URL=https://sso.hmac.kr
|
||||||
|
|
||||||
|
# Services proxied via Nginx
|
||||||
|
BACKEND_URL=${USERFRONT_URL}/api
|
||||||
|
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
|
||||||
|
|
||||||
|
# ory-stack 변수들
|
||||||
|
ORY_POSTGRES_TAG=17-trixie
|
||||||
|
ORY_POSTGRES_USER=ory
|
||||||
|
ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9
|
||||||
|
ORY_POSTGRES_DB=ory
|
||||||
|
# ORY_POSTGRES_PORT=5433 # Internal only
|
||||||
|
|
||||||
|
KRATOS_DB=ory_kratos
|
||||||
|
HYDRA_DB=ory_hydra
|
||||||
|
KETO_DB=ory_keto
|
||||||
|
|
||||||
|
# Ory Kratos Configuration
|
||||||
|
KRATOS_VERSION=v25.4.0-distroless
|
||||||
|
# KRATOS_PUBLIC_PORT=4433 # Internal only
|
||||||
|
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
|
||||||
|
|
||||||
|
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||||
|
# KRATOS_UI_PORT=4455 # Internal only
|
||||||
|
|
||||||
|
# Ory Hydra Configuration
|
||||||
|
HYDRA_VERSION=v25.4.0-distroless
|
||||||
|
# HYDRA_PUBLIC_PORT=4441 # Internal only
|
||||||
|
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
|
||||||
|
|
||||||
|
# Ory Keto Configuration
|
||||||
|
KETO_VERSION=v25.4.0-distroless
|
||||||
|
# KETO_READ_PORT=4466 # Internal only
|
||||||
|
# KETO_WRITE_PORT=4467 # Internal only
|
||||||
|
|
||||||
|
# Kratos Selfservice UI upstreams (override for deployments)
|
||||||
|
ORY_SDK_URL=http://kratos:4433
|
||||||
|
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||||
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
|
|
||||||
|
# 브라우저가 접근할 Kratos Public/UI 외부 URL
|
||||||
|
# Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다.
|
||||||
|
KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth
|
||||||
|
# Kratos UI는 UserFront가 렌더링합니다.
|
||||||
|
KRATOS_UI_URL=http://localhost:5000
|
||||||
|
|
||||||
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||||
|
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||||
|
|
||||||
|
# Oathkeeper JWKS (내부 통신용)
|
||||||
|
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||||
|
|
||||||
|
# Oathkeeper 실행 사용자/프로브 설정
|
||||||
|
OATHKEEPER_VERSION=v25.4.0
|
||||||
|
OATHKEEPER_UID=1001
|
||||||
|
OATHKEEPER_GID=1001
|
||||||
|
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||||
|
OATHKEEPER_HEALTH_INTERVAL_SECONDS=10
|
||||||
|
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2
|
||||||
|
OATHKEEPER_HEALTH_ENABLED=true
|
||||||
|
|
||||||
|
# Kratos Selfservice UI required secrets (local only)
|
||||||
|
COOKIE_SECRET=localcookie123
|
||||||
|
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
||||||
|
CSRF_COOKIE_SECRET=localcsrf123
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Path": "./backend/coverage.out",
|
||||||
|
"Thresholds": {
|
||||||
|
"baron-sso-backend/internal/handler": 10,
|
||||||
|
"baron-sso-backend/internal/service": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Backend Test Coverage Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
working-directory: ./backend
|
||||||
|
run: |
|
||||||
|
go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/...
|
||||||
|
|
||||||
|
- name: Check coverage
|
||||||
|
uses: vladopajic/go-test-coverage@v2
|
||||||
|
with:
|
||||||
|
config: ./.gitea/coverage.json
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Build Baron SSO RC
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version_tag:
|
||||||
|
description: "The version tag to release to staging (e.g., v1.2601.1)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y jq curl
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.HARBOR_ENDPOINT }}
|
||||||
|
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
password: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
|
||||||
|
- name: Calculate next RC tag
|
||||||
|
id: rc_calculator
|
||||||
|
env:
|
||||||
|
INPUT_TAG: ${{ github.event.inputs.version_tag }}
|
||||||
|
REGISTRY_URL: ${{ vars.HARBOR_ENDPOINT }}
|
||||||
|
HARBOR_USER: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
run: |
|
||||||
|
# Generate YYMM dynamically for the new tag
|
||||||
|
CURRENT_YYMM=$(date +'%y%m')
|
||||||
|
|
||||||
|
# Reconstruct the base tag with the current YYMM
|
||||||
|
MAJOR_VERSION=$(echo "${INPUT_TAG}" | cut -d'.' -f1)
|
||||||
|
MINOR_VERSION=$(echo "${INPUT_TAG}" | cut -d'.' -f3)
|
||||||
|
BASE_TAG="${MAJOR_VERSION}.${CURRENT_YYMM}.${MINOR_VERSION}"
|
||||||
|
|
||||||
|
echo "Input tag: ${INPUT_TAG}"
|
||||||
|
echo "Generated dynamic base tag: ${BASE_TAG}"
|
||||||
|
|
||||||
|
# Using the backend repository as the source for RC version calculation
|
||||||
|
API_URL="${REGISTRY_URL}/api/v2.0/projects/baron_sso/repositories/backend/artifacts?sort=-creation_time&page_size=100"
|
||||||
|
|
||||||
|
AUTH_HEADER=$(echo -n "${HARBOR_USER}:${HARBOR_PASSWORD}" | base64)
|
||||||
|
API_RESPONSE=$(curl -s -k -H "Authorization: Basic ${AUTH_HEADER}" "${API_URL}")
|
||||||
|
|
||||||
|
# Define a search pattern to find RCs across different months for the same major/minor version
|
||||||
|
# e.g., matches v1.2508.1-RC, v1.2509.1-RC, etc.
|
||||||
|
SEARCH_PATTERN="^${MAJOR_VERSION}\.[0-9]{4}\.${MINOR_VERSION}-RC"
|
||||||
|
echo "Using search pattern: ${SEARCH_PATTERN}"
|
||||||
|
|
||||||
|
# Disable pipefail for grep, as it will exit with 1 if no match is found
|
||||||
|
set +o pipefail
|
||||||
|
# Find the highest RC number regardless of the YYMM part
|
||||||
|
LATEST_RC_NUM=$(echo "${API_RESPONSE}" | jq -r '.[] | .tags[]? | .name' | grep -E "${SEARCH_PATTERN}" | sed 's/.*-RC//' | sort -rn | head -n 1)
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
if [ -z "$LATEST_RC_NUM" ]; then
|
||||||
|
NEXT_RC_NUM=1
|
||||||
|
else
|
||||||
|
NEXT_RC_NUM=$((LATEST_RC_NUM + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the new tag using the dynamically generated BASE_TAG and the incremented RC number
|
||||||
|
NEW_RC_TAG="${BASE_TAG}-RC${NEXT_RC_NUM}"
|
||||||
|
echo "new_rc_tag=$NEW_RC_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found latest RC number: ${LATEST_RC_NUM:-0}"
|
||||||
|
echo "Calculated new RC tag: $NEW_RC_TAG"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push backend RC image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
- name: Build and push adminfront RC image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./adminfront
|
||||||
|
file: ./adminfront/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
- name: Build and push devfront RC image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./devfront
|
||||||
|
file: ./devfront/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
- name: Build and push userfront RC image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./userfront
|
||||||
|
file: ./userfront/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
name: Code Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
run_lint:
|
||||||
|
description: "Run linters for Go and Flutter"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
run_backend_tests:
|
||||||
|
description: "Run backend Go tests"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
run_userfront_tests:
|
||||||
|
description: "Run userfront Flutter tests"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
if: ${{ inputs.run_lint == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: "stable"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Lint Go backend
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: v1.59
|
||||||
|
working-directory: backend
|
||||||
|
args: --enable-only=gofmt,gofumpt
|
||||||
|
|
||||||
|
- name: Analyze Flutter userfront
|
||||||
|
run: |
|
||||||
|
cd userfront
|
||||||
|
flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||||
|
|
||||||
|
backend-tests:
|
||||||
|
needs: lint
|
||||||
|
if: ${{ inputs.run_backend_tests == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
options: >
|
||||||
|
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:24.6
|
||||||
|
options: >
|
||||||
|
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
REDIS_ADDR: redis:6379
|
||||||
|
CLICKHOUSE_HOST: clickhouse
|
||||||
|
CLICKHOUSE_PORT_NATIVE: 9000
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
userfront-tests:
|
||||||
|
needs: lint
|
||||||
|
if: ${{ inputs.run_userfront_tests == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: "stable"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run userfront tests
|
||||||
|
run: |
|
||||||
|
cd userfront
|
||||||
|
if [ -d test ]; then
|
||||||
|
flutter test
|
||||||
|
else
|
||||||
|
echo "No userfront tests: skipping (test/ directory not found)."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
name: Release Baron SSO to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rc_version_tag:
|
||||||
|
description: "The version tag to release to production (e.g., v1.2601.1-RC1)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Harbor Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.HARBOR_ENDPOINT }}
|
||||||
|
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
password: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
|
||||||
|
- name: Parse RC and re-tag to final
|
||||||
|
id: retag
|
||||||
|
env:
|
||||||
|
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||||
|
HARBOR_USER: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
run: |
|
||||||
|
BASE_TAG=$(echo "${{ github.event.inputs.rc_version_tag }}" | xargs)
|
||||||
|
|
||||||
|
if [[ ! "$BASE_TAG" =~ ^v[0-9]+\.[0-9]{4}\.[0-9]+-RC[0-9]+$ ]]; then
|
||||||
|
echo "::error::rc_version_tag must look like vX.YYMM.Z-RC## (got: $BASE_TAG)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RE_TAG="${BASE_TAG%-RC*}"
|
||||||
|
echo "Final tag will be: ${RE_TAG}"
|
||||||
|
|
||||||
|
if ! command -v skopeo >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update -y && sudo apt-get install -y skopeo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-tag backend image
|
||||||
|
echo "Re-tagging backend image..."
|
||||||
|
skopeo copy --preserve-digests \
|
||||||
|
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||||
|
--src-tls-verify=false --dest-tls-verify=false \
|
||||||
|
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
|
||||||
|
|
||||||
|
# Re-tag userfront image
|
||||||
|
echo "Re-tagging userfront image..."
|
||||||
|
skopeo copy --preserve-digests \
|
||||||
|
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||||
|
--src-tls-verify=false --dest-tls-verify=false \
|
||||||
|
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
|
||||||
|
|
||||||
|
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
uses: webfactory/ssh-agent@v0.9.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Deploy to Production
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
|
||||||
|
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
|
||||||
|
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||||
|
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||||
|
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||||
|
PROD_USER: ${{ vars.PROD_USER }}
|
||||||
|
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||||
|
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "DEBUG: PROD_USER='${PROD_USER}'"
|
||||||
|
echo "DEBUG: PROD_HOST='${PROD_HOST}'"
|
||||||
|
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
|
||||||
|
|
||||||
|
# Sanity check (fail fast with a clear message)
|
||||||
|
if [ -z "${PROD_USER}" ] || [ -z "${PROD_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
|
||||||
|
echo "::error::Missing required vars (PROD_USER/PROD_HOST/DEPLOY_PATH). Check Gitea repo variables."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
|
||||||
|
# Create the main .env file for Baron SSO on the remote server
|
||||||
|
# Note: All values are pulled from Gitea secrets and variables
|
||||||
|
printf '%s\n' \
|
||||||
|
"APP_ENV=production" \
|
||||||
|
"TZ=Asia/Seoul" \
|
||||||
|
"DB_PORT=${{ vars.PROD_DB_PORT }}" \
|
||||||
|
"CLICKHOUSE_PORT_HTTP=${{ vars.PROD_CLICKHOUSE_PORT_HTTP }}" \
|
||||||
|
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
|
||||||
|
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
|
||||||
|
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
|
||||||
|
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||||
|
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
|
||||||
|
"DB_USER=${{ vars.PROD_DB_USER }}" \
|
||||||
|
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
|
||||||
|
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
|
||||||
|
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
|
||||||
|
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
|
||||||
|
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
|
||||||
|
"NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \
|
||||||
|
"NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \
|
||||||
|
"NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \
|
||||||
|
"NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}" \
|
||||||
|
"AWS_REGION=${{ vars.AWS_REGION }}" \
|
||||||
|
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
|
||||||
|
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
|
||||||
|
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
|
||||||
|
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \
|
||||||
|
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
||||||
|
> .env
|
||||||
|
|
||||||
|
# Copy compose template and .env file to the remote server
|
||||||
|
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
|
||||||
|
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||||
|
|
||||||
|
# Deploy Baron SSO
|
||||||
|
echo "${HARBOR_ROBOT_KEY}" | ssh "${PROD_USER}@${PROD_HOST}" \
|
||||||
|
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
|
||||||
|
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
|
||||||
|
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||||
|
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||||
|
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||||
|
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||||
|
set -e; \
|
||||||
|
cd \"\${DEPLOY_PATH}\"; \
|
||||||
|
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
|
||||||
|
set -a; \
|
||||||
|
. ./.env; \
|
||||||
|
set +a; \
|
||||||
|
envsubst < docker-compose.template.yaml > docker-compose.yml; \
|
||||||
|
docker compose -f compose.infra.yml -f docker-compose.yml pull; \
|
||||||
|
docker compose -f compose.infra.yml -f docker-compose.yml up -d --remove-orphans"
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
name: Release Baron SSO to Staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rc_version_tag:
|
||||||
|
description: "The version tag to deploy to staging (e.g., v1.2601.1-RC1)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-staging:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
uses: webfactory/ssh-agent@v0.9.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Deploy to Staging
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ github.event.inputs.rc_version_tag }}
|
||||||
|
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
|
||||||
|
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||||
|
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||||
|
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||||
|
|
||||||
|
# Staging-specific variables
|
||||||
|
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
|
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||||
|
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||||
|
|
||||||
|
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||||
|
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||||
|
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
|
||||||
|
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
|
||||||
|
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
|
||||||
|
|
||||||
|
# Sanity check
|
||||||
|
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
|
||||||
|
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
|
||||||
|
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
|
||||||
|
# .env 파일 생성
|
||||||
|
cat <<'EOF' > .env
|
||||||
|
APP_ENV=${{ vars.APP_ENV }}
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
# DB & Clickhouse
|
||||||
|
DB_PORT=${{ vars.DB_PORT }}
|
||||||
|
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||||
|
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||||
|
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||||
|
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||||
|
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||||
|
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||||
|
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||||
|
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||||
|
|
||||||
|
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||||
|
|
||||||
|
DB_USER=${{ vars.DB_USER }}
|
||||||
|
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||||
|
DB_NAME=${{ vars.DB_NAME }}
|
||||||
|
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
|
||||||
|
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
|
||||||
|
REDIS_ADDR=${{ vars.REDIS_ADDR }}
|
||||||
|
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
|
||||||
|
AUDIT_WORKER_COUNT=5
|
||||||
|
AUDIT_QUEUE_SIZE=2000
|
||||||
|
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||||
|
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||||
|
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||||
|
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
|
||||||
|
AWS_REGION=${{ vars.AWS_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
|
||||||
|
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||||
|
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||||
|
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||||
|
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||||
|
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||||
|
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||||
|
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||||
|
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||||
|
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
|
||||||
|
KRATOS_DB=${{ vars.KRATOS_DB }}
|
||||||
|
HYDRA_DB=${{ vars.HYDRA_DB }}
|
||||||
|
KETO_DB=${{ vars.KETO_DB }}
|
||||||
|
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
|
||||||
|
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
|
||||||
|
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
|
||||||
|
KETO_VERSION=${{ vars.KETO_VERSION }}
|
||||||
|
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
|
||||||
|
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
|
||||||
|
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
|
||||||
|
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
|
||||||
|
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||||
|
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||||
|
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||||
|
JWKS_URL=${{ vars.JWKS_URL }}
|
||||||
|
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||||
|
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||||
|
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
|
||||||
|
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
|
||||||
|
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||||
|
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||||
|
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||||
|
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||||
|
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||||
|
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||||
|
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 파일 복사
|
||||||
|
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
|
||||||
|
|
||||||
|
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||||
|
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||||
|
|
||||||
|
if [ -d "docker/init-metadata" ]; then
|
||||||
|
scp -r docker/init-metadata "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "gateway" ]; then
|
||||||
|
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||||
|
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||||
|
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||||
|
|
||||||
|
# 배포 실행
|
||||||
|
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
|
||||||
|
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
|
||||||
|
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
|
||||||
|
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||||
|
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||||
|
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||||
|
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||||
|
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||||
|
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||||
|
cd \"\${DEPLOY_PATH}\"; \
|
||||||
|
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
|
||||||
|
set -a; . ./.env; set +a; \
|
||||||
|
|
||||||
|
# 네트워크 생성
|
||||||
|
for net in baron_net public_net ory-net hydranet kratosnet; do
|
||||||
|
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
|
||||||
|
done
|
||||||
|
|
||||||
|
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
|
||||||
|
|
||||||
|
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
|
||||||
|
chmod -R 777 docker/ory
|
||||||
|
|
||||||
|
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml pull; \
|
||||||
|
|
||||||
|
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
|
||||||
|
# DB 초기화 문제를 확실히 해결하기 위해 기존 볼륨을 날리고 다시 띄움 (데이터 삭제됨 주의)
|
||||||
|
# 스테이징이므로 초기화 진행. 데이터 보존이 필요하면 이 줄 제거하고 수동으로 DB 만들어야 함.
|
||||||
|
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down -v || true
|
||||||
|
|
||||||
|
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d --remove-orphans; \
|
||||||
|
|
||||||
|
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||||
|
sleep 10; \
|
||||||
|
if [ \$(docker inspect -f '{{.State.ExitCode}}' baron-sso-staging-kratos-migrate-1) -ne 0 ]; then \
|
||||||
|
echo 'Kratos Migrate Failed. Logs:'; \
|
||||||
|
docker logs baron-sso-staging-kratos-migrate-1; \
|
||||||
|
exit 1; \
|
||||||
|
fi"
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# General
|
||||||
|
.env
|
||||||
|
.temp
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.codex
|
||||||
|
.codex/
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Docker Services Data (Volumes)
|
||||||
|
postgres_data/
|
||||||
|
clickhouse_data/
|
||||||
|
|
||||||
|
# Backend (Go)
|
||||||
|
backend/main
|
||||||
|
backend/bin/
|
||||||
|
backend/vendor/
|
||||||
|
backend/tmp/
|
||||||
|
backend/.env
|
||||||
|
backend/server
|
||||||
|
|
||||||
|
# userfront (Flutter)
|
||||||
|
# Note: userfront might have its own .gitignore, but adding here just in case
|
||||||
|
userfront/build/
|
||||||
|
userfront/.dart_tool/
|
||||||
|
userfront/.packages
|
||||||
|
userfront/.pub/
|
||||||
|
userfront/.env
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Baron SSO용 Docker Compose 헬퍼
|
||||||
|
|
||||||
|
# 환경 변수 로드
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
include .env
|
||||||
|
export
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Compose 파일 경로
|
||||||
|
COMPOSE_INFRA := compose.infra.yaml
|
||||||
|
COMPOSE_ORY := compose.ory.yaml
|
||||||
|
COMPOSE_APP := docker-compose.yaml
|
||||||
|
|
||||||
|
# --- 기본 실행 ---
|
||||||
|
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||||
|
up-all:
|
||||||
|
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||||
|
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
|
||||||
|
|
||||||
|
# --- 개별 스택 실행 ---
|
||||||
|
up-infra:
|
||||||
|
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||||
|
docker compose -f $(COMPOSE_INFRA) up -d
|
||||||
|
|
||||||
|
up-ory:
|
||||||
|
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||||
|
docker compose -f $(COMPOSE_ORY) up -d
|
||||||
|
|
||||||
|
up-app:
|
||||||
|
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||||
|
docker compose -f $(COMPOSE_APP) up -d
|
||||||
|
|
||||||
|
up-backend:
|
||||||
|
@echo "Starting Backend only..."
|
||||||
|
docker compose -f $(COMPOSE_APP) up -d backend
|
||||||
|
|
||||||
|
up-dev: up-infra up-ory
|
||||||
|
@echo "Dev stack is up (infra + ory)."
|
||||||
|
|
||||||
|
up-front-dev: up-infra up-ory up-backend
|
||||||
|
@echo "Dev stack is up (infra + ory + backend)."
|
||||||
|
|
||||||
|
# --- 종료 (Down) ---
|
||||||
|
down-all:
|
||||||
|
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||||
|
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||||
|
|
||||||
|
down-app:
|
||||||
|
@echo "Stopping App stack..."
|
||||||
|
docker compose -f $(COMPOSE_APP) down
|
||||||
|
|
||||||
|
down-backend:
|
||||||
|
@echo "Stopping Backend only..."
|
||||||
|
docker compose -f $(COMPOSE_APP) stop backend
|
||||||
|
|
||||||
|
down-infra:
|
||||||
|
@echo "Stopping Infra stack..."
|
||||||
|
docker compose -f $(COMPOSE_INFRA) down
|
||||||
|
|
||||||
|
down-ory:
|
||||||
|
@echo "Stopping Ory stack..."
|
||||||
|
docker compose -f $(COMPOSE_ORY) down
|
||||||
|
|
||||||
|
# --- 유틸리티 ---
|
||||||
|
# 인프라 상태 확인
|
||||||
|
check-infra:
|
||||||
|
@echo "Checking infra status..."
|
||||||
|
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
|
||||||
|
echo "Error: PostgreSQL is not running or not healthy."; \
|
||||||
|
echo "Please run 'make up-infra' first."; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "PostgreSQL is healthy."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
|
||||||
|
|
||||||
|
logs-infra:
|
||||||
|
docker compose -f $(COMPOSE_INFRA) logs -f
|
||||||
|
|
||||||
|
logs-ory:
|
||||||
|
docker compose -f $(COMPOSE_ORY) logs -f
|
||||||
|
|
||||||
|
logs-app:
|
||||||
|
docker compose -f $(COMPOSE_APP) logs -f
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# Baron SSO
|
||||||
|
|
||||||
|
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||||
|
|
||||||
|
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||||
|
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||||
|
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||||
|
* UserFront: Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다.
|
||||||
|
* 로그인 : 비밀번호, SMS, QR 스캔 등의 수단으로 로그인 가능
|
||||||
|
* 향후 모바일 앱 지원으로 인증 Push 승인 등 MFA 확장 (예정)
|
||||||
|
* 정보변경, 앱 연동 이력 확인 등 개인화 기능
|
||||||
|
* 사용 가능한 앱 리스트 (예정)
|
||||||
|
* AdminFront: 사용자 관리 등 Admin 기능
|
||||||
|
* DevFront: RP 관리 등 개발자 기능
|
||||||
|
|
||||||
|
## 🏗 아키텍처 (Architecture)
|
||||||
|
|
||||||
|
### 0. Ory Stack
|
||||||
|
- Ory Kratos: 사용자 인증/계정 관리(Identity).
|
||||||
|
- Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리.
|
||||||
|
- Ory Keto: 권한/정책 기반 접근 제어.
|
||||||
|
- Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart
|
||||||
|
subgraph Edge
|
||||||
|
OK["Oathkeeper<br/>(Only Public Entry)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph App
|
||||||
|
BE["Backend<br/>(Only Upstream)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OryStack
|
||||||
|
KR[Kratos]
|
||||||
|
HY[Hydra]
|
||||||
|
KE[Keto]
|
||||||
|
KR --- HY --- KE
|
||||||
|
end
|
||||||
|
BE -->|Command| OryStack
|
||||||
|
OK -->|Query| KR
|
||||||
|
OK -->|Query| HY
|
||||||
|
OK -->|Query| KE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Backend (Go Fiber)
|
||||||
|
- **Language**: Go 1.25+
|
||||||
|
- **Framework**: Fiber v2.25+
|
||||||
|
- **Database**:
|
||||||
|
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
|
||||||
|
- **PostgreSQL**: 메타데이터 저장소 (Primary)
|
||||||
|
- **Features**:
|
||||||
|
- 인증용 SMS 발송 등 Ory-Stack으로 구현 어려운 부분 직접 구현
|
||||||
|
- `POST /api/v1/audit`: 감사 로그 수집 API
|
||||||
|
- userfront가 바라보는 backend
|
||||||
|
|
||||||
|
### 2. UserFront(Flutter Web/App)
|
||||||
|
- **Framework**: Flutter 3.32+
|
||||||
|
- **Key Packages**: `flutter_riverpod`, `go_router`
|
||||||
|
- **Features**:
|
||||||
|
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
|
||||||
|
|
||||||
|
### 3. adminfront(Web)
|
||||||
|
- **Framework**: Vite, React 19+, Shadcn/ui 등
|
||||||
|
- **Features**:
|
||||||
|
- 사용자 관리, 권한 부여 등 관리자 기능
|
||||||
|
- 앱 별 사용량(호출량) 등 통계
|
||||||
|
- 핵심 Audit 대상
|
||||||
|
|
||||||
|
### 4. devfront(Web)
|
||||||
|
- **Framework**: Vite, React 19+, Shadcn/ui 등
|
||||||
|
- **Features**:
|
||||||
|
- RP 등록 및 관리
|
||||||
|
- RP별 Consent 관리
|
||||||
|
|
||||||
|
|
||||||
|
### 4. 주요 시나리오 (Core Scenarios)
|
||||||
|
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
|
||||||
|
1.1. 단 약관동의(Consent) 이력이 없으면 Consent 단계로 이동
|
||||||
|
2. **Cross-Device Auth**: 이메일 SMS 등의 수단으로 링크를 전달받고 해당 링크를 사용자가 클릭하면 최초 로그인 요청한 세션이 활성화
|
||||||
|
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
|
||||||
|
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
|
||||||
|
|
||||||
|
|
||||||
|
### 전체 연결 구조도
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Clients ["External Clients"]
|
||||||
|
AF[adminfront]
|
||||||
|
DF[devfront]
|
||||||
|
UF["userfront"]
|
||||||
|
DS["일반SW"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph AppService ["Control Plane"]
|
||||||
|
BE["Backend (Command/Audit Controller)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OryBundle ["Ory Deployment Stack"]
|
||||||
|
direction TB
|
||||||
|
OK["Oathkeeper (Public Proxy/OIDC)"]
|
||||||
|
|
||||||
|
subgraph OryEngines ["Ory Services"]
|
||||||
|
direction LR
|
||||||
|
HY["Hydra"]
|
||||||
|
KR["Kratos"]
|
||||||
|
KE["Keto"]
|
||||||
|
end
|
||||||
|
|
||||||
|
ICH[(Internal Clickhouse)]
|
||||||
|
|
||||||
|
%% Internal Flow within Bundle
|
||||||
|
OK -->|Routing/Queries| OryEngines
|
||||||
|
OK -.->|Access/Usage Log| ICH
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph AuditDB ["Audit Storage"]
|
||||||
|
ECH[(External Clickhouse)]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Key Command Path
|
||||||
|
AF & DF & UF & DS ==>|Actions / Commands| BE
|
||||||
|
|
||||||
|
%% Backend Responsibilities
|
||||||
|
BE -->|Admin/State Control| OryEngines
|
||||||
|
BE -.->|Mandatory Audit Log| ECH
|
||||||
|
|
||||||
|
%% Connection Note (Hidden flow mentioned in logic)
|
||||||
|
%% OK is technically the entry for OIDC, but removed as per request
|
||||||
|
|
||||||
|
%% Styles
|
||||||
|
style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px
|
||||||
|
style BE fill:#bbf,stroke:#333,stroke-width:2px
|
||||||
|
style ECH fill:#fdd,stroke:#333
|
||||||
|
style ICH fill:#dfd,stroke:#333
|
||||||
|
style OK fill:#f9f,stroke:#333
|
||||||
|
style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 시작하기 (Getting Started)
|
||||||
|
|
||||||
|
### 사전 요구사항 (Prerequisites)
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Flutter SDK (로컬 개발용)
|
||||||
|
- Go (로컬 백엔드 개발용)
|
||||||
|
|
||||||
|
### 환경 설정 (Environment Setup)
|
||||||
|
1. 예제 환경 설정 파일을 복사합니다.
|
||||||
|
```bash
|
||||||
|
cp .env.sample .env
|
||||||
|
```
|
||||||
|
2. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 입니다
|
||||||
|
```
|
||||||
|
IDP_PROVIDER=ory
|
||||||
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
HYDRA_PUBLIC_URL=http://hydra:4444
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전체 스택 실행 (Running the Stack)
|
||||||
|
|
||||||
|
#### 1. 네트워크 생성 (최초 1회)
|
||||||
|
Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생성합니다.
|
||||||
|
```bash
|
||||||
|
# ory-net은 bridge 모드로 생성
|
||||||
|
docker network create -d bridge ory-net
|
||||||
|
docker network create hydranet
|
||||||
|
docker network create kratosnet
|
||||||
|
docker network create public_net #서비스용
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 인프라 및 Ory Stack 실행
|
||||||
|
데이터베이스와 Ory 서비스(Kratos, Hydra, Keto 등)를 실행합니다.
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.infra.yaml -f compose.ory.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 애플리케이션 실행
|
||||||
|
userfront와 backend 서비스를 실행합니다.
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yaml up -d
|
||||||
|
```
|
||||||
|
(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`)
|
||||||
|
|
||||||
|
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
|
||||||
|
- **backend**: http://localhost:3000 (API)
|
||||||
|
- **ClickHouse**: http://localhost:8123
|
||||||
|
- **Kratos Public**: http://localhost:4433
|
||||||
|
- **Hydra Public**: http://localhost:4444
|
||||||
|
- **Kratos UI (UserFront)**: http://localhost:5000
|
||||||
|
|
||||||
|
### MCP 서버 (Hydra/Kratos/Keto)
|
||||||
|
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||||
|
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-mcp-server keto-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
- MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다.
|
||||||
|
- 최초 실행시거나 빌드된 이미지가 없으면 `docker compose -f mcp/compose.mcp.ory.yaml build' 후에 사용 가능합니다
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.kratos-mcp]
|
||||||
|
command = "docker"
|
||||||
|
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "kratos-mcp-server"]
|
||||||
|
|
||||||
|
[mcp_servers.kratos-mcp.env]
|
||||||
|
KRATOS_ADMIN_URL = "http://kratos:4434"
|
||||||
|
|
||||||
|
[mcp_servers.hydra-mcp]
|
||||||
|
command = "docker"
|
||||||
|
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "hydra-mcp-server"]
|
||||||
|
|
||||||
|
[mcp_servers.hydra-mcp.env]
|
||||||
|
HYDRA_PUBLIC_URL = "http://hydra:4444"
|
||||||
|
HYDRA_ADMIN_URL = "http://hydra:4445"
|
||||||
|
|
||||||
|
[mcp_servers.keto-mcp]
|
||||||
|
command = "docker"
|
||||||
|
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "keto-mcp-server"]
|
||||||
|
|
||||||
|
[mcp_servers.keto-mcp.env]
|
||||||
|
KETO_READ_URL = "http://keto:4466"
|
||||||
|
KETO_WRITE_URL = "http://keto:4467"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로컬 개발 (Manual)
|
||||||
|
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||||
|
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod tidy
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
**userfront:**
|
||||||
|
```bash
|
||||||
|
cd userfront
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
**adminfront:**
|
||||||
|
```bash
|
||||||
|
cd adminfront
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**devfront:**
|
||||||
|
```bash
|
||||||
|
cd devfront
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 프로젝트 구조 (Project Structure)
|
||||||
|
|
||||||
|
```
|
||||||
|
baron_sso/
|
||||||
|
├── backend/ # Go Fiber 애플리케이션
|
||||||
|
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||||
|
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── userfront/ # Flutter 애플리케이션
|
||||||
|
│ ├── src/ # UI 및 로직
|
||||||
|
│ └── pubspec.yaml
|
||||||
|
├── adminfront/ # React 기반 관리
|
||||||
|
│ ├── src/ # UI 및 로직
|
||||||
|
│ └── pubspec.yaml
|
||||||
|
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||||
|
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||||
|
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||||
|
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||||
|
├── .env.sample # 환경 설정 템플릿
|
||||||
|
└── README.md # 본 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 상태 및 로드맵 (Status & Roadmap)
|
||||||
|
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
|
||||||
|
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
|
||||||
|
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
|
||||||
|
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
|
||||||
|
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# Baron SSO
|
||||||
|
|
||||||
|
**Baron SSO** is a white-labeled User Authentication Hub and Unified Launcher.
|
||||||
|
It leverages **Descope** for secure, passwordless authentication (Enchanted Link / Magic Link) and provides a custom Flutter UI for a seamless user experience. A Go (Fiber) backend manages Audit Logs via ClickHouse.
|
||||||
|
|
||||||
|
## 🏗 Architecture
|
||||||
|
|
||||||
|
### 1. Frontend (Flutter Web)
|
||||||
|
- **Framework**: Flutter 3.32+
|
||||||
|
- **Organization**: `kr.co.baroncs`
|
||||||
|
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
|
||||||
|
- **Features**:
|
||||||
|
- Login UI with Tabs (Email / SMS)
|
||||||
|
- Descope SDK Integration (Enchanted Link, Magic Link)
|
||||||
|
|
||||||
|
### 2. Backend (Go Fiber)
|
||||||
|
- **Language**: Go 1.25+
|
||||||
|
- **Framework**: Fiber v2.25+
|
||||||
|
- **Database**:
|
||||||
|
- **ClickHouse**: Audit Logs (High performance ingestion)
|
||||||
|
- **PostgreSQL**: Metadata storage (Primary)
|
||||||
|
- **Features**:
|
||||||
|
- `POST /api/v1/audit`: Endpoint to ingest audit logs.
|
||||||
|
|
||||||
|
### 3. Infrastructure (Docker)
|
||||||
|
- **Services**: `postgres`, `clickhouse` (defined in `compose.infra.yaml`)
|
||||||
|
- **App**: `userfront`, `backend` (defined in `docker-compose.yaml`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Flutter SDK (for local development)
|
||||||
|
- Go (for local backend development)
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
1. Copy the sample environment file.
|
||||||
|
```bash
|
||||||
|
cp .env.sample .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
|
||||||
|
```env
|
||||||
|
IDP_PROVIDER=ory,descope
|
||||||
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Stack
|
||||||
|
|
||||||
|
#### 1. Start Infrastructure (Databases)
|
||||||
|
Start the persistent data layer first.
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.infra.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Start Applications
|
||||||
|
Start the userfront and backend services.
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
- **userfront**: Accessible at http://localhost:5000
|
||||||
|
- **backend**: API active at http://localhost:3000
|
||||||
|
- **ClickHouse**: http://localhost:8123
|
||||||
|
|
||||||
|
### Local Development (Manual)
|
||||||
|
If you prefer running without Docker for code editing:
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod tidy
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
**userfront:**
|
||||||
|
```bash
|
||||||
|
cd userfront
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
baron_sso/
|
||||||
|
├── backend/ # Go Fiber Application
|
||||||
|
│ ├── cmd/server/ # Entry point
|
||||||
|
│ ├── internal/ # Domain, Handlers, Repository
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── userfront/ # Flutter Application
|
||||||
|
│ ├── lib/ # UI & Logic
|
||||||
|
│ └── pubspec.yaml
|
||||||
|
├── compose.infra.yaml # DB Services (Postgres, ClickHouse)
|
||||||
|
├── docker-compose.yaml # App Services
|
||||||
|
├── .env.sample # Env Config Template
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Status & Roadmap
|
||||||
|
- [x] **Phase 1**: Initial Setup & Architecture (Done)
|
||||||
|
- [x] **Phase 2**: Backend Audit API (Done)
|
||||||
|
- [x] **Phase 3**: Frontend Login UI & Descope Auth Logic (Done)
|
||||||
|
- [ ] **Phase 4**: Connect Frontend to Audit API (Todo)
|
||||||
|
- [ ] **Phase 5**: Dashboard & Unified Launcher (Todo)
|
||||||
@@ -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?
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 패키지 정보 복사 및 의존성 설치
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# 소스 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vite 기본 포트
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
|
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
||||||
|
echo 'Running in production mode...'; \
|
||||||
|
npm run build && serve -s dist -l 5173; \
|
||||||
|
else \
|
||||||
|
echo 'Running in development mode...'; \
|
||||||
|
npm run dev -- --host 0.0.0.0; \
|
||||||
|
fi"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Admin Front (React 19 + Vite)
|
||||||
|
|
||||||
|
관리자 포털을 위한 React/Vite 기반 웹입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
|
||||||
|
|
||||||
|
## 주요 스택
|
||||||
|
- React 19, Vite 7, 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 인스턴스(토큰/테넌트 헤더 주입 스텁)
|
||||||
|
|
||||||
|
## 다음 작업 가이드
|
||||||
|
- IDP 중립 Auth 레이어 추가 후 관리자 역할 가드/세션 TTL 적용
|
||||||
|
- 테넌트 선택 UI 추가 → `X-Tenant-ID` 헤더에 반영
|
||||||
|
- shadcn/ui 도입 및 폼/테이블 컴포넌트 연결
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useEnumInitializers": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["dist", "node_modules", "tsconfig*.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+3622
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "adminfront",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check . --write",
|
||||||
|
"format": "biome format . --write",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@tanstack/react-query": "^5.66.8",
|
||||||
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-router-dom": "^6.28.2",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@playwright/test": "^1.58.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "html",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: "http://localhost:5173",
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 |
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
|
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||||
|
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||||
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
|
import UserCreatePage from "../features/users/UserCreatePage";
|
||||||
|
import UserDetailPage from "../features/users/UserDetailPage";
|
||||||
|
import UserListPage from "../features/users/UserListPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AppLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <GlobalOverviewPage /> },
|
||||||
|
{ path: "dashboard", element: <DashboardPage /> },
|
||||||
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
|
{ path: "auth", element: <AuthPage /> },
|
||||||
|
{ path: "users", element: <UserListPage /> },
|
||||||
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
|
{
|
||||||
|
path: "tenants/:tenantId",
|
||||||
|
element: <TenantDetailPage />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
|
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
||||||
|
{
|
||||||
|
future: {
|
||||||
|
v7_startTransition: true,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||||
|
);
|
||||||
@@ -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,148 @@
|
|||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Building2,
|
||||||
|
Key,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
Moon,
|
||||||
|
NotebookTabs,
|
||||||
|
ShieldHalf,
|
||||||
|
Sun,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||||
|
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||||
|
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||||
|
{ label: "Users", to: "/users", icon: Users },
|
||||||
|
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||||
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
|
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
|
return stored === "dark" ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
if (theme === "light") {
|
||||||
|
root.classList.add("light");
|
||||||
|
} else {
|
||||||
|
root.classList.add("dark");
|
||||||
|
}
|
||||||
|
window.localStorage.setItem("admin_theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||||
|
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||||
|
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||||
|
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||||
|
<ShieldHalf size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Baron 로그인
|
||||||
|
</p>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
Admin Control
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||||
|
<BadgeCheck size={14} />
|
||||||
|
Scoped to /admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
|
IDP env: prod
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
|
Tenant-aware headers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{navItems.map(({ label, to, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||||
|
<p>관리 기능은 /admin 네임스페이스에서만 노출합니다.</p>
|
||||||
|
<p>
|
||||||
|
IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·
|
||||||
|
레이트리밋을 기본 적용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Admin Plane
|
||||||
|
</p>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
Tenant isolation & least privilege by default
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{theme === "light" ? (
|
||||||
|
<Sun size={16} />
|
||||||
|
) : (
|
||||||
|
<Moon size={16} />
|
||||||
|
)}
|
||||||
|
{theme === "light" ? "Light" : "Dark"}
|
||||||
|
</button>
|
||||||
|
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||||
|
Session TTL: 15m admin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<RoleSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const RoleSwitcher: React.FC = () => {
|
||||||
|
const [currentRole, setCurrentRole] = useState<string>('super_admin');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// localStorage에서 역할 읽기
|
||||||
|
const savedRole = window.localStorage.getItem('X-Mock-Role');
|
||||||
|
if (savedRole) {
|
||||||
|
setCurrentRole(savedRole);
|
||||||
|
} else {
|
||||||
|
// 기본값 설정
|
||||||
|
window.localStorage.setItem('X-Mock-Role', 'super_admin');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchRole = (role: string) => {
|
||||||
|
// localStorage 설정
|
||||||
|
window.localStorage.setItem('X-Mock-Role', role);
|
||||||
|
setCurrentRole(role);
|
||||||
|
// 페이지 새로고침하여 권한 적용
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.env.MODE === 'production') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9999,
|
||||||
|
background: '#1A1F2C',
|
||||||
|
color: 'white',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
|
||||||
|
🛠 DEV Role Switcher
|
||||||
|
</div>
|
||||||
|
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => switchRole(role)}
|
||||||
|
style={{
|
||||||
|
background: currentRole === role ? '#3b82f6' : '#333',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSwitcher;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
muted: "border-border bg-secondary/60 text-muted-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-6 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted/50 font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-6 align-middle text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
const AVAILABLE_SCOPES = [
|
||||||
|
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
|
||||||
|
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
|
||||||
|
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
|
||||||
|
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
|
||||||
|
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
|
||||||
|
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ApiKeyCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
|
||||||
|
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<{ name: string }>({
|
||||||
|
defaultValues: { name: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: ApiKeyCreateRequest) => createApiKey(payload),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
||||||
|
setCreatedResult(data);
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleScope = (scopeId: string) => {
|
||||||
|
setSelectedScopes((prev) =>
|
||||||
|
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: { name: string }) => {
|
||||||
|
if (selectedScopes.length === 0) {
|
||||||
|
setError("최소 하나 이상의 권한을 선택해야 합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate({ name: data.name, scopes: selectedScopes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createdResult) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto py-12 space-y-8">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
|
||||||
|
<ShieldCheck size={32} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">API 키 생성 완료</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
아래의 비밀번호(Secret)는 보안을 위해 <span className="text-destructive font-bold">지금 한 번만</span> 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-2 border-primary/20 shadow-xl">
|
||||||
|
<CardHeader className="bg-primary/5 border-b">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<AlertCircle size={16} className="text-primary" />
|
||||||
|
보안 시크릿 복사
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-8 pb-8 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
||||||
|
X-Baron-Key-Secret
|
||||||
|
</Label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={createdResult.clientSecret}
|
||||||
|
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
|
||||||
|
onClick={() => handleCopy(createdResult.clientSecret)}
|
||||||
|
>
|
||||||
|
<Copy size={20} className="text-primary" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-center text-muted-foreground italic">
|
||||||
|
복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex flex-col gap-2">
|
||||||
|
<Button size="lg" className="w-full font-bold" asChild>
|
||||||
|
<Link to="/api-keys">저장했습니다. 목록으로 이동</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-10">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
|
||||||
|
<ChevronLeft size={16} className="mr-1" />
|
||||||
|
돌아가기
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">새 API 키 생성</h2>
|
||||||
|
<p className="text-muted-foreground">내부 시스템 연동을 위한 보안 인증 키를 구성합니다.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* 섹션 1: 이름 설정 */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
|
||||||
|
<h3 className="font-semibold text-lg">키 이름 지정</h3>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-sm font-medium">서비스 또는 목적 식별 이름</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="예: Jenkins-CI, Grafana-Dashboard"
|
||||||
|
className="text-base py-5"
|
||||||
|
{...register("name", { required: "이름은 필수입니다." })}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 섹션 2: 권한 선택 */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
|
||||||
|
<h3 className="font-semibold text-lg">권한 범위(Scopes) 선택</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{AVAILABLE_SCOPES.map((scope) => {
|
||||||
|
const isSelected = selectedScopes.includes(scope.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scope.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleScope(scope.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 shadow-md"
|
||||||
|
: "border-border bg-card hover:border-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
|
||||||
|
<div className={cn(
|
||||||
|
"h-5 w-5 rounded-md flex items-center justify-center border",
|
||||||
|
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
|
||||||
|
)}>
|
||||||
|
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{scope.desc}
|
||||||
|
</p>
|
||||||
|
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 하단 실행 버튼 */}
|
||||||
|
<div className="pt-6 flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg flex items-center gap-3">
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
<p className="text-sm font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">총 {selectedScopes.length}개의 권한이 할당됩니다.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">생성 즉시 활성화되어 사용 가능합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
size="lg"
|
||||||
|
className="px-8 font-bold shadow-lg shadow-primary/20"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-5 w-5" />
|
||||||
|
)}
|
||||||
|
API 키 발급하기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyCreatePage;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function ApiKeyListPage() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => fetchApiKeys(50, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteApiKey(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
const handleDelete = (id: string, name: string) => {
|
||||||
|
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>API Keys</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">List</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">API 키 관리 (M2M)</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고
|
||||||
|
관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/api-keys/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
API 키 생성
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>API Key Registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {query.data?.total ?? 0}개 API 키
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">System</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>CLIENT ID</TableHead>
|
||||||
|
<TableHead>SCOPES</TableHead>
|
||||||
|
<TableHead>LAST USED</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>등록된 API 키가 없습니다.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((key) => (
|
||||||
|
<TableRow key={key.id}>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key size={14} className="text-[var(--color-muted)]" />
|
||||||
|
{key.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code>{key.client_id}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{key.scopes.map((scope) => (
|
||||||
|
<Badge
|
||||||
|
key={scope}
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{key.lastUsedAt
|
||||||
|
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||||
|
: "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(key.id, key.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyListPage;
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
ListChecks,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
const defaultAuditFilters = [
|
||||||
|
"method:POST path:/api/v1/*",
|
||||||
|
"status:failure",
|
||||||
|
"latency_ms:>1000",
|
||||||
|
];
|
||||||
|
|
||||||
|
type AuditDetails = {
|
||||||
|
request_id?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: number;
|
||||||
|
latency_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
actor_id?: string;
|
||||||
|
action?: string;
|
||||||
|
target?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDetails(details?: string): AuditDetails {
|
||||||
|
if (!details) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as AuditDetails;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIsoDateTime(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return { date: "-", time: "-" };
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return { date: value, time: "-" };
|
||||||
|
}
|
||||||
|
const date = parsed.toISOString().slice(0, 10);
|
||||||
|
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||||
|
return { date, time };
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogsPage() {
|
||||||
|
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||||
|
const [filterDraft, setFilterDraft] = React.useState("");
|
||||||
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleCopy = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["audit-logs"],
|
||||||
|
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs =
|
||||||
|
data?.pages?.flatMap(
|
||||||
|
(page) =>
|
||||||
|
page?.items?.filter((item): item is AuditLog =>
|
||||||
|
Boolean(item),
|
||||||
|
) ?? [],
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const handleAddFilter = () => {
|
||||||
|
const trimmed = filterDraft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilters((prev) =>
|
||||||
|
prev.includes(trimmed) ? prev : [...prev, trimmed],
|
||||||
|
);
|
||||||
|
setFilterDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-center">Loading audit logs...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errMsg =
|
||||||
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
|
(error as Error).message;
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-red-500">
|
||||||
|
Error loading logs: {errMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>Audit</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Logs</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Command 요청 기반 ClickHouse 로그를 조회합니다.
|
||||||
|
사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<ListChecks size={16} />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Audit registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
로드된 로그 {logs.length}건
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Command only</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
value={filterDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilterDraft(event.target.value)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
handleAddFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="필터 추가 (예: status:failure)"
|
||||||
|
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddFilter}
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{filters.length === 0 ? (
|
||||||
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
필터 없음
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
filters.map((filter) => (
|
||||||
|
<span
|
||||||
|
key={filter}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||||
|
>
|
||||||
|
<Terminal size={12} />
|
||||||
|
{filter}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(item) =>
|
||||||
|
item !== filter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||||
|
aria-label={`${filter} 필터 제거`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[140px]">
|
||||||
|
TIME
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[160px]">
|
||||||
|
ACTOR (ID)
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>REQUEST</TableHead>
|
||||||
|
<TableHead>PATH</TableHead>
|
||||||
|
<TableHead className="w-[120px]">
|
||||||
|
STATUS
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Action / Target</TableHead>
|
||||||
|
<TableHead className="w-[80px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && logs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
아직 수집된 감사 로그가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{logs.map((row, index) => {
|
||||||
|
const details = parseDetails(row.details);
|
||||||
|
const actionLabel =
|
||||||
|
details.action ||
|
||||||
|
(details.method && details.path
|
||||||
|
? `${details.method} ${details.path}`
|
||||||
|
: row.event_type);
|
||||||
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
|
const isExpanded = Boolean(
|
||||||
|
expandedRows[rowKey],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rowKey}>
|
||||||
|
<TableRow className="bg-card/40">
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
{(() => {
|
||||||
|
const { date, time } =
|
||||||
|
formatIsoDateTime(
|
||||||
|
row.timestamp,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
{row.user_id ||
|
||||||
|
details.actor_id ||
|
||||||
|
"-"}
|
||||||
|
</code>
|
||||||
|
{(row.user_id ||
|
||||||
|
details.actor_id) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy actor id"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
row.user_id ||
|
||||||
|
details.actor_id ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
{formatCellValue(
|
||||||
|
details.request_id,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{details.request_id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy request id"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
details.request_id ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{formatCellValue(
|
||||||
|
details.method,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
{formatCellValue(
|
||||||
|
details.path,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status ===
|
||||||
|
"success" ||
|
||||||
|
row.status === "ok"
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{actionLabel}
|
||||||
|
</div>
|
||||||
|
{details.target && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
Target ·{" "}
|
||||||
|
{details.target}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy target"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
details.target ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows(
|
||||||
|
(prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]:
|
||||||
|
!isExpanded,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow className="bg-card/20">
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Request
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Request ID ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.request_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Event ID ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
row.event_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
IP ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
row.ip_address,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Latency ·{" "}
|
||||||
|
{details.latency_ms !==
|
||||||
|
undefined
|
||||||
|
? `${details.latency_ms}ms`
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Actor
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Actor ID ·{" "}
|
||||||
|
{row.user_id ||
|
||||||
|
details.actor_id ||
|
||||||
|
"-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Tenant ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.tenant_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Device ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
row.device_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Result
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Error ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.error,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Before ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.before,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
After ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.after,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="pt-4 text-center">
|
||||||
|
{hasNextPage ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage
|
||||||
|
? "Loading..."
|
||||||
|
: "Load more"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
End of audit feed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogsPage;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
LineChart,
|
||||||
|
Radio,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const guardHighlights = [
|
||||||
|
{
|
||||||
|
title: "Tenant isolation",
|
||||||
|
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
|
||||||
|
metric: "Header guard",
|
||||||
|
accent: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Admin TTL",
|
||||||
|
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
|
||||||
|
metric: "15m default",
|
||||||
|
accent: "ttl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Audit-first",
|
||||||
|
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
|
||||||
|
metric: "per-action",
|
||||||
|
accent: "audit",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackReadiness = [
|
||||||
|
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||||
|
"TanStack Query 5 provider ready with sane defaults.",
|
||||||
|
"Axios client stub with bearer + tenant header injector.",
|
||||||
|
"Tailwind v4 tokens tuned for admin dark plane.",
|
||||||
|
"React Hook Form + Zod planned for client forms.",
|
||||||
|
"IdP-neutral auth hook point reserved for role guard.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextSteps = [
|
||||||
|
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
|
||||||
|
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
|
||||||
|
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
|
||||||
|
];
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||||
|
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
adminfront ready
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold leading-tight">
|
||||||
|
Build the admin plane with{" "}
|
||||||
|
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
|
||||||
|
defaults and{" "}
|
||||||
|
<span className="text-[var(--color-accent-strong)]">
|
||||||
|
least privilege
|
||||||
|
</span>{" "}
|
||||||
|
UX.
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--color-muted)]">
|
||||||
|
Route, query, and styling scaffolds are in place. Use this canvas
|
||||||
|
to ship RP registry, audit exploration, and guarded login aligned
|
||||||
|
with issue #60 while keeping providers swappable.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||||
|
Router + Query wired
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||||
|
Admin namespace only
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
Auth hook pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
Admin guard scoped to /admin
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<Building2 size={16} />
|
||||||
|
Tenant selection placeholder ready
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||||
|
<Radio size={16} />
|
||||||
|
Audit stream hook for ClickHouse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{guardHighlights.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||||
|
<div className="relative flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
{item.metric}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||||
|
{item.accent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-3 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Stack readiness
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">Matches issue #60</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||||
|
>
|
||||||
|
Setup notes
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
{stackReadiness.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
size={16}
|
||||||
|
className="text-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
<p className="text-sm">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Next actions
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-semibold">
|
||||||
|
Ship the first guarded flows
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{nextSteps.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text)]">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Ops board
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">What to prototype next</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
Audit → ClickHouse
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||||
|
RP registry
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<LineChart size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.16em]">
|
||||||
|
Metrics
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-2 text-lg font-semibold">
|
||||||
|
RP registration funnel
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Visualize create → secret rotate → redirect URI edits per tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<Activity size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Live feed of admin API calls with per-action tenant, actor, and
|
||||||
|
rate-limit outcome.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<span className="text-xs uppercase tracking-[0.16em]">
|
||||||
|
Access
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Outline SMS + app-based MFA choice and emphasize “admin session”
|
||||||
|
TTL with logout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
Box,
|
||||||
|
Database,
|
||||||
|
ShieldCheck,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{
|
||||||
|
label: "Total Tenants",
|
||||||
|
value: "-",
|
||||||
|
hint: "Tenant-aware core",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "OIDC Clients",
|
||||||
|
value: "-",
|
||||||
|
hint: "Hydra registry",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Audit Events (24h)",
|
||||||
|
value: "-",
|
||||||
|
hint: "ClickHouse stream",
|
||||||
|
icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Policy Gate",
|
||||||
|
value: "Planned",
|
||||||
|
hint: "Keto + Admin checks",
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function GlobalOverviewPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||||
|
Global Overview
|
||||||
|
</p>
|
||||||
|
<h2 className="text-3xl font-semibold">
|
||||||
|
Tenant-independent control plane
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="muted">IDP: Ory primary</Badge>
|
||||||
|
<Badge variant="muted">Fallback: Descope</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
|
||||||
|
<Card key={label} className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{value}</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Admin playbook</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">
|
||||||
|
Backend-only IDP access
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos
|
||||||
|
admin 포트는 외부에 노출하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
|
||||||
|
<Box size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">
|
||||||
|
Tenant isolation
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto
|
||||||
|
정책으로 확장 예정입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">빠른 이동</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
주요 운영 화면으로 바로 이동합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full justify-between"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link to="/tenants/new">
|
||||||
|
테넌트 추가
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full justify-between"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link to="/audit-logs">
|
||||||
|
감사 로그 보기
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full justify-between"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link to="/dashboard">
|
||||||
|
테넌트 대시보드
|
||||||
|
<ArrowUpRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalOverviewPage;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Building2, Sparkles } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import { createTenant } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [status, setStatus] = useState("active");
|
||||||
|
const [domains, setDomains] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createTenant({
|
||||||
|
name,
|
||||||
|
slug: slug || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
status,
|
||||||
|
domains: domains
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter((d) => d !== ""),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/tenants");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Create</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-semibold">테넌트 추가</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
글로벌 운영 기준의 신규 테넌트를 등록합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Admin only</Badge>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 size={18} />
|
||||||
|
Tenant Profile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Tenant name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Slug</Label>
|
||||||
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="tenant-slug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Allowed Domains (Comma separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={domains}
|
||||||
|
onChange={(e) => setDomains(e.target.value)}
|
||||||
|
placeholder="example.com, example.kr"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Users with these email domains will be automatically assigned to
|
||||||
|
this tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Status</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={status === "active" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus("active")}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus("inactive")}
|
||||||
|
>
|
||||||
|
Inactive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
정책 메모
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||||
|
생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={mutation.isPending || name.trim() === ""}
|
||||||
|
>
|
||||||
|
생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantCreatePage;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { fetchTenant } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantDetailPage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const tenantQuery = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFederationTab = location.pathname.includes("/federation");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<Link to="/tenants" className="inline-flex items-center gap-2">
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Tenants
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Detail</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">
|
||||||
|
{tenantQuery.data?.name ?? "Loading Tenant..."}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
Edit tenant information or manage federation settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Admin only</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b">
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}`}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
!isFederationTab
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/federation`}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
isFederationTab
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Federation
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/schema`}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
location.pathname.includes("/schema")
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Schema
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outlet for nested routes */}
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantDetailPage;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
|
||||||
|
function TenantGroupsPage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["groups", tenantId],
|
||||||
|
queryFn: () => fetchGroups(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 목록 조회 (멤버 추가용)
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { limit: 100 }],
|
||||||
|
queryFn: () => fetchUsers(100, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
|
||||||
|
onSuccess: () => {
|
||||||
|
groupsQuery.refetch();
|
||||||
|
setNewGroupName("");
|
||||||
|
setNewGroupNameDesc("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteGroup(id),
|
||||||
|
onSuccess: () => groupsQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMemberMutation = useMutation({
|
||||||
|
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
|
||||||
|
onSuccess: () => groupsQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
|
||||||
|
onSuccess: () => groupsQuery.refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddMember = (groupId: string) => {
|
||||||
|
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
|
||||||
|
if (userId) {
|
||||||
|
addMemberMutation.mutate({ groupId, userId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{/* 그룹 생성 폼 */}
|
||||||
|
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Plus size={16} /> 새 그룹 생성
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="name">그룹 이름</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={e => setNewGroupName(e.target.value)}
|
||||||
|
placeholder="예: 개발팀, 인사팀"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="desc">설명</Label>
|
||||||
|
<Input
|
||||||
|
id="desc"
|
||||||
|
value={newGroupDesc}
|
||||||
|
onChange={e => setNewGroupNameDesc(e.target.value)}
|
||||||
|
placeholder="그룹 용도 설명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={!newGroupName || createMutation.isPending}
|
||||||
|
>
|
||||||
|
생성하기
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 그룹 목록 */}
|
||||||
|
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>User Groups</CardTitle>
|
||||||
|
<CardDescription>이 테넌트에 정의된 사용자 그룹 목록입니다.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>MEMBERS</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupsQuery.data?.map((group) => (
|
||||||
|
<TableRow
|
||||||
|
key={group.id}
|
||||||
|
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
|
||||||
|
onClick={() => setSelectedGroupId(group.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
<Users size={14} className="text-muted-foreground" />
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{group.description}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{group.members?.length || 0} 명</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
|
||||||
|
<Trash2 size={14} className="text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||||
|
{currentGroup && (
|
||||||
|
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield size={18} className="text-primary" />
|
||||||
|
[{currentGroup.name}] 멤버 관리
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>이메일</TableHead>
|
||||||
|
<TableHead className="text-right">제거</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{currentGroup.members?.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground">멤버가 없습니다.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{currentGroup.members?.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{user.email}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
|
||||||
|
>
|
||||||
|
<UserMinus size={14} className="text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupsPage;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => fetchTenants(50, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||||
|
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(tenantId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">List</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">테넌트 목록</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
현재 등록된 테넌트를 확인하고 상태를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/tenants/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
테넌트 추가
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Tenant registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {query.data?.total ?? 0}개 테넌트
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Admin only</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>SLUG</TableHead>
|
||||||
|
<TableHead>STATUS</TableHead>
|
||||||
|
<TableHead>UPDATED</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>
|
||||||
|
아직 등록된 테넌트가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((tenant) => (
|
||||||
|
<TableRow key={tenant.id}>
|
||||||
|
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||||
|
<TableCell>{tenant.slug}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
tenant.status === "active"
|
||||||
|
? "default"
|
||||||
|
: tenant.status === "pending"
|
||||||
|
? "secondary"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
tenant.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-800"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tenant.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{tenant.updatedAt
|
||||||
|
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantListPage;
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Save, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import {
|
||||||
|
approveTenant,
|
||||||
|
deleteTenant,
|
||||||
|
fetchTenant,
|
||||||
|
updateTenant,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
export function TenantProfilePage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return <div>Tenant ID is missing</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantQuery = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [status, setStatus] = useState("active");
|
||||||
|
const [domains, setDomains] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tenantQuery.data) {
|
||||||
|
setName(tenantQuery.data.name);
|
||||||
|
setSlug(tenantQuery.data.slug);
|
||||||
|
setDescription(tenantQuery.data.description ?? "");
|
||||||
|
setStatus(tenantQuery.data.status);
|
||||||
|
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||||
|
}
|
||||||
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateTenant(tenantId, {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: description || undefined,
|
||||||
|
status,
|
||||||
|
domains: domains
|
||||||
|
.split(",")
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter((d) => d !== ""),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
alert("Tenant updated successfully");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: () => approveTenant(tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
alert("Tenant approved successfully");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteTenant(tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/tenants");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (updateMutation.error as AxiosError<{ error?: string }>)
|
||||||
|
?.response?.data?.error;
|
||||||
|
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
|
||||||
|
?.response?.data?.error;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (window.confirm("Are you sure you want to delete this tenant?")) {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
if (window.confirm("Approve this tenant?")) {
|
||||||
|
approveMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-[var(--color-panel)] mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tenant profile</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Changes to slug and status are applied immediately.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Tenant name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Slug</Label>
|
||||||
|
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Allowed Domains (Comma separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={domains}
|
||||||
|
onChange={(e) => setDomains(e.target.value)}
|
||||||
|
placeholder="example.com, example.kr"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Users with these email domains will be automatically assigned to
|
||||||
|
this tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Status</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={status === "active" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus("active")}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus("inactive")}
|
||||||
|
>
|
||||||
|
Inactive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status === "pending" && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
Approve Tenant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => navigate("/tenants")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
updateMutation.isPending ||
|
||||||
|
tenantQuery.isLoading ||
|
||||||
|
name.trim() === ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
type SchemaField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "number" | "boolean";
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenantSchemaPage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
if (!tenantId) return <div>Tenant ID missing</div>;
|
||||||
|
|
||||||
|
const tenantQuery = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tenantQuery.data?.config?.userSchema) {
|
||||||
|
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
|
||||||
|
}
|
||||||
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (newFields: SchemaField[]) =>
|
||||||
|
updateTenant(tenantId, {
|
||||||
|
config: {
|
||||||
|
...tenantQuery.data?.config,
|
||||||
|
userSchema: newFields,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
|
alert("Schema updated successfully");
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
alert(err.response?.data?.error || "Failed to update schema");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addField = () => {
|
||||||
|
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
setFields(fields.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (index: number, updates: Partial<SchemaField>) => {
|
||||||
|
const newFields = [...fields];
|
||||||
|
newFields[index] = { ...newFields[index], ...updates };
|
||||||
|
setFields(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>User Schema Extension</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define custom attributes for users in this tenant.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={addField} size="sm">
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
|
||||||
|
No custom fields defined. Click "Add Field" to begin.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label>Field Key (ID)</Label>
|
||||||
|
<Input
|
||||||
|
value={field.key}
|
||||||
|
onChange={(e) => updateField(index, { key: e.target.value })}
|
||||||
|
placeholder="e.g. employee_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label>Display Label</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||||
|
placeholder="e.g. 사번"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||||
|
value={field.type}
|
||||||
|
onChange={(e) => updateField(index, { type: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="boolean">Boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => removeField(index)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => updateMutation.mutate(fields)}
|
||||||
|
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
||||||
|
>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
Save Schema Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Building2, Plus, ArrowRight } from "lucide-react";
|
||||||
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { fetchTenants } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantSubTenantsPage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["sub-tenants", tenantId],
|
||||||
|
queryFn: () => fetchTenants(50, 0, tenantId),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subTenants = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 size={18} className="text-primary" />
|
||||||
|
Sub-tenants ({subTenants.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>현재 테넌트 하위에 생성된 조직입니다.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link to={`/tenants/new?parentId=${tenantId}`}>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
하위 테넌트 추가
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>SLUG</TableHead>
|
||||||
|
<TableHead>STATUS</TableHead>
|
||||||
|
<TableHead className="text-right">ACTION</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subTenants.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||||
|
하위 테넌트가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{subTenants.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="font-semibold">{t.name}</TableCell>
|
||||||
|
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={t.status === "active" ? "default" : "secondary"}>
|
||||||
|
{t.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
|
||||||
|
관리 <ArrowRight size={12} className="ml-1" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantSubTenantsPage;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantUsersPage() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
|
||||||
|
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
||||||
|
const tenantQuery = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyCode = tenantQuery.data?.slug;
|
||||||
|
|
||||||
|
// 해당 슬러그로 사용자 검색
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { companyCode }],
|
||||||
|
queryFn: () => fetchUsers(100, 0, companyCode),
|
||||||
|
enabled: !!companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = usersQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User size={18} className="text-primary" />
|
||||||
|
Tenant Members ({users.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>EMAIL</TableHead>
|
||||||
|
<TableHead>ROLE</TableHead>
|
||||||
|
<TableHead>STATUS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||||
|
소속된 사용자가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<Mail size={12} className="text-muted-foreground" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{user.role.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.status === "active" ? "default" : "muted"}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantUsersPage;
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
fetchTenants,
|
||||||
|
fetchTenant,
|
||||||
|
type UserCreateRequest,
|
||||||
|
type UserCreateResponse,
|
||||||
|
} from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function UserCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
|
||||||
|
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||||
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||||
|
|
||||||
|
const { data: tenantsData } = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
role: "user",
|
||||||
|
companyCode: "",
|
||||||
|
department: "",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCompanyCode = watch("companyCode");
|
||||||
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||||
|
|
||||||
|
const { data: tenantDetail } = useQuery({
|
||||||
|
queryKey: ["tenant", selectedTenant?.id],
|
||||||
|
queryFn: () => fetchTenant(selectedTenant!.id),
|
||||||
|
enabled: !!selectedTenant?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createUser,
|
||||||
|
onSuccess: (data: UserCreateResponse) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
if (data.initialPassword) {
|
||||||
|
setGeneratedPassword(data.initialPassword);
|
||||||
|
setCreatedEmail(data.email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate("/users");
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: UserCreateRequest) => {
|
||||||
|
setError(null);
|
||||||
|
setGeneratedPassword(null);
|
||||||
|
setCreatedEmail(null);
|
||||||
|
|
||||||
|
if (autoPassword) {
|
||||||
|
mutation.mutate({ ...data, password: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.password) {
|
||||||
|
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyPassword = async () => {
|
||||||
|
if (!generatedPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedPassword);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<Link to="/users" className="hover:underline">
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">New</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/users">
|
||||||
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
|
목록으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{generatedPassword && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
|
||||||
|
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||||
|
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => navigate("/users")}>목록으로 이동</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>계정 정보</CardTitle>
|
||||||
|
<CardDescription>새로운 사용자를 시스템에 등록합니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">이메일</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
{...register("email", { required: "이메일은 필수입니다." })}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password">비밀번호</Label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoPassword}
|
||||||
|
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||||
|
/>
|
||||||
|
자동 생성
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="********"
|
||||||
|
disabled={autoPassword}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{autoPassword
|
||||||
|
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
|
||||||
|
: "초기 비밀번호를 직접 설정합니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">이름</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="홍길동"
|
||||||
|
{...register("name", { required: "이름은 필수입니다." })}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">전화번호</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
{...register("phone")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
id="companyCode"
|
||||||
|
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
|
||||||
|
{...register("companyCode")}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="">시스템 전역 (소속 없음)</option>
|
||||||
|
|
||||||
|
{tenants.map((t) => (
|
||||||
|
|
||||||
|
<option key={t.id} value={t.slug}>
|
||||||
|
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
|
||||||
|
</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor="department">부서</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
|
||||||
|
id="department"
|
||||||
|
|
||||||
|
placeholder="개발팀"
|
||||||
|
|
||||||
|
{...register("department")}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{userSchema.length > 0 && (
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||||
|
|
||||||
|
테넌트 확장 정보 (Custom Fields)
|
||||||
|
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
|
{userSchema.map((field) => (
|
||||||
|
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor={`metadata.${field.key}`}>
|
||||||
|
|
||||||
|
{field.label}
|
||||||
|
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
|
||||||
|
id={`metadata.${field.key}`}
|
||||||
|
|
||||||
|
type={field.type === "number" ? "number" : "text"}
|
||||||
|
|
||||||
|
{...register(`metadata.${field.key}` as any)}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">역할 (Role)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
{...register("role")}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
시스템 접근 권한을 결정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/users")}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
사용자 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserCreatePage;
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
|
import {
|
||||||
|
fetchUser,
|
||||||
|
fetchTenants,
|
||||||
|
fetchTenant,
|
||||||
|
updateUser,
|
||||||
|
type UserUpdateRequest,
|
||||||
|
} from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function UserDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: user, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["user", id],
|
||||||
|
queryFn: () => fetchUser(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tenantsData } = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
companyCode: "",
|
||||||
|
department: "",
|
||||||
|
password: "",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCompanyCode = watch("companyCode");
|
||||||
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||||
|
|
||||||
|
const { data: tenantDetail } = useQuery({
|
||||||
|
queryKey: ["tenant", selectedTenant?.id],
|
||||||
|
queryFn: () => fetchTenant(selectedTenant!.id),
|
||||||
|
enabled: !!selectedTenant?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
reset({
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone || "",
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
companyCode: user.companyCode || "",
|
||||||
|
department: user.department || "",
|
||||||
|
password: "",
|
||||||
|
metadata: user.metadata || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, reset]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user", id] });
|
||||||
|
setSuccessMsg("사용자 정보가 수정되었습니다.");
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
|
||||||
|
setSuccessMsg(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: UserUpdateRequest) => {
|
||||||
|
const payload = { ...data };
|
||||||
|
if (!payload.password) {
|
||||||
|
delete payload.password;
|
||||||
|
}
|
||||||
|
mutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 text-center">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !user) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-destructive">
|
||||||
|
사용자를 찾을 수 없습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<Link to="/users" className="hover:underline">
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">사용자 상세</h2>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/users">
|
||||||
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
|
목록으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>정보 수정</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{user.email} 계정의 정보를 수정합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">이름</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="홍길동"
|
||||||
|
{...register("name", { required: "이름은 필수입니다." })}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">전화번호</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
{...register("phone")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">상태</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
{...register("status")}
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">역할 (Role)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
{...register("role")}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
id="companyCode"
|
||||||
|
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
|
||||||
|
{...register("companyCode")}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="">시스템 전역 (소속 없음)</option>
|
||||||
|
|
||||||
|
{tenants.map((t) => (
|
||||||
|
|
||||||
|
<option key={t.id} value={t.slug}>
|
||||||
|
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
|
||||||
|
</option>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor="department">부서</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
|
||||||
|
id="department"
|
||||||
|
|
||||||
|
placeholder="개발팀"
|
||||||
|
|
||||||
|
{...register("department")}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{userSchema.length > 0 && (
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||||
|
|
||||||
|
테넌트 확장 정보 (Custom Fields)
|
||||||
|
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|
||||||
|
{userSchema.map((field) => (
|
||||||
|
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
|
||||||
|
<Label htmlFor={`metadata.${field.key}`}>
|
||||||
|
|
||||||
|
{field.label}
|
||||||
|
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
|
||||||
|
id={`metadata.${field.key}`}
|
||||||
|
|
||||||
|
type={field.type === "number" ? "number" : "text"}
|
||||||
|
|
||||||
|
{...register(`metadata.${field.key}` as any)}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">보안 설정</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">비밀번호 변경</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="변경할 경우에만 입력"
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/users")}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDetailPage;
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||||
|
|
||||||
|
function UserListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [searchDraft, setSearchDraft] = React.useState("");
|
||||||
|
const limit = 50;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["users", { limit, offset, search }],
|
||||||
|
queryFn: () => fetchUsers(limit, offset, search),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => deleteUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setSearch(searchDraft);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
const total = query.data?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
console.log("User items:", items);
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handleDelete = (userId: string, userName: string) => {
|
||||||
|
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>Users</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">List</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">사용자 관리</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
시스템 사용자를 조회하고 관리합니다. (Local DB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/users/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
사용자 추가
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>User Registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {total}명의 사용자가 등록되어 있습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="이름 또는 이메일 검색..."
|
||||||
|
className="pl-9"
|
||||||
|
value={searchDraft}
|
||||||
|
onChange={(e) => setSearchDraft(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={handleSearch}>
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME / EMAIL</TableHead>
|
||||||
|
<TableHead>ROLE</TableHead>
|
||||||
|
<TableHead>STATUS</TableHead>
|
||||||
|
<TableHead>TENANT / DEPT</TableHead>
|
||||||
|
<TableHead>CREATED</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||||
|
<User size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{user.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col text-sm">
|
||||||
|
<span className="font-medium text-blue-600">
|
||||||
|
{user.tenant?.name || user.companyCode || "-"}
|
||||||
|
</span>
|
||||||
|
{user.tenant && (
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase">
|
||||||
|
Slug: {user.tenant.slug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.department || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(`/users/${user.id}`)}
|
||||||
|
aria-label={`사용자 수정: ${user.name}`}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(user.id, user.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
aria-label={`사용자 삭제: ${user.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1 || query.isFetching}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages || query.isFetching}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserListPage;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 223 25% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 223 25% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 223 25% 12%;
|
||||||
|
--primary: 209 79% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 17% 94%;
|
||||||
|
--secondary-foreground: 223 25% 20%;
|
||||||
|
--muted: 223 15% 45%;
|
||||||
|
--muted-foreground: 223 15% 45%;
|
||||||
|
--accent: 40 96% 62%;
|
||||||
|
--accent-foreground: 223 25% 12%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 17% 90%;
|
||||||
|
--input: 220 17% 90%;
|
||||||
|
--ring: 209 79% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
|
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%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-inherit no-underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass-panel {
|
||||||
|
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
|
export type AuditLog = {
|
||||||
|
event_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogListResponse = {
|
||||||
|
items: AuditLog[];
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantListResponse = {
|
||||||
|
items: TenantSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantUpdateRequest = {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client_id: string;
|
||||||
|
scopes: string[];
|
||||||
|
status: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyListResponse = {
|
||||||
|
items: ApiKeySummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleListResponse = {
|
||||||
|
items: RoleSummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
|
params: { limit, cursor },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(limit = 50, offset = 0) {
|
||||||
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
|
"/v1/admin/tenants",
|
||||||
|
{
|
||||||
|
params: { limit, offset },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenant(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(payload: TenantCreateRequest) {
|
||||||
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
|
"/v1/admin/tenants",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(
|
||||||
|
tenantId: string,
|
||||||
|
payload: TenantUpdateRequest,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(tenantId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveTenant(tenantId: string) {
|
||||||
|
const { data } = await apiClient.post<TenantSummary>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/approve`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key Management (M2M)
|
||||||
|
export type ApiKeyCreateRequest = {
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyCreateResponse = {
|
||||||
|
apiKey: ApiKeySummary;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
{
|
||||||
|
params: { limit, offset },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||||
|
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||||
|
"/v1/admin/api-keys",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
export type UserSummary = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
companyCode?: string;
|
||||||
|
tenant?: TenantSummary;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
department?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserListResponse = {
|
||||||
|
items: UserSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCreateRequest = {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
department?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCreateResponse = UserSummary & {
|
||||||
|
initialPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserUpdateRequest = {
|
||||||
|
password?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
department?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
|
||||||
|
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||||
|
params: { limit, offset, search },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUser(userId: string) {
|
||||||
|
const { data } = await apiClient.get<UserSummary>(
|
||||||
|
`/v1/admin/users/${userId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload: UserCreateRequest) {
|
||||||
|
const { data } = await apiClient.post<UserCreateResponse>(
|
||||||
|
"/v1/admin/users",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||||
|
const { data } = await apiClient.put<UserSummary>(
|
||||||
|
`/v1/admin/users/${userId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relying Party Management
|
||||||
|
export type RelyingParty = {
|
||||||
|
clientId: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HydraClientReq = {
|
||||||
|
client_id?: string;
|
||||||
|
client_name: string;
|
||||||
|
client_secret?: string;
|
||||||
|
redirect_uris: string[];
|
||||||
|
scope?: string;
|
||||||
|
token_endpoint_auth_method?: string;
|
||||||
|
grant_types?: string[];
|
||||||
|
response_types?: string[];
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRelyingParties(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllRelyingParties() {
|
||||||
|
const { data } = await apiClient.get<RelyingParty[]>(
|
||||||
|
"/v1/admin/relying-parties",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRelyingParty(
|
||||||
|
tenantId: string,
|
||||||
|
payload: HydraClientReq,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<RelyingParty>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relying-parties`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRelyingParty(id: string) {
|
||||||
|
const { data } = await apiClient.get<{
|
||||||
|
relyingParty: RelyingParty;
|
||||||
|
oauth2Config: HydraClientReq;
|
||||||
|
}>(`/v1/admin/relying-parties/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
||||||
|
const { data } = await apiClient.put<RelyingParty>(
|
||||||
|
`/v1/admin/relying-parties/${id}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRelyingParty(id: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
|
||||||
|
const sessionToken = window.localStorage.getItem("admin_session");
|
||||||
|
if (sessionToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${sessionToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
|
||||||
|
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||||
|
if (tenantId) {
|
||||||
|
config.headers["X-Tenant-ID"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Development Only] Inject Mock Role from RoleSwitcher
|
||||||
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
|
if (mockRole) {
|
||||||
|
config.headers["X-Test-Role"] = mockRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { queryClient } from "./app/queryClient";
|
||||||
|
import { router } from "./app/routes";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1.5rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Space Grotesk", "Pretendard Variable", ...fontFamily.sans],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 12px 40px rgba(7, 15, 26, 0.25)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("has title", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/바론 어드민 서비스/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
type UserSummary = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
companyCode?: string;
|
||||||
|
department?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserCreatePayload = {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
department?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("user create and delete flow", async ({ page }) => {
|
||||||
|
const users: UserSummary[] = [];
|
||||||
|
let idSeq = 1;
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/users**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const isCollection = path.endsWith("/api/v1/admin/users");
|
||||||
|
const isItem = path.includes("/api/v1/admin/users/");
|
||||||
|
|
||||||
|
if (request.method() === "GET" && isCollection) {
|
||||||
|
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||||
|
const offset = Number(url.searchParams.get("offset") ?? "0");
|
||||||
|
const filtered = search
|
||||||
|
? users.filter(
|
||||||
|
(user) =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.email.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
: users;
|
||||||
|
const items = filtered.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
items,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: filtered.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method() === "POST" && isCollection) {
|
||||||
|
const payload = request.postDataJSON() as UserCreatePayload;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const user: UserSummary = {
|
||||||
|
id: `user-${idSeq++}`,
|
||||||
|
email: payload.email,
|
||||||
|
name: payload.name,
|
||||||
|
phone: payload.phone,
|
||||||
|
role: payload.role ?? "user",
|
||||||
|
status: "active",
|
||||||
|
companyCode: payload.companyCode,
|
||||||
|
department: payload.department,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
users.unshift(user);
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(user),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method() === "DELETE" && isItem) {
|
||||||
|
const userId = path.split("/").pop();
|
||||||
|
const index = users.findIndex((user) => user.id === userId);
|
||||||
|
if (index === -1) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "User not found" }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
users.splice(index, 1);
|
||||||
|
await route.fulfill({ status: 204, body: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "Not found" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/users");
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "사용자 추가" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/users\/new$/);
|
||||||
|
|
||||||
|
const uniqueEmail = `playwright-${Date.now()}@example.com`;
|
||||||
|
|
||||||
|
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
|
||||||
|
await page.getByLabel("이메일").fill(uniqueEmail);
|
||||||
|
await page.getByLabel("비밀번호").fill("Test1234!");
|
||||||
|
await page.getByLabel("이름").fill("Playwright User");
|
||||||
|
await page.getByLabel("전화번호").fill("010-0000-0000");
|
||||||
|
await page.getByLabel("회사 코드").fill("E2E");
|
||||||
|
await page.getByLabel("부서").fill("QA");
|
||||||
|
await page.getByLabel("역할 (Role)").selectOption("admin");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "사용자 생성" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/users$/);
|
||||||
|
|
||||||
|
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
|
||||||
|
await expect(createdRow).toBeVisible();
|
||||||
|
|
||||||
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.25-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install git for go mod download if needed
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Pre-copy go.mod/sum to cache dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build for production (optional, can just run go run for dev)
|
||||||
|
RUN go build -o main ./cmd/server
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Default command (can be overridden by compose)
|
||||||
|
CMD ["./main"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// KetoService 초기화
|
||||||
|
// KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요
|
||||||
|
os.Setenv("KETO_READ_URL", "http://keto:4466")
|
||||||
|
os.Setenv("KETO_WRITE_URL", "http://keto:4467")
|
||||||
|
|
||||||
|
keto := service.NewKetoService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := "test-user-id"
|
||||||
|
tenantID := "test-tenant-id"
|
||||||
|
|
||||||
|
fmt.Println("--- Keto ReBAC Test Start ---")
|
||||||
|
|
||||||
|
// 1. 초기 권한 체크 (당연히 거부되어야 함)
|
||||||
|
allowed, _ := keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
|
||||||
|
fmt.Printf("1. Initial Check (view): %v (Expected: false)\n", allowed)
|
||||||
|
|
||||||
|
// 2. 관계(Relation) 추가
|
||||||
|
fmt.Println("2. Adding relation: User is member of Tenant...")
|
||||||
|
err := keto.CreateRelation(ctx, "Tenant", tenantID, "members", userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to create relation: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 다시 권한 체크 (허용되어야 함)
|
||||||
|
// OPL 정의에 의해 members는 view 권한을 포함함
|
||||||
|
allowed, _ = keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
|
||||||
|
fmt.Printf("3. Final Check (view): %v (Expected: true)\n", allowed)
|
||||||
|
|
||||||
|
fmt.Println("--- Test Completed ---")
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKey struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||||
|
Name string
|
||||||
|
ClientID string `gorm:"uniqueIndex"`
|
||||||
|
ClientSecretHash string
|
||||||
|
Scopes string
|
||||||
|
Status string `gorm:"default:'active'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
godotenv.Load(".env")
|
||||||
|
godotenv.Load("backend/.env")
|
||||||
|
|
||||||
|
pgHost := os.Getenv("DB_HOST")
|
||||||
|
if pgHost == "" {
|
||||||
|
pgHost = "localhost"
|
||||||
|
}
|
||||||
|
pgPort := os.Getenv("DB_PORT")
|
||||||
|
if pgPort == "" {
|
||||||
|
pgPort = "5432"
|
||||||
|
}
|
||||||
|
pgUser := os.Getenv("DB_USER")
|
||||||
|
if pgUser == "" {
|
||||||
|
pgUser = "baron"
|
||||||
|
}
|
||||||
|
pgPass := os.Getenv("DB_PASSWORD")
|
||||||
|
if pgPass == "" {
|
||||||
|
pgPass = "password"
|
||||||
|
}
|
||||||
|
pgName := os.Getenv("DB_NAME")
|
||||||
|
if pgName == "" {
|
||||||
|
pgName = "baron_sso"
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
|
||||||
|
pgHost, pgUser, pgPass, pgName, pgPort)
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := generateToken(8)
|
||||||
|
plainSecret := generateToken(16)
|
||||||
|
hashedSecret, _ := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||||
|
|
||||||
|
key := ApiKey{
|
||||||
|
Name: "Test Admin Key",
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecretHash: string(hashedSecret),
|
||||||
|
Scopes: "tenant:read tenant:write user:read user:write audit:read audit:write",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Table("api_keys").Create(&key).Error; err != nil {
|
||||||
|
log.Fatalf("Failed to create API key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
fmt.Println("✅ API Key Generated Successfully!")
|
||||||
|
fmt.Printf("Client ID: %s\n", clientID)
|
||||||
|
fmt.Printf("Client Secret: %s\n", plainSecret)
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
fmt.Println("Usage Example:")
|
||||||
|
fmt.Printf("curl -H \"X-Baron-Key-ID: %s\" -H \"X-Baron-Key-Secret: %s\" http://localhost:3000/api/v1/admin/tenants\n", clientID, plainSecret)
|
||||||
|
fmt.Println("====================================================")
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPProbe struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
interval time.Duration
|
||||||
|
timeout time.Duration
|
||||||
|
client *http.Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
status string
|
||||||
|
lastError string
|
||||||
|
lastChecked time.Time
|
||||||
|
lastSuccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProbeSnapshot struct {
|
||||||
|
Status string
|
||||||
|
Error string
|
||||||
|
LastChecked time.Time
|
||||||
|
LastSuccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 2 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPProbe{
|
||||||
|
name: name,
|
||||||
|
url: url,
|
||||||
|
interval: interval,
|
||||||
|
timeout: timeout,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start는 프로브를 백그라운드에서 주기적으로 실행합니다.
|
||||||
|
func (p *HTTPProbe) Start() {
|
||||||
|
go func() {
|
||||||
|
p.checkOnce()
|
||||||
|
ticker := time.NewTicker(p.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
p.checkOnce()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProbe) Snapshot() ProbeSnapshot {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return ProbeSnapshot{
|
||||||
|
Status: p.status,
|
||||||
|
Error: p.lastError,
|
||||||
|
LastChecked: p.lastChecked,
|
||||||
|
LastSuccess: p.lastSuccess,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProbe) StatusText() string {
|
||||||
|
s := p.Snapshot()
|
||||||
|
if s.Status == "ok" {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
if s.Status == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if s.Error == "" {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
return "error: " + s.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProbe) checkOnce() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
|
if err != nil {
|
||||||
|
p.update("error", fmt.Sprintf("request build failed: %v", err), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
p.update("error", err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.update("ok", "", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProbe) update(status, errMsg string, success bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
prevStatus := p.status
|
||||||
|
p.status = status
|
||||||
|
p.lastError = errMsg
|
||||||
|
p.lastChecked = time.Now()
|
||||||
|
if success {
|
||||||
|
p.lastSuccess = p.lastChecked
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if prevStatus == status {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status == "ok" {
|
||||||
|
slog.Info("Service probe recovered", "name", p.name, "url", p.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/bootstrap"
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/handler"
|
||||||
|
"baron-sso-backend/internal/idp"
|
||||||
|
"baron-sso-backend/internal/logger"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/validator"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/snowflake"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
gormLogger "gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDocsPrefix(prefix string) string {
|
||||||
|
trimmed := strings.TrimSpace(prefix)
|
||||||
|
if trimmed == "" || trimmed == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "/") {
|
||||||
|
trimmed = "/" + trimmed
|
||||||
|
}
|
||||||
|
return strings.TrimRight(trimmed, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDocsRoutes(app *fiber.App, prefix string) {
|
||||||
|
base := normalizeDocsPrefix(prefix)
|
||||||
|
docsPath := base + "/docs"
|
||||||
|
redocPath := base + "/redoc"
|
||||||
|
openapiPath := base + "/openapi.yaml"
|
||||||
|
|
||||||
|
app.Get(docsPath, func(c *fiber.Ctx) error {
|
||||||
|
return c.SendFile("./docs/swagger-ui/index.html")
|
||||||
|
})
|
||||||
|
app.Get(docsPath+"/", func(c *fiber.Ctx) error {
|
||||||
|
return c.SendFile("./docs/swagger-ui/index.html")
|
||||||
|
})
|
||||||
|
app.Static(docsPath, "./docs/swagger-ui")
|
||||||
|
|
||||||
|
app.Get(redocPath, func(c *fiber.Ctx) error {
|
||||||
|
return c.SendFile("./docs/redoc/index.html")
|
||||||
|
})
|
||||||
|
app.Get(redocPath+"/", func(c *fiber.Ctx) error {
|
||||||
|
return c.SendFile("./docs/redoc/index.html")
|
||||||
|
})
|
||||||
|
app.Static(redocPath, "./docs/redoc")
|
||||||
|
|
||||||
|
app.Get(openapiPath, func(c *fiber.Ctx) error {
|
||||||
|
c.Type("yaml")
|
||||||
|
return c.SendFile("./docs/openapi.yaml")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load .env file from possible paths
|
||||||
|
// 1. .env (Current Directory)
|
||||||
|
// 2. ../.env (Project Root when running from backend/)
|
||||||
|
// 3. ../../.env (Project Root when running from backend/cmd/server/)
|
||||||
|
if err := godotenv.Load(".env"); err != nil {
|
||||||
|
if err := godotenv.Load("../.env"); err != nil {
|
||||||
|
godotenv.Load("../../.env")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. Initialize Logger
|
||||||
|
logger.Init(logger.Config{
|
||||||
|
ServiceName: "baron-sso",
|
||||||
|
Environment: getEnv("GO_ENV", "dev"),
|
||||||
|
})
|
||||||
|
// Initialize Snowflake Node (Node 2 for Baron)
|
||||||
|
node, err := snowflake.NewNode(2)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to initialize snowflake node", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Log Config on Startup
|
||||||
|
fmt.Println("============================================================")
|
||||||
|
fmt.Println(`
|
||||||
|
|\__/,| (\
|
||||||
|
_.|o o |_ ) )
|
||||||
|
-(((---(((--------
|
||||||
|
`)
|
||||||
|
fmt.Println("🚀 Baron SSO Backend Starting...")
|
||||||
|
|
||||||
|
slog.Info("Service starting",
|
||||||
|
"service", "baron-sso",
|
||||||
|
"app_env", getEnv("APP_ENV", "dev"),
|
||||||
|
"db_port", getEnv("DB_PORT", "5532"),
|
||||||
|
"backend_port", getEnv("BACKEND_PORT", "3000"),
|
||||||
|
"userfront_port", getEnv("USERFRONT_PORT", "5000"),
|
||||||
|
"userfront_url", getEnv("USERFRONT_URL", "http://sso.hmac.kr"),
|
||||||
|
"redis_addr", getEnv("REDIS_ADDR", "redis:6379"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Fail-Fast Schema Validation ---
|
||||||
|
// 팩토리를 사용하여 IDP 공급자를 초기화합니다.
|
||||||
|
idpProvider, err := idp.InitializeProvider()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
|
||||||
|
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
|
||||||
|
"idp", idpProvider.Name(),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err)
|
||||||
|
os.Exit(1) // Break the build/deployment
|
||||||
|
}
|
||||||
|
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||||
|
// -----------------------------------
|
||||||
|
if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||||
|
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Initialize DB Connections
|
||||||
|
// ClickHouse
|
||||||
|
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
||||||
|
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
|
chUser := getEnv("CLICKHOUSE_USER", "baron")
|
||||||
|
chPass := getEnv("CLICKHOUSE_PASSWORD", "password")
|
||||||
|
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
||||||
|
|
||||||
|
var auditRepo domain.AuditRepository
|
||||||
|
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
|
||||||
|
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
|
||||||
|
auditRepo = nil // Explicitly set to nil interface
|
||||||
|
} else {
|
||||||
|
auditRepo = repo
|
||||||
|
slog.Info("✅ Connected to ClickHouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
var oathkeeperRepo domain.OathkeeperLogRepository
|
||||||
|
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
|
||||||
|
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
|
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
|
||||||
|
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
|
||||||
|
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
|
||||||
|
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
|
||||||
|
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
|
||||||
|
oathkeeperRepo = nil
|
||||||
|
} else {
|
||||||
|
oathkeeperRepo = repo
|
||||||
|
slog.Info("✅ Connected to Ory ClickHouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
redisService, err := service.NewRedisService()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ketoService := service.NewKetoService()
|
||||||
|
|
||||||
|
// PostgreSQL (Meta Store)
|
||||||
|
pgHost := getEnv("DB_HOST", "localhost")
|
||||||
|
pgPort := getEnv("DB_PORT", "5432")
|
||||||
|
pgUser := getEnv("DB_USER", "baron")
|
||||||
|
pgPass := getEnv("DB_PASSWORD", "password")
|
||||||
|
pgName := getEnv("DB_NAME", "baron_sso")
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
||||||
|
pgHost, pgUser, pgPass, pgName, pgPort)
|
||||||
|
|
||||||
|
gormLog := gormLogger.New(
|
||||||
|
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||||
|
gormLogger.Config{
|
||||||
|
SlowThreshold: time.Second,
|
||||||
|
LogLevel: gormLogger.Warn,
|
||||||
|
IgnoreRecordNotFoundError: true,
|
||||||
|
Colorful: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
|
Logger: gormLog,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
slog.Info("✅ Connected to PostgreSQL")
|
||||||
|
|
||||||
|
// Run Bootstrap (Migrations & Seeding)
|
||||||
|
if err := bootstrap.Run(db); err != nil {
|
||||||
|
slog.Error("❌ Bootstrap failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [New] Sync existing data to Keto
|
||||||
|
if ketoService != nil {
|
||||||
|
if err := bootstrap.SyncKetoRelations(db, ketoService); err != nil {
|
||||||
|
slog.Warn("⚠️ Keto synchronization failed during startup", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
||||||
|
var oathkeeperProbe *HTTPProbe
|
||||||
|
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
||||||
|
intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10"))
|
||||||
|
if err != nil || intervalSec <= 0 {
|
||||||
|
intervalSec = 10
|
||||||
|
}
|
||||||
|
timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2"))
|
||||||
|
if err != nil || timeoutSec <= 0 {
|
||||||
|
timeoutSec = 2
|
||||||
|
}
|
||||||
|
oathkeeperProbe = NewHTTPProbe(
|
||||||
|
"oathkeeper",
|
||||||
|
getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"),
|
||||||
|
time.Duration(intervalSec)*time.Second,
|
||||||
|
time.Duration(timeoutSec)*time.Second,
|
||||||
|
)
|
||||||
|
oathkeeperProbe.Start()
|
||||||
|
} else {
|
||||||
|
slog.Info("Oathkeeper probe disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Initialize Handlers
|
||||||
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||||
|
hydraService := service.NewHydraAdminService()
|
||||||
|
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||||
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
|
consentRepo := repository.NewClientConsentRepository(db)
|
||||||
|
|
||||||
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||||
|
adminHandler := handler.NewAdminHandler()
|
||||||
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
||||||
|
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||||
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||||
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
|
oryAdminProvider := service.NewOryProvider()
|
||||||
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||||
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
|
// 3. Initialize Fiber
|
||||||
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
AppName: "Baron SSO Backend",
|
||||||
|
DisableStartupMessage: true, // Clean logs
|
||||||
|
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
|
||||||
|
// Global Error Handler for Production Masking
|
||||||
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||||
|
// Default status code
|
||||||
|
code := fiber.StatusInternalServerError
|
||||||
|
|
||||||
|
// Check if it's a known fiber.Error
|
||||||
|
var e *fiber.Error
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production or stage, mask detailed 500+ errors
|
||||||
|
if appEnv == "production" || appEnv == "stage" {
|
||||||
|
if code >= 500 {
|
||||||
|
// Log the actual error for developers
|
||||||
|
slog.Error("Internal Server Error",
|
||||||
|
"error", err.Error(),
|
||||||
|
"path", c.Path(),
|
||||||
|
"method", c.Method(),
|
||||||
|
)
|
||||||
|
// Return masked message
|
||||||
|
return c.Status(code).JSON(fiber.Map{
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For development or non-500 errors, return the actual error message
|
||||||
|
return c.Status(code).JSON(fiber.Map{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.Use(requestid.New(requestid.Config{
|
||||||
|
Generator: func() string {
|
||||||
|
return node.Generate().String()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// [Standardized] HTTP Request Logger Middleware using slog
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Handle request
|
||||||
|
err := c.Next()
|
||||||
|
|
||||||
|
// Log after request
|
||||||
|
latency := time.Since(start)
|
||||||
|
status := c.Response().StatusCode()
|
||||||
|
path := c.Path()
|
||||||
|
|
||||||
|
// Skip logging for all successful requests (status < 400)
|
||||||
|
if status < 400 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "http_request"
|
||||||
|
if err != nil {
|
||||||
|
msg = "http_request_error"
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info(msg,
|
||||||
|
"status", status,
|
||||||
|
"method", c.Method(),
|
||||||
|
"path", path,
|
||||||
|
"latency", latency.String(),
|
||||||
|
"ip", c.IP(),
|
||||||
|
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Use(recover.New(recover.Config{
|
||||||
|
EnableStackTrace: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||||
|
allowCredentials := allowedOrigins != "*"
|
||||||
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: allowedOrigins,
|
||||||
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||||
|
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||||
|
AllowCredentials: allowCredentials,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||||
|
cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!")
|
||||||
|
if len(cookieSecret) != 32 {
|
||||||
|
slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret))
|
||||||
|
if len(cookieSecret) > 32 {
|
||||||
|
cookieSecret = cookieSecret[:32]
|
||||||
|
} else {
|
||||||
|
// Pad with '0' if too short
|
||||||
|
cookieSecret = fmt.Sprintf("%-32s", cookieSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Use(encryptcookie.New(encryptcookie.Config{
|
||||||
|
Key: cookieSecret,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// [Security] Disable Swagger/ReDoc in Production
|
||||||
|
if appEnv != "production" {
|
||||||
|
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
||||||
|
registerDocsRoutes(app, "")
|
||||||
|
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
||||||
|
registerDocsRoutes(app, normalized)
|
||||||
|
}
|
||||||
|
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
||||||
|
} else {
|
||||||
|
slog.Info("🔒 API Docs disabled in production")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.Get("/", func(c *fiber.Ctx) error {
|
||||||
|
return c.SendString("Baron SSO Audit Backend Online")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
|
status := "ok"
|
||||||
|
checks := make(map[string]string)
|
||||||
|
|
||||||
|
// Check ClickHouse
|
||||||
|
if auditRepo != nil {
|
||||||
|
if err := auditRepo.Ping(c.Context()); err != nil {
|
||||||
|
checks["clickhouse"] = "error: " + err.Error()
|
||||||
|
status = "error"
|
||||||
|
} else {
|
||||||
|
checks["clickhouse"] = "ok"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks["clickhouse"] = "not_initialized"
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis
|
||||||
|
if redisService != nil {
|
||||||
|
if err := redisService.Ping(c.Context()); err != nil {
|
||||||
|
checks["redis"] = "error: " + err.Error()
|
||||||
|
status = "error"
|
||||||
|
} else {
|
||||||
|
checks["redis"] = "ok"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks["redis"] = "not_initialized"
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Oathkeeper
|
||||||
|
if oathkeeperProbe != nil {
|
||||||
|
snapshot := oathkeeperProbe.Snapshot()
|
||||||
|
switch snapshot.Status {
|
||||||
|
case "ok":
|
||||||
|
checks["oathkeeper"] = "ok"
|
||||||
|
case "":
|
||||||
|
checks["oathkeeper"] = "unknown"
|
||||||
|
if status != "error" {
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if snapshot.Error == "" {
|
||||||
|
checks["oathkeeper"] = "error"
|
||||||
|
} else {
|
||||||
|
checks["oathkeeper"] = "error: " + snapshot.Error
|
||||||
|
}
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks["oathkeeper"] = "disabled"
|
||||||
|
if status != "error" {
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "error" {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// API Group
|
||||||
|
api := app.Group("/api/v1")
|
||||||
|
|
||||||
|
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
|
||||||
|
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
|
||||||
|
|
||||||
|
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
|
||||||
|
Repo: auditRepo,
|
||||||
|
ExcludePaths: map[string]struct{}{
|
||||||
|
"/api/v1/audit": {},
|
||||||
|
"/api/v1/client-log": {},
|
||||||
|
},
|
||||||
|
BodyDump: true,
|
||||||
|
WorkerCount: workerCount,
|
||||||
|
QueueSize: queueSize,
|
||||||
|
}))
|
||||||
|
api.Post("/audit", auditHandler.CreateLog)
|
||||||
|
api.Get("/audit", auditHandler.ListLogs)
|
||||||
|
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||||
|
|
||||||
|
// Public Tenant Registration
|
||||||
|
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||||
|
|
||||||
|
// Auth Proxy Routes
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
|
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||||
|
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||||
|
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||||
|
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||||
|
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||||
|
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
|
||||||
|
|
||||||
|
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||||
|
|
||||||
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
|
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||||
|
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||||
|
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||||
|
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||||
|
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||||
|
|
||||||
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
|
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||||
|
// [Added] Use POST for actual verification triggered by the user
|
||||||
|
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||||
|
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||||
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
|
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||||
|
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||||
|
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||||
|
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||||
|
|
||||||
|
// Signup Routes
|
||||||
|
signup := auth.Group("/signup")
|
||||||
|
signup.Post("/check-email", authHandler.CheckEmail)
|
||||||
|
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
|
||||||
|
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
|
||||||
|
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
||||||
|
signup.Post("/", authHandler.Signup)
|
||||||
|
|
||||||
|
// User Routes (My Page)
|
||||||
|
user := api.Group("/user")
|
||||||
|
user.Get("/me", authHandler.GetMe)
|
||||||
|
user.Put("/me", authHandler.UpdateMe)
|
||||||
|
user.Post("/me/password", authHandler.ChangeMyPassword)
|
||||||
|
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||||
|
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||||
|
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||||
|
user.Get("/rp/history", authHandler.ListRpHistory)
|
||||||
|
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
|
||||||
|
|
||||||
|
// Admin Routes
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||||
|
|
||||||
|
// RBAC Middleware Instances
|
||||||
|
requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||||
|
AllowedRoles: []string{domain.RoleSuperAdmin},
|
||||||
|
AuthHandler: authHandler,
|
||||||
|
KetoService: ketoService,
|
||||||
|
})
|
||||||
|
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
|
||||||
|
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
|
||||||
|
AuthHandler: authHandler,
|
||||||
|
KetoService: ketoService,
|
||||||
|
})
|
||||||
|
|
||||||
|
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||||
|
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||||
|
|
||||||
|
// Tenant Management (Super Admin Only)
|
||||||
|
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
||||||
|
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||||
|
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
||||||
|
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||||
|
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||||
|
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||||
|
|
||||||
|
// Relying Party Management (Global List)
|
||||||
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
|
||||||
|
// Relying Party Management (Tenant Context)
|
||||||
|
admin.Post("/tenants/:tenantId/relying-parties",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"),
|
||||||
|
relyingPartyHandler.Create)
|
||||||
|
|
||||||
|
admin.Get("/tenants/:tenantId/relying-parties",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"),
|
||||||
|
relyingPartyHandler.List)
|
||||||
|
|
||||||
|
admin.Get("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
|
||||||
|
relyingPartyHandler.Get)
|
||||||
|
|
||||||
|
admin.Put("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
relyingPartyHandler.Update)
|
||||||
|
|
||||||
|
admin.Delete("/relying-parties/:id",
|
||||||
|
requireAdmin,
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
relyingPartyHandler.Delete)
|
||||||
|
|
||||||
|
// Admin User Management
|
||||||
|
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||||
|
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||||
|
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||||
|
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||||
|
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||||
|
|
||||||
|
// API Key Management (M2M) - Super Admin Only
|
||||||
|
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
||||||
|
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
|
||||||
|
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||||
|
|
||||||
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
|
dev := api.Group("/dev")
|
||||||
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
|
dev.Get("/clients/:id", devHandler.GetClient)
|
||||||
|
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||||
|
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
||||||
|
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||||
|
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||||
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
|
||||||
|
// Webhook for Kratos courier (HTTP delivery)
|
||||||
|
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||||
|
|
||||||
|
// Client Logging Route (Standardized & Flattened)
|
||||||
|
api.Post("/client-log", func(c *fiber.Ctx) error {
|
||||||
|
type LogReq struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
var req LogReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.SendStatus(fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare attributes for flattening
|
||||||
|
attrs := []any{
|
||||||
|
slog.String("source", "client"),
|
||||||
|
}
|
||||||
|
for k, v := range req.Data {
|
||||||
|
// Skip svc if it's already set by the global logger to avoid confusion,
|
||||||
|
// or keep it as client_svc
|
||||||
|
if k == "svc" {
|
||||||
|
attrs = append(attrs, slog.Any("client_svc", v))
|
||||||
|
} else {
|
||||||
|
attrs = append(attrs, slog.Any(k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map and log with correct level
|
||||||
|
var level slog.Level
|
||||||
|
switch req.Level {
|
||||||
|
case "SEVERE", "ERROR":
|
||||||
|
level = slog.LevelError
|
||||||
|
case "WARNING", "WARN":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
default:
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out noisy client navigation logs
|
||||||
|
if level == slog.LevelInfo {
|
||||||
|
msg := strings.ToLower(req.Message)
|
||||||
|
if strings.Contains(msg, "navigating to") ||
|
||||||
|
strings.Contains(msg, "going to") ||
|
||||||
|
strings.Contains(msg, "redirecting to") ||
|
||||||
|
strings.Contains(msg, "full paths for routes") {
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Log(c.Context(), level, req.Message, attrs...)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start Server
|
||||||
|
port := getEnv("BACKEND_PORT", "3000")
|
||||||
|
slog.Info("Server listening", "port", port)
|
||||||
|
fmt.Println("============================================================")
|
||||||
|
if err := app.Listen(":" + port); err != nil {
|
||||||
|
slog.Error("Server failed to start", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Baron SSO ReDoc</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="redoc-root"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const docsBase = path.replace(/\/redoc\/?$/, "/redoc/");
|
||||||
|
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||||
|
const openapiUrl = assetBase.replace(/redoc\/$/, "openapi.yaml");
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = assetBase + "redoc.standalone.js";
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.Redoc) {
|
||||||
|
window.Redoc.init(openapiUrl, {}, document.getElementById("redoc-root"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
script.onerror = (err) => {
|
||||||
|
console.error("ReDoc load failed", err);
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Baron SSO Swagger UI</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #f7f7f8;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const docsBase = path.replace(/\/docs\/?$/, "/docs/");
|
||||||
|
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
|
||||||
|
|
||||||
|
const css = document.createElement("link");
|
||||||
|
css.rel = "stylesheet";
|
||||||
|
css.href = assetBase + "swagger-ui.css";
|
||||||
|
document.head.appendChild(css);
|
||||||
|
|
||||||
|
const loadScript = (src) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = src;
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = reject;
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
loadScript(assetBase + "swagger-ui-bundle.js"),
|
||||||
|
loadScript(assetBase + "swagger-ui-standalone-preset.js"),
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
const openapiUrl = assetBase.replace(/docs\/$/, "openapi.yaml");
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: openapiUrl,
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
deepLinking: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset,
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Swagger UI load failed", err);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
|||||||
|
module baron-sso-backend
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
|
github.com/descope/go-sdk v1.7.0
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.10
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/lib/pq v1.11.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
|
golang.org/x/oauth2 v0.34.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.69.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
|
||||||
|
github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 h1:2Lnd3ZNTyWpFJJM55y0mP0aESovm+vFuFEwLijucUL8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18/go.mod h1:BLwHw6wdkA6NfnW/cFaVcvpwdIXHLAkpe6nsLF9BVww=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||||
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/descope/go-sdk v1.7.0 h1:DIRmnA4Q8TDtWdGJ9z0I11+AWMrzyNiiozFH557LrgQ=
|
||||||
|
github.com/descope/go-sdk v1.7.0/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||||
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
|
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
|
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
|
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||||
|
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||||
|
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||||
|
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||||
|
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||||
|
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
|
||||||
|
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes the application bootstrap logic (migrations, seeding, etc.)
|
||||||
|
func Run(db *gorm.DB) error {
|
||||||
|
slog.Info("[Bootstrap] Starting application bootstrap...")
|
||||||
|
|
||||||
|
// 1. Auto Migration
|
||||||
|
if err := migrateSchemas(db); err != nil {
|
||||||
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Seed Tenants
|
||||||
|
if err := SeedTenants(db); err != nil {
|
||||||
|
return fmt.Errorf("tenant seeding failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
|
||||||
|
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateSchemas(db *gorm.DB) error {
|
||||||
|
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||||
|
// Add all domain models here
|
||||||
|
return db.AutoMigrate(
|
||||||
|
&domain.Tenant{},
|
||||||
|
&domain.TenantDomain{},
|
||||||
|
&domain.User{},
|
||||||
|
&domain.ApiKey{},
|
||||||
|
&domain.IdentityProviderConfig{},
|
||||||
|
&domain.ClientSecret{},
|
||||||
|
&domain.ClientConsent{},
|
||||||
|
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncKetoRelations synchronizes all existing DB users and tenants to Ory Keto.
|
||||||
|
// This ensures data consistency for existing data when ReBAC is introduced.
|
||||||
|
func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
||||||
|
slog.Info("🚀 Starting Keto ReBAC relation synchronization...")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Sync All Tenants (Ensure they exist in Keto if needed)
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
if err := db.Find(&tenants).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("Syncing tenants to Keto", "count", len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
if t.ParentID != nil {
|
||||||
|
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync All Users
|
||||||
|
var users []domain.User
|
||||||
|
if err := db.Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("Syncing users to Keto", "count", len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
// Membership
|
||||||
|
if u.TenantID != nil {
|
||||||
|
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
if u.Role == domain.RoleSuperAdmin {
|
||||||
|
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", u.ID)
|
||||||
|
} else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||||
|
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("✅ Keto ReBAC synchronization completed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
|
||||||
|
func SeedAdminIdentity(idp domain.IdentityProvider) error {
|
||||||
|
if idp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||||
|
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||||
|
if adminEmail == "" || adminPassword == "" {
|
||||||
|
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
|
||||||
|
if adminName == "" {
|
||||||
|
adminName = "System Admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &domain.BrokerUser{
|
||||||
|
Email: adminEmail,
|
||||||
|
Name: adminName,
|
||||||
|
PhoneNumber: "",
|
||||||
|
Attributes: map[string]interface{}{
|
||||||
|
"department": "Admin",
|
||||||
|
"affiliationType": "internal",
|
||||||
|
"companyCode": "",
|
||||||
|
"grade": "admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := idp.CreateUser(user, adminPassword)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
slog.Info("[Bootstrap] Admin identity already exists in IDP", "email", adminEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InitialTenantConfig struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
Domains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardcoded for now, can be moved to config file or env later
|
||||||
|
var defaultTenants = []InitialTenantConfig{
|
||||||
|
{
|
||||||
|
Name: "Hanmac Engineering",
|
||||||
|
Slug: "hanmac",
|
||||||
|
Description: "Primary Family Company",
|
||||||
|
Domains: []string{"hanmaceng.co.kr", "hmac.kr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeedTenants(db *gorm.DB) error {
|
||||||
|
slog.Info("[Bootstrap] Seeding initial tenants...")
|
||||||
|
repo := repository.NewTenantRepository(db)
|
||||||
|
svc := service.NewTenantService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, config := range defaultTenants {
|
||||||
|
existing, err := repo.FindBySlug(ctx, config.Slug)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug)
|
||||||
|
// Optional: Check and add missing domains
|
||||||
|
for _, d := range config.Domains {
|
||||||
|
found := false
|
||||||
|
for _, ed := range existing.Domains {
|
||||||
|
if ed.Domain == d {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
slog.Info("[Bootstrap] Adding missing domain to tenant", "slug", config.Slug, "domain", d)
|
||||||
|
if err := repo.AddDomain(ctx, existing.ID, d); err != nil {
|
||||||
|
slog.Error("Failed to add domain", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||||
|
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Explicitly set to active during seed
|
||||||
|
tenant.Status = domain.TenantStatusActive
|
||||||
|
db.Save(tenant)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApiKey represents an internal API key for Machine-to-Machine communication.
|
||||||
|
type ApiKey struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
ClientID string `gorm:"uniqueIndex;not null" json:"clientId"`
|
||||||
|
ClientSecretHash string `gorm:"not null" json:"-"`
|
||||||
|
Scopes string `json:"scopes"` // Space or comma separated
|
||||||
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
|
func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
|
if k.ID == "" {
|
||||||
|
k.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type EnchantedLinkInitRequest struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||||
|
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||||
|
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||||
|
DryRun bool `json:"dryRun,omitempty"`
|
||||||
|
DrySend bool `json:"drySend,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkInitResponse struct {
|
||||||
|
LinkID string `json:"linkId"`
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
MaskedEmail string `json:"maskedEmail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkPollRequest struct {
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkPollResponse struct {
|
||||||
|
SessionToken string `json:"sessionToken"` // JWT
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MagicLinkVerifyRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QRInitResponse struct {
|
||||||
|
QRCode string `json:"qrCode"` // Base64 or URL
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signup Flow Models
|
||||||
|
|
||||||
|
type CheckEmailRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendSignupCodeRequest struct {
|
||||||
|
Target string `json:"target"` // Email or Phone
|
||||||
|
Type string `json:"type"` // "email" or "phone"
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifySignupCodeRequest struct {
|
||||||
|
Target string `json:"target"` // Email or Phone
|
||||||
|
Type string `json:"type"` // "email" or "phone"
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignupRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||||
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
|
Department string `json:"department"`
|
||||||
|
Metadata JSONMap `json:"metadata,omitempty"`
|
||||||
|
TermsAccepted bool `json:"termsAccepted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Profile Models
|
||||||
|
|
||||||
|
type UserProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Role string `json:"role"` // 추가
|
||||||
|
Department string `json:"department"`
|
||||||
|
AffiliationType string `json:"affiliationType"`
|
||||||
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Department string `json:"department"`
|
||||||
|
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||||
|
type PasswordResetInitiateRequest struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
DryRun bool `json:"dryRun,omitempty"`
|
||||||
|
DrySend bool `json:"drySend,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
||||||
|
type PasswordResetCompleteRequest struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다.
|
||||||
|
type PasswordChangeRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientConsent struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
ClientID string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"clientId"`
|
||||||
|
Subject string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"subject"` // User UUID
|
||||||
|
GrantedScopes pq.StringArray `gorm:"type:text[];not null" json:"grantedScopes"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientConsentWithTenantInfo is a struct to hold joined data for API responses
|
||||||
|
type ClientConsentWithTenantInfo struct {
|
||||||
|
ClientConsent
|
||||||
|
TenantID string `gorm:"column:tenant_id" json:"tenantId"`
|
||||||
|
TenantName string `gorm:"column:tenant_name" json:"tenantName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClientConsent) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
|
if c.ID == "" {
|
||||||
|
c.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientSecret represents the stored client secret for OIDC clients.
|
||||||
|
// Since Hydra only returns the secret once during creation, we store it here.
|
||||||
|
type ClientSecret struct {
|
||||||
|
ClientID string `gorm:"primaryKey;column:client_id"`
|
||||||
|
ClientSecret string `gorm:"column:client_secret;not null"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientSecretRepository interface {
|
||||||
|
Upsert(ctx context.Context, clientID, secret string) error
|
||||||
|
GetByID(ctx context.Context, clientID string) (string, error)
|
||||||
|
Delete(ctx context.Context, clientID string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// EmailService defines the interface for sending emails.
|
||||||
|
type EmailService interface {
|
||||||
|
SendEmail(to, subject, body string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderType defines the type of the identity provider.
|
||||||
|
type ProviderType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProviderTypeOIDC ProviderType = "oidc"
|
||||||
|
ProviderTypeSAML ProviderType = "saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IdentityProviderConfig stores the configuration for an external Identity Provider.
|
||||||
|
type IdentityProviderConfig struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
|
||||||
|
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
|
||||||
|
DisplayName string `gorm:"not null" json:"display_name"`
|
||||||
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
|
|
||||||
|
// OIDC Specific Fields
|
||||||
|
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
|
||||||
|
OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
|
||||||
|
OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
|
||||||
|
// Scopes are space-separated
|
||||||
|
Scopes *string `gorm:"null" json:"scopes,omitempty"`
|
||||||
|
|
||||||
|
// SAML Specific Fields
|
||||||
|
MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"`
|
||||||
|
MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"`
|
||||||
|
EntityID *string `gorm:"null" json:"entity_id,omitempty"`
|
||||||
|
AcsURL *string `gorm:"null" json:"acs_url,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
|
func (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
|
if idc.ID == "" {
|
||||||
|
idc.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type HydraClient struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||||
|
ClientURI string `json:"client_uri,omitempty"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydraConsentRequest struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
RequestedScope []string `json:"requested_scope"`
|
||||||
|
RequestedAudience []string `json:"requested_access_token_audience"`
|
||||||
|
Skip bool `json:"skip"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Client HydraClient `json:"client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydraLoginRequest struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Skip bool `json:"skip"`
|
||||||
|
Client HydraClient `json:"client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydraConsentSession struct {
|
||||||
|
ConsentRequestID string `json:"consent_request_id,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
GrantedScope []string `json:"grant_scope,omitempty"`
|
||||||
|
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
|
||||||
|
Remember bool `json:"remember"`
|
||||||
|
RememberFor int `json:"remember_for,omitempty"`
|
||||||
|
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
||||||
|
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
||||||
|
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||||
|
Client HydraClient `json:"client,omitempty"`
|
||||||
|
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다.
|
||||||
|
var ErrNotSupported = errors.New("idp: not supported")
|
||||||
|
|
||||||
|
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||||
|
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||||
|
type BrokerUser struct {
|
||||||
|
ID string `json:"id" required:"true"`
|
||||||
|
Email string `json:"email" required:"true"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
// Attributes stores custom user attributes.
|
||||||
|
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
||||||
|
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
||||||
|
type IDPMetadata struct {
|
||||||
|
// SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports.
|
||||||
|
// For custom attributes, use the key name directly (e.g., "grade").
|
||||||
|
SupportedFields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordPolicy는 비밀번호 정책 정보를 표현합니다.
|
||||||
|
type PasswordPolicy struct {
|
||||||
|
MinLength int
|
||||||
|
Lowercase bool
|
||||||
|
Uppercase bool
|
||||||
|
Number bool
|
||||||
|
NonAlphanumeric bool
|
||||||
|
MinCharacterTypes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token represents a session or refresh token.
|
||||||
|
type Token struct {
|
||||||
|
JWT string
|
||||||
|
Expiration time.Time
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthInfo contains authentication information after a successful login.
|
||||||
|
type AuthInfo struct {
|
||||||
|
SessionToken *Token
|
||||||
|
RefreshToken *Token
|
||||||
|
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||||
|
type LinkLoginInit struct {
|
||||||
|
FlowID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
||||||
|
Mode string
|
||||||
|
// LoginID는 IDP에 실제 전달된 식별자입니다.
|
||||||
|
LoginID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||||
|
type IdentityProvider interface {
|
||||||
|
Name() string
|
||||||
|
// GetMetadata returns the schema support information for this IDP.
|
||||||
|
// This is used for startup-time validation.
|
||||||
|
GetMetadata() (*IDPMetadata, error)
|
||||||
|
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
|
||||||
|
CreateUser(user *BrokerUser, password string) (string, error)
|
||||||
|
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
|
||||||
|
SignIn(loginID, password string) (*AuthInfo, error)
|
||||||
|
// UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다.
|
||||||
|
UserExists(loginID string) (bool, error)
|
||||||
|
// IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다.
|
||||||
|
IssueSession(loginID string) (*AuthInfo, error)
|
||||||
|
// InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다.
|
||||||
|
InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error)
|
||||||
|
// VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다.
|
||||||
|
VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error)
|
||||||
|
// GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다.
|
||||||
|
GetPasswordPolicy() (*PasswordPolicy, error)
|
||||||
|
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||||
|
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||||
|
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
|
||||||
|
type JSONMap map[string]any
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (m JSONMap) Value() (driver.Value, error) {
|
||||||
|
if m == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ba, err := json.Marshal(m)
|
||||||
|
return string(ba), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface
|
||||||
|
func (m *JSONMap) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
*m = make(JSONMap)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
default:
|
||||||
|
return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(JSONMap)
|
||||||
|
err := json.Unmarshal(bytes, &result)
|
||||||
|
*m = result
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLog represents a single audit event
|
||||||
|
type AuditLog struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
||||||
|
Status string `json:"status"` // e.g., "success", "failure"
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
|
Details string `json:"details,omitempty"` // JSON string or simple text
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRepository defines interface for storing logs
|
||||||
|
type AuditRepository interface {
|
||||||
|
Create(log *AuditLog) error
|
||||||
|
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
||||||
|
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditCursor struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
EventID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisRepository defines interface for KV storage (Redis)
|
||||||
|
type RedisRepository interface {
|
||||||
|
Set(key string, value string, expiration time.Duration) error
|
||||||
|
Get(key string) (string, error)
|
||||||
|
Delete(key string) error
|
||||||
|
StoreVerificationCode(phone, code string) error
|
||||||
|
GetVerificationCode(phone string) (string, error)
|
||||||
|
DeleteVerificationCode(phone string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OathkeeperAccessLog struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
RequestID string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Status int
|
||||||
|
LatencyMs int
|
||||||
|
ClientID string
|
||||||
|
RP string
|
||||||
|
Action string
|
||||||
|
Target string
|
||||||
|
Subject string
|
||||||
|
ClientIP string
|
||||||
|
UserAgent string
|
||||||
|
Decision string
|
||||||
|
TraceID string
|
||||||
|
SpanID string
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OathkeeperLogRepository interface {
|
||||||
|
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
||||||
|
// It maps 1:1 to a Hydra Client.
|
||||||
|
type RelyingParty struct {
|
||||||
|
ClientID string `json:"clientId"` // Maps to Hydra Client ID
|
||||||
|
TenantID string `json:"tenantId"`
|
||||||
|
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
// DeletedAt removed as it's not a DB model anymore
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName removed
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// SmsService defines the interface for sending SMS messages.
|
||||||
|
type SmsService interface {
|
||||||
|
SendSms(to, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaverSmsRequest represents the request body for the Naver Cloud SMS API.
|
||||||
|
type NaverSmsRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Messages []SmsMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsMessage represents a single message to be sent.
|
||||||
|
type SmsMessage struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaverSmsResponse represents the response from the Naver Cloud SMS API.
|
||||||
|
type NaverSmsResponse struct {
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
RequestTime string `json:"requestTime"`
|
||||||
|
StatusCode string `json:"statusCode"`
|
||||||
|
StatusName string `json:"statusName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsRequest represents the request body for sending an SMS.
|
||||||
|
type SmsRequest struct {
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsVerifyRequest represents the request body for verifying an SMS code.
|
||||||
|
type SmsVerifyRequest struct {
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tenant statuses
|
||||||
|
const (
|
||||||
|
TenantStatusPending = "pending"
|
||||||
|
TenantStatusActive = "active"
|
||||||
|
TenantStatusSuspended = "suspended"
|
||||||
|
TenantStatusDeleted = "deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tenant represents a tenant model stored in PostgreSQL.
|
||||||
|
type Tenant struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
|
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tenant) IsActive() bool {
|
||||||
|
return t.Status == TenantStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
|
if t.ID == "" {
|
||||||
|
t.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user