forked from baron/baron-sso
Merge commit 'ac778f836fb78550dce8088a567dc8bf5ffb8d2e' into feature/adminfront
This commit is contained in:
@@ -24,6 +24,11 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_e2e_tests:
|
||||
description: "Run userfront WASM Playwright E2E tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_adminfront_tests:
|
||||
description: "Run adminfront Playwright tests"
|
||||
required: true
|
||||
@@ -90,16 +95,21 @@ jobs:
|
||||
npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
- name: Lint Go backend
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.59
|
||||
working-directory: backend
|
||||
args: --enable-only=gofmt,gofumpt
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}/backend:/app" \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v2.10.1 \
|
||||
golangci-lint fmt -E gofmt -E gofumpt -d
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Install Userfront dependencies
|
||||
run: |
|
||||
cd userfront
|
||||
flutter pub get
|
||||
cd userfront
|
||||
flutter pub get
|
||||
|
||||
- name: Format Flutter userfront
|
||||
run: |
|
||||
@@ -201,6 +211,10 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Run userfront tests
|
||||
run: |
|
||||
cd userfront
|
||||
@@ -276,6 +290,222 @@ jobs:
|
||||
reports/userfront-test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
userfront-e2e-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: userfront-e2e/package-lock.json
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Sync userfront locales
|
||||
run: |
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
- name: Install userfront-e2e dependencies
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npm ci 2>&1 | tee ../reports/userfront-e2e-install.log
|
||||
install_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Dependency install failed\`"
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront-e2e && npm ci\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-install.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build userfront WASM
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront
|
||||
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
|
||||
build_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$build_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`WASM build failed\`"
|
||||
echo "- Exit Code: \`$build_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront && flutter build web --wasm --release\`"
|
||||
echo
|
||||
echo "## Build Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-build.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Provision browsers for userfront-e2e tests
|
||||
run: |
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npx playwright install --with-deps chromium 2>&1 | tee ../reports/userfront-e2e-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Browser provisioning failed\`"
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd userfront-e2e && npx playwright install --with-deps chromium\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-provision.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run userfront-e2e tests
|
||||
run: |
|
||||
mkdir -p reports
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
npm test 2>&1 | tee ../reports/userfront-e2e-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
cd ..
|
||||
set -e
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd userfront-e2e\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`cd ../userfront && flutter build web --wasm --release\`"
|
||||
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`"
|
||||
echo "5. \`npm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-test.log
|
||||
echo '```'
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
|
||||
- name: Ensure userfront-e2e failure report exists
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
mkdir -p reports
|
||||
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Userfront E2E Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`userfront-e2e-tests\`"
|
||||
echo "- Reason: \`Job failed before detailed report generation\`"
|
||||
echo
|
||||
if [ -f reports/userfront-e2e-install.log ]; then
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-install.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-build.log ]; then
|
||||
echo "## Build Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-build.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-provision.log ]; then
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-provision.log
|
||||
echo '```'
|
||||
echo
|
||||
fi
|
||||
if [ -f reports/userfront-e2e-test.log ]; then
|
||||
echo "## Test Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/userfront-e2e-test.log
|
||||
echo '```'
|
||||
fi
|
||||
} > reports/userfront-e2e-test-failure-report.md
|
||||
|
||||
- name: Publish userfront-e2e failure summary
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
|
||||
cat reports/userfront-e2e-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload userfront-e2e failure report artifact
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: userfront-e2e-test-failure-report
|
||||
path: |
|
||||
reports/userfront-e2e-test-failure-report.md
|
||||
reports/userfront-e2e-install.log
|
||||
reports/userfront-e2e-build.log
|
||||
reports/userfront-e2e-provision.log
|
||||
reports/userfront-e2e-test.log
|
||||
userfront-e2e/playwright-report
|
||||
userfront-e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
adminfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
*.log
|
||||
*.out
|
||||
*.exe
|
||||
.npm-cache/
|
||||
reports
|
||||
reports/*
|
||||
|
||||
|
||||
146
Makefile
146
Makefile
@@ -107,11 +107,30 @@ logs-app:
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
.PHONY: code-check code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests
|
||||
ifeq ($(CI),)
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium
|
||||
else
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
||||
endif
|
||||
|
||||
code-check: code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
code-check: code-check-lint code-check-test-jobs
|
||||
@echo "code-check complete."
|
||||
|
||||
code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint
|
||||
|
||||
code-check-test-jobs:
|
||||
@echo "==> run CI-equivalent test jobs (parallel)"
|
||||
@$(MAKE) --no-print-directory -j5 --output-sync=target \
|
||||
code-check-backend-tests \
|
||||
code-check-userfront-tests \
|
||||
code-check-userfront-e2e-tests \
|
||||
code-check-adminfront-tests \
|
||||
code-check-devfront-tests
|
||||
|
||||
code-check-i18n:
|
||||
@echo "==> i18n resource check"
|
||||
@mkdir -p reports
|
||||
@@ -122,63 +141,132 @@ code-check-i18n:
|
||||
code-check-go-lint:
|
||||
@echo "==> go lint/format check"
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
cd backend && golangci-lint run --enable-only=gofmt,gofumpt; \
|
||||
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
|
||||
elif command -v docker >/dev/null 2>&1; then \
|
||||
docker run --rm \
|
||||
-v "$$(pwd)/backend:/app" \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v2.10.1 \
|
||||
golangci-lint fmt -E gofmt -E gofumpt -d; \
|
||||
else \
|
||||
echo "WARN: golangci-lint not found, fallback to gofmt check only."; \
|
||||
unformatted="$$(cd backend && gofmt -l .)"; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt required:"; \
|
||||
echo "$$unformatted"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "ERROR: golangci-lint not found and docker is unavailable."; \
|
||||
echo "Install golangci-lint v2.10.1 or Docker to match CI lint step."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
code-check-sync-userfront-locales:
|
||||
@echo "==> sync userfront locales"
|
||||
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||
|
||||
code-check-userfront-install:
|
||||
@echo "==> install userfront dependencies"
|
||||
cd userfront && flutter pub get
|
||||
|
||||
code-check-userfront-lint:
|
||||
@echo "==> userfront format/analyze"
|
||||
cd userfront && flutter pub get
|
||||
cd userfront && dart format --output=show --set-exit-if-changed lib test
|
||||
cd userfront && dart format --output=none --set-exit-if-changed lib test
|
||||
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||
|
||||
code-check-front-lint:
|
||||
@echo "==> adminfront biome lint/format check"
|
||||
rm -rf adminfront/playwright-report adminfront/test-results
|
||||
cd adminfront && npm ci
|
||||
cd adminfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
@echo "==> devfront biome lint/format check"
|
||||
rm -rf devfront/playwright-report devfront/test-results
|
||||
cd devfront && npm ci
|
||||
cd devfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
|
||||
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
|
||||
|
||||
code-check-backend-tests:
|
||||
@echo "==> backend tests"
|
||||
cd backend && go test -v ./...
|
||||
|
||||
code-check-userfront-tests:
|
||||
@echo "==> userfront tests"
|
||||
cd userfront && flutter test
|
||||
@echo "==> userfront tests (isolated workspace)"
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
cp -R locales "$$tmp_dir/locales"; \
|
||||
if command -v rsync >/dev/null 2>&1; then \
|
||||
rsync -a --delete \
|
||||
--exclude '.dart_tool' \
|
||||
--exclude 'build' \
|
||||
--exclude '.pub-cache' \
|
||||
--exclude '.flutter-plugins' \
|
||||
--exclude '.flutter-plugins-dependencies' \
|
||||
userfront/ "$$tmp_dir/userfront/"; \
|
||||
else \
|
||||
cp -R userfront "$$tmp_dir/userfront"; \
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
fi; \
|
||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||
cd "$$tmp_dir/userfront" && flutter test
|
||||
|
||||
code-check-adminfront-tests:
|
||||
@echo "==> adminfront tests"
|
||||
@mkdir -p reports/adminfront
|
||||
@rm -rf reports/adminfront/playwright-report reports/adminfront/test-results
|
||||
@status=0; \
|
||||
(cd adminfront && npx playwright install) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd adminfront && npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d adminfront/playwright-report ] && cp -R adminfront/playwright-report reports/adminfront/ || true; \
|
||||
[ -d adminfront/test-results ] && cp -R adminfront/test-results reports/adminfront/ || true; \
|
||||
exit $$status
|
||||
./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
code-check-devfront-tests:
|
||||
@echo "==> devfront tests"
|
||||
@mkdir -p reports/devfront
|
||||
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
|
||||
@status=0; \
|
||||
(cd devfront && npx playwright install) || status=$$?; \
|
||||
(cd devfront && npm ci) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
exit $$status
|
||||
|
||||
code-check-userfront-e2e-tests:
|
||||
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
|
||||
@mkdir -p reports/userfront-e2e
|
||||
@rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results
|
||||
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
|
||||
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
|
||||
mkdir -p "$$tmp_dir/scripts"; \
|
||||
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
|
||||
cp -R locales "$$tmp_dir/locales"; \
|
||||
if command -v rsync >/dev/null 2>&1; then \
|
||||
rsync -a --delete \
|
||||
--exclude '.dart_tool' \
|
||||
--exclude 'build' \
|
||||
--exclude '.pub-cache' \
|
||||
--exclude '.flutter-plugins' \
|
||||
--exclude '.flutter-plugins-dependencies' \
|
||||
userfront/ "$$tmp_dir/userfront/"; \
|
||||
rsync -a --delete \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'playwright-report' \
|
||||
--exclude 'test-results' \
|
||||
userfront-e2e/ "$$tmp_dir/userfront-e2e/"; \
|
||||
else \
|
||||
cp -R userfront "$$tmp_dir/userfront"; \
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
cp -R userfront-e2e "$$tmp_dir/userfront-e2e"; \
|
||||
rm -rf "$$tmp_dir/userfront-e2e/node_modules" "$$tmp_dir/userfront-e2e/playwright-report" "$$tmp_dir/userfront-e2e/test-results"; \
|
||||
fi; \
|
||||
status=0; \
|
||||
(cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh) || status=$$?; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && npm ci) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd "$$tmp_dir/userfront-e2e" && npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
||||
exit $$status
|
||||
|
||||
29
README.md
29
README.md
@@ -193,6 +193,22 @@ USERFRONT_URL=https://sso.example.com
|
||||
- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택)
|
||||
- 빈값: `[]`
|
||||
- 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback`
|
||||
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
||||
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
||||
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
||||
- `USERFRONT_DEBUG_LOG`: UserFront 측 디버그 로그 fallback 플래그
|
||||
- `CLIENT_LOG_DEBUG`가 없을 때만 UserFront에서 대체로 참조
|
||||
|
||||
### 클라이언트 로그 정책 (중요)
|
||||
- 기본 원칙: 운영 환경에서는 민감정보 보호를 우선하며, 과도한 로그 수집을 제한합니다.
|
||||
- 환경별 동작:
|
||||
- 비운영(`dev/local/stage` 등): 디버그 로그 허용 (`INFO/DEBUG/WARN/ERROR`)
|
||||
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG` 미설정: `WARN/ERROR`만 수집
|
||||
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG=true`: 디버그 로그 허용
|
||||
- 민감정보는 환경과 무관하게 마스킹합니다.
|
||||
- 예: `password`, `newPassword`, `token`, `authorization`, `cookie`, `sessionJwt`
|
||||
- 문자열 패턴(`token=...`, `authorization:...`, JSON body 내 민감 key)도 마스킹
|
||||
- 상세 정책 문서: `docs/client-log-policy.md`
|
||||
|
||||
### `.env` 작성 후 권장 점검
|
||||
```bash
|
||||
@@ -320,6 +336,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
- `run_lint`: Go/Flutter lint 실행 여부
|
||||
- `run_backend_tests`: backend 테스트 실행 여부
|
||||
- `run_userfront_tests`: userfront 테스트 실행 여부
|
||||
- `run_userfront_e2e_tests`: userfront WASM Playwright E2E 실행 여부
|
||||
- `run_adminfront_tests`: adminfront 테스트 실행 여부
|
||||
- `run_devfront_tests`: devfront 테스트 실행 여부
|
||||
|
||||
@@ -327,6 +344,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
- `lint`
|
||||
- `backend-tests`
|
||||
- `userfront-tests`
|
||||
- `userfront-e2e-tests`
|
||||
- `adminfront-tests`
|
||||
- `devfront-tests`
|
||||
|
||||
@@ -353,6 +371,17 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
- 단일 파일만 확인하려면 다음 명령을 사용합니다.
|
||||
- `flutter test test/locale_storage_platform_test.dart`
|
||||
|
||||
### userfront WASM Playwright E2E
|
||||
- 워크스페이스: `userfront-e2e/`
|
||||
- 빌드+실행:
|
||||
- `cd userfront-e2e && npm run test:wasm`
|
||||
- 빌드 결과가 이미 있을 때:
|
||||
- `cd userfront-e2e && npm test`
|
||||
- Makefile 타깃:
|
||||
- `make code-check-userfront-e2e-tests`
|
||||
- 전수 인벤토리:
|
||||
- `docs/test-plan/userfront-wasm-e2e-route-inventory.md`
|
||||
|
||||
### 로컬 개발 (Manual)
|
||||
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -376,7 +376,7 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||
error = "연동 정보를 불러오지 못했습니다."
|
||||
|
||||
[msg.userfront.dashboard.approved_session]
|
||||
copy_click = "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다."
|
||||
copy_click = "{{label}}: {{id}} \\n클릭하면 복사됩니다."
|
||||
copy_tap = "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다."
|
||||
none = "{{label}} 없음"
|
||||
|
||||
|
||||
@@ -288,6 +288,8 @@ func main() {
|
||||
|
||||
// 3. Initialize Fiber
|
||||
appEnv := getEnv("APP_ENV", "dev")
|
||||
clientLogDebugFlag := getEnv("CLIENT_LOG_DEBUG", "")
|
||||
clientDebugEnabled := logger.ClientDebugEnabled(appEnv, clientLogDebugFlag)
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Baron SSO Backend",
|
||||
DisableStartupMessage: true, // Clean logs
|
||||
@@ -378,6 +380,10 @@ func main() {
|
||||
} else {
|
||||
slog.Info("🔒 API Docs disabled in production")
|
||||
}
|
||||
slog.Info("Client log policy configured",
|
||||
"app_env", appEnv,
|
||||
"client_debug_enabled", clientDebugEnabled,
|
||||
)
|
||||
|
||||
// Routes
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
@@ -642,12 +648,20 @@ func main() {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.SendStatus(fiber.StatusBadRequest)
|
||||
}
|
||||
if !logger.ShouldAcceptClientLog(appEnv, clientLogDebugFlag, req.Level) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
level := logger.NormalizeClientLogLevel(req.Level)
|
||||
if level == slog.LevelInfo && logger.ShouldFilterNoisyClientInfo(appEnv, clientLogDebugFlag, req.Message) {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
// Prepare attributes for flattening
|
||||
attrs := []any{
|
||||
slog.String("source", "client"),
|
||||
}
|
||||
for k, v := range req.Data {
|
||||
sanitizedData := logger.SanitizeClientLogData(req.Data)
|
||||
for k, v := range sanitizedData {
|
||||
// Skip svc if it's already set by the global logger to avoid confusion,
|
||||
// or keep it as client_svc
|
||||
if k == "svc" {
|
||||
@@ -656,30 +670,7 @@ func main() {
|
||||
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...)
|
||||
slog.Log(c.Context(), level, logger.SanitizeClientLogMessage(req.Message), attrs...)
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type apiKeyListResponse struct {
|
||||
|
||||
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
@@ -43,12 +43,12 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
||||
|
||||
var total int64
|
||||
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
var keys []domain.ApiKey
|
||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
items := make([]apiKeySummary, 0, len(keys))
|
||||
@@ -73,7 +73,7 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
||||
|
||||
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -81,11 +81,11 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
||||
}
|
||||
|
||||
// Generate Client ID (16 chars hex)
|
||||
@@ -96,7 +96,7 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash secret"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
|
||||
}
|
||||
|
||||
apiKey := domain.ApiKey{
|
||||
@@ -108,7 +108,7 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&apiKey).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Return summary + PLAIN SECRET (only this time)
|
||||
@@ -129,16 +129,16 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||
|
||||
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
||||
}
|
||||
|
||||
if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
||||
@@ -23,9 +23,7 @@ func NewAuditHandler(repo domain.AuditRepository) *AuditHandler {
|
||||
func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
||||
var req domain.AuditLog
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Cannot parse JSON",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Cannot parse JSON")
|
||||
}
|
||||
|
||||
// Auto-fill metadata if missing
|
||||
@@ -43,16 +41,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if h.repo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "Audit service unavailable",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
}
|
||||
|
||||
if err := h.repo.Create(&req); err != nil {
|
||||
// Log internal error but don't expose details
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to save audit log",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to save audit log")
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
@@ -66,22 +60,16 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
||||
cursorRaw := c.Query("cursor")
|
||||
cursor, err := parseAuditCursor(cursorRaw)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid cursor",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
|
||||
}
|
||||
|
||||
if h.repo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "Audit service unavailable",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
}
|
||||
|
||||
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to retrieve audit logs",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -121,3 +121,26 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
|
||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||
assert.NotEmpty(t, initResp["userCode"])
|
||||
}
|
||||
|
||||
func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"pendingRef": "missing-ref",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.Equal(t, "expired_token", got["error"])
|
||||
assert.Equal(t, "expired_token", got["code"])
|
||||
}
|
||||
|
||||
206
backend/internal/handler/auth_handler_login_code_test.go
Normal file
206
backend/internal/handler/auth_handler_login_code_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func newVerifyLoginCodeTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode)
|
||||
app.Post("/api/v1/auth/login/code/verify-short", h.VerifyLoginShortCode)
|
||||
return app
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_InvalidBody_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_IdpUnavailable_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "user@example.com",
|
||||
"code": "AA-111111",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "service_unavailable" {
|
||||
t.Fatalf("expected code=service_unavailable, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginCode_VerifyOnlyInvalidCode_ReturnsExplicitCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
redis.data[prefixLoginCode+"user@example.com"] = "flow-1"
|
||||
redis.data[prefixLoginCodePending+"user@example.com"] = "pending-1"
|
||||
redis.data[prefixLoginCodeValue+"pending-1"] = "AB-123"
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"loginId": "user@example.com",
|
||||
"code": "ZZ-999",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_code" {
|
||||
t.Fatalf("expected code=invalid_code, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_MissingShortCode_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: &mockRedisRepo{data: make(map[string]string)},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_InvalidOrExpired_ReturnsExplicitCode(t *testing.T) {
|
||||
h := &AuthHandler{
|
||||
RedisService: &mockRedisRepo{data: make(map[string]string)},
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "AB-123456",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_or_expired_code" {
|
||||
t.Fatalf("expected code=invalid_or_expired_code, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLoginShortCode_VerifyOnlyMissingPendingRef_ReturnsExplicitCode(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
payload, _ := json.Marshal(shortLoginCodePayload{
|
||||
LoginID: "user@example.com",
|
||||
Code: "AB-123",
|
||||
})
|
||||
redis.data[prefixLoginCodeShort+"AB-123456"] = string(payload)
|
||||
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := newVerifyLoginCodeTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"shortCode": "AB-123456",
|
||||
"verifyOnly": true,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := decodeJSONBody(t, resp)
|
||||
if got["code"] != "invalid_session_reference" {
|
||||
t.Fatalf("expected code=invalid_session_reference, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
109
backend/internal/handler/auth_handler_profile_cache_test.go
Normal file
109
backend/internal/handler/auth_handler_profile_cache_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
|
||||
token := "token-abc"
|
||||
identityID := "user-1"
|
||||
traits := map[string]interface{}{
|
||||
"email": "qa@example.com",
|
||||
"name": "QA User",
|
||||
"phone_number": "+821012345678",
|
||||
"department": "Old Dept",
|
||||
"affiliationType": "employee",
|
||||
"companyCode": "",
|
||||
"role": domain.RoleUser,
|
||||
}
|
||||
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/sessions/whoami" &&
|
||||
r.Method == http.MethodGet:
|
||||
if r.Header.Get("X-Session-Token") != token {
|
||||
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"identity": map[string]interface{}{
|
||||
"id": identityID,
|
||||
"traits": traits,
|
||||
},
|
||||
}), nil
|
||||
|
||||
case r.URL.Host == "kratos.test" &&
|
||||
r.URL.Path == "/admin/identities/"+identityID &&
|
||||
r.Method == http.MethodPut:
|
||||
var payload struct {
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
|
||||
}
|
||||
for k, v := range payload.Traits {
|
||||
traits[k] = v
|
||||
}
|
||||
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
|
||||
}
|
||||
|
||||
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||
})
|
||||
setDefaultHTTPClientForTest(t, transport)
|
||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/user/me", h.GetMe)
|
||||
app.Put("/api/v1/user/me", h.UpdateMe)
|
||||
|
||||
// 1) 첫 조회로 Old Dept가 캐시에 저장됨
|
||||
getReq1 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
getReq1.Header.Set("Authorization", "Bearer "+token)
|
||||
getResp1, err := app.Test(getReq1, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, getResp1.StatusCode)
|
||||
var profile1 map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1))
|
||||
require.Equal(t, "Old Dept", profile1["department"])
|
||||
|
||||
// 2) 소속을 New Dept로 변경
|
||||
updateBody, _ := json.Marshal(map[string]string{
|
||||
"name": "QA User",
|
||||
"phone": "01012345678",
|
||||
"department": "New Dept",
|
||||
})
|
||||
updateReq := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/user/me",
|
||||
bytes.NewReader(updateBody),
|
||||
)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateResp, err := app.Test(updateReq, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
require.Equal(t, "New Dept", traits["department"])
|
||||
|
||||
// 3) 새로고침 재조회 시 New Dept가 보여야 함(캐시 무효화 회귀 방지)
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
|
||||
getReq2.Header.Set("Authorization", "Bearer "+token)
|
||||
getResp2, err := app.Test(getReq2, -1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, getResp2.StatusCode)
|
||||
var profile2 map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
|
||||
require.Equal(t, "New Dept", profile2["department"])
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
|
||||
var pollResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||
assert.Equal(t, "authorization_pending", pollResp["error"])
|
||||
assert.Equal(t, "authorization_pending", pollResp["code"])
|
||||
|
||||
// 3. Mock Approval
|
||||
sessionData, _ := json.Marshal(map[string]string{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -26,6 +27,13 @@ func newResetFlowTestApp(h *AuthHandler) *fiber.App {
|
||||
return app
|
||||
}
|
||||
|
||||
func newResetInitAppWithErrorCodeEnricher(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
type testRedisRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
@@ -286,3 +294,35 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||
t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Login ID is required" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
if got["code"] != "bad_request" {
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,14 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func setDefaultHTTPClientForTest(t interface{ Cleanup(func()) }, transport http.RoundTripper) {
|
||||
origDefault := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{Transport: transport}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient = origDefault
|
||||
})
|
||||
}
|
||||
|
||||
func httpResponse(r *http.Request, code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
|
||||
@@ -93,15 +93,17 @@ type clientEndpoints struct {
|
||||
}
|
||||
|
||||
type consentSummary struct {
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
GrantedScopes []string `json:"grantedScopes"`
|
||||
AuthenticatedAt string `json:"authenticatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
}
|
||||
|
||||
type consentListResponse struct {
|
||||
@@ -275,15 +277,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "clients not found")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
|
||||
}
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
@@ -306,15 +306,15 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
clientID := c.Params("id")
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*client)
|
||||
@@ -323,10 +323,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
if summary.Type == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,19 +345,19 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
clientID := c.Params("id")
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(req.Status))
|
||||
if status != "active" && status != "inactive" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
|
||||
}
|
||||
|
||||
// [Security] Check permission before patching
|
||||
@@ -367,7 +367,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,9 +375,9 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*updated)
|
||||
@@ -396,7 +396,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
var req clientUpsertRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
||||
@@ -411,7 +411,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
|
||||
redirectURIs := derefSlice(req.RedirectURIs, nil)
|
||||
if len(redirectURIs) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "redirectUris is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required")
|
||||
}
|
||||
|
||||
scopes := derefSlice(req.Scopes, defaultClientScopes())
|
||||
@@ -420,23 +420,23 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
|
||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
if clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
|
||||
}
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
|
||||
if status != "active" && status != "inactive" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
|
||||
}
|
||||
|
||||
metadata := mergeMetadata(nil, req.Metadata)
|
||||
@@ -468,7 +468,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
|
||||
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Store secret in metadata for later retrieval
|
||||
@@ -500,27 +500,27 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
var req clientUpsertRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
clientType := ""
|
||||
if req.Type != nil {
|
||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,10 +529,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if currentSummary.Type == "private" || clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if req.Status != nil {
|
||||
status = strings.ToLower(strings.TrimSpace(*req.Status))
|
||||
if status != "active" && status != "inactive" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +554,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "redirectUris cannot be empty"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty")
|
||||
}
|
||||
|
||||
metadata := mergeMetadata(current.Metadata, req.Metadata)
|
||||
@@ -579,9 +579,9 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*updatedClient)
|
||||
@@ -600,7 +600,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
@@ -610,16 +610,16 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// 1. Clean up PostgreSQL
|
||||
@@ -638,7 +638,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client_id is required")
|
||||
}
|
||||
|
||||
subject := strings.TrimSpace(c.Query("subject"))
|
||||
@@ -650,6 +650,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
|
||||
// [Isolation] Get admin tenant ID from header or locals
|
||||
adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev
|
||||
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
|
||||
var consents []domain.ClientConsentWithTenantInfo
|
||||
var total int64
|
||||
@@ -678,7 +679,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
items := make([]consentSummary, 0, len(consents))
|
||||
@@ -688,6 +689,23 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
var deletedAt *time.Time
|
||||
status := "active"
|
||||
if consent.DeletedAt.Valid {
|
||||
deletedAt = &consent.DeletedAt.Time
|
||||
status = "revoked"
|
||||
}
|
||||
|
||||
// Filter by status if requested
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
if statusFilter == "active" && status != "active" {
|
||||
continue
|
||||
}
|
||||
if statusFilter == "revoked" && status != "revoked" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
userName := ""
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject)
|
||||
if err == nil && identity != nil {
|
||||
@@ -705,6 +723,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
GrantedScopes: consent.GrantedScopes,
|
||||
AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339),
|
||||
CreatedAt: consent.CreatedAt,
|
||||
DeletedAt: deletedAt,
|
||||
Status: status,
|
||||
TenantID: consent.TenantID,
|
||||
TenantName: consent.TenantName,
|
||||
})
|
||||
@@ -719,7 +739,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
subject := strings.TrimSpace(c.Query("subject"))
|
||||
if subject == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
||||
}
|
||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||
|
||||
@@ -733,7 +753,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
|
||||
// 1. Revoke in Hydra
|
||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// 2. Sync to Local DB (Delete)
|
||||
@@ -747,7 +767,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
@@ -757,7 +777,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,22 +785,22 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
// 1. Generate new secret
|
||||
newSecret, err := generateRandomSecret(20)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret")
|
||||
}
|
||||
|
||||
// 2. Get current client to preserve other fields (already fetched above)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// 3. Update Hydra
|
||||
current.ClientSecret = newSecret
|
||||
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// 4. Update Persistence (DB & Redis)
|
||||
|
||||
17
backend/internal/handler/error_helper.go
Normal file
17
backend/internal/handler/error_helper.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/response"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// errorJSON은 기존 error 필드를 유지하면서 기계 판독용 code를 명시적으로 추가합니다.
|
||||
func errorJSON(c *fiber.Ctx, status int, message string) error {
|
||||
return response.Error(c, status, response.StatusCode(status), message)
|
||||
}
|
||||
|
||||
// errorJSONCode는 상태코드 기반 매핑만으로 부족한 경우 명시 코드를 강제할 때 사용합니다.
|
||||
func errorJSONCode(c *fiber.Ctx, status int, code, message string) error {
|
||||
return response.Error(c, status, code, message)
|
||||
}
|
||||
@@ -33,13 +33,13 @@ func (h *FederationHandler) InitiateOIDCLogin(c *fiber.Ctx) error {
|
||||
loginChallenge := c.Query("login_challenge")
|
||||
|
||||
if providerID == "" || loginChallenge == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider_id and login_challenge are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "provider_id and login_challenge are required")
|
||||
}
|
||||
|
||||
redirectURL, err := h.fedSvc.InitiateOIDCLogin(c.Context(), providerID, loginChallenge)
|
||||
if err != nil {
|
||||
// Log the error properly in a real application
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to initiate OIDC login"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to initiate OIDC login")
|
||||
}
|
||||
|
||||
return c.Redirect(redirectURL, fiber.StatusFound)
|
||||
@@ -51,12 +51,12 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error {
|
||||
state := c.Query("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "code and state are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "code and state are required")
|
||||
}
|
||||
|
||||
redirectURL, err := h.fedSvc.HandleOIDCCallback(c.Context(), code, state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to handle OIDC callback"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to handle OIDC callback")
|
||||
}
|
||||
|
||||
return c.Redirect(redirectURL, fiber.StatusFound)
|
||||
@@ -68,12 +68,12 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error {
|
||||
func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error {
|
||||
clientID := c.Params("clientId")
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "clientId is required")
|
||||
}
|
||||
|
||||
var configs []domain.IdentityProviderConfig
|
||||
if err := h.db.Where("client_id = ?", clientID).Find(&configs).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(configs)
|
||||
@@ -83,12 +83,12 @@ func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error {
|
||||
func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||
clientID := c.Params("clientId")
|
||||
if clientID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required in path"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "clientId is required in path")
|
||||
}
|
||||
|
||||
var req domain.IdentityProviderConfig
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
// Assign clientID from path parameter
|
||||
@@ -96,14 +96,14 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||
|
||||
// Basic validation
|
||||
if req.DisplayName == "" || req.ProviderType == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "display_name and provider_type are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "display_name and provider_type are required")
|
||||
}
|
||||
|
||||
// TODO: Optionally, validate if the clientID exists in Hydra
|
||||
|
||||
// Create in DB
|
||||
if err := h.db.Create(&req).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(req)
|
||||
@@ -115,7 +115,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||
func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
// This is a temporary solution. We should create a proper method in the repository.
|
||||
@@ -123,7 +123,7 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error {
|
||||
// Note: This now queries client_id, which is incorrect for tenants.
|
||||
// This method is deprecated.
|
||||
if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(configs)
|
||||
@@ -133,26 +133,26 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error {
|
||||
func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
|
||||
var req domain.IdentityProviderConfig
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
// Basic validation - This is the old validation logic
|
||||
if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id, display_name, and provider_type are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client_id, display_name, and provider_type are required")
|
||||
}
|
||||
|
||||
// This check is now incorrect and deprecated.
|
||||
var tenant domain.Tenant
|
||||
if err := h.db.First(&tenant, "id = ?", req.ClientID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Create in DB
|
||||
if err := h.db.Create(&req).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(req)
|
||||
|
||||
@@ -20,17 +20,17 @@ func NewRelyingPartyHandler(s service.RelyingPartyService, kratos service.Kratos
|
||||
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
var req domain.HydraClient
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
rp, err := h.Service.Create(c.Context(), tenantID, req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(rp)
|
||||
@@ -39,7 +39,7 @@ func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
||||
func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found in context"})
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: user profile not found in context")
|
||||
}
|
||||
|
||||
var rps []domain.RelyingParty
|
||||
@@ -51,11 +51,11 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
||||
rps, err = h.Service.List(c.Context(), *profile.TenantID)
|
||||
} else {
|
||||
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", profile.Role)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient role to list all applications"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient role to list all applications")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(rps)
|
||||
@@ -64,12 +64,12 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
||||
func (h *RelyingPartyHandler) List(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
rps, err := h.Service.List(c.Context(), tenantID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(rps)
|
||||
@@ -79,7 +79,7 @@ func (h *RelyingPartyHandler) Get(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
rp, hydraClient, err := h.Service.Get(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "relying party not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "relying party not found")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -92,12 +92,12 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var req domain.HydraClient
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
rp, err := h.Service.Update(c.Context(), id, req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(rp)
|
||||
@@ -106,7 +106,7 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error {
|
||||
func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := h.Service.Delete(c.Context(), id); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
|
||||
@@ -65,17 +65,17 @@ func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if req.Name == "" || req.Domain == "" || req.AdminEmail == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name, domain, and adminEmail are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name, domain, and adminEmail are required")
|
||||
}
|
||||
|
||||
tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||||
@@ -87,11 +87,11 @@ func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||
func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||
}
|
||||
|
||||
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
||||
@@ -139,20 +139,20 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
|
||||
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
tenantID := strings.TrimSpace(c.Params("id"))
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||
}
|
||||
|
||||
var tenant domain.Tenant
|
||||
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
|
||||
@@ -168,7 +168,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
|
||||
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -182,12 +182,12 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
||||
}
|
||||
|
||||
tenantType := normalizeTenantType(req.Type)
|
||||
@@ -206,7 +206,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
slug = utils.GenerateSlug(slug)
|
||||
}
|
||||
if slug == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "slug is required")
|
||||
}
|
||||
|
||||
status := normalizeTenantStatus(req.Status)
|
||||
@@ -224,9 +224,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
summary := mapTenantSummary(*tenant)
|
||||
@@ -243,20 +243,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
|
||||
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
tenantID := strings.TrimSpace(c.Params("id"))
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||
}
|
||||
|
||||
var tenant domain.Tenant
|
||||
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -270,13 +270,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name cannot be empty"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name cannot be empty")
|
||||
}
|
||||
tenant.Name = name
|
||||
}
|
||||
@@ -290,14 +290,14 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
if req.Slug != nil {
|
||||
slug := utils.GenerateSlug(*req.Slug)
|
||||
if slug == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug cannot be empty"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty")
|
||||
}
|
||||
if slug != tenant.Slug {
|
||||
var exists domain.Tenant
|
||||
if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "slug already exists"})
|
||||
return errorJSON(c, fiber.StatusConflict, "slug already exists")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
tenant.Slug = slug
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
if req.Status != nil {
|
||||
status := normalizeTenantStatus(*req.Status)
|
||||
if status == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
|
||||
}
|
||||
tenant.Status = status
|
||||
}
|
||||
@@ -341,14 +341,14 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Update domains if provided
|
||||
if req.Domains != nil {
|
||||
// Simple approach: Delete existing and recreate
|
||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to clear old domains"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to clear old domains")
|
||||
}
|
||||
for _, d := range req.Domains {
|
||||
if strings.TrimSpace(d) == "" {
|
||||
@@ -356,7 +356,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
// Use repository for consistency
|
||||
if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to add domain: " + d})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to add domain: "+d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,30 +369,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
|
||||
func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
tenantID := strings.TrimSpace(c.Params("id"))
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||
}
|
||||
|
||||
var tenant domain.Tenant
|
||||
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Rename slug to release it for reuse before soft delete
|
||||
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
|
||||
if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to release slug"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to release slug")
|
||||
}
|
||||
|
||||
if err := h.DB.Delete(&tenant).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
@@ -401,13 +401,13 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||
}
|
||||
|
||||
// Fetch admins from Keto
|
||||
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
type adminInfo struct {
|
||||
@@ -453,7 +453,7 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
userID := c.Params("userId")
|
||||
if tenantID == "" || userID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||
}
|
||||
|
||||
if h.KetoOutbox != nil {
|
||||
@@ -473,7 +473,7 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
userID := c.Params("userId")
|
||||
if tenantID == "" || userID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||
}
|
||||
|
||||
if h.KetoOutbox != nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ func (h *UserGroupHandler) List(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
groups, err := h.Service.List(c.Context(), tenantID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(groups)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
group, err := h.Service.Get(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to get group: "+err.Error())
|
||||
}
|
||||
return c.JSON(group)
|
||||
}
|
||||
@@ -77,11 +77,11 @@ func (h *UserGroupHandler) AddMember(c *fiber.Ctx) error {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "userId is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "userId is required")
|
||||
}
|
||||
|
||||
if err := h.Service.AddMember(c.Context(), groupID, req.UserID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
|
||||
userID := c.Params("userId")
|
||||
|
||||
if err := h.Service.RemoveMember(c.Context(), groupID, userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -103,11 +103,11 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid body")
|
||||
}
|
||||
|
||||
if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
|
||||
groupID := c.Params("id")
|
||||
roles, err := h.Service.ListRoles(c.Context(), groupID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(roles)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
|
||||
relation := c.Params("relation")
|
||||
|
||||
if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
// Fetch from UserRepo
|
||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, len(users))
|
||||
@@ -177,20 +177,20 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
if userID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
||||
// [New] Check access scope
|
||||
@@ -198,7 +198,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: access to user in another tenant denied"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
|
||||
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -221,19 +221,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if email == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "email is required")
|
||||
}
|
||||
if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email format"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid email format")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
||||
}
|
||||
|
||||
password := strings.TrimSpace(req.Password)
|
||||
@@ -253,13 +253,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if password == "" {
|
||||
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
||||
if genErr != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to generate password")
|
||||
}
|
||||
password = generated
|
||||
generatedPassword = generated
|
||||
} else {
|
||||
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,15 +305,15 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
||||
return errorJSON(c, fiber.StatusConflict, "email already exists")
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Fetch the newly created identity to ensure we have all traits
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
@@ -350,20 +350,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
if userID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
||||
// Capture current local state for transition comparison
|
||||
@@ -383,7 +383,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot update user in another tenant"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,13 +398,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: tenant admins cannot change user's tenant"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
state := normalizeKratosState(req.Status)
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
||||
@@ -488,7 +488,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,12 +497,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
if userID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
|
||||
}
|
||||
|
||||
// [New] Check access scope before deletion
|
||||
@@ -512,13 +512,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if err == nil && identity != nil {
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot delete user in another tenant"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// [Keto] Cleanup relations via Outbox
|
||||
|
||||
143
backend/internal/logger/client_log_policy.go
Normal file
143
backend/internal/logger/client_log_policy.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var sensitiveClientLogKeys = map[string]struct{}{
|
||||
"password": {},
|
||||
"currentpassword": {},
|
||||
"newpassword": {},
|
||||
"oldpassword": {},
|
||||
"token": {},
|
||||
"accesstoken": {},
|
||||
"refreshtoken": {},
|
||||
"secret": {},
|
||||
"clientsecret": {},
|
||||
"authorization": {},
|
||||
"cookie": {},
|
||||
"setcookie": {},
|
||||
"verificationcode": {},
|
||||
"code": {},
|
||||
"loginchallenge": {},
|
||||
"loginverifier": {},
|
||||
"sessionjwt": {},
|
||||
"accessjwt": {},
|
||||
"refreshjwt": {},
|
||||
}
|
||||
|
||||
var (
|
||||
logJSONSensitivePattern = regexp.MustCompile(`(?i)"(password|currentpassword|newpassword|oldpassword|token|accesstoken|refreshtoken|secret|clientsecret|authorization|cookie|setcookie|verificationcode|code|loginchallenge|loginverifier|sessionjwt|accessjwt|refreshjwt)"\s*:\s*"[^"]*"`)
|
||||
logKVPattern = regexp.MustCompile(`(?i)\b(password|current_password|currentpassword|new_password|newpassword|old_password|oldpassword|token|access_token|accesstoken|refresh_token|refreshtoken|authorization|cookie|session_jwt|sessionjwt|access_jwt|accessjwt|refresh_jwt|refreshjwt)\b\s*[:=]\s*([^\s,;]+)`)
|
||||
)
|
||||
|
||||
func IsProductionEnv(appEnv string) bool {
|
||||
env := strings.ToLower(strings.TrimSpace(appEnv))
|
||||
return env == "prod" || env == "production"
|
||||
}
|
||||
|
||||
func parseBoolFlag(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ClientDebugEnabled(appEnv, productionDebugFlag string) bool {
|
||||
if !IsProductionEnv(appEnv) {
|
||||
return true
|
||||
}
|
||||
return parseBoolFlag(productionDebugFlag)
|
||||
}
|
||||
|
||||
func NormalizeClientLogLevel(level string) slog.Level {
|
||||
switch strings.ToUpper(strings.TrimSpace(level)) {
|
||||
case "SEVERE", "ERROR":
|
||||
return slog.LevelError
|
||||
case "WARNING", "WARN":
|
||||
return slog.LevelWarn
|
||||
case "DEBUG", "FINE", "TRACE":
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldAcceptClientLog(appEnv, productionDebugFlag, level string) bool {
|
||||
if ClientDebugEnabled(appEnv, productionDebugFlag) {
|
||||
return true
|
||||
}
|
||||
return NormalizeClientLogLevel(level) >= slog.LevelWarn
|
||||
}
|
||||
|
||||
func ShouldFilterNoisyClientInfo(appEnv, productionDebugFlag, message string) bool {
|
||||
if ClientDebugEnabled(appEnv, productionDebugFlag) {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(message)
|
||||
return strings.Contains(msg, "navigating to") ||
|
||||
strings.Contains(msg, "going to") ||
|
||||
strings.Contains(msg, "redirecting to") ||
|
||||
strings.Contains(msg, "full paths for routes")
|
||||
}
|
||||
|
||||
func SanitizeClientLogMessage(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return message
|
||||
}
|
||||
sanitized := logJSONSensitivePattern.ReplaceAllStringFunc(message, func(segment string) string {
|
||||
parts := strings.SplitN(segment, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return segment
|
||||
}
|
||||
return parts[0] + `:"*****"`
|
||||
})
|
||||
sanitized = logKVPattern.ReplaceAllString(sanitized, `$1=*****`)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func SanitizeClientLogData(data map[string]interface{}) map[string]interface{} {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
out := make(map[string]interface{}, len(data))
|
||||
for k, v := range data {
|
||||
if isSensitiveClientLogKey(k) {
|
||||
out[k] = "*****"
|
||||
continue
|
||||
}
|
||||
out[k] = sanitizeClientLogValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeClientLogValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
return SanitizeClientLogData(val)
|
||||
case []interface{}:
|
||||
next := make([]interface{}, len(val))
|
||||
for i := range val {
|
||||
next[i] = sanitizeClientLogValue(val[i])
|
||||
}
|
||||
return next
|
||||
case string:
|
||||
return SanitizeClientLogMessage(val)
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveClientLogKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, "_", "")
|
||||
normalized = strings.ReplaceAll(normalized, ".", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
_, ok := sensitiveClientLogKeys[normalized]
|
||||
return ok
|
||||
}
|
||||
79
backend/internal/logger/client_log_policy_test.go
Normal file
79
backend/internal/logger/client_log_policy_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestClientDebugEnabled(t *testing.T) {
|
||||
t.Run("non production enables debug by default", func(t *testing.T) {
|
||||
assert.True(t, ClientDebugEnabled("dev", ""))
|
||||
assert.True(t, ClientDebugEnabled("local", "false"))
|
||||
})
|
||||
|
||||
t.Run("production disables debug by default", func(t *testing.T) {
|
||||
assert.False(t, ClientDebugEnabled("production", ""))
|
||||
assert.False(t, ClientDebugEnabled("prod", "false"))
|
||||
})
|
||||
|
||||
t.Run("production accepts explicit debug override", func(t *testing.T) {
|
||||
assert.True(t, ClientDebugEnabled("production", "true"))
|
||||
assert.True(t, ClientDebugEnabled("production", "1"))
|
||||
assert.True(t, ClientDebugEnabled("prod", "on"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldAcceptClientLog(t *testing.T) {
|
||||
assert.False(t, ShouldAcceptClientLog("production", "", "INFO"))
|
||||
assert.False(t, ShouldAcceptClientLog("production", "", "DEBUG"))
|
||||
assert.True(t, ShouldAcceptClientLog("production", "", "WARN"))
|
||||
assert.True(t, ShouldAcceptClientLog("production", "", "ERROR"))
|
||||
assert.True(t, ShouldAcceptClientLog("production", "true", "INFO"))
|
||||
assert.True(t, ShouldAcceptClientLog("dev", "", "INFO"))
|
||||
}
|
||||
|
||||
func TestShouldFilterNoisyClientInfo(t *testing.T) {
|
||||
assert.True(t, ShouldFilterNoisyClientInfo("production", "", "Navigating to /ko/signin"))
|
||||
assert.False(t, ShouldFilterNoisyClientInfo("production", "true", "Navigating to /ko/signin"))
|
||||
assert.False(t, ShouldFilterNoisyClientInfo("dev", "", "Navigating to /ko/signin"))
|
||||
}
|
||||
|
||||
func TestSanitizeClientLogData(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"token": "raw-token",
|
||||
"safe": "ok",
|
||||
"nested": map[string]interface{}{
|
||||
"new_password": "secret-1",
|
||||
"path": "/ko/profile",
|
||||
},
|
||||
"arr": []interface{}{
|
||||
map[string]interface{}{"authorization": "Bearer abc"},
|
||||
"token=abc123",
|
||||
},
|
||||
}
|
||||
|
||||
result := SanitizeClientLogData(input)
|
||||
|
||||
assert.Equal(t, "*****", result["token"])
|
||||
assert.Equal(t, "ok", result["safe"])
|
||||
|
||||
nested := result["nested"].(map[string]interface{})
|
||||
assert.Equal(t, "*****", nested["new_password"])
|
||||
assert.Equal(t, "/ko/profile", nested["path"])
|
||||
|
||||
arr := result["arr"].([]interface{})
|
||||
first := arr[0].(map[string]interface{})
|
||||
assert.Equal(t, "*****", first["authorization"])
|
||||
assert.Equal(t, "token=*****", arr[1])
|
||||
}
|
||||
|
||||
func TestSanitizeClientLogMessage(t *testing.T) {
|
||||
msg := `FLUTTER_ERROR token=abc123 payload={"password":"hello","safe":"ok"} authorization:BearerX`
|
||||
sanitized := SanitizeClientLogMessage(msg)
|
||||
assert.NotContains(t, sanitized, "abc123")
|
||||
assert.NotContains(t, sanitized, `"password":"hello"`)
|
||||
assert.Contains(t, sanitized, `"password":"*****"`)
|
||||
assert.Contains(t, sanitized, "token=*****")
|
||||
assert.Contains(t, sanitized, "authorization=*****")
|
||||
}
|
||||
17
backend/internal/middleware/error_helper.go
Normal file
17
backend/internal/middleware/error_helper.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/response"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// errorJSON은 legacy error 필드를 유지하면서 status 기반 code를 함께 반환합니다.
|
||||
func errorJSON(c *fiber.Ctx, status int, message string) error {
|
||||
return response.Error(c, status, response.StatusCode(status), message)
|
||||
}
|
||||
|
||||
// errorJSONCode는 상태코드 매핑과 다른 명시 코드가 필요할 때 사용합니다.
|
||||
func errorJSONCode(c *fiber.Ctx, status int, code, message string) error {
|
||||
return response.Error(c, status, code, message)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
return func(c *fiber.Ctx) error {
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"})
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_keto)")
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -49,7 +49,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
|
||||
if objectID == "" {
|
||||
slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||
return errorJSON(c, fiber.StatusBadRequest, "missing object id for permission check")
|
||||
}
|
||||
|
||||
slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
@@ -63,12 +63,12 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||
if err != nil {
|
||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
@@ -85,9 +85,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "unauthorized (trace:rbac_role): " + err.Error(),
|
||||
})
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_role): "+err.Error())
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -114,9 +112,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
"allowedRoles", config.AllowedRoles,
|
||||
"path", c.Path(),
|
||||
)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: insufficient permissions",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions")
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -136,7 +132,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
|
||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"})
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_match)")
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
@@ -174,13 +170,11 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
||||
|
||||
if !isAllowed {
|
||||
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "forbidden: you do not have access to this tenant",
|
||||
})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: you do not have access to this tenant")
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"})
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,12 @@ func NewClientConsentRepository(db *gorm.DB) ClientConsentRepository {
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
|
||||
return r.db.WithContext(ctx).
|
||||
return r.db.WithContext(ctx).Unscoped().
|
||||
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
|
||||
Assign(map[string]interface{}{
|
||||
"granted_scopes": consent.GrantedScopes,
|
||||
"updated_at": gorm.Expr("NOW()"),
|
||||
"deleted_at": nil,
|
||||
}).
|
||||
FirstOrCreate(consent).Error
|
||||
}
|
||||
@@ -44,13 +45,13 @@ func (r *clientConsentRepo) List(ctx context.Context, clientID string, limit, of
|
||||
var total int64
|
||||
|
||||
// Base query for counting
|
||||
countQuery := r.db.WithContext(ctx).Model(&domain.ClientConsent{}).Where("client_id = ?", clientID)
|
||||
countQuery := r.db.WithContext(ctx).Unscoped().Model(&domain.ClientConsent{}).Where("client_id = ?", clientID)
|
||||
if err := countQuery.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Query for fetching data
|
||||
query := r.db.WithContext(ctx).
|
||||
query := r.db.WithContext(ctx).Unscoped().
|
||||
Model(&domain.ClientConsent{}).
|
||||
Select("client_consents.*, users.tenant_id, tenants.name as tenant_name").
|
||||
Joins("LEFT JOIN users ON users.id::text = client_consents.subject").
|
||||
@@ -66,7 +67,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
||||
var total int64
|
||||
|
||||
// Base query for counting
|
||||
countQuery := r.db.WithContext(ctx).
|
||||
countQuery := r.db.WithContext(ctx).Unscoped().
|
||||
Model(&domain.ClientConsent{}).
|
||||
Joins("JOIN users ON users.id::text = client_consents.subject").
|
||||
Where("client_consents.client_id = ? AND users.tenant_id = ?", clientID, tenantID)
|
||||
@@ -76,7 +77,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
||||
}
|
||||
|
||||
// Query for fetching data
|
||||
query := r.db.WithContext(ctx).
|
||||
query := r.db.WithContext(ctx).Unscoped().
|
||||
Model(&domain.ClientConsent{}).
|
||||
Select("client_consents.*, users.tenant_id, tenants.name as tenant_name").
|
||||
Joins("JOIN users ON users.id::text = client_consents.subject").
|
||||
@@ -94,7 +95,7 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
||||
|
||||
func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
|
||||
var consents []domain.ClientConsent
|
||||
err := r.db.WithContext(ctx).
|
||||
err := r.db.WithContext(ctx).Unscoped().
|
||||
Where("subject = ?", subject).
|
||||
Order("updated_at DESC").
|
||||
Find(&consents).Error
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -18,10 +18,14 @@ const navItems = [
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
|
||||
@@ -41,10 +45,109 @@ function AppLayout() {
|
||||
window.localStorage.setItem("admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
profileMenuRef.current &&
|
||||
!profileMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const profileName =
|
||||
auth.user?.profile?.name?.toString().trim() ||
|
||||
auth.user?.profile?.preferred_username?.toString().trim() ||
|
||||
auth.user?.profile?.nickname?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
||||
const profileEmail =
|
||||
auth.user?.profile?.email?.toString().trim() ||
|
||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
||||
const expiresAtSec = auth.user?.expires_at;
|
||||
const remainingMs =
|
||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||
const remainingTotalSec =
|
||||
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
|
||||
const remainingMinutes =
|
||||
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
|
||||
const remainingSeconds =
|
||||
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
|
||||
|
||||
let sessionToneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let sessionText = t("ui.dev.session.active", "세션 만료 시간 확인 중");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "세션 만료 시간 확인 불가");
|
||||
} else if (remainingMs <= 0) {
|
||||
sessionToneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
sessionText = t("ui.dev.session.expired", "세션 만료됨");
|
||||
} else {
|
||||
if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
sessionToneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
sessionText = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료까지 {{minutes}}분 {{seconds}}초",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshSessionExpiry = async () => {
|
||||
if (isRefreshingSession) {
|
||||
return;
|
||||
}
|
||||
setIsRefreshingSession(true);
|
||||
try {
|
||||
await auth.signinSilent();
|
||||
setNowMs(Date.now());
|
||||
setIsProfileMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh session expiry:", error);
|
||||
} finally {
|
||||
setIsRefreshingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 flex flex-col justify-between">
|
||||
@@ -94,7 +197,20 @@ function AppLayout() {
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="px-3 pt-4 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
|
||||
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
|
||||
<p>
|
||||
{t(
|
||||
@@ -104,17 +220,6 @@ function AppLayout() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-6 md:px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
@@ -141,6 +246,68 @@ function AppLayout() {
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
</span>
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
|
||||
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="mt-2 w-full rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleRefreshSessionExpiry}
|
||||
disabled={isRefreshingSession}
|
||||
>
|
||||
{isRefreshingSession
|
||||
? t(
|
||||
"ui.dev.session.refreshing",
|
||||
"세션 만료 시간 갱신 중...",
|
||||
)
|
||||
: t("ui.dev.session.refresh", "세션 만료 시간 갱신")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// 팝업으로 열린 경우 signinPopupCallback 처리
|
||||
if (window.opener) {
|
||||
userManager.signinPopupCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
navigate("/", { replace: true });
|
||||
} else if (auth.error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -11,10 +12,15 @@ import {
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
// OIDC client-side authentication flow started here
|
||||
auth.signinRedirect();
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
await auth.signinPopup();
|
||||
navigate("/clients", { replace: true });
|
||||
} catch (error) {
|
||||
console.error("Popup login failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,12 +27,17 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
const clientId = params.id ?? "";
|
||||
const [subjectInput, setSubjectInput] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [scopeFilter, setScopeFilter] = useState("all");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
@@ -44,9 +49,9 @@ function ClientConsentsPage() {
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["consents", clientId, subject],
|
||||
queryFn: () => fetchConsents(subject, clientId),
|
||||
enabled: clientId.length > 0, // Removed subject.length > 0 check
|
||||
queryKey: ["consents", clientId, subject, statusFilter],
|
||||
queryFn: () => fetchConsents(subject, clientId, statusFilter),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (payload: { subject: string }) =>
|
||||
@@ -56,7 +61,74 @@ function ClientConsentsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRevoke = (sub: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.consents.revoke_confirm",
|
||||
"정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.",
|
||||
),
|
||||
)
|
||||
) {
|
||||
revokeMutation.mutate({ subject: sub });
|
||||
}
|
||||
};
|
||||
|
||||
const rows = consentsData?.items ?? [];
|
||||
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
|
||||
const filteredRows = rows.filter((row) => {
|
||||
return scopeFilter === "all" || row.grantedScopes.includes(scopeFilter);
|
||||
});
|
||||
|
||||
const handleExportCSV = () => {
|
||||
if (filteredRows.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
t("ui.dev.clients.consents.table.user", "User"),
|
||||
t("ui.dev.clients.consents.table.tenant", "Tenant"),
|
||||
t("ui.dev.clients.table.status", "Status"),
|
||||
t("ui.dev.clients.consents.table.scopes", "Granted Scopes"),
|
||||
t("ui.dev.clients.consents.table.first_granted", "First Granted"),
|
||||
t(
|
||||
"ui.dev.clients.consents.table.last_auth",
|
||||
"Last Authenticated / Revoked",
|
||||
),
|
||||
];
|
||||
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...filteredRows.map((row) => {
|
||||
const lastAuthRevoked =
|
||||
row.status === "revoked" && row.deletedAt
|
||||
? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}`
|
||||
: row.authenticatedAt
|
||||
? new Date(row.authenticatedAt).toLocaleString()
|
||||
: "-";
|
||||
|
||||
return [
|
||||
`"${row.subject} (${row.userName || ""})"`,
|
||||
`"${row.tenantName || row.tenantId || ""}"`,
|
||||
`"${row.status}"`,
|
||||
`"${row.grantedScopes.join(", ")}"`,
|
||||
`"${new Date(row.createdAt).toLocaleString()}"`,
|
||||
`"${lastAuthRevoked}"`,
|
||||
].join(",");
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvContent}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `consents_${clientId}_${date}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -117,7 +189,7 @@ function ClientConsentsPage() {
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
@@ -132,55 +204,109 @@ function ClientConsentsPage() {
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.search_placeholder",
|
||||
"사용자 ID, 이름, 이메일로 검색",
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.search_placeholder",
|
||||
"사용자 ID, 이름, 이메일로 검색",
|
||||
)}
|
||||
value={subjectInput}
|
||||
onChange={(e) => setSubjectInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
value={subjectInput}
|
||||
onChange={(e) => setSubjectInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select className="h-10 rounded-lg border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option>
|
||||
{t("ui.dev.clients.consents.status_all", "All Statuses")}
|
||||
</option>
|
||||
<option selected>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</option>
|
||||
</select>
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" className="gap-1 text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button className="shadow-sm shadow-primary/30">
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.consents.status_all", "All Statuses")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="revoked">
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.consents.scope_all", "All Scopes")}
|
||||
</option>
|
||||
{allScopes.map((scope) => (
|
||||
<option key={scope} value={scope}>
|
||||
{scope}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter("all");
|
||||
setScopeFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -226,7 +352,7 @@ function ClientConsentsPage() {
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.dev.clients.consents.table.last_auth",
|
||||
"Last Authenticated",
|
||||
"Last Authenticated / Revoked",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
@@ -235,15 +361,18 @@ function ClientConsentsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
{filteredRows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
{t("msg.dev.clients.consents.empty", "No consents found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={`${row.subject}-${row.clientId}`}>
|
||||
filteredRows.map((row) => (
|
||||
<TableRow
|
||||
key={`${row.subject}-${row.clientId}`}
|
||||
className={row.status === "revoked" ? "opacity-60" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
|
||||
@@ -273,9 +402,15 @@ function ClientConsentsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="success">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</Badge>
|
||||
{row.status === "active" ? (
|
||||
<Badge variant="success">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -294,20 +429,28 @@ function ClientConsentsPage() {
|
||||
{new Date(row.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.authenticatedAt
|
||||
? new Date(row.authenticatedAt).toLocaleString()
|
||||
: "-"}
|
||||
{row.status === "revoked" && row.deletedAt ? (
|
||||
<span className="text-destructive font-medium">
|
||||
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
|
||||
{new Date(row.deletedAt).toLocaleString()}
|
||||
</span>
|
||||
) : row.authenticatedAt ? (
|
||||
new Date(row.authenticatedAt).toLocaleString()
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() =>
|
||||
revokeMutation.mutate({ subject: row.subject })
|
||||
}
|
||||
>
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
</Button>
|
||||
{row.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRevoke(row.subject)}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -320,8 +463,8 @@ function ClientConsentsPage() {
|
||||
"msg.dev.clients.consents.showing",
|
||||
"Showing {{from}} to {{to}} of {{total}} users",
|
||||
{
|
||||
from: rows.length > 0 ? 1 : 0,
|
||||
to: rows.length,
|
||||
from: filteredRows.length > 0 ? 1 : 0,
|
||||
to: filteredRows.length,
|
||||
total: rows.length,
|
||||
},
|
||||
)}
|
||||
@@ -330,7 +473,7 @@ function ClientConsentsPage() {
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" disabled={rows.length === 0}>
|
||||
<Button size="sm" disabled={filteredRows.length === 0}>
|
||||
1
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
@@ -349,7 +492,9 @@ function ClientConsentsPage() {
|
||||
"Active Grants",
|
||||
)}
|
||||
</p>
|
||||
<CardTitle className="text-2xl font-black">{rows.length}</CardTitle>
|
||||
<CardTitle className="text-2xl font-black">
|
||||
{rows.filter((r) => r.status === "active").length}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="glass-panel">
|
||||
|
||||
@@ -218,7 +218,7 @@ function ClientDetailsPage() {
|
||||
to={`/clients/${clientId}`}
|
||||
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Connection")}
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Plus, Shield, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { Plus, Save, Shield, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -16,7 +16,13 @@ import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { createClient, fetchClient, updateClient } from "../../lib/devApi";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
updateClient,
|
||||
} from "../../lib/devApi";
|
||||
import type {
|
||||
ClientStatus,
|
||||
ClientType,
|
||||
@@ -123,6 +129,21 @@ function ClientGeneralPage() {
|
||||
setScopes(scopes.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||||
setStatus(nextStatus);
|
||||
const statusLabel =
|
||||
nextStatus === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.status_changed",
|
||||
"상태가 {{status}}로 변경되었습니다.",
|
||||
{ status: statusLabel },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||
@@ -174,6 +195,39 @@ function ClientGeneralPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteClient(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
|
||||
navigate("/clients");
|
||||
},
|
||||
onError: (err) => {
|
||||
const errorMessage =
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error)?.message;
|
||||
alert(
|
||||
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
clientId &&
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.delete_confirm",
|
||||
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(clientId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCreate && isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
@@ -220,14 +274,16 @@ function ClientGeneralPage() {
|
||||
: t("ui.dev.clients.general.title_edit", "Client Settings")}
|
||||
</h1>
|
||||
</div>
|
||||
<Badge
|
||||
variant={status === "active" ? "success" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
{!isCreate && (
|
||||
<Badge
|
||||
variant={status === "active" ? "success" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{!isCreate && (
|
||||
@@ -254,15 +310,22 @@ function ClientGeneralPage() {
|
||||
|
||||
{/* 1. Application Identity */}
|
||||
<div className="glass-panel p-6">
|
||||
<CardTitle className="text-xl font-bold mb-2">
|
||||
{t("ui.dev.clients.general.identity.title", "Application Identity")}
|
||||
</CardTitle>
|
||||
<CardDescription className="mb-6">
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.subtitle",
|
||||
"앱 이름과 설명, 로고를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold mb-2">
|
||||
{t(
|
||||
"ui.dev.clients.general.identity.title",
|
||||
"Application Identity",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.subtitle",
|
||||
"앱 이름과 설명, 로고를 설정합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
@@ -297,40 +360,66 @@ function ClientGeneralPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.identity.logo_placeholder",
|
||||
"https://example.com/logo.png",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.logo_help",
|
||||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={t(
|
||||
"ui.dev.clients.general.identity.logo_preview",
|
||||
"Logo Preview",
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.identity.logo_placeholder",
|
||||
"https://example.com/logo.png",
|
||||
)}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.identity.logo_help",
|
||||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={t(
|
||||
"ui.dev.clients.general.identity.logo_preview",
|
||||
"Logo Preview",
|
||||
)}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.dev.clients.table.status", "상태")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("active")}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("inactive")}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,7 +608,10 @@ function ClientGeneralPage() {
|
||||
/>
|
||||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
{t("ui.dev.clients.general.security.private", "Private")}
|
||||
{t(
|
||||
"ui.dev.clients.general.security.private",
|
||||
"Server side App",
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -565,43 +657,49 @@ function ClientGeneralPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
|
||||
<Button variant="outline" onClick={() => navigate("/clients")}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="px-8 shadow-lg shadow-primary/20"
|
||||
>
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "저장 중...")
|
||||
: isCreate
|
||||
? t("ui.dev.clients.general.create", "클라이언트 생성")
|
||||
: t("ui.dev.clients.general.save", "설정 저장")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCreate && (
|
||||
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4 opacity-70">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("ui.dev.clients.general.footer.client_id", "Client ID")}
|
||||
</span>
|
||||
<span className="font-mono text-sm block">{data?.client?.id}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("ui.dev.clients.general.footer.created_on", "Created On")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground block">
|
||||
{data?.client?.createdAt
|
||||
? new Date(data.client.createdAt).toLocaleString()
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<div>
|
||||
{!isCreate && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleteMutation.isPending
|
||||
? t("msg.common.requesting", "요청 중...")
|
||||
: t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/clients")}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
isLoading ||
|
||||
name.trim() === "" ||
|
||||
(isCreate && redirectUris.trim() === "")
|
||||
}
|
||||
className="shadow-lg shadow-primary/20"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<Save size={16} className="mr-2" />
|
||||
)}
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "저장 중...")
|
||||
: isCreate
|
||||
? t("ui.dev.clients.general.create", "클라이언트 생성")
|
||||
: t("ui.common.save", "저장")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
BookOpenText,
|
||||
Filter,
|
||||
Plus,
|
||||
Search,
|
||||
ServerCog,
|
||||
ShieldHalf,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -22,10 +24,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,58 +34,35 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
deleteClient,
|
||||
fetchClients,
|
||||
updateClientStatus,
|
||||
} from "../../lib/devApi";
|
||||
import { fetchClients } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: fetchClients,
|
||||
});
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
|
||||
updateClientStatus(payload.id, payload.status),
|
||||
onSuccess: (_, variables) => {
|
||||
const statusText =
|
||||
variables.status === "active"
|
||||
? t("ui.common.status.active", "활성화")
|
||||
: t("ui.common.status.inactive", "비활성화");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.status_updated",
|
||||
"클라이언트가 {{status}}되었습니다.",
|
||||
{
|
||||
status: statusText,
|
||||
},
|
||||
),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
const errMsg =
|
||||
error.response?.data?.error ??
|
||||
error.message ??
|
||||
t(
|
||||
"msg.dev.clients.status_update_error",
|
||||
"Failed to update client status",
|
||||
);
|
||||
toast(errMsg, "error");
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clientId: string) => deleteClient(clientId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
|
||||
const clients = data?.items || [];
|
||||
|
||||
const filteredClients = clients.filter((client) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || client.status === statusFilter;
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
const totalClients = clients.length;
|
||||
const activeClients = clients.filter(
|
||||
(client) => client.status === "active",
|
||||
@@ -159,7 +136,7 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.registry.title", "RP registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.clients.registry.subtitle", "Relying Parties")}
|
||||
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -179,25 +156,105 @@ function ClientsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1.5fr,1fr]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.search_placeholder",
|
||||
"클라이언트 이름/ID로 검색...",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 md:justify-start">
|
||||
<Badge variant="muted">
|
||||
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")}
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.search_placeholder",
|
||||
"클라이언트 이름/ID로 검색...",
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Badge variant="muted">
|
||||
{t(
|
||||
"ui.dev.clients.badge.tenant_selected",
|
||||
"테넌트: 선택됨",
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.filter.type_label", "Type:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.type_all", "모든 유형")}
|
||||
</option>
|
||||
<option value="private">
|
||||
{t("ui.dev.clients.type.private", "Server side App")}
|
||||
</option>
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.clients.filter.status_all", "모든 상태")}
|
||||
</option>
|
||||
<option value="active">
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</option>
|
||||
<option value="inactive">
|
||||
{t("ui.common.status.inactive", "Inactive")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
@@ -264,10 +321,13 @@ function ClientsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clients.map((client) => (
|
||||
{filteredClients.map((client) => (
|
||||
<TableRow key={client.id} className="bg-card/40">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={`/clients/${client.id}`}
|
||||
className="flex items-center gap-3 transition-colors hover:text-primary"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{client.type === "private" ? (
|
||||
<ServerCog className="h-4 w-4" />
|
||||
@@ -284,30 +344,13 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
{client.id}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={client.id}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(
|
||||
"ui.dev.clients.copy_client_id",
|
||||
"Copy client id",
|
||||
)}
|
||||
onCopy={() =>
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.copy_client_id",
|
||||
"클라이언트 ID가 복사되었습니다.",
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -315,38 +358,19 @@ function ClientsPage() {
|
||||
variant={client.type === "private" ? "success" : "muted"}
|
||||
>
|
||||
{client.type === "private"
|
||||
? t("ui.dev.clients.type.private", "Private")
|
||||
? t("ui.dev.clients.type.private", "Server side App")
|
||||
: t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
disabled={
|
||||
updateStatusMutation.isPending &&
|
||||
updateStatusMutation.variables?.id === client.id
|
||||
}
|
||||
checked={client.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
updateStatusMutation.mutate({
|
||||
id: client.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
client.status === "active"
|
||||
? "text-emerald-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{client.status === "active"
|
||||
? t("ui.common.status.active", "활성")
|
||||
: t("ui.common.status.inactive", "비활성")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={client.status === "active" ? "success" : "muted"}
|
||||
className="px-3 py-1 text-xs uppercase"
|
||||
>
|
||||
{client.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{client.createdAt
|
||||
@@ -357,17 +381,9 @@ function ClientsPage() {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/clients/${client.id}`}>
|
||||
{t("ui.common.edit", "Edit")}
|
||||
{t("ui.common.view", "View")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteMutation.mutate(client.id)}
|
||||
>
|
||||
{t("ui.common.delete", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -379,7 +395,7 @@ function ClientsPage() {
|
||||
{t(
|
||||
"msg.dev.clients.showing",
|
||||
"Showing {{shown}} of {{total}} clients",
|
||||
{ shown: clients.length, total: totalClients },
|
||||
{ shown: filteredClients.length, total: totalClients },
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
createIdpConfigForClient,
|
||||
listIdpConfigsForClient,
|
||||
} from "../../../lib/devApi";
|
||||
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
// Proper Modal Component with Form
|
||||
const CreateIdpModal = ({
|
||||
@@ -37,12 +56,10 @@ const CreateIdpModal = ({
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
// Basic error handling
|
||||
alert(`Failed to create configuration: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// 이 내용으로 교체해주세요
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
@@ -61,104 +78,100 @@ const CreateIdpModal = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg shadow-xl w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4">Add New IdP Configuration</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Display Name */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="display_name"
|
||||
value={formData.display_name}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.federation.add_subtitle",
|
||||
"Connect an external OIDC provider.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Display Name</label>
|
||||
<Input
|
||||
name="display_name"
|
||||
value={formData.display_name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Google Workspace"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Issuer URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="issuer_url"
|
||||
value={formData.issuer_url}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder="https://accounts.google.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Issuer URL</label>
|
||||
<Input
|
||||
type="url"
|
||||
name="issuer_url"
|
||||
value={formData.issuer_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://accounts.google.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="oidc_client_id"
|
||||
value={formData.oidc_client_id}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Client ID</label>
|
||||
<Input
|
||||
name="oidc_client_id"
|
||||
value={formData.oidc_client_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Client Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
name="oidc_client_secret"
|
||||
value={formData.oidc_client_secret}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="oidc_client_secret"
|
||||
value={formData.oidc_client_secret}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">Scopes</label>
|
||||
<Input
|
||||
name="scopes"
|
||||
value={formData.scopes}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Scopes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="scopes"
|
||||
value={formData.scopes}
|
||||
onChange={handleChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded mr-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Configuration"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("ui.common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
formData.display_name.trim() === "" ||
|
||||
(formData.issuer_url?.trim() ?? "") === ""
|
||||
}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<Save size={16} className="mr-2" />
|
||||
)}
|
||||
{mutation.isPending
|
||||
? t("msg.common.saving", "Saving...")
|
||||
: t("ui.common.save", "Save Configuration")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -168,7 +181,11 @@ export function ClientFederationPage() {
|
||||
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
if (!clientId) {
|
||||
return <div>Client ID is missing</div>;
|
||||
return (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
Client ID is missing
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -177,94 +194,113 @@ export function ClientFederationPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Identity Federation Settings</h1>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Manage external identity providers for this application.
|
||||
</p>
|
||||
<div className="space-y-6 p-1">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Globe className="h-6 w-6 text-primary" />
|
||||
{t("ui.dev.clients.federation.title", "Identity Federation")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.federation.subtitle",
|
||||
"Manage external identity providers for this application.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
+ Add IdP Configuration
|
||||
</button>
|
||||
</div>
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>Provider Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
{t("msg.common.loading", "Loading...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : error ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="h-24 text-center text-destructive"
|
||||
>
|
||||
{(error as Error).message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.dev.clients.federation.empty",
|
||||
"No IdP configurations found.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.map((config: IdpConfig) => (
|
||||
<tr
|
||||
key={config.id}
|
||||
className="border-b transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{config.display_name}
|
||||
</TableCell>
|
||||
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
config.status === "active"
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{config.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateIdpModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
clientId={clientId}
|
||||
/>
|
||||
|
||||
{isLoading && <div>Loading configurations...</div>}
|
||||
{error && (
|
||||
<div className="text-red-500">
|
||||
Failed to load configurations: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white">
|
||||
<thead className="bg-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 px-4 border-b">Display Name</th>
|
||||
<th className="py-2 px-4 border-b">Provider Type</th>
|
||||
<th className="py-2 px-4 border-b">Status</th>
|
||||
<th className="py-2 px-4 border-b">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-4">
|
||||
No IdP configurations found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((config: IdpConfig) => (
|
||||
<tr key={config.id}>
|
||||
<td className="py-2 px-4 border-b">
|
||||
{config.display_name}
|
||||
</td>
|
||||
<td className="py-2 px-4 border-b">
|
||||
{config.provider_type.toUpperCase()}
|
||||
</td>
|
||||
<td className="py-2 px-4 border-b">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
config.status === "active"
|
||||
? "bg-green-200 text-green-800"
|
||||
: "bg-gray-200 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{config.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 border-b">
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-500 hover:underline mr-2"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-red-500 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ apiClient.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
||||
const isAuthPath = window.location.pathname.startsWith("/callback");
|
||||
if (!isAuthPath) {
|
||||
userManager.signinRedirect();
|
||||
const isLoginPath = window.location.pathname === "/login";
|
||||
if (!isAuthPath && !isLoginPath) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const oidcConfig: AuthProviderProps = {
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: window.location.origin,
|
||||
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
automaticSilentRenew: true,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,8 @@ export type ConsentSummary = {
|
||||
grantedScopes: string[];
|
||||
authenticatedAt?: string;
|
||||
createdAt: string;
|
||||
deletedAt?: string;
|
||||
status: "active" | "revoked";
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
};
|
||||
@@ -148,11 +150,18 @@ export async function deleteClient(clientId: string) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function fetchConsents(subject: string, clientId?: string) {
|
||||
export async function fetchConsents(
|
||||
subject: string,
|
||||
clientId?: string,
|
||||
status?: string,
|
||||
) {
|
||||
const params: Record<string, string> = { subject };
|
||||
if (clientId) {
|
||||
params.client_id = clientId;
|
||||
}
|
||||
if (status && status !== "all") {
|
||||
params.status = status;
|
||||
}
|
||||
const { data } = await apiClient.get<ConsentListResponse>("/dev/consents", {
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -214,6 +214,9 @@ loading = "Loading apps..."
|
||||
showing = "Showing {{shown}} of {{total}} apps"
|
||||
status_update_error = "Failed to update client status"
|
||||
status_updated = "The app has been {{status}}."
|
||||
deleted = "App deleted."
|
||||
delete_error = "Failed to delete: {{error}}"
|
||||
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = "No consents found."
|
||||
@@ -248,20 +251,27 @@ note = "Note"
|
||||
load_error = "Error loading client: {{error}}"
|
||||
loading = "Loading client..."
|
||||
saved = "Saved"
|
||||
save_error = "Failed to save: {{error}}"
|
||||
status_changed = "Status changed to {{status}}."
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = "Manage external identity providers for this application."
|
||||
add_subtitle = "Connect an external OIDC provider."
|
||||
empty = "No IdP configurations found."
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = "Logo Help"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = "Help"
|
||||
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "Empty"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
private_help = "Private App (Server-side): For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||
subtitle = "Select application type. Security level determines authentication method."
|
||||
|
||||
@@ -315,6 +325,7 @@ approved_device = "Approved Device"
|
||||
approved_ip = "Approve IP: {{ip}}"
|
||||
audit_empty = "Audit Empty"
|
||||
audit_load_error = "Audit Load Error"
|
||||
render_error = "Dashboard render error: {{error}}"
|
||||
auth_method = "Auth Method"
|
||||
client_id = "Client ID: {{id}}"
|
||||
client_id_missing = "Client Id Missing"
|
||||
@@ -903,6 +914,7 @@ theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unknown = "Unknown"
|
||||
view = "View"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -979,7 +991,7 @@ user = "User"
|
||||
|
||||
[ui.dev.clients.details.breadcrumb]
|
||||
current = "Current"
|
||||
section = "Relying Parties"
|
||||
section = "Applications"
|
||||
|
||||
[ui.dev.clients.details.credentials]
|
||||
client_id = "Client ID"
|
||||
@@ -1006,17 +1018,21 @@ show = "Show"
|
||||
title = "Title"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "Connection"
|
||||
connection = "Federation"
|
||||
consents = "Consent & Users"
|
||||
settings = "Settings"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "Create Application"
|
||||
display_new = "Add Connected Application"
|
||||
save = "Settings Save"
|
||||
title_create = "Create Client"
|
||||
title_edit = "Client Settings"
|
||||
|
||||
[ui.dev.clients.federation]
|
||||
title = "Identity Federation"
|
||||
add_title = "Add Identity Provider"
|
||||
add_btn = "Add Provider"
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = "Applications"
|
||||
|
||||
@@ -1051,7 +1067,7 @@ name = "Scope Name"
|
||||
delete = "Delete"
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Private"
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
title = "Security Settings"
|
||||
|
||||
@@ -1073,7 +1089,7 @@ subtitle = "Tenant admin on-call"
|
||||
title = "Owner"
|
||||
|
||||
[ui.dev.clients.registry]
|
||||
subtitle = "Relying Parties"
|
||||
subtitle = "Applications"
|
||||
title = "RP registry"
|
||||
|
||||
[ui.dev.clients.table]
|
||||
@@ -1085,7 +1101,7 @@ status = "Status"
|
||||
type = "Type"
|
||||
|
||||
[ui.dev.clients.type]
|
||||
private = "Private"
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
@@ -1122,6 +1138,12 @@ title = "Stack readiness"
|
||||
plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
active = "Checking expiration..."
|
||||
unknown = "Unknown"
|
||||
expired = "Session expired"
|
||||
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
|
||||
remaining = "Expires in: {{minutes}}m {{seconds}}s"
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW Portal"
|
||||
|
||||
@@ -214,6 +214,9 @@ loading = "Loading apps..."
|
||||
showing = "Showing {{shown}} of {{total}} apps"
|
||||
status_update_error = "Failed to update client status"
|
||||
status_updated = "앱이 {{status}}되었습니다."
|
||||
deleted = "앱이 삭제되었습니다."
|
||||
delete_error = "삭제 실패: {{error}}"
|
||||
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = "No consents found."
|
||||
@@ -248,20 +251,27 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
|
||||
load_error = "Error loading client: {{error}}"
|
||||
loading = "Loading client..."
|
||||
saved = "설정이 저장되었습니다."
|
||||
save_error = "저장 실패: {{error}}"
|
||||
status_changed = "상태가 {{status}}로 변경되었습니다."
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
|
||||
add_subtitle = "외부 OIDC 제공자를 연결합니다."
|
||||
empty = "등록된 IdP 설정이 없습니다."
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
||||
subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
||||
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다."
|
||||
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "등록된 스코프가 없습니다."
|
||||
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
private_help = "Private 앱 (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||
|
||||
@@ -315,6 +325,7 @@ approved_device = "승인 기기: {{device}}"
|
||||
approved_ip = "승인 IP: {{ip}}"
|
||||
audit_empty = "최근 접속 이력이 없습니다."
|
||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||
render_error = "대시보드 렌더링 오류: {{error}}"
|
||||
auth_method = "인증수단: {{method}}"
|
||||
client_id = "Client ID: {{id}}"
|
||||
client_id_missing = "Client ID 없음"
|
||||
@@ -903,6 +914,7 @@ theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unknown = "Unknown"
|
||||
view = "보기"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -944,42 +956,43 @@ admin_session = "관리자 세션"
|
||||
tenant_selected = "테넌트: 선택됨"
|
||||
|
||||
[ui.dev.clients.consents]
|
||||
export_csv = "Export CSV"
|
||||
revoke = "Revoke"
|
||||
export_csv = "CSV 내보내기"
|
||||
revoke = "권한 철회"
|
||||
revoked_at = "철회일: "
|
||||
search_placeholder = "사용자 ID, 이름, 이메일로 검색"
|
||||
status_all = "All Statuses"
|
||||
status_label = "Status:"
|
||||
status_revoked = "Revoked"
|
||||
subject = "Subject"
|
||||
title = "User Consent Grants"
|
||||
status_all = "모든 상태"
|
||||
status_label = "상태:"
|
||||
status_revoked = "철회됨"
|
||||
subject = "사용자 ID"
|
||||
title = "사용자 동의 권한 관리"
|
||||
|
||||
[ui.dev.clients.consents.breadcrumb]
|
||||
clients = "Clients"
|
||||
current = "User Consent Grants"
|
||||
home = "Home"
|
||||
clients = "애플리케이션"
|
||||
current = "사용자 동의 권한"
|
||||
home = "홈"
|
||||
|
||||
[ui.dev.clients.consents.filters]
|
||||
advanced = "Advanced Filters"
|
||||
advanced = "상세 필터"
|
||||
|
||||
[ui.dev.clients.consents.stats]
|
||||
active_grants = "Active Grants"
|
||||
avg_scopes = "Avg. Scopes per User"
|
||||
total_scopes = "Total Scopes Issued"
|
||||
active_grants = "활성 권한"
|
||||
avg_scopes = "사용자당 평균 권한 수"
|
||||
total_scopes = "전체 부여된 권한 수"
|
||||
|
||||
[ui.dev.clients.consents.table]
|
||||
action = "Action"
|
||||
first_granted = "First Granted"
|
||||
last_auth = "Last Authenticated"
|
||||
scopes = "Granted Scopes"
|
||||
status = "Status"
|
||||
tenant = "Tenant"
|
||||
user = "User"
|
||||
action = "작업"
|
||||
first_granted = "최초 동의"
|
||||
last_auth = "최근 인증 / 철회"
|
||||
scopes = "부여된 권한 (Scopes)"
|
||||
status = "상태"
|
||||
tenant = "테넌트"
|
||||
user = "사용자"
|
||||
|
||||
[ui.dev.clients.details]
|
||||
|
||||
[ui.dev.clients.details.breadcrumb]
|
||||
current = "연동 앱 상세"
|
||||
section = "Relying Parties"
|
||||
section = "연동 앱"
|
||||
|
||||
[ui.dev.clients.details.credentials]
|
||||
client_id = "Client ID"
|
||||
@@ -1006,17 +1019,21 @@ show = "비밀키 보기"
|
||||
title = "보안 메모"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "Connection"
|
||||
connection = "연동 설정"
|
||||
consents = "Consent & Users"
|
||||
settings = "Settings"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "앱 생성"
|
||||
display_new = "연동 앱 추가"
|
||||
save = "설정 저장"
|
||||
title_create = "Create Client"
|
||||
title_edit = "Client Settings"
|
||||
|
||||
[ui.dev.clients.federation]
|
||||
title = "Identity Federation"
|
||||
add_title = "Add Identity Provider"
|
||||
add_btn = "Add Provider"
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = "Applications"
|
||||
|
||||
@@ -1051,7 +1068,7 @@ name = "Scope Name"
|
||||
delete = "Delete"
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Private"
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
title = "보안 설정"
|
||||
|
||||
@@ -1073,7 +1090,7 @@ subtitle = "Tenant admin on-call"
|
||||
title = "Owner"
|
||||
|
||||
[ui.dev.clients.registry]
|
||||
subtitle = "Relying Parties"
|
||||
subtitle = "연동 앱"
|
||||
title = "RP registry"
|
||||
|
||||
[ui.dev.clients.table]
|
||||
@@ -1085,7 +1102,7 @@ status = "상태"
|
||||
type = "유형"
|
||||
|
||||
[ui.dev.clients.type]
|
||||
private = "Private"
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
@@ -1122,6 +1139,12 @@ title = "Stack readiness"
|
||||
plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
active = "만료 시간 확인 중..."
|
||||
unknown = "확인 불가"
|
||||
expired = "세션 만료"
|
||||
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
|
||||
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW 포탈"
|
||||
|
||||
@@ -214,6 +214,9 @@ loading = ""
|
||||
showing = ""
|
||||
status_update_error = ""
|
||||
status_updated = ""
|
||||
deleted = ""
|
||||
delete_error = ""
|
||||
delete_confirm = ""
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = ""
|
||||
@@ -248,6 +251,13 @@ note = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
saved = ""
|
||||
save_error = ""
|
||||
status_changed = ""
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = ""
|
||||
add_subtitle = ""
|
||||
empty = ""
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = ""
|
||||
@@ -315,6 +325,7 @@ approved_device = ""
|
||||
approved_ip = ""
|
||||
audit_empty = ""
|
||||
audit_load_error = ""
|
||||
render_error = ""
|
||||
auth_method = ""
|
||||
client_id = ""
|
||||
client_id_missing = ""
|
||||
@@ -915,6 +926,7 @@ theme_dark = ""
|
||||
theme_light = ""
|
||||
theme_toggle = ""
|
||||
unknown = ""
|
||||
view = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
@@ -1025,10 +1037,14 @@ settings = ""
|
||||
[ui.dev.clients.general]
|
||||
create = ""
|
||||
display_new = ""
|
||||
save = ""
|
||||
title_create = ""
|
||||
title_edit = ""
|
||||
|
||||
[ui.dev.clients.federation]
|
||||
title = ""
|
||||
add_title = ""
|
||||
add_btn = ""
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = ""
|
||||
|
||||
@@ -1134,6 +1150,12 @@ title = ""
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
active = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
remaining = ""
|
||||
|
||||
[ui.userfront]
|
||||
app_title = ""
|
||||
|
||||
@@ -92,3 +92,7 @@ oidc:
|
||||
salt: youReallyNeedToChangeThis
|
||||
dynamic_client_registration:
|
||||
enabled: true
|
||||
|
||||
ttl:
|
||||
access_token: 15m
|
||||
id_token: 15m
|
||||
|
||||
@@ -48,10 +48,19 @@
|
||||
- 환경 변수 추가/변경 시
|
||||
- `.env.sample` 반영
|
||||
- 문서/가이드 갱신
|
||||
- 클라이언트 로그 정책 영향 확인 (`CLIENT_LOG_DEBUG`, `USERFRONT_DEBUG_LOG`)
|
||||
|
||||
- 배포/운영 변경 시
|
||||
- `Makefile`/compose 실행 절차 영향 확인
|
||||
- 최소 Smoke 테스트 수행
|
||||
- 로그 수집 레벨이 운영 기본 정책(`WARN/ERROR`)을 유지하는지 확인
|
||||
|
||||
## 클라이언트 로그 정책
|
||||
- 상세 정책은 `docs/client-log-policy.md`를 기준으로 유지합니다.
|
||||
- 원칙:
|
||||
- 운영 기본값은 `WARN/ERROR`만 수집
|
||||
- 운영 디버그는 `CLIENT_LOG_DEBUG=true`로만 일시 허용
|
||||
- 민감정보 마스킹은 환경과 무관하게 항상 적용
|
||||
|
||||
## 테스트 참고
|
||||
- 테스트 계획 및 수동 실행 기준은 `docs/test-plan.md`를 따른다.
|
||||
|
||||
67
docs/client-log-policy.md
Normal file
67
docs/client-log-policy.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Client Log Policy
|
||||
|
||||
## 1. 목적
|
||||
- 운영 환경에서 클라이언트 로그를 최소권한으로 수집하고, 민감정보 유출을 방지합니다.
|
||||
- 장애 분석이 필요한 경우에만 명시적 디버그 옵션으로 로그 레벨을 확장합니다.
|
||||
|
||||
## 2. 환경별 수집 정책
|
||||
|
||||
### 2.1 기준 변수
|
||||
- `APP_ENV`
|
||||
- `CLIENT_LOG_DEBUG`
|
||||
- (UserFront fallback) `USERFRONT_DEBUG_LOG`
|
||||
|
||||
### 2.2 동작 매트릭스
|
||||
- `APP_ENV != production|prod`
|
||||
- 클라이언트 로그: `DEBUG/INFO/WARN/ERROR` 수집 허용
|
||||
- `APP_ENV == production|prod` AND `CLIENT_LOG_DEBUG` 미설정
|
||||
- 클라이언트 로그: `WARN/ERROR`만 수집
|
||||
- `INFO` 네비게이션 노이즈 로그는 필터
|
||||
- `APP_ENV == production|prod` AND `CLIENT_LOG_DEBUG=true|1|on|yes`
|
||||
- 클라이언트 로그: `DEBUG/INFO/WARN/ERROR` 수집 허용
|
||||
|
||||
## 3. 민감정보 마스킹 규칙
|
||||
|
||||
### 3.1 Key 기반 마스킹
|
||||
아래 키는 값 전체를 `*****`로 치환합니다.
|
||||
- `password`, `currentPassword`, `newPassword`, `oldPassword`
|
||||
- `token`, `accessToken`, `refreshToken`
|
||||
- `secret`, `clientSecret`
|
||||
- `authorization`, `cookie`, `setCookie`
|
||||
- `verificationCode`, `code`
|
||||
- `loginChallenge`, `loginVerifier`
|
||||
- `sessionJwt`, `accessJwt`, `refreshJwt`
|
||||
|
||||
### 3.2 문자열 패턴 마스킹
|
||||
메시지 본문에서도 아래 패턴을 마스킹합니다.
|
||||
- `token=...`
|
||||
- `authorization:...` 또는 `authorization=...`
|
||||
- JSON 문자열 내 민감 key/value
|
||||
|
||||
## 4. 구현 위치
|
||||
- Backend
|
||||
- 정책/마스킹 로직: `backend/internal/logger/client_log_policy.go`
|
||||
- 수집 엔드포인트 적용: `backend/cmd/server/main.go` (`POST /api/v1/client-log`)
|
||||
- UserFront
|
||||
- 정책/마스킹 로직: `userfront/lib/core/services/log_policy.dart`
|
||||
- 로그 출력/전송 정책: `userfront/lib/core/services/logger_service.dart`
|
||||
- 전송 직전 마스킹: `userfront/lib/core/services/auth_proxy_service.dart`
|
||||
|
||||
## 5. 검증
|
||||
|
||||
### 5.1 Backend
|
||||
```bash
|
||||
cd backend
|
||||
go test ./internal/logger -count=1
|
||||
go test ./cmd/server -count=1
|
||||
```
|
||||
|
||||
### 5.2 UserFront
|
||||
```bash
|
||||
cd userfront
|
||||
flutter test test/log_policy_test.dart
|
||||
```
|
||||
|
||||
## 6. 운영 가이드
|
||||
- 운영에서 디버그 로그가 필요하면 `CLIENT_LOG_DEBUG=true`를 명시적으로 설정하고, 이슈 해결 후 즉시 원복합니다.
|
||||
- 운영에서도 민감정보 마스킹은 항상 강제되며 비활성화할 수 없습니다.
|
||||
@@ -13,6 +13,7 @@
|
||||
- Backend(Go): **104개**
|
||||
- UserFront(Flutter): **47개**
|
||||
- AdminFront/DevFront(Playwright): **4개**
|
||||
- UserFront WASM Playwright E2E: **42개**
|
||||
|
||||
### Backend 패키지별 커버리지
|
||||
- `cmd/server`: 2.6%
|
||||
@@ -29,6 +30,7 @@
|
||||
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
||||
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md`
|
||||
- UserFront WASM Playwright E2E 확장 계획: `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`
|
||||
- UserFront WASM Playwright E2E 전수 목록: `docs/test-plan/userfront-wasm-e2e-route-inventory.md`
|
||||
|
||||
## 4) 실행 커맨드
|
||||
- Backend 전체 테스트: `cd backend && go test ./...`
|
||||
@@ -36,7 +38,8 @@
|
||||
- UserFront 테스트: `cd userfront && flutter test`
|
||||
- AdminFront E2E: `cd adminfront && npm test`
|
||||
- DevFront E2E: `cd devfront && npm test`
|
||||
- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행
|
||||
- UserFront WASM E2E: `cd userfront-e2e && npm run test:wasm`
|
||||
- UserFront WASM E2E(테스트만): `cd userfront-e2e && npm test`
|
||||
|
||||
## 5) 유지 원칙
|
||||
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
||||
|
||||
@@ -58,12 +58,46 @@
|
||||
- 범위 6 구현
|
||||
- null-check 복구 라우팅 검증
|
||||
|
||||
## 4) 완료 기준
|
||||
## 4) 현재 구현 상태 (2026-02-24)
|
||||
- Phase 0: 완료
|
||||
- `userfront-e2e/` 워크스페이스 추가
|
||||
- 로컬 SPA fallback 서버(`scripts/serve-userfront-build.mjs`) 추가
|
||||
- 실행 커맨드: `cd userfront-e2e && npm run test:wasm`
|
||||
- CI 잡 연결: `.gitea/workflows/code_check.yml`의 `userfront-e2e-tests`
|
||||
- Phase 1: 완료
|
||||
- `tests/auth-routing.spec.ts` 추가
|
||||
- 구현 시나리오:
|
||||
- 비로그인 `/ko` → `/ko/signin` 리다이렉트
|
||||
- 로그인 상태 `/ko` 진입 + 새로고침 후 `/ko/dashboard` 유지
|
||||
- 비로그인 `/ko/approve?ref=...` 진입 시 `notice=qr_login_required`와 함께 signin 이동
|
||||
- 로그인 상태 `/ko/approve?ref=...`에서 approve API 호출 후 dashboard 이동
|
||||
- Phase 2: 완료
|
||||
- `tests/password-and-reset.spec.ts` 추가
|
||||
- 구현 시나리오:
|
||||
- 비밀번호 로그인 성공 시 dashboard 이동 + 토큰 저장 확인
|
||||
- 비밀번호 로그인 실패 시 코드 기반 에러(`password_or_email_mismatch`)가 client-log로 기록되는지 확인
|
||||
- reset-password 성공 시 signin 이동 확인
|
||||
- 참고:
|
||||
- WASM 렌더링에서는 접근성/DOM selector가 제한되어 로그인/리셋 폼은 `flt-glass-pane` 좌표 기반 입력으로 검증
|
||||
- 전수 인벤토리:
|
||||
- `docs/test-plan/userfront-wasm-e2e-route-inventory.md`
|
||||
- 라우트 22개 + 기능 회귀 12개(총 42 테스트) 코드화 완료
|
||||
- 프로필 소속 회귀 강화:
|
||||
- `tests/profile-department.spec.ts` 추가
|
||||
- 구현 시나리오:
|
||||
- 소속 수정 후 blur 저장 요청 전송
|
||||
- 입력 후 즉시 새로고침 시 저장 요청 미전송 재현
|
||||
- 동일값/빈값 입력 시 저장 요청 미전송
|
||||
- 수정 후 새로고침 뒤 재수정 저장 요청 누락 방지
|
||||
|
||||
## 5) 완료 기준
|
||||
- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다.
|
||||
- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
|
||||
- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
|
||||
|
||||
## 5) 운영 원칙
|
||||
## 6) 운영 원칙
|
||||
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
|
||||
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
|
||||
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
|
||||
- 테스트 하네스는 단계별로 초기화/정리합니다.
|
||||
- 예: `beforeEach`에서 auth/mock state 재시드, `afterEach`에서 route mock 해제(`page.unroute`) 및 누수 상태 정리
|
||||
|
||||
59
docs/test-plan/userfront-wasm-e2e-route-inventory.md
Normal file
59
docs/test-plan/userfront-wasm-e2e-route-inventory.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# UserFront WASM E2E 라우트/기능 전수 인벤토리
|
||||
|
||||
- 기준 소스: `userfront/lib/main.dart`
|
||||
- 목적: 라우트 전수 항목을 Playwright 테스트로 코드화하고 CI에서 상시 검증
|
||||
- 현재 구현 파일:
|
||||
- `userfront-e2e/tests/route-inventory.spec.ts`
|
||||
- `userfront-e2e/tests/auth-routing.spec.ts`
|
||||
- `userfront-e2e/tests/password-and-reset.spec.ts`
|
||||
- `userfront-e2e/tests/profile-department.spec.ts`
|
||||
|
||||
## 1) 라우트 전수 (main.dart 기준)
|
||||
|
||||
| ID | Route | 검증 상태 | 테스트 파일 |
|
||||
|---|---|---|---|
|
||||
| R01 | `/` | 비로그인 시 `/{locale}/signin` 리다이렉트 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R02 | `/:locale` (`/ko`) | 비로그인 `signin` / 로그인 `dashboard` 분기 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| R03 | `/:locale/dashboard` | 비로그인 `signin` / 로그인 유지 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| R04 | `/:locale/profile` | 비로그인 `signin` / 로그인 유지 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R05 | `/:locale/signin` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R06 | `/:locale/login` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R07 | `/:locale/consent` | challenge 유무 케이스 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R08 | `/:locale/signup` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R09 | `/:locale/registration` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R10 | `/:locale/verify` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R11 | `/:locale/verify/:token` | verify 경로 진입/처리 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R12 | `/:locale/verification` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R13 | `/:locale/l/:shortCode` | short code 경로 진입/처리 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R14 | `/:locale/forgot-password` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R15 | `/:locale/recovery` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R16 | `/:locale/reset-password` | token 기반 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/password-and-reset.spec.ts` |
|
||||
| R17 | `/:locale/error` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R18 | `/:locale/settings` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R19 | `/:locale/approve` | 비로그인 `signin?notice=...` / 로그인 `dashboard` 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| R20 | `/:locale/ql/:ref` | 비로그인 `signin?notice=...` / 로그인 `dashboard` 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R21 | `/:locale/scan` | 비로그인 `signin` / 로그인 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
| R22 | `/:locale/admin/users` | 비로그인 `signin` / 로그인 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` |
|
||||
|
||||
## 2) 기능 회귀 (핵심)
|
||||
|
||||
| ID | 기능 | 검증 상태 | 테스트 파일 |
|
||||
|---|---|---|---|
|
||||
| F01 | `/ko` 비로그인 리다이렉트 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| F02 | 로그인 후 `/ko` + 새로고침 세션 유지 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| F03 | approve 경로 비로그인 보호 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| F04 | approve 경로 로그인 자동 승인 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` |
|
||||
| F05 | 비밀번호 로그인 성공 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` |
|
||||
| F06 | 비밀번호 로그인 실패 코드 처리 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` |
|
||||
| F07 | 비밀번호 재설정 완료 후 signin 이동 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` |
|
||||
| F08 | 프로필 소속 수정 후 blur 저장 요청 전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` |
|
||||
| F09 | 프로필 소속 입력 후 즉시 새로고침 시 저장 요청 미전송(재현) | 완료 | `userfront-e2e/tests/profile-department.spec.ts` |
|
||||
| F10 | 프로필 소속 동일값 입력 시 저장 요청 미전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` |
|
||||
| F11 | 프로필 소속 빈값 입력 시 저장 요청 미전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` |
|
||||
| F12 | 프로필 소속 수정 후 새로고침 뒤 재수정 저장 요청 누락 방지 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` |
|
||||
|
||||
## 3) 실행/CI
|
||||
|
||||
- 로컬 실행: `cd userfront-e2e && npm run test:wasm`
|
||||
- CI 워크플로우: `.gitea/workflows/code_check.yml`의 `userfront-e2e-tests` 잡에서 매 실행 검증
|
||||
- 현재 스위트 수량: 총 42 테스트(라우트 30 + 인증/리다이렉트 4 + 비밀번호/리셋 3 + 프로필 소속 5)
|
||||
@@ -0,0 +1,47 @@
|
||||
# #303 로그인 링크/코드 진입 실패 (`/{locale}/l/{shortCode}`) 대응
|
||||
|
||||
## 요약
|
||||
- 로그인 링크 발송은 정상이나, 링크 클릭 후 로그인 검증 진입이 실패하는 사례가 있었습니다.
|
||||
- 특히 locale prefix가 포함된 short-code 경로(`/{locale}/l/{shortCode}`)에서 재현되었습니다.
|
||||
- 원인은 라우터의 public path 판별과 short-code 추출 로직이 locale prefix 경로를 고려하지 못한 점이었습니다.
|
||||
|
||||
## 증상
|
||||
- 링크 클릭 URL이 `https://.../ko/l/AB123456` 형태일 때 로그인 검증이 자동 시작되지 않음
|
||||
- 비로그인 상태에서 해당 경로 접근 시 signin으로 리다이렉트되어 short-code 검증이 끊김
|
||||
|
||||
## 원인
|
||||
1. Public path 판별에서 `/l/` 경로가 제외되어 있었음
|
||||
2. `LoginScreen`의 short-code 추출이 `uri.pathSegments.first == 'l'`에만 의존
|
||||
- `/{locale}/l/{shortCode}`에서는 첫 세그먼트가 locale이므로 추출 실패
|
||||
|
||||
## 조치 내용
|
||||
1. 경로 정책 분리
|
||||
- `userfront/lib/features/auth/domain/login_link_route_policy.dart` 신규 추가
|
||||
- public path 판별(`isPublicAuthPath`)과 short-code 추출(`extractLoginShortCode`)을 공용화
|
||||
|
||||
2. 라우터 반영
|
||||
- `userfront/lib/main.dart` redirect에서 `isPublicAuthPath` 사용
|
||||
- `/l/` 경로를 public path로 허용
|
||||
|
||||
3. 로그인 화면 반영
|
||||
- `userfront/lib/features/auth/presentation/login_screen.dart`에서
|
||||
`extractLoginShortCode(Uri.base)`로 short-code를 추출하도록 변경
|
||||
- locale prefix 유무와 관계없이 short-code 검증 진입 가능
|
||||
|
||||
## 테스트
|
||||
### 재현 테스트 (Failing first)
|
||||
- `flutter test test/login_link_route_policy_test.dart`
|
||||
- 초기 실패 확인:
|
||||
- localized short-code 추출 실패
|
||||
- localized short-code public path 판별 실패
|
||||
|
||||
### 수정 후 회귀 테스트
|
||||
- `flutter test test/login_link_route_policy_test.dart` 통과
|
||||
- `flutter test test/router_redirect_widget_test.dart` 통과
|
||||
|
||||
## 영향 범위
|
||||
- 링크/코드 로그인 진입 라우팅 (`/l/{shortCode}` 및 `/{locale}/l/{shortCode}`)
|
||||
- 기존 `/verify`, `/signin`, `/login` 경로에는 동작 변화 없음
|
||||
|
||||
## 관련 이슈
|
||||
- Gitea: #303 `[bug][auth] 링크 클릭/코드 입력 로그인 실패 재현 및 수정`
|
||||
@@ -66,7 +66,17 @@ cd userfront
|
||||
flutter test test/error_screen_test.dart
|
||||
```
|
||||
|
||||
## 6. 관련 이슈
|
||||
## 6. Backend `code` 주입/매핑 경로
|
||||
- 기본 매핑 함수: `backend/internal/response/error_response.go`
|
||||
- `404 -> not_found`
|
||||
- `429 -> rate_limited`
|
||||
- legacy 응답 보강 미들웨어: `backend/internal/middleware/error_code_enricher.go`
|
||||
- 핸들러가 `{"error": ...}`만 반환해도 status 기반 `code`를 주입
|
||||
- 신규 권장 패턴: `response.Error(...)` 또는 공통 helper(`errorJSON`, `errorJSONCode`)로 핸들러에서 명시 코드 반환
|
||||
|
||||
UserFront는 위 경로로 전달된 `code`를 기준으로 whitelist/ory/unknown 분기를 수행합니다.
|
||||
|
||||
## 7. 관련 이슈
|
||||
- `#164` `[UserFront] 에러 노출 whitelist 정의 및 적용`
|
||||
- `#259` `백엔드 i18n/에러 메시지 fallback 정책 재정리 및 반영 계획 수립`
|
||||
- `#260` `[Backend] 에러 응답 code 통일 구현 계획 (phase rollout)`
|
||||
|
||||
File diff suppressed because one or more lines are too long
104
locales/ko.toml
104
locales/ko.toml
File diff suppressed because one or more lines are too long
@@ -207,18 +207,19 @@ count = ""
|
||||
[msg.common]
|
||||
loading = ""
|
||||
saving = ""
|
||||
requesting = ""
|
||||
unknown_error = ""
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = ""
|
||||
|
||||
[msg.dev.clients]
|
||||
copy_client_id = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
showing = ""
|
||||
status_update_error = ""
|
||||
status_updated = ""
|
||||
deleted = ""
|
||||
delete_error = ""
|
||||
delete_confirm = ""
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = ""
|
||||
@@ -226,6 +227,7 @@ load_error = ""
|
||||
loading = ""
|
||||
showing = ""
|
||||
subtitle = ""
|
||||
revoke_confirm = ""
|
||||
|
||||
[msg.dev.clients.details]
|
||||
copy_client_id = ""
|
||||
@@ -254,6 +256,12 @@ load_error = ""
|
||||
loading = ""
|
||||
saved = ""
|
||||
save_error = ""
|
||||
status_changed = ""
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = ""
|
||||
add_subtitle = ""
|
||||
empty = ""
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = ""
|
||||
@@ -414,7 +422,6 @@ token_missing = ""
|
||||
verification_failed = ""
|
||||
|
||||
[msg.userfront.login.link]
|
||||
approved = ""
|
||||
helper = ""
|
||||
missing_login_id = ""
|
||||
missing_phone = ""
|
||||
@@ -477,8 +484,6 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[msg.userfront.qr]
|
||||
approve_error = ""
|
||||
approve_success = ""
|
||||
camera_error = ""
|
||||
permission_error = ""
|
||||
permission_required = ""
|
||||
@@ -915,6 +920,7 @@ create = ""
|
||||
delete = ""
|
||||
details = ""
|
||||
edit = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
manage = ""
|
||||
na = ""
|
||||
@@ -924,6 +930,7 @@ page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
qr = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
remove = ""
|
||||
@@ -975,8 +982,13 @@ scope_badge = ""
|
||||
clients = ""
|
||||
logout = ""
|
||||
|
||||
[ui.dev.profile]
|
||||
menu_aria = ""
|
||||
menu_title = ""
|
||||
unknown_email = ""
|
||||
unknown_name = ""
|
||||
|
||||
[ui.dev.clients]
|
||||
copy_client_id = ""
|
||||
new = ""
|
||||
search_placeholder = ""
|
||||
tenant_scoped = ""
|
||||
@@ -986,9 +998,17 @@ untitled = ""
|
||||
admin_session = ""
|
||||
tenant_selected = ""
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
status_all = ""
|
||||
type_all = ""
|
||||
type_label = ""
|
||||
|
||||
[ui.dev.clients.consents]
|
||||
export_csv = ""
|
||||
revoke = ""
|
||||
revoked_at = ""
|
||||
scope_all = ""
|
||||
scope_label = ""
|
||||
search_placeholder = ""
|
||||
status_all = ""
|
||||
status_label = ""
|
||||
@@ -1056,17 +1076,17 @@ settings = ""
|
||||
[ui.dev.clients.general]
|
||||
create = ""
|
||||
display_new = ""
|
||||
save = ""
|
||||
title_create = ""
|
||||
title_edit = ""
|
||||
|
||||
[ui.dev.clients.federation]
|
||||
title = ""
|
||||
add_title = ""
|
||||
add_btn = ""
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = ""
|
||||
|
||||
[ui.dev.clients.general.footer]
|
||||
client_id = ""
|
||||
created_on = ""
|
||||
|
||||
[ui.dev.clients.general.identity]
|
||||
description = ""
|
||||
description_placeholder = ""
|
||||
@@ -1165,6 +1185,14 @@ title = ""
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
active = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
remaining = ""
|
||||
refresh = ""
|
||||
refreshing = ""
|
||||
|
||||
[ui.userfront]
|
||||
app_title = ""
|
||||
@@ -1241,12 +1269,9 @@ login_id = ""
|
||||
password = ""
|
||||
|
||||
[ui.userfront.login.link]
|
||||
action_label = ""
|
||||
code_only = ""
|
||||
page_title = ""
|
||||
resend_with_time = ""
|
||||
send = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.login.qr]
|
||||
expired = ""
|
||||
@@ -1316,9 +1341,7 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[ui.userfront.qr]
|
||||
request_permission = ""
|
||||
rescan = ""
|
||||
result_failure = ""
|
||||
result_success = ""
|
||||
title = ""
|
||||
|
||||
@@ -1395,8 +1418,6 @@ delete_confirm = ""
|
||||
delete_error = ""
|
||||
delete_success = ""
|
||||
empty = ""
|
||||
import_error = ""
|
||||
import_success = ""
|
||||
loading = ""
|
||||
|
||||
[msg.admin.groups.members]
|
||||
@@ -1430,7 +1451,6 @@ no_description = ""
|
||||
|
||||
[ui.admin.groups]
|
||||
add_unit = ""
|
||||
import_csv = ""
|
||||
|
||||
[ui.admin.groups.create]
|
||||
description = ""
|
||||
@@ -1450,10 +1470,6 @@ parent_none = ""
|
||||
unit_level_label = ""
|
||||
unit_level_placeholder = ""
|
||||
|
||||
[ui.admin.groups.table]
|
||||
created_at = ""
|
||||
level = ""
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = ""
|
||||
already_admin = ""
|
||||
|
||||
@@ -5,6 +5,14 @@ job_name="${1:-adminfront-tests}"
|
||||
|
||||
mkdir -p reports
|
||||
|
||||
if [ -n "${CI:-}" ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
else
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
fi
|
||||
|
||||
set +e
|
||||
(
|
||||
cd adminfront
|
||||
@@ -36,7 +44,7 @@ fi
|
||||
set +e
|
||||
(
|
||||
cd adminfront
|
||||
npx playwright install --with-deps
|
||||
"${playwright_install_cmd[@]}"
|
||||
) 2>&1 | tee reports/adminfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
@@ -51,7 +59,7 @@ if [ "$provision_exit_code" -ne 0 ]; then
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd adminfront && npx playwright install --with-deps\`"
|
||||
echo "\`cd adminfront && ${playwright_install_desc}\`"
|
||||
echo
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
@@ -80,7 +88,7 @@ if [ "$test_exit_code" -ne 0 ]; then
|
||||
echo "## Commands"
|
||||
echo "1. \`cd adminfront\`"
|
||||
echo "2. \`npm ci\`"
|
||||
echo "3. \`npx playwright install --with-deps\`"
|
||||
echo "3. \`${playwright_install_desc}\`"
|
||||
echo "4. \`npm test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
|
||||
@@ -14,6 +14,7 @@ t("ui.admin.nav.audit_logs");
|
||||
t("ui.admin.nav.auth_guard");
|
||||
t("ui.admin.nav.logout");
|
||||
t("ui.admin.nav.relying_parties");
|
||||
t("ui.dev.nav.clients");
|
||||
|
||||
// Common & Info
|
||||
t("err.common.unknown");
|
||||
|
||||
3
userfront-e2e/.gitignore
vendored
Normal file
3
userfront-e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
29
userfront-e2e/README.md
Normal file
29
userfront-e2e/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# UserFront WASM E2E
|
||||
|
||||
`userfront` WASM 빌드 산출물을 Playwright로 검증하는 테스트 워크스페이스입니다.
|
||||
|
||||
## 실행 방법
|
||||
|
||||
1. 의존성 설치
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 테스트 실행(빌드 포함)
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm run test:wasm
|
||||
```
|
||||
|
||||
3. 이미 빌드가 있을 때 테스트만 실행
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm test
|
||||
```
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `BASE_URL`: 외부 배포 URL을 테스트할 때 사용합니다. 설정하면 로컬 정적 서버를 띄우지 않습니다.
|
||||
- `PORT`: 로컬 정적 서버 포트 (기본 `4173`)
|
||||
- `LOCALE`: 브라우저 locale (기본 `ko-KR`)
|
||||
111
userfront-e2e/package-lock.json
generated
Normal file
111
userfront-e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
|
||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
userfront-e2e/package.json
Normal file
18
userfront-e2e/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"serve:build": "node ./scripts/serve-userfront-build.mjs",
|
||||
"build:userfront:wasm": "cd ../userfront && flutter build web --wasm --release",
|
||||
"test:wasm": "npm run build:userfront:wasm && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
39
userfront-e2e/playwright.config.ts
Normal file
39
userfront-e2e/playwright.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '4173', 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
locale: process.env.LOCALE ?? 'ko-KR',
|
||||
serviceWorkers: 'block',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: 'node ./scripts/serve-userfront-build.mjs',
|
||||
url: defaultBaseUrl,
|
||||
reuseExistingServer,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
68
userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
68
userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { dirname, extname, join, normalize } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const root = normalize(join(__dirname, '../../userfront/build/web'));
|
||||
|
||||
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
||||
console.error(
|
||||
'[userfront-e2e] userfront/build/web not found. Run: cd userfront && flutter build web --wasm --release',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '4173', 10);
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.mjs': 'application/javascript; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const relative = pathname === '/' ? '/index.html' : pathname;
|
||||
const candidate = normalize(join(root, relative));
|
||||
|
||||
if (!candidate.startsWith(root)) {
|
||||
res.statusCode = 403;
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = candidate;
|
||||
|
||||
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
||||
// Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리
|
||||
filePath = join(root, 'index.html');
|
||||
}
|
||||
|
||||
const ext = extname(filePath);
|
||||
const contentType = contentTypes[ext] ?? 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
createReadStream(filePath)
|
||||
.on('error', () => {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
})
|
||||
.pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`);
|
||||
});
|
||||
155
userfront-e2e/tests/auth-routing.spec.ts
Normal file
155
userfront-e2e/tests/auth-routing.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
type MockOptions = {
|
||||
sessionStatus?: number;
|
||||
captureApprove?: (pendingRef: string | null) => void;
|
||||
};
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('baron_auth_token', 'e30.e30.e30');
|
||||
window.localStorage.setItem('baron_auth_provider', 'ory');
|
||||
window.localStorage.removeItem('baron_auth_cookie_mode');
|
||||
window.localStorage.removeItem('baron_auth_pending_provider');
|
||||
});
|
||||
}
|
||||
|
||||
async function mockUserfrontApis(
|
||||
page: Page,
|
||||
options: MockOptions = {},
|
||||
): Promise<void> {
|
||||
const sessionStatus = options.sessionStatus ?? 200;
|
||||
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
|
||||
if (path.endsWith('/api/v1/user/me')) {
|
||||
if (sessionStatus === 200) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
tenant: {
|
||||
id: 'tenant-1',
|
||||
name: 'Baron',
|
||||
slug: 'baron',
|
||||
description: 'E2E tenant',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: sessionStatus,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/qr/approve')) {
|
||||
if (route.request().method() == 'POST') {
|
||||
let pendingRef: string | null = null;
|
||||
try {
|
||||
const body = (route.request().postDataJSON() ?? {}) as {
|
||||
pendingRef?: string;
|
||||
};
|
||||
pendingRef = body.pendingRef ?? null;
|
||||
} catch (_) {
|
||||
pendingRef = null;
|
||||
}
|
||||
options.captureApprove?.(pendingRef);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM auth routing', () => {
|
||||
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto('/ko');
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test('로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다', async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedTokenLogin(page);
|
||||
await mockUserfrontApis(page);
|
||||
|
||||
await page.goto('/ko');
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto('/ko/approve?ref=e2e-ref');
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
|
||||
test('로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다', async ({
|
||||
page,
|
||||
}) => {
|
||||
let approvedRef: string | null = null;
|
||||
|
||||
await seedTokenLogin(page);
|
||||
await mockUserfrontApis(page, {
|
||||
captureApprove: (pendingRef) => {
|
||||
approvedRef = pendingRef;
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/ko/approve?ref=e2e-approve-ref');
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(approvedRef).toBe('e2e-approve-ref');
|
||||
});
|
||||
});
|
||||
266
userfront-e2e/tests/password-and-reset.spec.ts
Normal file
266
userfront-e2e/tests/password-and-reset.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
type RequestCapture = {
|
||||
loginBody?: Record<string, unknown>;
|
||||
resetBody?: Record<string, unknown>;
|
||||
resetToken?: string | null;
|
||||
clientLogs: string[];
|
||||
};
|
||||
|
||||
const SIGNIN_PASSWORD_TAB_X = 522;
|
||||
const SIGNIN_TAB_Y = 158;
|
||||
const SIGNIN_LOGIN_ID_X = 640;
|
||||
const SIGNIN_LOGIN_ID_Y = 245;
|
||||
const SIGNIN_PASSWORD_X = 640;
|
||||
const SIGNIN_PASSWORD_Y = 311;
|
||||
const SIGNIN_SUBMIT_X = 640;
|
||||
const SIGNIN_SUBMIT_Y = 381;
|
||||
|
||||
const RESET_NEW_PASSWORD_X = 640;
|
||||
const RESET_NEW_PASSWORD_Y = 382;
|
||||
const RESET_CONFIRM_PASSWORD_X = 640;
|
||||
const RESET_CONFIRM_PASSWORD_Y = 464;
|
||||
const RESET_SUBMIT_X = 640;
|
||||
const RESET_SUBMIT_Y = 534;
|
||||
|
||||
async function clickPasswordTab(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
await pane.click({
|
||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
await pane.click({
|
||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
await pane.click({ position: { x, y }, force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/login')) {
|
||||
capture.loginBody = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const loginId = String(capture.loginBody.loginId ?? '');
|
||||
const password = String(capture.loginBody.password ?? '');
|
||||
if (loginId === 'e2e@example.com' && password === 'ValidPass1!') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sessionJwt: 'e30.e30.e30',
|
||||
provider: 'ory',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'password_or_email_mismatch' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/policy')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
minLength: 12,
|
||||
minCharacterTypes: 3,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/reset/complete')) {
|
||||
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
capture.resetToken = requestUrl.searchParams.get('token');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
const payload = (route.request().postDataJSON() ?? {}) as {
|
||||
message?: string;
|
||||
};
|
||||
if (payload.message != null) {
|
||||
capture.clientLogs.push(payload.message);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/me')) {
|
||||
const authHeader = route.request().headers()['authorization'] ?? '';
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
tenant: {
|
||||
id: 'tenant-1',
|
||||
name: 'Baron',
|
||||
slug: 'baron',
|
||||
description: 'E2E tenant',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM password login and reset', () => {
|
||||
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto('/ko/signin');
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!');
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
expect(capture.loginBody?.loginId).toBe('e2e@example.com');
|
||||
expect(capture.loginBody?.password).toBe('ValidPass1!');
|
||||
|
||||
const storedToken = await page.evaluate(() =>
|
||||
window.localStorage.getItem('baron_auth_token'),
|
||||
);
|
||||
expect(storedToken).toBe('e30.e30.e30');
|
||||
});
|
||||
|
||||
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto('/ko/signin');
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!');
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
capture.clientLogs.some((message) =>
|
||||
message.includes('password_or_email_mismatch'),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => {
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
const policyLoaded = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/v1/auth/password/policy') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
||||
await policyLoaded;
|
||||
await page.waitForTimeout(900);
|
||||
await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A');
|
||||
await fillAt(
|
||||
page,
|
||||
RESET_CONFIRM_PASSWORD_X,
|
||||
RESET_CONFIRM_PASSWORD_Y,
|
||||
'ValidPass1!A',
|
||||
);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => capture.resetBody?.newPassword as string | undefined)
|
||||
.toBe('ValidPass1!A');
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
|
||||
expect(capture.resetToken).toBe('reset-token-e2e');
|
||||
expect(capture.resetBody?.newPassword).toBe('ValidPass1!A');
|
||||
});
|
||||
});
|
||||
275
userfront-e2e/tests/profile-department.spec.ts
Normal file
275
userfront-e2e/tests/profile-department.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
type ProfileState = {
|
||||
department: string;
|
||||
getMeCount: number;
|
||||
putBodies: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const PROFILE_DEPARTMENT_EDIT_X = 1170;
|
||||
const PROFILE_DEPARTMENT_EDIT_Y = 680;
|
||||
const PROFILE_DEPARTMENT_INPUT_X = 110;
|
||||
const PROFILE_DEPARTMENT_INPUT_Y = 685;
|
||||
const PROFILE_BLUR_X = 200;
|
||||
const PROFILE_BLUR_Y = 260;
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('baron_auth_token', 'e30.e30.e30');
|
||||
window.localStorage.setItem('baron_auth_provider', 'ory');
|
||||
window.localStorage.removeItem('baron_auth_cookie_mode');
|
||||
window.localStorage.removeItem('baron_auth_pending_provider');
|
||||
});
|
||||
}
|
||||
|
||||
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
await pane.click({ position: { x, y }, force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function openDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const requestUrl = new URL(request.url());
|
||||
const path = requestUrl.pathname;
|
||||
const method = request.method().toUpperCase();
|
||||
|
||||
if (path.endsWith('/api/v1/user/me') && method === 'GET') {
|
||||
const authHeader = request.headers()['authorization'] ?? '';
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.getMeCount += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: state.department,
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
tenant: {
|
||||
id: 'tenant-1',
|
||||
name: 'Baron',
|
||||
slug: 'baron',
|
||||
description: 'E2E tenant',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/me') && method === 'PUT') {
|
||||
const body = (request.postDataJSON() ?? {}) as Record<string, unknown>;
|
||||
state.putBodies.push(body);
|
||||
const nextDepartment = String(body.department ?? '').trim();
|
||||
if (nextDepartment !== '') {
|
||||
state.department = nextDepartment;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'success',
|
||||
updatedAt: '2026-02-24T00:00:00Z',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfilePage(page: Page): Promise<void> {
|
||||
await page.goto('/ko/profile');
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
async function waitForInitialProfileLoad(state: ProfileState): Promise<void> {
|
||||
await expect.poll(() => state.getMeCount).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM profile department editing', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
await page.unroute('**/api/v1/**');
|
||||
});
|
||||
|
||||
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
expect(state.putBodies[0]?.department).toBe('QA-Updated');
|
||||
expect(state.department).toBe('QA-Updated');
|
||||
|
||||
const getCountBeforeReload = state.getMeCount;
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload);
|
||||
});
|
||||
|
||||
test('재현: 소속 입력만 하고 즉시 새로고침하면 저장 요청이 전송되지 않는다', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro');
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
expect(state.department).toBe('QA');
|
||||
});
|
||||
|
||||
test('소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA');
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다', async ({ page }) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, '');
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
expect(state.department).toBe('QA');
|
||||
});
|
||||
|
||||
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
||||
await blurDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
||||
await blurDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||
|
||||
expect(state.putBodies[0]?.department).toBe('QA-1');
|
||||
expect(state.putBodies[1]?.department).toBe('QA-2');
|
||||
expect(state.department).toBe('QA-2');
|
||||
});
|
||||
});
|
||||
320
userfront-e2e/tests/route-inventory.spec.ts
Normal file
320
userfront-e2e/tests/route-inventory.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('baron_auth_token', 'e30.e30.e30');
|
||||
window.localStorage.setItem('baron_auth_provider', 'ory');
|
||||
window.localStorage.removeItem('baron_auth_cookie_mode');
|
||||
window.localStorage.removeItem('baron_auth_pending_provider');
|
||||
});
|
||||
}
|
||||
|
||||
async function mockInventoryApis(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
const method = route.request().method().toUpperCase();
|
||||
|
||||
if (path.endsWith('/api/v1/user/me')) {
|
||||
const authHeader = route.request().headers()['authorization'] ?? '';
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'e2e-user',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
phone: '+821012341234',
|
||||
department: 'QA',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
tenant: {
|
||||
id: 'tenant-1',
|
||||
name: 'Baron',
|
||||
slug: 'baron',
|
||||
description: 'E2E tenant',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unauthorized' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/user/rp/linked')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/audit/auth/timeline')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], next_cursor: '' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/password/policy')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
minLength: 12,
|
||||
minCharacterTypes: 3,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/magic-link/verify')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/login/code/verify')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/login/code/verify-short')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'approved' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/consent') && method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
client: {
|
||||
client_name: 'E2E Client',
|
||||
client_id: 'e2e-client',
|
||||
},
|
||||
requested_scope: ['openid'],
|
||||
scope_details: {
|
||||
openid: {
|
||||
description: 'OpenID',
|
||||
mandatory: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/auth/qr/approve')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith('/api/v1/client-log')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('UserFront WASM route inventory (unauth)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockInventoryApis(page);
|
||||
});
|
||||
|
||||
test('route: /', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test('route: /ko', async ({ page }) => {
|
||||
await page.goto('/ko');
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test('route: /ko/dashboard', async ({ page }) => {
|
||||
await page.goto('/ko/dashboard');
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test('route: /ko/profile', async ({ page }) => {
|
||||
await page.goto('/ko/profile');
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test('route: /ko/admin/users', async ({ page }) => {
|
||||
await page.goto('/ko/admin/users');
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test('route: /ko/scan', async ({ page }) => {
|
||||
await page.goto('/ko/scan');
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test('route: /ko/signin', async ({ page }) => {
|
||||
await page.goto('/ko/signin');
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test('route: /ko/login', async ({ page }) => {
|
||||
await page.goto('/ko/login');
|
||||
await expect(page).toHaveURL(/\/ko\/login$/);
|
||||
});
|
||||
|
||||
test('route: /ko/signup', async ({ page }) => {
|
||||
await page.goto('/ko/signup');
|
||||
await expect(page).toHaveURL(/\/ko\/signup$/);
|
||||
});
|
||||
|
||||
test('route: /ko/registration', async ({ page }) => {
|
||||
await page.goto('/ko/registration');
|
||||
await expect(page).toHaveURL(/\/ko\/registration$/);
|
||||
});
|
||||
|
||||
test('route: /ko/verify', async ({ page }) => {
|
||||
await page.goto('/ko/verify');
|
||||
await expect(page).toHaveURL(/\/ko\/verify$/);
|
||||
});
|
||||
|
||||
test('route: /ko/verify/:token', async ({ page }) => {
|
||||
await page.goto('/ko/verify/e2e-token');
|
||||
await expect(page).toHaveURL(/\/ko\/verify\/e2e-token$/);
|
||||
});
|
||||
|
||||
test('route: /ko/verification', async ({ page }) => {
|
||||
await page.goto('/ko/verification');
|
||||
await expect(page).toHaveURL(/\/ko\/verification$/);
|
||||
});
|
||||
|
||||
test('route: /ko/l/:shortCode', async ({ page }) => {
|
||||
await page.goto('/ko/l/AB123456');
|
||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
||||
});
|
||||
|
||||
test('route: /ko/forgot-password', async ({ page }) => {
|
||||
await page.goto('/ko/forgot-password');
|
||||
await expect(page).toHaveURL(/\/ko\/forgot-password$/);
|
||||
});
|
||||
|
||||
test('route: /ko/recovery', async ({ page }) => {
|
||||
await page.goto('/ko/recovery');
|
||||
await expect(page).toHaveURL(/\/ko\/recovery$/);
|
||||
});
|
||||
|
||||
test('route: /ko/reset-password', async ({ page }) => {
|
||||
await page.goto('/ko/reset-password?token=e2e-reset-token');
|
||||
await expect(page).toHaveURL(/\/ko\/reset-password\?token=e2e-reset-token$/);
|
||||
});
|
||||
|
||||
test('route: /ko/error', async ({ page }) => {
|
||||
await page.goto('/ko/error?error=invalid_request');
|
||||
await expect(page).toHaveURL(/\/ko\/error\?error=invalid_request$/);
|
||||
});
|
||||
|
||||
test('route: /ko/settings', async ({ page }) => {
|
||||
await page.goto('/ko/settings');
|
||||
await expect(page).toHaveURL(/\/ko\/settings$/);
|
||||
});
|
||||
|
||||
test('route: /ko/consent (missing challenge)', async ({ page }) => {
|
||||
await page.goto('/ko/consent');
|
||||
await expect(page).toHaveURL(/\/ko\/consent$/);
|
||||
});
|
||||
|
||||
test('route: /ko/consent?consent_challenge=...', async ({ page }) => {
|
||||
await page.goto('/ko/consent?consent_challenge=e2e-consent');
|
||||
await expect(page).toHaveURL(/\/ko\/consent\?consent_challenge=e2e-consent$/);
|
||||
});
|
||||
|
||||
test('route: /ko/approve?ref=...', async ({ page }) => {
|
||||
await page.goto('/ko/approve?ref=e2e-ref');
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
|
||||
test('route: /ko/ql/:ref', async ({ page }) => {
|
||||
await page.goto('/ko/ql/e2e-ref');
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('UserFront WASM route inventory (authed)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedTokenLogin(page);
|
||||
await mockInventoryApis(page);
|
||||
});
|
||||
|
||||
test('route: /ko -> /ko/dashboard', async ({ page }) => {
|
||||
await page.goto('/ko');
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('route: /ko/dashboard', async ({ page }) => {
|
||||
await page.goto('/ko/dashboard');
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('route: /ko/profile', async ({ page }) => {
|
||||
await page.goto('/ko/profile');
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
});
|
||||
|
||||
test('route: /ko/admin/users', async ({ page }) => {
|
||||
await page.goto('/ko/admin/users');
|
||||
await expect(page).toHaveURL(/\/ko\/admin\/users$/);
|
||||
});
|
||||
|
||||
test('route: /ko/scan', async ({ page }) => {
|
||||
await page.goto('/ko/scan');
|
||||
await expect(page).toHaveURL(/\/ko\/scan$/);
|
||||
});
|
||||
|
||||
test('route: /ko/approve?ref=... -> /ko/dashboard', async ({ page }) => {
|
||||
await page.goto('/ko/approve?ref=e2e-ref');
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test('route: /ko/ql/:ref -> /ko/dashboard', async ({ page }) => {
|
||||
await page.goto('/ko/ql/e2e-ref');
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
});
|
||||
14
userfront-e2e/tsconfig.json
Normal file
14
userfront-e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "@playwright/test"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["playwright.config.ts", "tests/**/*.ts", "scripts/**/*.mjs"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -80,6 +80,7 @@ title_with_code = ""
|
||||
type = ""
|
||||
|
||||
[msg.userfront.error.whitelist]
|
||||
"$normalizedCode" = ""
|
||||
settings_disabled = ""
|
||||
invalid_session = ""
|
||||
verification_required = ""
|
||||
@@ -91,6 +92,7 @@ bad_request = ""
|
||||
password_or_email_mismatch = ""
|
||||
|
||||
[msg.userfront.error.ory]
|
||||
"$normalizedCode" = ""
|
||||
access_denied = ""
|
||||
consent_required = ""
|
||||
interaction_required = ""
|
||||
@@ -129,7 +131,6 @@ token_missing = ""
|
||||
verification_failed = ""
|
||||
|
||||
[msg.userfront.login.link]
|
||||
approved = ""
|
||||
helper = ""
|
||||
missing_login_id = ""
|
||||
missing_phone = ""
|
||||
@@ -192,8 +193,6 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[msg.userfront.qr]
|
||||
approve_error = ""
|
||||
approve_success = ""
|
||||
camera_error = ""
|
||||
permission_error = ""
|
||||
permission_required = ""
|
||||
@@ -304,6 +303,7 @@ create = ""
|
||||
delete = ""
|
||||
details = ""
|
||||
edit = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
na = ""
|
||||
never = ""
|
||||
@@ -312,9 +312,9 @@ page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
qr = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
requesting = ""
|
||||
resend = ""
|
||||
retry = ""
|
||||
save = ""
|
||||
@@ -421,12 +421,9 @@ login_id = ""
|
||||
password = ""
|
||||
|
||||
[ui.userfront.login.link]
|
||||
action_label = ""
|
||||
code_only = ""
|
||||
page_title = ""
|
||||
resend_with_time = ""
|
||||
send = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.login.qr]
|
||||
expired = ""
|
||||
@@ -496,9 +493,7 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[ui.userfront.qr]
|
||||
request_permission = ""
|
||||
rescan = ""
|
||||
result_failure = ""
|
||||
result_success = ""
|
||||
title = ""
|
||||
|
||||
@@ -558,3 +553,13 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
# Auto-added missing keys
|
||||
|
||||
[ui.common]
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
none = ""
|
||||
select = ""
|
||||
select_placeholder = ""
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'http_client.dart';
|
||||
import 'auth_token_store.dart';
|
||||
import 'log_policy.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -793,12 +794,30 @@ class AuthProxyService {
|
||||
if (!_canSendClientLog()) {
|
||||
return;
|
||||
}
|
||||
final appEnv = _envOrDefault('APP_ENV', 'dev');
|
||||
final productionDebugFlag = _envOrDefault(
|
||||
'CLIENT_LOG_DEBUG',
|
||||
_envOrDefault('USERFRONT_DEBUG_LOG', ''),
|
||||
);
|
||||
if (!LogPolicy.shouldRelayClientLog(
|
||||
level: level,
|
||||
appEnv: appEnv,
|
||||
productionDebugFlag: productionDebugFlag,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||
final sanitizedMessage = LogPolicy.sanitizeMessage(message);
|
||||
final sanitizedData = data == null ? null : LogPolicy.sanitizeData(data);
|
||||
try {
|
||||
await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'level': level, 'message': message, 'data': ?data}),
|
||||
body: jsonEncode({
|
||||
'level': level,
|
||||
'message': sanitizedMessage,
|
||||
if (sanitizedData != null) 'data': sanitizedData,
|
||||
}),
|
||||
);
|
||||
_recordClientLogSuccess();
|
||||
} catch (_) {
|
||||
|
||||
123
userfront/lib/core/services/log_policy.dart
Normal file
123
userfront/lib/core/services/log_policy.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
class LogPolicy {
|
||||
static const Set<String> _sensitiveKeys = {
|
||||
'password',
|
||||
'currentpassword',
|
||||
'newpassword',
|
||||
'oldpassword',
|
||||
'token',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'secret',
|
||||
'clientsecret',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'setcookie',
|
||||
'verificationcode',
|
||||
'code',
|
||||
'loginchallenge',
|
||||
'loginverifier',
|
||||
'sessionjwt',
|
||||
'accessjwt',
|
||||
'refreshjwt',
|
||||
};
|
||||
|
||||
static bool isProductionEnv(String? appEnv) {
|
||||
final env = (appEnv ?? '').trim().toLowerCase();
|
||||
return env == 'prod' || env == 'production';
|
||||
}
|
||||
|
||||
static bool parseBoolFlag(String? raw) {
|
||||
final value = (raw ?? '').trim().toLowerCase();
|
||||
return value == '1' ||
|
||||
value == 'true' ||
|
||||
value == 'yes' ||
|
||||
value == 'y' ||
|
||||
value == 'on';
|
||||
}
|
||||
|
||||
static bool debugEnabled({
|
||||
required String? appEnv,
|
||||
required String? productionDebugFlag,
|
||||
}) {
|
||||
if (!isProductionEnv(appEnv)) {
|
||||
return true;
|
||||
}
|
||||
return parseBoolFlag(productionDebugFlag);
|
||||
}
|
||||
|
||||
static bool shouldRelayClientLog({
|
||||
required String level,
|
||||
required String? appEnv,
|
||||
required String? productionDebugFlag,
|
||||
}) {
|
||||
if (debugEnabled(
|
||||
appEnv: appEnv,
|
||||
productionDebugFlag: productionDebugFlag,
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
final normalized = level.trim().toUpperCase();
|
||||
return normalized == 'SEVERE' ||
|
||||
normalized == 'ERROR' ||
|
||||
normalized == 'WARNING' ||
|
||||
normalized == 'WARN';
|
||||
}
|
||||
|
||||
static String sanitizeMessage(String message) {
|
||||
if (message.trim().isEmpty) {
|
||||
return message;
|
||||
}
|
||||
var sanitized = message.replaceAllMapped(
|
||||
RegExp(
|
||||
r'"(password|currentpassword|newpassword|oldpassword|token|accesstoken|refreshtoken|secret|clientsecret|authorization|cookie|setcookie|verificationcode|code|loginchallenge|loginverifier|sessionjwt|accessjwt|refreshjwt)"\s*:\s*"[^"]*"',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(match) {
|
||||
final key = match.group(1) ?? 'sensitive';
|
||||
return '"$key":"*****"';
|
||||
},
|
||||
);
|
||||
sanitized = sanitized.replaceAllMapped(
|
||||
RegExp(
|
||||
r'\b(password|current_password|currentpassword|new_password|newpassword|old_password|oldpassword|token|access_token|accesstoken|refresh_token|refreshtoken|authorization|cookie|session_jwt|sessionjwt|access_jwt|accessjwt|refresh_jwt|refreshjwt)\b\s*[:=]\s*([^\s,;]+)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(match) {
|
||||
final key = match.group(1) ?? 'sensitive';
|
||||
return '$key=*****';
|
||||
},
|
||||
);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> sanitizeData(Map<String, dynamic> input) {
|
||||
final output = <String, dynamic>{};
|
||||
for (final entry in input.entries) {
|
||||
if (_isSensitiveKey(entry.key)) {
|
||||
output[entry.key] = '*****';
|
||||
} else {
|
||||
output[entry.key] = _sanitizeValue(entry.value);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static dynamic _sanitizeValue(dynamic value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return sanitizeData(value);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_sanitizeValue).toList(growable: false);
|
||||
}
|
||||
if (value is String) {
|
||||
return sanitizeMessage(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool _isSensitiveKey(String key) {
|
||||
var normalized = key.trim().toLowerCase();
|
||||
normalized = normalized.replaceAll(RegExp(r'[-_.\s]'), '');
|
||||
return _sensitiveKeys.contains(normalized);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:logging/logging.dart' as std_log;
|
||||
import 'package:logger/logger.dart' as pretty_log;
|
||||
import 'auth_proxy_service.dart';
|
||||
import 'log_policy.dart';
|
||||
|
||||
/// Global Logger Service for Baron SSO Frontend
|
||||
class LoggerService {
|
||||
@@ -10,8 +12,20 @@ class LoggerService {
|
||||
factory LoggerService() => _instance;
|
||||
|
||||
late final pretty_log.Logger _prettyLogger;
|
||||
late final String _appEnv;
|
||||
late final String _productionDebugFlag;
|
||||
|
||||
LoggerService._internal() {
|
||||
_appEnv = _envOrDefault('APP_ENV', 'dev');
|
||||
_productionDebugFlag = _envOrDefault(
|
||||
'CLIENT_LOG_DEBUG',
|
||||
_envOrDefault('USERFRONT_DEBUG_LOG', ''),
|
||||
);
|
||||
final debugEnabled = LogPolicy.debugEnabled(
|
||||
appEnv: _appEnv,
|
||||
productionDebugFlag: _productionDebugFlag,
|
||||
);
|
||||
|
||||
// 1. Initialize Pretty Logger for Dev
|
||||
_prettyLogger = pretty_log.Logger(
|
||||
printer: pretty_log.PrettyPrinter(
|
||||
@@ -25,9 +39,9 @@ class LoggerService {
|
||||
);
|
||||
|
||||
// 2. Configure Standard Logger (logging package)
|
||||
std_log.Logger.root.level = kReleaseMode
|
||||
? std_log.Level.WARNING
|
||||
: std_log.Level.ALL;
|
||||
std_log.Logger.root.level = debugEnabled
|
||||
? std_log.Level.ALL
|
||||
: std_log.Level.WARNING;
|
||||
|
||||
std_log.Logger.root.onRecord.listen((record) {
|
||||
if (kReleaseMode) {
|
||||
@@ -40,6 +54,17 @@ class LoggerService {
|
||||
});
|
||||
}
|
||||
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
final value = dotenv.env[key];
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Initialize the logger. Call this in main.dart
|
||||
static void init() {
|
||||
// Accessing the instance triggers the constructor
|
||||
@@ -64,10 +89,11 @@ class LoggerService {
|
||||
}
|
||||
|
||||
void _logJson(std_log.LogRecord record) {
|
||||
final sanitizedMessage = LogPolicy.sanitizeMessage(record.message);
|
||||
final logData = {
|
||||
'time': record.time.toUtc().toIso8601String(), // Use UTC for consistency
|
||||
'level': record.level.name,
|
||||
'msg': record.message,
|
||||
'msg': sanitizedMessage,
|
||||
'svc': 'baron-userfront',
|
||||
if (record.error != null) 'error': record.error.toString(),
|
||||
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
|
||||
@@ -77,10 +103,14 @@ class LoggerService {
|
||||
debugPrint(jsonEncode(logData));
|
||||
|
||||
// 2. Relay to Backend (Docker Terminal)
|
||||
if (record.level >= std_log.Level.WARNING) {
|
||||
if (LogPolicy.shouldRelayClientLog(
|
||||
level: record.level.name,
|
||||
appEnv: _appEnv,
|
||||
productionDebugFlag: _productionDebugFlag,
|
||||
)) {
|
||||
AuthProxyService.sendLog(
|
||||
record.level.name,
|
||||
record.message,
|
||||
sanitizedMessage,
|
||||
data: {
|
||||
'client_time': record.time.toUtc().toIso8601String(),
|
||||
'logger': record.loggerName,
|
||||
|
||||
@@ -1361,6 +1361,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey(
|
||||
'password_login_id_input',
|
||||
),
|
||||
controller: _passwordLoginIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
@@ -1375,6 +1378,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const ValueKey(
|
||||
'password_login_password_input',
|
||||
),
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -1390,6 +1396,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey(
|
||||
'password_login_submit_button',
|
||||
),
|
||||
onPressed: _handlePasswordLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
|
||||
@@ -192,6 +192,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_new_input'),
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
@@ -263,6 +264,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_confirm_input'),
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
@@ -292,6 +294,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey('reset_password_submit_button'),
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -31,6 +32,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
static const _surface = Colors.white;
|
||||
static const _border = Color(0xFFE5E7EB);
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
static const double _historySessionMinWidth = 92;
|
||||
static const double _historyOtherColumnsBaselineWidth = 780;
|
||||
static const int _historySessionMinVisibleChars = 8;
|
||||
|
||||
final ScrollController _pageScrollController = ScrollController();
|
||||
final ScrollController _rpScrollController = ScrollController();
|
||||
@@ -1370,6 +1374,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sessionColumnWidth = _historySessionColumnWidth(
|
||||
constraints.maxWidth,
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
@@ -1379,10 +1386,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
horizontalMargin: 12,
|
||||
columns: [
|
||||
DataColumn(
|
||||
label: Text(
|
||||
tr(
|
||||
'ui.userfront.audit.table.session_id',
|
||||
fallback: 'Session ID',
|
||||
label: SizedBox(
|
||||
width: sessionColumnWidth,
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.audit.table.session_id',
|
||||
fallback: 'Session ID',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1426,10 +1436,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
_selectableText(
|
||||
log.sessionId.isEmpty
|
||||
? tr('ui.common.hyphen', fallback: '-')
|
||||
: log.sessionId,
|
||||
SizedBox(
|
||||
width: sessionColumnWidth,
|
||||
child: _buildHistorySessionIdCell(
|
||||
log.sessionId.isEmpty
|
||||
? tr('ui.common.hyphen', fallback: '-')
|
||||
: log.sessionId,
|
||||
sessionColumnWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
@@ -1474,6 +1488,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
double _historySessionColumnWidth(double maxWidth) {
|
||||
return math.max(
|
||||
_historySessionMinWidth,
|
||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||
);
|
||||
}
|
||||
|
||||
String _compactSessionId(String sessionId) {
|
||||
if (sessionId.length <= _historySessionMinVisibleChars) {
|
||||
return sessionId;
|
||||
}
|
||||
return '${sessionId.substring(0, _historySessionMinVisibleChars)}...';
|
||||
}
|
||||
|
||||
Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) {
|
||||
final compactMode = columnWidth <= _historySessionMinWidth + 0.5;
|
||||
final displayText = compactMode ? _compactSessionId(sessionId) : sessionId;
|
||||
final textWidget = Text(
|
||||
displayText,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
|
||||
if (displayText == sessionId) {
|
||||
return textWidget;
|
||||
}
|
||||
return Tooltip(message: sessionId, child: textWidget);
|
||||
}
|
||||
|
||||
Widget _buildHistoryList(AuthTimelineState state) {
|
||||
return _buildHistoryContainer(
|
||||
child: Column(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
@@ -22,6 +23,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
static const _surface = Colors.white;
|
||||
static const _border = Color(0xFFE5E7EB);
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
static final _log = Logger('ProfilePage');
|
||||
|
||||
UserProfile? _cachedProfile;
|
||||
String? _editingField;
|
||||
@@ -41,6 +43,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _phoneTouched = false;
|
||||
bool _phoneCodeTouched = false;
|
||||
bool _isSavingField = false;
|
||||
String? _skipAutoSaveField;
|
||||
|
||||
String _initialPhone = '';
|
||||
bool _isPhoneChanged = false;
|
||||
@@ -64,6 +67,22 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
|
||||
}
|
||||
|
||||
void _debugLog(
|
||||
String event, {
|
||||
String? field,
|
||||
String? reason,
|
||||
bool? changed,
|
||||
bool? hasFocus,
|
||||
}) {
|
||||
final parts = <String>['event=$event'];
|
||||
if (field != null) parts.add('field=$field');
|
||||
if (reason != null) parts.add('reason=$reason');
|
||||
if (changed != null) parts.add('changed=$changed');
|
||||
if (hasFocus != null) parts.add('hasFocus=$hasFocus');
|
||||
if (_editingField != null) parts.add('editing=$_editingField');
|
||||
_log.fine(parts.join(' '));
|
||||
}
|
||||
|
||||
void _onNameFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
||||
@@ -76,6 +95,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
void _onDepartmentFocusChange() {
|
||||
if (!mounted) return;
|
||||
_debugLog(
|
||||
'department_focus_change',
|
||||
field: 'department',
|
||||
hasFocus: _departmentFocus.hasFocus,
|
||||
);
|
||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
||||
@@ -179,6 +203,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
|
||||
void _startEditing(String field, UserProfile profile) {
|
||||
_debugLog('start_editing', field: field);
|
||||
setState(() {
|
||||
_editingField = field;
|
||||
if (field == 'name') {
|
||||
@@ -354,9 +379,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||
if (_editingField != field) return;
|
||||
if (_isVerifying) return;
|
||||
if (_isSavingField) return;
|
||||
if (_skipAutoSaveField == field) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'verifying');
|
||||
return;
|
||||
}
|
||||
if (_isSavingField) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
if (!_hasFieldChanged(profile, field)) {
|
||||
_debugLog(
|
||||
'autosave_skip',
|
||||
field: field,
|
||||
reason: 'unchanged',
|
||||
changed: false,
|
||||
);
|
||||
setState(() {
|
||||
if (field == 'phone') {
|
||||
_resetPhoneState();
|
||||
@@ -370,11 +412,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
_debugLog('autosave_trigger', field: field, changed: true);
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
void _handlePhoneFocusChange(UserProfile profile) {
|
||||
if (_editingField != 'phone') return;
|
||||
if (_skipAutoSaveField == 'phone') {
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) return;
|
||||
if (_isSavingField) return;
|
||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||
@@ -403,25 +450,33 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
Future<void> _saveField(UserProfile profile) async {
|
||||
if (_editingField == null) return;
|
||||
if (_isSavingField) return;
|
||||
if (_isSavingField) {
|
||||
_debugLog('save_skip', reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
final currentField = _editingField!;
|
||||
|
||||
final nextName = _editingField == 'name'
|
||||
final nextName = currentField == 'name'
|
||||
? _nameController!.text.trim()
|
||||
: profile.name;
|
||||
final nextPhone = _editingField == 'phone'
|
||||
final nextPhone = currentField == 'phone'
|
||||
? _phoneController!.text.trim()
|
||||
: profile.phone;
|
||||
final nextDepartment = _editingField == 'department'
|
||||
final nextDepartment = currentField == 'department'
|
||||
? _departmentController!.text.trim()
|
||||
: profile.department;
|
||||
|
||||
if (_editingField == 'name' && nextName.isEmpty) {
|
||||
_debugLog('save_attempt', field: currentField);
|
||||
|
||||
if (currentField == 'name' && nextName.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_editingField == 'department' && nextDepartment.isEmpty) {
|
||||
if (currentField == 'department' && nextDepartment.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.department_required')),
|
||||
@@ -429,14 +484,20 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_editingField == 'phone') {
|
||||
if (currentField == 'phone') {
|
||||
if (nextPhone.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||
_debugLog(
|
||||
'save_skip',
|
||||
field: currentField,
|
||||
reason: 'phone_not_verified',
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.phone_verify_required')),
|
||||
@@ -446,7 +507,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!_hasFieldChanged(profile, _editingField!)) {
|
||||
if (!_hasFieldChanged(profile, currentField)) {
|
||||
_debugLog(
|
||||
'save_skip',
|
||||
field: currentField,
|
||||
reason: 'unchanged',
|
||||
changed: false,
|
||||
);
|
||||
setState(() {
|
||||
if (_editingField == 'phone') {
|
||||
_resetPhoneState();
|
||||
@@ -459,6 +526,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
|
||||
_isSavingField = true;
|
||||
_debugLog('save_dispatch', field: currentField, changed: true);
|
||||
|
||||
try {
|
||||
await ref
|
||||
@@ -470,7 +538,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (_editingField == 'phone') {
|
||||
if (currentField == 'phone') {
|
||||
_initialPhone = nextPhone;
|
||||
_resetPhoneState();
|
||||
}
|
||||
@@ -478,11 +546,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
});
|
||||
_debugLog('save_success', field: currentField);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_debugLog('save_failed', field: currentField, reason: e.toString());
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -704,6 +774,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
title: Text(label),
|
||||
subtitle: Text(displayValue),
|
||||
trailing: TextButton(
|
||||
key: Key('profile-$field-edit-button'),
|
||||
onPressed: isUpdating ? null : () => _startEditing(field, profile),
|
||||
child: Text(tr('ui.common.edit')),
|
||||
),
|
||||
@@ -720,6 +791,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: Key('profile-$field-input'),
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
@@ -731,9 +803,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = field;
|
||||
},
|
||||
child: OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -796,9 +874,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = 'phone';
|
||||
},
|
||||
child: OutlinedButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -268,14 +268,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,18 +320,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -653,26 +645,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.26.3"
|
||||
version: "1.29.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.9"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.12"
|
||||
version: "0.6.15"
|
||||
toml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -143,4 +143,54 @@ void main() {
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 not_found 코드를 whitelist 메시지로 노출한다 (404 매핑)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'not_found',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.not_found',
|
||||
fallback: internalErrorWhitelistMessages['not_found']!,
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'not_found'},
|
||||
);
|
||||
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 rate_limited 코드를 whitelist 메시지로 노출한다 (429 매핑)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'rate_limited',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.rate_limited',
|
||||
fallback: internalErrorWhitelistMessages['rate_limited']!,
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'rate_limited'},
|
||||
);
|
||||
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
114
userfront/test/log_policy_test.dart
Normal file
114
userfront/test/log_policy_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/log_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('LogPolicy.debugEnabled', () {
|
||||
test('non production enables debug by default', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: null),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'staging', productionDebugFlag: 'false'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('production disables debug unless explicitly enabled', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'production', productionDebugFlag: ''),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: 'true',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'prod', productionDebugFlag: '1'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogPolicy.shouldRelayClientLog', () {
|
||||
test('production default forwards only warning or higher', () {
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'WARNING',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'ERROR',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('production debug option forwards info logs', () {
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: 'true',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogPolicy.sanitize', () {
|
||||
test('sanitizes sensitive message patterns', () {
|
||||
const message =
|
||||
'token=abc123 payload={"password":"hello","safe":"ok"} authorization:BearerXYZ';
|
||||
final sanitized = LogPolicy.sanitizeMessage(message);
|
||||
expect(sanitized, isNot(contains('abc123')));
|
||||
expect(sanitized, contains('token=*****'));
|
||||
expect(sanitized, contains('"password":"*****"'));
|
||||
expect(sanitized, contains('authorization=*****'));
|
||||
});
|
||||
|
||||
test('sanitizes nested sensitive keys', () {
|
||||
final data = <String, dynamic>{
|
||||
'token': 'tok',
|
||||
'ok': 'value',
|
||||
'nested': {'new_password': 'pw', 'safe': 'x'},
|
||||
'arr': [
|
||||
{'authorization': 'Bearer secret'},
|
||||
'cookie=session=raw',
|
||||
],
|
||||
};
|
||||
|
||||
final sanitized = LogPolicy.sanitizeData(data);
|
||||
expect(sanitized['token'], '*****');
|
||||
expect(sanitized['ok'], 'value');
|
||||
expect(
|
||||
(sanitized['nested'] as Map<String, dynamic>)['new_password'],
|
||||
'*****',
|
||||
);
|
||||
expect((sanitized['nested'] as Map<String, dynamic>)['safe'], 'x');
|
||||
expect(
|
||||
((sanitized['arr'] as List).first
|
||||
as Map<String, dynamic>)['authorization'],
|
||||
'*****',
|
||||
);
|
||||
expect((sanitized['arr'] as List)[1], 'cookie=*****');
|
||||
});
|
||||
});
|
||||
}
|
||||
112
userfront/test/profile_notifier_persistence_test.dart
Normal file
112
userfront/test/profile_notifier_persistence_test.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
import 'package:userfront/features/profile/data/repositories/profile_repository.dart';
|
||||
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class _FakeProfileRepository extends ProfileRepository {
|
||||
_FakeProfileRepository({
|
||||
required this.initialProfile,
|
||||
required this.persistUpdate,
|
||||
}) : _profile = initialProfile;
|
||||
|
||||
final UserProfile initialProfile;
|
||||
final bool persistUpdate;
|
||||
UserProfile _profile;
|
||||
|
||||
int updateCount = 0;
|
||||
String? lastRequestedDepartment;
|
||||
|
||||
@override
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMyProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
updateCount += 1;
|
||||
lastRequestedDepartment = department;
|
||||
if (!persistUpdate) {
|
||||
return;
|
||||
}
|
||||
_profile = _profile.copyWith(
|
||||
name: name,
|
||||
phone: phone,
|
||||
department: department,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserProfile _seedProfile({required String department}) {
|
||||
return UserProfile(
|
||||
id: 'user-1',
|
||||
email: 'qa@example.com',
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: department,
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('재현: 저장소가 소속 변경을 반영하지 않으면 loadProfile 이후 이전 값으로 보인다', () async {
|
||||
final repository = _FakeProfileRepository(
|
||||
initialProfile: _seedProfile(department: 'Old Dept'),
|
||||
persistUpdate: false,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final initial = await container.read(profileProvider.future);
|
||||
expect(initial?.department, 'Old Dept');
|
||||
|
||||
await container
|
||||
.read(profileProvider.notifier)
|
||||
.updateProfile(
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'New Dept',
|
||||
);
|
||||
|
||||
expect(repository.updateCount, 1);
|
||||
expect(repository.lastRequestedDepartment, 'New Dept');
|
||||
|
||||
await container.read(profileProvider.notifier).loadProfile();
|
||||
expect(container.read(profileProvider).value?.department, 'Old Dept');
|
||||
});
|
||||
|
||||
test('소속 변경이 저장소에 반영되면 loadProfile 이후에도 변경값이 유지된다', () async {
|
||||
final repository = _FakeProfileRepository(
|
||||
initialProfile: _seedProfile(department: 'Old Dept'),
|
||||
persistUpdate: true,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final initial = await container.read(profileProvider.future);
|
||||
expect(initial?.department, 'Old Dept');
|
||||
|
||||
await container
|
||||
.read(profileProvider.notifier)
|
||||
.updateProfile(
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'New Dept',
|
||||
);
|
||||
|
||||
expect(repository.updateCount, 1);
|
||||
expect(repository.lastRequestedDepartment, 'New Dept');
|
||||
|
||||
await container.read(profileProvider.notifier).loadProfile();
|
||||
expect(container.read(profileProvider).value?.department, 'New Dept');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user