diff --git a/.dockerignore b/.dockerignore index e85ec56..70c6df0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,8 @@ **/node_modules **/.next **/dist +.git +.gitignore **/.env* !apps/web/.env.build docker diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index 1e3b67c..375ea8c 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -1,6 +1,6 @@ -name: Deploy staging +name: Deploy feedback demo -run-name: Deploy staging from main +run-name: Deploy feedback demo from main on: push: @@ -22,143 +22,83 @@ jobs: STAGING_HOST: ${{ vars.STAGING_HOST }} STAGING_PORT: ${{ vars.STAGING_PORT }} STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }} - STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }} + WEB_PORT: ${{ vars.WEB_PORT }} + SSO_CLIENT_ID_VAR: ${{ vars.SSO_CLIENT_ID }} + SSO_CLIENT_ID_SECRET: ${{ secrets.SSO_CLIENT_ID }} STAGING_USER: ${{ secrets.STAGING_USER }} STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }} - NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} - ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }} - INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }} - ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }} - BASE_URL: ${{ vars.BASE_URL }} - SMTP_ENABLED: ${{ vars.SMTP_ENABLED }} - SMTP_HOST: ${{ vars.SMTP_HOST }} - SMTP_SENDER: ${{ vars.SMTP_SENDER }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} - MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }} - SSO_ISSUER: ${{ vars.SSO_ISSUER }} - SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }} SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }} - SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }} - GITEA_API_URL: ${{ vars.GITEA_API_URL }} - GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }} - GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }} - JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} - JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }} - NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }} - NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }} - NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }} - NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }} - NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }} - NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }} - NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }} - NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }} - NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }} - NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} run: | set -eu - STAGING_HOST="${STAGING_HOST:-172.16.10.175}" - STAGING_PORT="${STAGING_PORT:-22}" - STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}" - STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}" - export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE - required_vars="NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SSO_ISSUER SSO_CLIENT_ID ADMIN_CANDIDATE_TENANT_ID INITIAL_SUPER_ADMIN_PHONE_NUMBER NAVER_WORKS_ENABLED NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID" - required_secrets="STAGING_USER STAGING_SSH_PRIVATE_KEY STAGING_SSH_KNOWN_HOSTS JWT_SECRET MASTER_API_KEY SSO_CLIENT_SECRET GITEA_API_TOKEN NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY" + test "${STAGING_HOST:-10.13.10.4}" = "10.13.10.4" || { + echo "STAGING_HOST must be 10.13.10.4" >&2 + exit 1 + } + test "${WEB_PORT:-8864}" = "8864" || { + echo "WEB_PORT must be 8864" >&2 + exit 1 + } - for name in $required_vars $required_secrets; do + SSO_CLIENT_ID="${SSO_CLIENT_ID_SECRET:-${SSO_CLIENT_ID_VAR:-}}" + export SSO_CLIENT_ID + for name in STAGING_USER STAGING_SSH_PRIVATE_KEY STAGING_SSH_KNOWN_HOSTS SSO_CLIENT_ID SSO_CLIENT_SECRET JWT_SECRET; do if [ -z "${!name:-}" ]; then - echo "Missing Gitea variable or secret: $name" >&2 + echo "Missing Gitea secret: $name" >&2 exit 1 fi done - test "$STAGING_COMPOSE_FILE" = "docker/docker-compose.prod.yml" || { - echo "STAGING_COMPOSE_FILE must point to docker/docker-compose.prod.yml" >&2 - exit 1 - } + echo "Feedback demo deployment settings are present." - echo "Deployment settings are present." - - - name: Deploy to staging over SSH + - name: Deploy to 10.13.10.4:8864 over SSH env: STAGING_HOST: ${{ vars.STAGING_HOST }} STAGING_PORT: ${{ vars.STAGING_PORT }} STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }} - STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }} + WEB_PORT: ${{ vars.WEB_PORT }} + SUPPORT_CONSOLE_API_BASE_URL: ${{ vars.SUPPORT_CONSOLE_API_BASE_URL }} + SSO_ISSUER: ${{ vars.SSO_ISSUER }} + SSO_AUTHORIZATION_ENDPOINT: ${{ vars.SSO_AUTHORIZATION_ENDPOINT }} + SSO_TOKEN_ENDPOINT: ${{ vars.SSO_TOKEN_ENDPOINT }} + SSO_USERINFO_ENDPOINT: ${{ vars.SSO_USERINFO_ENDPOINT }} + SSO_SCOPE: ${{ vars.SSO_SCOPE }} + SSO_CLIENT_ID_VAR: ${{ vars.SSO_CLIENT_ID }} + SSO_CLIENT_ID_SECRET: ${{ secrets.SSO_CLIENT_ID }} + SUPPORT_TENANT_ID: ${{ vars.SUPPORT_TENANT_ID }} STAGING_USER: ${{ secrets.STAGING_USER }} STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }} - APP_ENV: ${{ vars.APP_ENV }} - WEB_PORT: ${{ vars.WEB_PORT }} - API_PORT: ${{ vars.API_PORT }} - SECRETARY_API_PORT: ${{ vars.SECRETARY_API_PORT }} - MYSQL_PORT: ${{ vars.MYSQL_PORT }} - MYSQL_SECRETARY_PORT: ${{ vars.MYSQL_SECRETARY_PORT }} - NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} - ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }} - ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }} - BASE_URL: ${{ vars.BASE_URL }} - SMTP_ENABLED: ${{ vars.SMTP_ENABLED }} - SMTP_HOST: ${{ vars.SMTP_HOST }} - SMTP_PORT: ${{ vars.SMTP_PORT }} - SMTP_SENDER: ${{ vars.SMTP_SENDER }} - SMTP_TLS: ${{ vars.SMTP_TLS }} - SMTP_CIPHER_SPEC: ${{ vars.SMTP_CIPHER_SPEC }} - SMTP_OPPORTUNISTIC_TLS: ${{ vars.SMTP_OPPORTUNISTIC_TLS }} - ACCESS_TOKEN_EXPIRED_TIME: ${{ vars.ACCESS_TOKEN_EXPIRED_TIME }} - REFRESH_TOKEN_EXPIRED_TIME: ${{ vars.REFRESH_TOKEN_EXPIRED_TIME }} - AUTO_MIGRATION: ${{ vars.AUTO_MIGRATION }} - OPENSEARCH_USE: ${{ vars.OPENSEARCH_USE }} - OPENSEARCH_NODE: ${{ vars.OPENSEARCH_NODE }} - OPENSEARCH_USERNAME: ${{ vars.OPENSEARCH_USERNAME }} - OPENSEARCH_PASSWORD: ${{ vars.OPENSEARCH_PASSWORD }} - SSO_ISSUER: ${{ vars.SSO_ISSUER }} - SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} - MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }} - INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }} - ADMIN_CANDIDATE_EMAILS: ${{ secrets.ADMIN_CANDIDATE_EMAILS }} - SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} - SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }} - SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }} - GITEA_API_URL: ${{ vars.GITEA_API_URL }} - GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }} - GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }} - JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} - JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }} - NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }} - NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }} - NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }} - NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }} - NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }} - NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }} - NAVER_WORKS_MAX_RETRIES: ${{ vars.NAVER_WORKS_MAX_RETRIES }} - NAVER_WORKS_MAX_MESSAGE_LENGTH: ${{ vars.NAVER_WORKS_MAX_MESSAGE_LENGTH }} - NAVER_WORKS_ACCESS_TOKEN: ${{ secrets.NAVER_WORKS_ACCESS_TOKEN }} - NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }} - NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }} - NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }} - NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} run: | set -eu set -o pipefail umask 077 - STAGING_HOST="${STAGING_HOST:-172.16.10.175}" - STAGING_PORT="${STAGING_PORT:-22}" - STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}" - STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}" - export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE + staging_host="${STAGING_HOST:-10.13.10.4}" + staging_port="${STAGING_PORT:-22}" + staging_app_dir="${STAGING_APP_DIR:-/home/user/egbim_qa_platform}" + web_port="${WEB_PORT:-8864}" + support_console_api_base_url="${SUPPORT_CONSOLE_API_BASE_URL:-https://feedback.hmac.kr/api/support}" + sso_issuer="${SSO_ISSUER:-https://sso.hmac.kr/oidc}" + sso_authorization_endpoint="${SSO_AUTHORIZATION_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/auth}" + sso_token_endpoint="${SSO_TOKEN_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/token}" + sso_userinfo_endpoint="${SSO_USERINFO_ENDPOINT:-https://sso.hmac.kr/oidc/userinfo}" + sso_scope="${SSO_SCOPE:-openid profile email}" + sso_client_id="${SSO_CLIENT_ID_SECRET:-${SSO_CLIENT_ID_VAR:-}}" + sso_client_secret="$SSO_CLIENT_SECRET" + jwt_secret="$JWT_SECRET" + support_tenant_id="${SUPPORT_TENANT_ID:-}" + + test "$staging_host" = "10.13.10.4" + test "$web_port" = "8864" + ssh_dir="$RUNNER_TEMP/staging-ssh" mkdir -p "$ssh_dir" chmod 700 "$ssh_dir" - key_file="$ssh_dir/id_ed25519" known_hosts_file="$ssh_dir/known_hosts" printf '%s\n' "$STAGING_SSH_PRIVATE_KEY" > "$key_file" @@ -166,31 +106,23 @@ jobs: chmod 600 "$key_file" "$known_hosts_file" trap 'rm -rf "$ssh_dir"' EXIT - derived_public_key="$ssh_dir/id_ed25519.pub" - if ! ssh-keygen -y -f "$key_file" > "$derived_public_key" 2>/dev/null; then - echo "STAGING_SSH_PRIVATE_KEY is invalid or passphrase-protected." >&2 - exit 1 - fi - key_fingerprint="$(ssh-keygen -lf "$derived_public_key" | awk '{print $2}')" - echo "SSH target: $STAGING_USER@$STAGING_HOST:$STAGING_PORT" - echo "SSH key fingerprint: $key_fingerprint" - - shell_quote() { - printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\"'\\\"'/g")" - } - - remote="$STAGING_USER@$STAGING_HOST" - remote_app_dir="$(shell_quote "$STAGING_APP_DIR")" + ssh-keygen -y -f "$key_file" > "$ssh_dir/id_ed25519.pub" ssh_args=( -i "$key_file" - -p "$STAGING_PORT" + -p "$staging_port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$known_hosts_file" ) + remote="$STAGING_USER@$staging_host" + + shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\"'\\"'/g")" + } + + remote_app_dir="$(shell_quote "$staging_app_dir")" ssh "${ssh_args[@]}" "$remote" "mkdir -p $remote_app_dir" - ssh "${ssh_args[@]}" "$remote" "rm -f $remote_app_dir/apps/secretary-api/alembic/versions/0002_role_access_workspaces.py $remote_app_dir/apps/secretary-api/alembic/versions/0009_preassigned_admin_candidates.py" tar \ --exclude='.git' \ --exclude='node_modules' \ @@ -204,88 +136,32 @@ jobs: --exclude='apps/docs/static' \ -czf - . | ssh "${ssh_args[@]}" "$remote" "tar -xzf - -C $remote_app_dir" - payload_file="$RUNNER_TEMP/staging-env.json" - python3 - <<'PY' > "$payload_file" - import base64 - import json - import os - - keys = """ - APP_ENV WEB_PORT API_PORT SECRETARY_API_PORT MYSQL_PORT MYSQL_SECRETARY_PORT - NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SMTP_ENABLED SMTP_HOST SMTP_PORT SMTP_SENDER - SMTP_TLS SMTP_CIPHER_SPEC SMTP_OPPORTUNISTIC_TLS ACCESS_TOKEN_EXPIRED_TIME - REFRESH_TOKEN_EXPIRED_TIME AUTO_MIGRATION OPENSEARCH_USE OPENSEARCH_NODE - OPENSEARCH_USERNAME OPENSEARCH_PASSWORD SSO_ISSUER SSO_CLIENT_ID JWT_SECRET - MASTER_API_KEY INITIAL_SUPER_ADMIN_PHONE_NUMBER ADMIN_CANDIDATE_EMAILS ADMIN_CANDIDATE_TENANT_ID - SMTP_USERNAME SMTP_PASSWORD SSO_CLIENT_SECRET SECRETARY_ABC_API_KEY GITEA_API_URL GITEA_API_TOKEN GITHUB_API_TOKEN - JIRA_API_EMAIL JIRA_API_TOKEN JIRA_WEBHOOK_SECRET JIRA_ISSUE_TYPE - NAVER_WORKS_ENABLED NAVER_WORKS_API_BASE_URL NAVER_WORKS_AUTH_URL NAVER_WORKS_ACCESS_TOKEN - NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET - NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY NAVER_WORKS_SCOPE NAVER_WORKS_MAX_RETRIES - NAVER_WORKS_MAX_MESSAGE_LENGTH - """.split() - - values = {key: os.environ.get(key, "") for key in keys} - encoded = base64.b64encode( - json.dumps(values, ensure_ascii=False, separators=(",", ":")).encode() - ).decode() - print(encoded) - PY - + remote_compose_file="$(shell_quote "docker/docker-compose.prod.yml")" + remote_port="$(shell_quote "$web_port")" + remote_console_url="$(shell_quote "$support_console_api_base_url")" + remote_sso_issuer="$(shell_quote "$sso_issuer")" + remote_sso_authorization_endpoint="$(shell_quote "$sso_authorization_endpoint")" + remote_sso_token_endpoint="$(shell_quote "$sso_token_endpoint")" + remote_sso_userinfo_endpoint="$(shell_quote "$sso_userinfo_endpoint")" + remote_sso_scope="$(shell_quote "$sso_scope")" + remote_sso_client_id="$(shell_quote "$sso_client_id")" + remote_sso_client_secret="$(shell_quote "$sso_client_secret")" + remote_jwt_secret="$(shell_quote "$jwt_secret")" + remote_support_tenant_id="$(shell_quote "$support_tenant_id")" remote_command="set -eu - APP_DIR=$(shell_quote "$STAGING_APP_DIR") - COMPOSE_FILE=$(shell_quote "$STAGING_COMPOSE_FILE") - test -d \"\$APP_DIR\" - cd \"\$APP_DIR\" - python3 -c 'import base64,json,os,subprocess,sys,time,urllib.request - compose=sys.argv[1] - payload=sys.stdin.buffer.read().strip() - if not payload: - raise SystemExit(\"Deployment payload is empty.\") - values=json.loads(base64.b64decode(payload)) - os.environ.update(values) - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"config\",\"--quiet\"],check=True) - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"up\",\"-d\",\"--build\"],check=True) - checks=[\"http://127.0.0.1:\"+os.environ.get(\"WEB_PORT\",\"3030\")+\"/api/health\"] - for url in checks: - last_error=\"no response\" - for _ in range(60): - try: - with urllib.request.urlopen(url,timeout=3) as response: - if response.status < 500: - break - last_error=\"HTTP \"+str(response.status) - except Exception as error: - last_error=repr(error) - time.sleep(2) - else: - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False) - for service in (\"api\",\"secretary-api\",\"web\",\"mysql\",\"mysql-secretary\"): - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=100\",service],check=False) - raise SystemExit(\"Health check failed: \"+url+\" (\"+last_error+\")\") - secretary_check=[\"docker\",\"compose\",\"-f\",compose,\"exec\",\"-T\",\"secretary-api\",\"python3\",\"-c\",\"import urllib.request; urllib.request.urlopen(\\\"http://127.0.0.1:8010/api/health\\\", timeout=5)\"] - secretary_error=\"no response\" - for _ in range(60): - try: - subprocess.run(secretary_check,check=True) - break - except subprocess.CalledProcessError as error: - secretary_error=repr(error) - time.sleep(2) - else: - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False) - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=200\",\"secretary-api\"],check=False) - raise SystemExit(\"Secretary API health check failed: \"+secretary_error) - subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=True) - print(\"Staging deployment and health checks passed.\")' \"\$COMPOSE_FILE\"" + cd $remote_app_dir + WEB_PORT=$remote_port SUPPORT_CONSOLE_API_BASE_URL=$remote_console_url SSO_ISSUER=$remote_sso_issuer SSO_AUTHORIZATION_ENDPOINT=$remote_sso_authorization_endpoint SSO_TOKEN_ENDPOINT=$remote_sso_token_endpoint SSO_USERINFO_ENDPOINT=$remote_sso_userinfo_endpoint SSO_SCOPE=$remote_sso_scope SSO_CLIENT_ID=$remote_sso_client_id SSO_CLIENT_SECRET=$remote_sso_client_secret JWT_SECRET=$remote_jwt_secret SUPPORT_TENANT_ID=$remote_support_tenant_id docker compose -f $remote_compose_file config --quiet + WEB_PORT=$remote_port SUPPORT_CONSOLE_API_BASE_URL=$remote_console_url SSO_ISSUER=$remote_sso_issuer SSO_AUTHORIZATION_ENDPOINT=$remote_sso_authorization_endpoint SSO_TOKEN_ENDPOINT=$remote_sso_token_endpoint SSO_USERINFO_ENDPOINT=$remote_sso_userinfo_endpoint SSO_SCOPE=$remote_sso_scope SSO_CLIENT_ID=$remote_sso_client_id SSO_CLIENT_SECRET=$remote_sso_client_secret JWT_SECRET=$remote_jwt_secret SUPPORT_TENANT_ID=$remote_support_tenant_id docker compose -f $remote_compose_file up -d --build + for attempt in $(seq 1 60); do + if curl -fsS http://127.0.0.1:$web_port/api/health >/dev/null; then + docker compose -f $remote_compose_file ps + echo 'Feedback demo deployment and health check passed.' + exit 0 + fi + sleep 2 + done + docker compose -f $remote_compose_file ps + docker compose -f $remote_compose_file logs --tail=100 web + exit 1" - cat "$payload_file" | ssh \ - -i "$key_file" \ - -p "$STAGING_PORT" \ - -o BatchMode=yes \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$known_hosts_file" \ - "$remote" "$remote_command" - - rm -f "$payload_file" + ssh "${ssh_args[@]}" "$remote" "$remote_command" diff --git a/FEEDBACK_ONLY_TASKS.md b/FEEDBACK_ONLY_TASKS.md new file mode 100644 index 0000000..0a88978 --- /dev/null +++ b/FEEDBACK_ONLY_TASKS.md @@ -0,0 +1,217 @@ +# 피드백 작성 전용 웹 전환 작업 + +## 목표 + +이 저장소는 프로젝트별 피드백 작성 데모 페이지와 독립적인 BARON-SSO 로그인을 제공한다. 피드백 데이터만 운영 중인 관리자 콘솔의 API로 전송한다. + +- 관리자 콘솔: `https://feedback.hmac.kr/` +- 현재 작업 저장소: `https://gitea.hmac.kr/b24014/egbim_qa_platform.git` +- 현재 작업 대상: 프로젝트별 피드백 작성 데모 화면 및 데이터 API 연동 +- 연동 구조: 작성 데모 웹/자체 인증 → 피드백 데이터 API → 관리자 콘솔 데이터 +- 기존 `is-div/qa_platform` Pull Request: 병합하지 않음 + +이번 단계는 각 당사자에게 화면과 연동 방식을 설명하기 위한 데모 단계다. 관리자 콘솔의 화면이나 관리 기능을 이 저장소에 복제하지 않는다. + +## 범위 + +### 유지할 기능 + +- BARON-SSO 로그인 및 callback +- 피드백 작성 화면 +- 제목 및 내용 입력 +- 사용자 IP 주소 입력 +- MAC 주소 입력 +- 모든 파일 형식의 첨부파일 업로드 +- 첨부파일 용량 및 개수 제한 +- 작성 완료 및 오류 메시지 +- 피드백 댓글 작성·수정·삭제는 기존 관리자 콘솔에서 유지 +- 피드백 등록 시 프로젝트 관리자 대상 Naver Works 알림은 기존 콘솔 API에서 유지 +- 기존 스테이징 API 호출 + +### 제외할 기능 + +- 피드백 목록 및 상세 관리 +- 대시보드 +- 프로젝트·채널·테넌트 생성 +- 관리자 권한 설정 +- 이슈 관리 +- AI 설정 +- 로컬 ABC API +- 로컬 Secretary API +- 로컬 MySQL 및 migration 실행 + +## 작업 목록 + +### 1. 프로젝트별 데모 연동 설계 + +- [x] 데모에서 workspace code를 경로로 전달하는 구조 확정 +- [x] 프로젝트 코드, 프로젝트 ID, 채널 ID는 관리자 콘솔에서 매핑 +- [x] 프로젝트별 피드백 양식 조회 방식 확인 +- [x] 프로젝트별 피드백 등록 API 계약 확인 +- [x] 프로젝트별 첨부파일 저장은 관리자 콘솔/Secretary가 담당 +- [ ] 당사자 설명용 데모 URL과 예시 프로젝트 확정 + +### 2. 외부 API 계약 확정 + +- [x] 기존 관리자 콘솔 코드에서 공개 API base URL 확인 +- [x] 로그인 상태 확인 API 확인 +- [x] 피드백 양식/필드 조회 API 확인 +- [x] 피드백 생성 API 확인 +- [x] 첨부파일 업로드 API 확인 +- [x] API 인증 방식 확인 +- [x] 기본 writer workspace를 `EGBIM_DEMO`로 고정해 미지정 SSO 사용자를 END_USER로 허용 +- [x] CORS 회피를 위한 서버사이드 proxy 적용 +- [ ] workspace code, project ID, channel ID 확정 +- [ ] 기존 API가 사용하는 API Key 권한 확인 + +관리 콘솔이 공개하는 same-origin endpoint: + +```text +GET /api/support/access +GET /api/support/workspaces/{workspaceCode}/form-template +POST /api/support/workspaces/{workspaceCode}/tickets +``` + +작성 데모는 위 endpoint를 서버사이드 proxy로 호출한다. 데모 웹의 내부 호출은 다음과 같이 분리한다. + +```text +GET /api/support/access +GET /api/support/workspaces/{workspaceCode}/form-template +POST /api/support/workspaces/{workspaceCode}/submit +``` + +관리 콘솔 내부에서는 위 요청을 Secretary API와 프로젝트별 ABC API로 전달한다. 작성 데모는 ABC API를 직접 호출하지 않는다. + +데모 웹 환경변수: + +```text +# Empty: browser calls stay same-origin and Next proxies admin API requests. +NEXT_PUBLIC_API_BASE_URL= +NEXT_PUBLIC_FEEDBACK_ONLY=true +SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support +SSO_ISSUER=https://sso.hmac.kr/oidc +SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth +SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token +SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo +SSO_SCOPE=openid profile email +SSO_CLIENT_ID= +SSO_CLIENT_SECRET= +JWT_SECRET= +SUPPORT_TENANT_ID= +``` + +The feedback-only Docker app leaves `NEXT_PUBLIC_API_BASE_URL` empty so +browser calls stay same-origin. BARON-SSO login is handled by this +application's own server-side auth flow and its own RP credentials. The +management console is not used for login. + +로컬 작성 앱의 인증 서버 환경에는 다음 값을 사용한다. + +```text +SSO_ISSUER=https://sso.hmac.kr/oidc +SSO_CLIENT_ID= +SSO_CLIENT_SECRET= +# Leave blank to derive the callback from the browser host. This supports +# both localhost:8864 and 10.13.10.4:8864. +SSO_REDIRECT_URI= +``` + +`NEXT_PUBLIC_API_BASE_URL`은 피드백 전용 Docker에서 비워 둔다. 관리 콘솔 +주소는 데이터 연동용 `SUPPORT_CONSOLE_API_BASE_URL`에만 설정한다. Client +Secret은 브라우저 번들 또는 저장소에 포함하지 않는다. + +### 3. 피드백 작성 데모 화면 정리 + +- [x] 피드백 작성 페이지를 기본 진입 화면으로 설정 +- [x] 불필요한 목록·상세·관리 화면 링크 및 직접 접근 차단 +- [x] 콘솔과 동일한 프로젝트명 `EGBIM_DEMO` 생성 +- [x] `EGBIM_DEMO`를 첫 번째 예시 프로젝트로 사용 +- [x] 현재 콘솔 매핑 확인: `projectId=8`, `channelId=9` +- [x] 프로젝트 식별자만 바꾸면 다른 프로젝트에도 연결되도록 구성 +- [x] 작성 페이지에 필요한 필드만 표시 +- [x] 모든 파일 형식 첨부 허용 +- [x] 파일 크기 30MB 제한 유지 +- [x] 첨부파일 업로드 성공 여부 표시 +- [x] 작성 완료 후 작성 화면에서 등록 완료 상태 표시 +- [ ] API 오류를 사용자가 이해할 수 있는 메시지로 표시 + +### 4. 인증 및 보안 + +- [x] BARON-SSO 로그인 흐름 확인 +- [x] 새 웹 주소의 callback URI 등록 +- [x] 자체 BARON-SSO RP의 로그인 URL 생성·callback·토큰 교환 구현 +- [x] 자체 RP의 `SSO_CLIENT_ID`·`SSO_CLIENT_SECRET`을 서버 runtime에 주입 +- [x] 콘솔 API 인증과 작성 앱 인증을 분리하고 피드백 데이터 API만 연동 +- [x] access token을 브라우저에 불필요하게 노출하지 않도록 처리 +- [ ] API Key를 클라이언트 번들에 포함하지 않도록 처리 +- [x] 서버사이드 proxy 사용 시 authorization header 전달 확인 +- [ ] Secret 및 개인키가 저장소에 포함되지 않았는지 확인 + +### 5. 불필요한 코드 제거 + +API 계약과 화면 의존성 확인 후 아래 항목을 제거한다. + +- [ ] `apps/api` 제거 또는 별도 저장소로 분리 +- [ ] `apps/secretary-api` 제거 또는 별도 저장소로 분리 +- [ ] 로컬 DB compose 및 DB migration 제거 +- [x] 관리자·대시보드·이슈·AI 관련 페이지는 데모 화면에서 노출하지 않음 +- [x] 프로젝트·채널·테넌트 설정 화면은 데모 화면에서 노출하지 않음 +- [ ] 피드백 목록·상세·댓글 관리 화면은 이 데모 웹에서 노출하지 않되, 기존 관리자 콘솔과 콘솔 API에서는 유지 +- [ ] Naver Works 알림 처리와 프로젝트 관리자 라우팅은 기존 콘솔 API에서 유지 +- [ ] E2E 및 로컬 통합 테스트 제거 또는 별도 보관 +- [ ] CLI 및 개발용 실행 스크립트 정리 +- [ ] 기존 데이터 migration SQL은 필요 여부 확인 후 보관 또는 제거 +- [ ] 추적 중인 `.venv-migrate` 등 가상환경 파일 제거 + +삭제 전에 작성 페이지의 import와 Docker build가 통과하는지 확인한다. + +### 6. 웹 전용 Docker 구성 + +- [x] `web` 서비스만 포함한 `docker/docker-compose.prod.yml` 구성 작성 +- [x] 배포 workflow를 피드백 전용 compose와 8864 health check로 변경 +- [x] API·Secretary·MySQL 서비스 의존성 제거 +- [x] 웹 서비스 포트 환경변수화 +- [x] 외부 API base URL 환경변수화 +- [x] production build에서 브라우저 API base URL을 비우고 외부 API 주소를 서버 proxy에만 사용 +- [x] 업로드 파일 저장은 외부 관리 콘솔/Secretary가 담당하도록 구성 +- [x] Docker image build 확인 + +### 7. 검증 + +- [x] 로그인하지 않은 사용자의 접근 처리 확인 +- [ ] SSO 로그인 및 callback 확인 (새 RP/JWT_SECRET 주입 후 브라우저에서 확인) +- [ ] 작성 양식 표시 확인 +- [ ] 제목·내용만 작성하여 등록 확인 +- [ ] IP·MAC 값 포함 등록 확인 +- [ ] 이미지 첨부 등록 확인 +- [ ] PDF·문서 등 비이미지 파일 첨부 확인 +- [ ] 30MB 초과 파일 차단 확인 +- [ ] API 등록 결과가 기존 관리 콘솔에 표시되는지 확인 +- [ ] 오류·재시도 처리 확인 +- [x] production Docker build 및 health check 확인 + +### 8. 저장소 반영 및 배포 + +- [x] 변경 파일에 Secret·개인키·실제 환경파일이 없는지 확인 +- [ ] `git status --short` 확인 +- [ ] `git diff --stat` 확인 +- [ ] 기능 단위 commit 생성 +- [ ] `b24014/egbim_qa_platform`의 main에 push +- [ ] 내부 서버 배포 +- [ ] 배포된 API에서 실제 API 등록 테스트 + +## 완료 조건 + +- 사용자는 피드백 작성 화면만 접근할 수 있다. +- 로그인은 BARON-SSO를 통해 동작한다. +- 관리 콘솔의 로그인·테넌트 설정을 변경하지 않고 자체 RP로 로그인된다. +- 피드백은 `https://feedback.hmac.kr/` 관리자 콘솔에서 확인된다. +- 첨부파일은 이미지와 일반 파일 모두 등록된다. +- 프로젝트 식별자를 바꾸어도 같은 작성 화면 구조로 API 연동이 가능하다. +- 로컬 ABC API, Secretary API, MySQL 없이 웹 컨테이너만 실행된다. +- 저장소에 Secret이나 개인키가 포함되지 않는다. +- 내부 서버에서 Docker build와 health check가 성공한다. + +## 현재 보류 사항 + +API base URL만으로는 실제 endpoint와 인증 방식을 확정할 수 없다. 기존 관리 콘솔 주소와 API 서버 주소가 같지 않을 수 있으므로, API 계약 확인 전에는 `apps/api`, `apps/secretary-api`, 관련 server route를 삭제하지 않는다. diff --git a/apps/secretary-api/.env.example b/apps/secretary-api/.env.example index 15866e0..fe5b223 100644 --- a/apps/secretary-api/.env.example +++ b/apps/secretary-api/.env.example @@ -10,6 +10,7 @@ MASTER_API_KEY= JWT_SECRET=DEV ADMIN_CANDIDATE_TENANT_ID= INITIAL_SUPER_ADMIN_PHONE_NUMBER= +DEFAULT_SUPPORT_WORKSPACE_CODE=EGBIM_DEMO STORAGE_PROVIDER=LOCAL R2_ENDPOINT= R2_ACCESS_KEY_ID= diff --git a/apps/secretary-api/app/api/routes/tickets.py b/apps/secretary-api/app/api/routes/tickets.py index 294e451..7eb948d 100644 --- a/apps/secretary-api/app/api/routes/tickets.py +++ b/apps/secretary-api/app/api/routes/tickets.py @@ -265,8 +265,12 @@ async def create_ticket( ] request_payload = TicketCreateRequest( workspace_code=workspace_code, - requester_id=str(form.get("requester_id") or ""), - requester_tenant_id=str(form.get("requester_tenant_id") or ""), + # Multipart writers do not send requester identity fields. Use + # the verified SSO principal before Pydantic validates the + # required fields; applying model_copy afterwards is too late + # because empty values fail min_length validation first. + requester_id=principal.user_id, + requester_tenant_id=principal.tenant_id, requester_contact=str(form.get("requester_contact") or ""), title=str(form.get("title") or ""), description=str(form.get("description") or ""), diff --git a/apps/secretary-api/app/core/config.py b/apps/secretary-api/app/core/config.py index 0035ac7..4c9f51a 100644 --- a/apps/secretary-api/app/core/config.py +++ b/apps/secretary-api/app/core/config.py @@ -41,6 +41,9 @@ class Settings(BaseSettings): jwt_secret: str = "" admin_candidate_tenant_id: str = "" initial_super_admin_phone_number: str = "" + # Users without an explicit workspace role receive END_USER access to + # this public feedback workspace. + default_support_workspace_code: str = "EGBIM_DEMO" model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") diff --git a/apps/secretary-api/app/schemas/ticket.py b/apps/secretary-api/app/schemas/ticket.py index 7872ccf..d811336 100644 --- a/apps/secretary-api/app/schemas/ticket.py +++ b/apps/secretary-api/app/schemas/ticket.py @@ -72,6 +72,7 @@ class WorkspaceFormField(BaseModel): label: str field_type: str required: bool = False + options: list[dict[str, str | int]] = Field(default_factory=list) class WorkspaceFormTemplateResponse(BaseModel): diff --git a/apps/secretary-api/app/services/access_service.py b/apps/secretary-api/app/services/access_service.py index 3fce824..656bb9e 100644 --- a/apps/secretary-api/app/services/access_service.py +++ b/apps/secretary-api/app/services/access_service.py @@ -9,6 +9,7 @@ from app.db.models import ( UserWorkspaceAccess, Workspace, ) +from app.core.config import settings class AccessService: @@ -36,19 +37,25 @@ class AccessService: @staticmethod def default_user_workspace_code(db: Session) -> str | None: - """Return the first active ABC project workspace. + """Return the configured public feedback workspace. - The old Q&A_Platform seed workspace must not determine the first SSO - redirect after ABC projects are synchronized. + Users without an explicit role assignment receive END_USER access to + this workspace. A configured code prevents old seed data or a newly + created project from changing the public writer target. """ - return db.execute( + configured_code = settings.default_support_workspace_code.strip() + query = ( select(Workspace.workspace_code) .where( Workspace.is_active.is_(True), Workspace.workspace_type == "SOFTWARE_APP", ) - .order_by(Workspace.id.asc()), - ).scalar() + ) + if configured_code: + query = query.where(Workspace.workspace_code == configured_code) + else: + query = query.order_by(Workspace.id.asc()) + return db.execute(query).scalar() @staticmethod def _upsert_support_user( diff --git a/apps/secretary-api/app/services/ticket_service.py b/apps/secretary-api/app/services/ticket_service.py index bf163f1..4d05740 100644 --- a/apps/secretary-api/app/services/ticket_service.py +++ b/apps/secretary-api/app/services/ticket_service.py @@ -39,6 +39,11 @@ from app.schemas.ticket import ( SEOUL_TIMEZONE = ZoneInfo("Asia/Seoul") QNA_CATEGORY_CODES = {"ERROR_QNA", "IMPROVEMENT_QNA", "GENERAL_QNA"} +QNA_CATEGORY_OPTIONS = [ + {"id": 1, "key": "ERROR_QNA", "name": "오류 문의"}, + {"id": 2, "key": "IMPROVEMENT_QNA", "name": "개선 문의"}, + {"id": 3, "key": "GENERAL_QNA", "name": "일반 문의"}, +] access_service = AccessService() @@ -84,6 +89,20 @@ class TicketService: def get_workspace_form_template(self, db: Session, workspace_code: str) -> WorkspaceFormTemplateResponse: workspace = self._get_workspace_by_code(db, workspace_code, raise_not_found=False) + qna_fields = [ + WorkspaceFormField( + field_code="category", + label="구분", + field_type="select", + required=True, + options=QNA_CATEGORY_OPTIONS, + ), + WorkspaceFormField(field_code="title", label="제목", field_type="text", required=True), + WorkspaceFormField(field_code="description", label="내용", field_type="textarea", required=True), + WorkspaceFormField(field_code="ip_address", label="사용자 IP 주소", field_type="text"), + WorkspaceFormField(field_code="mac_address", label="MAC 주소", field_type="text"), + ] + templates = { "INTRA_BOOK_REQUEST": WorkspaceFormTemplateResponse( workspace_code="INTRA_BOOK_REQUEST", @@ -113,6 +132,12 @@ class TicketService: WorkspaceFormField(field_code="expected_result", label="기대 결과", field_type="textarea"), ], ), + "EGBIM_DEMO": WorkspaceFormTemplateResponse( + workspace_code="EGBIM_DEMO", + workspace_name=workspace.workspace_name if workspace else "EGBIM_DEMO", + requires_approval=False, + fields=qna_fields, + ), } return templates.get( @@ -1910,7 +1935,7 @@ class TicketService: if not normalized or normalized == "GENERAL": return self._default_category_code(workspace_code) - if workspace_code == "Q&A_Platform" and normalized not in QNA_CATEGORY_CODES: + if workspace_code in {"Q&A_Platform", "EGBIM_DEMO"} and normalized not in QNA_CATEGORY_CODES: return "GENERAL_QNA" return normalized diff --git a/apps/web/.env.build b/apps/web/.env.build index 914b49f..4a0fa5a 100644 --- a/apps/web/.env.build +++ b/apps/web/.env.build @@ -1,2 +1,3 @@ # DO NOT MODIFY THIS FILE. THIS FILE IS FOR THE DOCKER BUILD -NEXT_PUBLIC_API_BASE_URL=APP_NEXT_PUBLIC_API_BASE_URL \ No newline at end of file +NEXT_PUBLIC_API_BASE_URL=APP_NEXT_PUBLIC_API_BASE_URL +NEXT_PUBLIC_FEEDBACK_ONLY=APP_NEXT_PUBLIC_FEEDBACK_ONLY diff --git a/apps/web/.env.example b/apps/web/.env.example index b03aaa0..66f81a2 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,13 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:4000 +NEXT_PUBLIC_FEEDBACK_ONLY=true +SUPPORT_CONSOLE_API_BASE_URL=https://feedback.hmac.kr/api/support +SSO_ISSUER=https://sso.hmac.kr/oidc +SSO_AUTHORIZATION_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/auth +SSO_TOKEN_ENDPOINT=https://sso.hmac.kr/oidc/oauth2/token +SSO_USERINFO_ENDPOINT=https://sso.hmac.kr/oidc/userinfo +SSO_SCOPE=openid profile email +SSO_REDIRECT_URI= +SSO_CLIENT_ID= +SSO_CLIENT_SECRET= +JWT_SECRET= +SUPPORT_TENANT_ID= diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 7b0128e..009aabf 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -18,78 +18,21 @@ const nextConfig = { return [ { source: '/demo/feedback-writer', - destination: '/support/INTRA_GENERAL_QNA/new', + destination: '/support/EGBIM_DEMO/new', permanent: false, }, { source: '/demo/feedback-list', - destination: '/support/INTRA_GENERAL_QNA', + destination: '/support/EGBIM_DEMO/new', permanent: false, }, { source: '/demo/feedback/:feedbackId', - destination: '/support/INTRA_GENERAL_QNA', + destination: '/support/EGBIM_DEMO/new', permanent: false, }, ]; }, - async rewrites() { - const internalApiBaseUrl = - process.env.INTERNAL_API_BASE_URL ?? 'http://api:4000'; - const secretaryApiBaseUrl = - process.env.SUPPORT_API_BASE_URL ?? - (process.env.NODE_ENV === 'development' - ? 'http://127.0.0.1:8010' - : 'http://secretary-api:8010'); - - return { - fallback: [ - // Swagger is served by the Nest API, while the public staging - // hostname terminates at this Next.js web container. Keep the API - // port private and proxy the documentation through the web service. - { - source: '/docs', - destination: `${internalApiBaseUrl}/docs`, - }, - { - source: '/docs/:path*', - destination: `${internalApiBaseUrl}/docs/:path*`, - }, - { - source: '/admin-docs', - destination: `${internalApiBaseUrl}/admin-docs`, - }, - { - source: '/admin-docs/:path*', - destination: `${internalApiBaseUrl}/admin-docs/:path*`, - }, - { - source: '/docs-json', - destination: `${internalApiBaseUrl}/docs-json`, - }, - { - source: '/admin-docs-json', - destination: `${internalApiBaseUrl}/admin-docs-json`, - }, - { - source: '/secretary-docs', - destination: `${secretaryApiBaseUrl}/docs`, - }, - { - source: '/secretary-docs/:path*', - destination: `${secretaryApiBaseUrl}/:path*`, - }, - { - source: '/secretary-docs-json', - destination: `${secretaryApiBaseUrl}/openapi.json`, - }, - { - source: '/api/:path*', - destination: `${internalApiBaseUrl}/api/:path*`, - }, - ], - }; - }, }; export default nextConfig; diff --git a/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx b/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx index 4078311..c1ee9a1 100644 --- a/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx +++ b/apps/web/src/entities/feedback/ui/feedback-detail-sheet.ui.tsx @@ -81,6 +81,7 @@ interface SupportAttachmentRecord { mime_type?: string | null; file_size?: number | null; created_at: string; + download_url?: string; } interface SupportTicketRecord { @@ -1081,6 +1082,7 @@ const FeedbackDetailSheet = (props: Props) => { ) .map( (attachment) => + attachment.download_url ?? `/api/support/tickets/${supportTicketId ?? feedbackId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(resolvedWorkspaceCode)}&abcFeedbackId=${feedbackId}`, )} names={feedbackAttachments @@ -1504,6 +1506,7 @@ const FeedbackDetailSheet = (props: Props) => { + attachment.download_url ?? `/api/support/tickets/${supportTicketId ?? feedbackId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(resolvedWorkspaceCode)}&abcFeedbackId=${feedbackId}`, )} names={(comment.attachments ?? []).map( diff --git a/apps/web/src/entities/tenant/ui/tenant-guard.ui.tsx b/apps/web/src/entities/tenant/ui/tenant-guard.ui.tsx index 57807dc..5fca6fd 100644 --- a/apps/web/src/entities/tenant/ui/tenant-guard.ui.tsx +++ b/apps/web/src/entities/tenant/ui/tenant-guard.ui.tsx @@ -34,8 +34,10 @@ const TenantGuard: React.FC = ({ children }) => { const router = useRouter(); const { setTenant } = useTenantStore(); const { user } = useUserStore(); + const isFeedbackOnlyApp = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const isPreviewRoute = + isFeedbackOnlyApp || router.pathname.startsWith('/support') || router.pathname === '/ops' || router.pathname.startsWith('/admin/issues') || diff --git a/apps/web/src/entities/user/user.model.ts b/apps/web/src/entities/user/user.model.ts index cbadef3..03a82c3 100644 --- a/apps/web/src/entities/user/user.model.ts +++ b/apps/web/src/entities/user/user.model.ts @@ -61,7 +61,10 @@ interface Action { email: string; password: string; }) => Promise; - signInWithOAuth: (input: { code: string }) => Promise; + signInWithOAuth: (input: { + code: string; + redirectUri?: string; + }) => Promise; signOut: () => Promise; setUser: (jwt?: Jwt) => Promise; _signIn: (jwt: Jwt) => Promise; @@ -79,10 +82,10 @@ export const useUserStore = create((set, get) => ({ await get()._signIn(jwt); }, - signInWithOAuth: async ({ code }) => { + signInWithOAuth: async ({ code, redirectUri }) => { const { data: jwt } = await client.get({ path: '/api/admin/auth/signIn/oauth', - query: { code }, + query: { code, redirect_uri: redirectUri }, }); if (!jwt.accessToken || !jwt.refreshToken) { throw new Error('OAuth login did not return a valid session.'); @@ -90,6 +93,9 @@ export const useUserStore = create((set, get) => ({ await get()._signIn(jwt); }, signOut: async () => { + if (typeof window !== 'undefined') { + await fetch('/api/auth/sign-out', { method: 'POST' }); + } await cookieStorage.removeItem('jwt'); set({ user: null }); if (typeof window !== 'undefined') { @@ -108,12 +114,23 @@ export const useUserStore = create((set, get) => ({ if (!sub || !exp || dayjs().isAfter(dayjs.unix(exp))) { await get().signOut(); } else { - const { data } = await client.get({ - path: '/api/admin/users/{id}', - pathParams: { id: parseInt(sub) }, - options: { headers: { Authorization: `Bearer ${jwt.accessToken}` } }, + const payload = jwtDecode(jwt.accessToken); + set({ + user: { + id: Number.parseInt(sub, 10) || 0, + email: payload.email ?? `${sub}@sso.local`, + type: 'GENERAL', + name: payload.name ?? null, + department: payload.department ?? null, + phoneNumber: payload.phone_number ?? null, + signUpMethod: 'OAUTH', + } as User, }); - set({ user: data }); } }, async _signIn(jwt) { @@ -178,56 +195,47 @@ export const useUserStore = create((set, get) => ({ router.query.callback_url : storedCallbackUrl ?? callbackCookieUrl; - const hasWorkspaceManagerRole = - access?.workspaces?.some( - (workspace) => - workspace.workspace_role === 'PROJECT_MANAGER' || - workspace.can_manage === true, - ) ?? false; - const isRegisteredAdmin = - access?.is_admin === true || - access?.is_system_admin === true || - hasWorkspaceManagerRole || - Boolean(access?.default_admin_path) || - (!hasSupportAccess && get().user?.type === 'SUPER'); + // This deployment is the feedback writer only. Administrator status is + // handled by the existing console, so an administrator must not be sent + // to its dashboard from this web app. + const isSupportCreatePath = (value?: string | null) => + typeof value === 'string' && + /^\/support\/[^/]+\/new(?:\?.*)?$/.test(value); - if (isRegisteredAdmin) { - if (access?.default_admin_path) { - await router.push(access.default_admin_path); - } else { - // A manager without a DB project mapping must still enter the - // admin console. The mapped project route is preferred above; the - // main console is the safe fallback and never the end-user form. - await router.push({ pathname: Path.MAIN }); - } - } else if (callbackUrl) { + const safeCallbackUrl = isSupportCreatePath(callbackUrl) ? callbackUrl : null; + const defaultSupportCreatePath = isSupportCreatePath( + access?.default_support_create_path, + ) ? access?.default_support_create_path : null; + + if (process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true') { if (typeof window !== 'undefined') { window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY); window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY); document.cookie = 'ufb.oauth.callback-url=; Path=/; Max-Age=0; SameSite=Lax'; } - await router.push(callbackUrl); - } else if (access?.default_support_path) { - await router.push(access.default_support_path); - } else if (access?.workspaces?.[0]?.workspace_code) { - await router.push( - '/support/' + - encodeURIComponent( - access.workspaces[0].workspace_code ?? - DEFAULT_SUPPORT_WORKSPACE_CODE, - ) + - '/list', - ); - } else if (!hasSupportAccess) { await router.push( '/support/' + encodeURIComponent(DEFAULT_SUPPORT_WORKSPACE_CODE) + '/list', ); + return; + } + + if (safeCallbackUrl || defaultSupportCreatePath) { + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY); + window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY); + document.cookie = 'ufb.oauth.callback-url=; Path=/; Max-Age=0; SameSite=Lax'; + } + await router.push(safeCallbackUrl ?? defaultSupportCreatePath ?? ''); } else { + const workspaceCode = + hasSupportAccess && access?.workspaces?.[0]?.workspace_code + ? access.workspaces[0].workspace_code + : DEFAULT_SUPPORT_WORKSPACE_CODE; await router.push( '/support/' + - encodeURIComponent(DEFAULT_SUPPORT_WORKSPACE_CODE) + + encodeURIComponent(workspaceCode) + '/list', ); } diff --git a/apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts b/apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts index a38f57c..e1286a6 100644 --- a/apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts +++ b/apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts @@ -50,7 +50,12 @@ export const useOAuthCallback = () => { // effects twice during local development, so exchange each code once. processedCodeRef.current = code; - void signInWithOAuth({ code }).catch((error) => { + const redirectUri = + typeof window !== 'undefined' ? + `${window.location.origin}/api/auth/baron-sso/callback` + : undefined; + + void signInWithOAuth({ code, redirectUri }).catch((error) => { if (error instanceof AxiosError && error.response) { const message = error.response.data as IFetchError; toast.error( diff --git a/apps/web/src/features/auth/sign-in-with-oauth/ui/sign-in-with-oauth-button.ui.tsx b/apps/web/src/features/auth/sign-in-with-oauth/ui/sign-in-with-oauth-button.ui.tsx index a128100..d2558b3 100644 --- a/apps/web/src/features/auth/sign-in-with-oauth/ui/sign-in-with-oauth-button.ui.tsx +++ b/apps/web/src/features/auth/sign-in-with-oauth/ui/sign-in-with-oauth-button.ui.tsx @@ -13,127 +13,33 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { useEffect } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { Button } from '@ufb/react'; -import { useOAIQuery } from '@/shared'; -import { useTenantStore } from '@/entities/tenant'; - interface IProps {} -const OAUTH_CALLBACK_URL_STORAGE_KEY = 'ufb.oauth.callback-url'; -const OAUTH_FORCE_LOGIN_STORAGE_KEY = 'ufb.oauth.force-login'; - const SignInWithOAuthButton: React.FC = () => { const { t } = useTranslation(); const router = useRouter(); - const { tenant } = useTenantStore(); - - const callback_url = (router.query.callback_url ?? '') as string; - const force_login = - typeof window !== 'undefined' ? - (router.query.force_login ?? - window.sessionStorage.getItem(OAUTH_FORCE_LOGIN_STORAGE_KEY) ?? - '') - : ''; - - const { data } = useOAIQuery({ - path: '/api/admin/auth/signIn/oauth/loginURL', - queryOptions: { enabled: tenant?.useOAuth ?? false }, - variables: { - callback_url, - force_login: force_login ? 'true' : undefined, - }, - }); - - useEffect(() => { - if (!force_login || !data?.url || typeof window === 'undefined') return; - - if (callback_url) { - window.sessionStorage.setItem( - OAUTH_CALLBACK_URL_STORAGE_KEY, - callback_url, - ); - } else { - window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY); - } - - window.sessionStorage.setItem(OAUTH_FORCE_LOGIN_STORAGE_KEY, 'true'); - window.location.assign(data.url); - }, [callback_url, data?.url, force_login]); - - if (tenant?.oauthConfig?.loginButtonType === 'GOOGLE') { - return ( - - ); - } + const callbackUrl = + typeof router.query.callback_url === 'string' ? router.query.callback_url : ''; + const loginUrl = `/api/auth/baron-sso/login${ + callbackUrl ? `?callback_url=${encodeURIComponent(callbackUrl)}` : '' + }`; return ( ); }; diff --git a/apps/web/src/pages/api/auth/baron-sso/callback.ts b/apps/web/src/pages/api/auth/baron-sso/callback.ts index d121597..7907b9e 100644 --- a/apps/web/src/pages/api/auth/baron-sso/callback.ts +++ b/apps/web/src/pages/api/auth/baron-sso/callback.ts @@ -15,23 +15,21 @@ */ import type { NextApiRequest, NextApiResponse } from 'next'; -const handler = (req: NextApiRequest, res: NextApiResponse) => { - const query = new URLSearchParams(); +import { completeLogin } from '@/server/local-sso'; - for (const [key, value] of Object.entries(req.query)) { - if (typeof value === 'string') { - query.set(key, value); - continue; - } - - if (Array.isArray(value) && value.length > 0) { - query.set(key, value[0] ?? ''); - } +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') return res.status(405).send('Method not allowed'); + if (typeof req.query.error === 'string') { + const description = + typeof req.query.error_description === 'string' + ? req.query.error_description + : req.query.error; + return res.redirect(302, `/auth/sign-in?oauth_error=${encodeURIComponent(description)}`); } - - const suffix = query.toString() ? `?${query.toString()}` : ''; - - return res.redirect(302, `/auth/oauth-callback${suffix}`); -}; - -export default handler; + try { + return res.redirect(302, await completeLogin(req, res)); + } catch (error) { + const message = error instanceof Error ? error.message : 'BARON-SSO 로그인에 실패했습니다.'; + return res.redirect(302, `/auth/sign-in?oauth_error=${encodeURIComponent(message)}`); + } +} diff --git a/apps/web/src/pages/api/auth/baron-sso/login.ts b/apps/web/src/pages/api/auth/baron-sso/login.ts new file mode 100644 index 0000000..6e55554 --- /dev/null +++ b/apps/web/src/pages/api/auth/baron-sso/login.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { buildLoginUrl, setLoginCookies } from '@/server/local-sso'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') return res.status(405).send('Method not allowed'); + try { + const { state, url } = buildLoginUrl(req); + setLoginCookies(req, res, state, req.query.callback_url); + return res.redirect(302, url); + } catch (error) { + return res.status(500).json({ + message: error instanceof Error ? error.message : 'BARON-SSO 설정이 올바르지 않습니다.', + }); + } +} diff --git a/apps/web/src/pages/api/auth/sign-out.ts b/apps/web/src/pages/api/auth/sign-out.ts new file mode 100644 index 0000000..b9997d8 --- /dev/null +++ b/apps/web/src/pages/api/auth/sign-out.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { clearSession } from '@/server/local-sso'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST' && req.method !== 'GET') return res.status(405).send('Method not allowed'); + clearSession(req, res); + return res.status(204).end(); +} diff --git a/apps/web/src/pages/api/support/access.ts b/apps/web/src/pages/api/support/access.ts index 811b9b4..e45784d 100644 --- a/apps/web/src/pages/api/support/access.ts +++ b/apps/web/src/pages/api/support/access.ts @@ -15,16 +15,19 @@ */ import { createNextApiHandler } from '@/server/api-handler'; import { getSupportAuthHeaders } from '@/server/support-auth'; - -const supportApiBaseUrl = - process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +import { supportExternalUrl } from '@/server/support-external'; const handler = createNextApiHandler({ GET: async (req, res) => { try { + const query = + req.query.candidates ? + '?candidates=1' + : req.query.management ? + '?management=1' + : ''; const response = await fetch( - supportApiBaseUrl + - (req.query.candidates ? '/api/access/candidates' : req.query.management ? '/api/access/admins' : '/api/access/me'), + supportExternalUrl('/access') + query, { headers: getSupportAuthHeaders(req) }, ); const data = (await response.json()) as unknown; @@ -41,11 +44,12 @@ const handler = createNextApiHandler({ try { const target = isManagementRequest ? - supportApiBaseUrl + '/api/access/admins' - : supportApiBaseUrl + - '/api/access/workspaces/' + + supportExternalUrl('/access?management=1') + : supportExternalUrl( + '/access?workspaceCode=' + workspaceCode + - '/users'; + '&users=1', + ); const response = await fetch(target, { method: 'POST', headers: { @@ -66,7 +70,7 @@ const handler = createNextApiHandler({ const assignmentId = req.query.assignmentId as string; try { const response = await fetch( - supportApiBaseUrl + '/api/access/admins/' + assignmentId, + supportExternalUrl('/access?management=1&assignmentId=' + assignmentId), { headers: getSupportAuthHeaders(req), method: 'DELETE' }, ); const data = (await response.json()) as unknown; diff --git a/apps/web/src/pages/api/support/tickets/[ticketId].ts b/apps/web/src/pages/api/support/tickets/[ticketId].ts index de31d22..e26cfd5 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId].ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId].ts @@ -27,6 +27,7 @@ import { getSupportPrincipal, isSupportAdmin, } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; import { deleteSupportTicketStub, getSupportTicketDetail, @@ -36,6 +37,7 @@ import { const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const getFeedbackString = (feedback: Record, key: string) => typeof feedback[key] === 'string' ? feedback[key] : ''; @@ -324,6 +326,22 @@ const handler = createNextApiHandler({ query.set('workspaceCode', workspaceCode); } + if (isFeedbackOnly) { + try { + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}`)}${suffix}`, + { headers: getSupportAuthHeaders(req) }, + ); + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch { + return res + .status(503) + .json({ message: '관리 콘솔 API 연결에 실패했습니다.' }); + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); diff --git a/apps/web/src/pages/api/support/tickets/[ticketId]/attachments/[attachmentId].ts b/apps/web/src/pages/api/support/tickets/[ticketId]/attachments/[attachmentId].ts index 4bb775a..762371b 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId]/attachments/[attachmentId].ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId]/attachments/[attachmentId].ts @@ -20,9 +20,11 @@ import { listAbcSupportFeedbackComments, } from '@/server/support-abc'; import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const handler = createNextApiHandler({ GET: async (req, res) => { @@ -33,6 +35,37 @@ const handler = createNextApiHandler({ const abcFeedbackId = typeof req.query.abcFeedbackId === 'string' ? req.query.abcFeedbackId : ''; + // Feedback-only pages store ticket/comment attachments in the separate + // Secretary API. Do this before the legacy ABC mapping branch; a comment + // attachment has the same ticket_id but is not an ABC attachment. + if (isFeedbackOnly) { + const query = new URLSearchParams(); + if (workspaceCode) query.set('workspaceCode', workspaceCode); + if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId); + const suffix = query.toString() ? `?${query.toString()}` : ''; + + try { + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/attachments/${attachmentId}`)}${suffix}`, + { headers: getSupportAuthHeaders(req) }, + ); + const contentType = response.headers.get('content-type') ?? 'application/octet-stream'; + const contentDisposition = response.headers.get('content-disposition'); + const contentLength = response.headers.get('content-length'); + const body = Buffer.from(await response.arrayBuffer()); + + res.status(response.status); + res.setHeader('Content-Type', contentType); + if (contentDisposition) res.setHeader('Content-Disposition', contentDisposition); + if (contentLength) res.setHeader('Content-Length', contentLength); + res.send(body); + return; + } catch { + res.status(502).json({ message: '관리 콘솔 API 첨부파일 조회에 실패했습니다.' }); + return; + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); @@ -105,4 +138,4 @@ const handler = createNextApiHandler({ }, }); -export default handler; \ No newline at end of file +export default handler; diff --git a/apps/web/src/pages/api/support/tickets/[ticketId]/comments.ts b/apps/web/src/pages/api/support/tickets/[ticketId]/comments.ts index 7799b34..968b0bb 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId]/comments.ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId]/comments.ts @@ -24,9 +24,11 @@ import { getSupportPrincipal, applySupportCommentPermissions, } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const handler = createNextApiHandler({ GET: async (req, res) => { @@ -47,6 +49,23 @@ const handler = createNextApiHandler({ if (abcFeedbackId) { query.set('abcFeedbackId', abcFeedbackId); } + + if (isFeedbackOnly) { + try { + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/comments`)}${suffix}`, + { headers: getSupportAuthHeaders(req) }, + ); + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch { + return res + .status(503) + .json({ message: '관리 콘솔 API 연결에 실패했습니다.' }); + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); @@ -112,6 +131,29 @@ const handler = createNextApiHandler({ query.set('abcFeedbackId', abcFeedbackId); } + if (isFeedbackOnly) { + try { + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/comments`)}${suffix}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getSupportAuthHeaders(req), + }, + body: JSON.stringify(req.body), + }, + ); + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch { + return res + .status(503) + .json({ message: '관리 콘솔 API 연결에 실패했습니다.' }); + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); diff --git a/apps/web/src/pages/api/support/tickets/[ticketId]/comments/[commentId].ts b/apps/web/src/pages/api/support/tickets/[ticketId]/comments/[commentId].ts index 1a3fccd..2a0bca6 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId]/comments/[commentId].ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId]/comments/[commentId].ts @@ -26,9 +26,11 @@ import { applySupportCommentPermissions, isSupportAdmin, } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const handler = createNextApiHandler({ PUT: async (req, res) => { @@ -47,6 +49,29 @@ const handler = createNextApiHandler({ if (workspaceCode) query.set('workspaceCode', workspaceCode); if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId); + if (isFeedbackOnly) { + try { + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/comments/${commentId}`)}${suffix}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getSupportAuthHeaders(req), + }, + body: JSON.stringify(req.body), + }, + ); + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch { + return res + .status(503) + .json({ message: '관리 콘솔 API 연결에 실패했습니다.' }); + } + } + try { let isAbcMapped = true; try { @@ -140,6 +165,28 @@ const handler = createNextApiHandler({ if (workspaceCode) query.set('workspaceCode', workspaceCode); if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId); + if (isFeedbackOnly) { + try { + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/comments/${commentId}`)}${suffix}`, + { + method: 'DELETE', + headers: getSupportAuthHeaders(req), + }, + ); + + if (response.status === 204) return res.status(204).end(); + + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch { + return res + .status(503) + .json({ message: '관리 콘솔 API 연결에 실패했습니다.' }); + } + } + try { let isAbcMapped = true; try { diff --git a/apps/web/src/pages/api/support/tickets/[ticketId]/comments/attachments.ts b/apps/web/src/pages/api/support/tickets/[ticketId]/comments/attachments.ts index aad4bc2..3bd4e0f 100644 --- a/apps/web/src/pages/api/support/tickets/[ticketId]/comments/attachments.ts +++ b/apps/web/src/pages/api/support/tickets/[ticketId]/comments/attachments.ts @@ -24,9 +24,11 @@ import { getSupportAbcTargetConfig, } from '@/server/support-abc'; import { getSupportAuthHeaders, getSupportPrincipal } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024; const parseMultipart = (req: NextApiRequest) => { @@ -76,6 +78,45 @@ const handler = createNextApiHandler({ if (workspaceCode) query.set('workspaceCode', workspaceCode); if (abcFeedbackId) query.set('abcFeedbackId', abcFeedbackId); + if (isFeedbackOnly) { + try { + const { content, isInternal, files } = await parseMultipart(req); + const formData = new FormData(); + formData.append('content', content); + formData.append('is_internal', String(isInternal)); + + for (const file of files) { + const buffer = await readFile(file.filepath); + formData.append( + 'attachments', + new Blob([new Uint8Array(buffer)], { + type: file.mimetype ?? 'application/octet-stream', + }), + file.originalFilename ?? 'comment-attachment.bin', + ); + } + + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + `${supportExternalUrl(`/tickets/${ticketId}/comments/attachments`)}${suffix}`, + { + method: 'POST', + headers: getSupportAuthHeaders(req), + body: formData, + }, + ); + const data = (await response.json()) as unknown; + return res.status(response.status).json(data); + } catch (error) { + return res.status(502).json({ + message: + error instanceof Error ? + error.message + : '관리 콘솔 API 연결에 실패했습니다.', + }); + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); diff --git a/apps/web/src/pages/api/support/workspaces.ts b/apps/web/src/pages/api/support/workspaces.ts index 6589f2e..4aeca2d 100644 --- a/apps/web/src/pages/api/support/workspaces.ts +++ b/apps/web/src/pages/api/support/workspaces.ts @@ -16,6 +16,7 @@ import { createNextApiHandler } from '@/server/api-handler'; import { getSupportAuthHeaders } from '@/server/support-auth'; import { listSupportWorkspaces } from '@/server/support-stub'; +import { supportExternalUrl } from '@/server/support-external'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; @@ -23,7 +24,11 @@ const supportApiBaseUrl = const handler = createNextApiHandler({ GET: async (req, res) => { try { - const response = await fetch(`${supportApiBaseUrl}/api/workspaces`, { + const endpoint = + process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true' ? + supportExternalUrl('/workspaces') + : `${supportApiBaseUrl}/api/workspaces`; + const response = await fetch(endpoint, { headers: getSupportAuthHeaders(req), }); const data = (await response.json()) as unknown; @@ -35,4 +40,4 @@ const handler = createNextApiHandler({ }, }); -export default handler; \ No newline at end of file +export default handler; diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/form-template.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/form-template.ts index 0adf2b2..79e03de 100644 --- a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/form-template.ts +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/form-template.ts @@ -14,102 +14,41 @@ * under the License. */ import { createNextApiHandler } from '@/server/api-handler'; -import { - getSupportAbcTargetConfig, - listSupportAbcFields, -} from '@/server/support-abc'; import { getSupportAuthHeaders } from '@/server/support-auth'; - -const supportApiBaseUrl = - process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; - -const toPublicFieldCode = (fieldKey: string) => { - if (fieldKey.toLowerCase() === 'ip') return 'ip_address'; - if (fieldKey.toLowerCase().replace(/[_-]/g, '') === 'macaddress') { - return 'mac_address'; - } - if (fieldKey.toLowerCase() === 'category') return 'category'; - return fieldKey; -}; - -const toWorkspaceFormTemplate = ( - workspaceCode: string, - fields: Awaited>, -) => { - const visibleFields = fields - .filter((field) => field.status === 'ACTIVE') - .filter((field) => !['images', 'aiField'].includes(field.format)) - .sort((left, right) => { - const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; - const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; - return leftOrder - rightOrder || left.id - right.id; - }); - - return { - workspace_code: workspaceCode, - workspace_name: workspaceCode, - requires_approval: false, - fields: visibleFields.map((field) => ({ - field_code: - field.key === 'contents' || field.key === 'message' - ? 'description' - : toPublicFieldCode(field.key), - label: field.name, - field_type: - field.key === 'contents' || field.key === 'message' - ? 'textarea' - : field.format === 'select' || field.format === 'multiSelect' - ? 'select' - : 'text', - required: - ['title', 'contents', 'message'].includes(field.key) || - toPublicFieldCode(field.key) === 'category', - options: field.options ?? [], - })), - }; -}; +import { supportExternalUrl } from '@/server/support-external'; const handler = createNextApiHandler({ GET: async (req, res) => { - const workspaceCode = req.query.workspaceCode as string; + const workspaceCode = + typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : ''; - try { - getSupportAbcTargetConfig(workspaceCode); - const fields = await listSupportAbcFields(workspaceCode); - - return res - .status(200) - .json(toWorkspaceFormTemplate(workspaceCode, fields)); - } catch (error) { - if ( - error instanceof Error && - error.message.startsWith('No ABC mapping configured') - ) { - // Non-feedback workspaces still use the Secretary form definition. - } else { - return res.status(502).json({ - message: - error instanceof Error - ? error.message - : 'ABC feedback form fields could not be loaded', - }); - } + if (!workspaceCode) { + return res.status(400).json({ message: 'workspaceCode가 필요합니다.' }); } try { const response = await fetch( - `${supportApiBaseUrl}/api/workspaces/${encodeURIComponent(workspaceCode)}/form-template`, - { headers: getSupportAuthHeaders(req) }, + supportExternalUrl( + `/workspaces/${encodeURIComponent(workspaceCode)}/form-template`, + ), + { + headers: getSupportAuthHeaders(req), + signal: AbortSignal.timeout(10000), + }, ); - const data = (await response.json()) as unknown; + const raw = await response.text(); - return res.status(response.status).json(data); + try { + return res.status(response.status).json(raw ? JSON.parse(raw) : {}); + } catch { + return res.status(response.status).json({ message: raw.slice(0, 1000) }); + } } catch { return res.status(502).json({ - message: 'Support workspace form template could not be loaded', + message: '관리 콘솔 API에서 피드백 양식을 불러오지 못했습니다.', }); } }, }); -export default handler; \ No newline at end of file +export default handler; diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts new file mode 100644 index 0000000..80f6efa --- /dev/null +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts @@ -0,0 +1,188 @@ +import { readFile } from 'node:fs/promises'; +import type { NextApiRequest } from 'next'; +import formidable from 'formidable'; +import type { + Fields as FormidableFields, + File as FormidableFile, + Files as FormidableFiles, +} from 'formidable'; + +import { createNextApiHandler } from '@/server/api-handler'; +import { getSupportAuthHeaders } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; + +const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024; +const MAX_ATTACHMENT_COUNT = 10; + +const parseMultipartBody = (req: NextApiRequest) => + new Promise<{ + fields: Record; + files: FormidableFile[]; + }>((resolve, reject) => { + const form = formidable({ + multiples: true, + maxFiles: MAX_ATTACHMENT_COUNT, + maxFileSize: MAX_UPLOAD_SIZE_BYTES, + maxTotalFileSize: MAX_UPLOAD_SIZE_BYTES, + keepExtensions: true, + }); + + form.parse( + req, + ( + error: Error | null, + fields: FormidableFields, + files: FormidableFiles, + ) => { + if (error) { + reject(error); + return; + } + + const attachments = Object.entries(files) + .filter(([fieldName]) => fieldName === 'attachments') + .flatMap(([, value]) => (Array.isArray(value) ? value : [value])) + .filter((file): file is FormidableFile => file !== undefined); + + resolve({ + fields: fields as Record, + files: attachments, + }); + }, + ); + }); + +const readJsonOrMessage = async (response: Response) => { + const raw = await response.text(); + if (!raw.trim()) return {}; + + try { + return JSON.parse(raw) as unknown; + } catch { + return { message: raw.trim().slice(0, 1000) }; + } +}; + +const isUploadSizeError = (error: unknown) => { + if (!error || typeof error !== 'object') return false; + + const candidate = error as { + code?: unknown; + httpCode?: unknown; + message?: unknown; + }; + const message = typeof candidate.message === 'string' ? candidate.message : ''; + + return ( + candidate.httpCode === 413 || + candidate.code === 'ETOOBIG' || + /(?:file|request).*(?:too large|max.*size)|maxTotalFileSize|maxFileSize/i.test( + message, + ) + ); +}; + +const forwardMultipart = async ( + workspaceCode: string, + fields: Record, + files: FormidableFile[], + headers: Record, +) => { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + value.forEach((item) => formData.append(key, item)); + } else { + formData.append(key, value); + } + } + + for (const file of files) { + const buffer = await readFile(file.filepath); + formData.append( + 'attachments', + new Blob([new Uint8Array(buffer)], { + type: file.mimetype ?? 'application/octet-stream', + }), + file.originalFilename ?? 'attachment.bin', + ); + } + + return fetch( + supportExternalUrl( + `/workspaces/${encodeURIComponent(workspaceCode)}/tickets`, + ), + { + method: 'POST', + headers, + body: formData, + signal: AbortSignal.timeout(60000), + }, + ); +}; + +const handler = createNextApiHandler({ + POST: async (req, res) => { + const workspaceCode = + typeof req.query.workspaceCode === 'string' ? req.query.workspaceCode : ''; + const contentType = req.headers['content-type'] ?? ''; + + if (!workspaceCode) { + return res.status(400).json({ message: 'workspaceCode가 필요합니다.' }); + } + + try { + let response: Response; + + if (contentType.startsWith('multipart/form-data')) { + const { fields, files } = await parseMultipartBody(req); + response = await forwardMultipart( + workspaceCode, + fields, + files, + getSupportAuthHeaders(req), + ); + } else { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array), + ); + } + + response = await fetch( + supportExternalUrl( + `/workspaces/${encodeURIComponent(workspaceCode)}/tickets`, + ), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getSupportAuthHeaders(req), + }, + body: Buffer.concat(chunks), + signal: AbortSignal.timeout(60000), + }, + ); + } + + return res.status(response.status).json(await readJsonOrMessage(response)); + } catch (error) { + return res.status(isUploadSizeError(error) ? 413 : 502).json({ + message: + isUploadSizeError(error) ? + '첨부파일은 파일당 최대 30MB, 전체 최대 30MB까지 업로드할 수 있습니다.' + : '관리 콘솔 API로 피드백을 등록하지 못했습니다.', + }); + } + }, +}); + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default handler; diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts index 7fb9c2d..b219f23 100644 --- a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/tickets.ts @@ -34,6 +34,7 @@ import { getSupportAuthHeaders, getSupportPrincipal, } from '@/server/support-auth'; +import { supportExternalUrl } from '@/server/support-external'; import type { SupportPrincipal } from '@/server/support-auth'; import { createSupportTicketStub, @@ -45,6 +46,7 @@ import type { SupportTicketRecord } from '@/server/support-types'; const supportApiBaseUrl = process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +const isFeedbackOnly = process.env.NEXT_PUBLIC_FEEDBACK_ONLY === 'true'; const MAX_UPLOAD_SIZE_BYTES = 30 * 1024 * 1024; const parseJsonBody = async (req: NextApiRequest) => { @@ -359,6 +361,30 @@ const handler = createNextApiHandler({ const requesterId = req.query.requesterId as string | undefined; const requesterTenantId = req.query.requesterTenantId as string | undefined; + // The feedback-only writer must read the canonical list from the + // independent Secretary/management API. The legacy branch below reads + // ABC directly and requires a web-container API key, which is not part of + // this deployment by design. + if (isFeedbackOnly) { + try { + const query = new URLSearchParams(); + if (requesterId) query.set('requesterId', requesterId); + if (requesterTenantId) query.set('requesterTenantId', requesterTenantId); + const suffix = query.toString() ? `?${query.toString()}` : ''; + const response = await fetch( + supportExternalUrl( + `/workspaces/${encodeURIComponent(workspaceCode)}/tickets${suffix}`, + ), + { headers: getSupportAuthHeaders(req) }, + ); + return res.status(response.status).json(await readJsonOrMessage(response)); + } catch { + return res.status(502).json({ + message: '관리 콘솔 API에서 피드백 목록을 불러오지 못했습니다.', + }); + } + } + let isAbcMapped = true; try { getSupportAbcTargetConfig(workspaceCode); diff --git a/apps/web/src/pages/auth/sign-in.tsx b/apps/web/src/pages/auth/sign-in.tsx index 9d01ab7..b0cef10 100644 --- a/apps/web/src/pages/auth/sign-in.tsx +++ b/apps/web/src/pages/auth/sign-in.tsx @@ -13,65 +13,18 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { useEffect, useState } from 'react'; import type { GetStaticProps } from 'next'; -import Link from 'next/link'; -import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'next-i18next'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { Button, toast } from '@ufb/react'; - -import { AnonymousTemplate, TextInput } from '@/shared'; -import type { IFetchError, NextPageWithLayout } from '@/shared/types'; -import { useTenantStore } from '@/entities/tenant'; -import { useUserStore } from '@/entities/user'; +import { AnonymousTemplate } from '@/shared'; +import type { NextPageWithLayout } from '@/shared/types'; import { SignInWithOAuthButton } from '@/features/auth/sign-in-with-oauth'; import { AnonymousLayout } from '@/widgets/anonymous-layout'; import serverSideTranslations from '@/server-side-translations'; -const signInWithEmailSchema = z.object({ - email: z.email(), - password: z.string().min(8), -}); - -type FormType = z.infer; - const SignInPage: NextPageWithLayout = () => { const { t } = useTranslation(); - const { tenant, refetchTenant } = useTenantStore(); - const { signInWithEmail } = useUserStore(); - const [loginLoading, setLoginLoading] = useState(false); - - const { handleSubmit, register, formState, setError } = useForm({ - resolver: zodResolver(signInWithEmailSchema), - defaultValues: { email: '', password: '' }, - }); - - useEffect(() => { - if (tenant) return; - - void refetchTenant().catch(() => { - // Keep the preview fallback message when the tenant API is unavailable. - }); - }, [tenant, refetchTenant]); - - const onSubmit = async (data: FormType) => { - try { - setLoginLoading(true); - await signInWithEmail(data); - toast.success(t('v2.toast.success')); - } catch (error) { - const { message } = error as IFetchError; - setError('email', { message: 'invalid email' }); - setError('password', { message: 'invalid password' }); - toast.error(message); - } finally { - setLoginLoading(false); - } - }; return ( {

} > - {!tenant && ( -
- 로컬 미리보기에서는 tenant 설정 API(`/api/admin/tenants`)가 없어 로그인 입력칸이 표시되지 않습니다. -
- 지원 화면 확인은 `/support/INTRA_BOOK_REQUEST/new`, `/ops`, `/admin/issues` 같은 공개 미리보기 경로를 직접 사용해 주세요. -
- )} - {tenant?.useOAuth && } - {tenant?.useOAuth && tenant.useEmail && ( -
-
- or With Email -
-
- )} - {tenant?.useEmail && ( -
- - - - )} - {tenant?.useEmail && ( -
- -
- - {t('link.reset-password.title')} - - - {t('button.sign-up')} - -
-
- )} + +

+ BARON-SSO 로그인 후 EGBIM_DEMO 피드백 목록으로 이동합니다. +

); }; diff --git a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx index ee62782..101a22c 100644 --- a/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx +++ b/apps/web/src/pages/support/[workspaceCode]/[ticketId].tsx @@ -30,7 +30,6 @@ import { formatSupportTimestamp } from '@/features/support-portal/lib/format-sup import { getSupportCategoryLabel } from '@/features/support-portal/lib/support-category'; import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui'; -import serverSideTranslations from '@/server-side-translations'; import type { SupportTicketRecord } from '@/server/support-types'; interface SupportTicketCommentRecord { @@ -839,6 +838,7 @@ const SupportDetailPage: NextPageWithLayout = () => { + attachment.download_url ?? `/api/support/tickets/${ticketId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(workspaceCode)}`, )} names={(comment.attachments ?? []).map( @@ -914,10 +914,15 @@ SupportDetailPage.getLayout = (page: React.ReactNode) => { return page; }; -export const getServerSideProps: GetServerSideProps = async ({ locale }) => { +export const getServerSideProps: GetServerSideProps = async ({ params }) => { + const workspaceCode = + typeof params?.workspaceCode === 'string' ? params.workspaceCode : 'EGBIM_DEMO'; + return { props: { - ...(await serverSideTranslations(locale)), + workspaceCode, + ticketId: + typeof params?.ticketId === 'string' ? params.ticketId : null, }, }; }; diff --git a/apps/web/src/pages/support/[workspaceCode]/index.tsx b/apps/web/src/pages/support/[workspaceCode]/index.tsx index 40a09b7..2d82f67 100644 --- a/apps/web/src/pages/support/[workspaceCode]/index.tsx +++ b/apps/web/src/pages/support/[workspaceCode]/index.tsx @@ -21,7 +21,7 @@ export const getServerSideProps: GetServerSideProps = ({ params }) => { return Promise.resolve({ redirect: { - destination: `/support/${workspaceCode}/list`, + destination: `/support/${encodeURIComponent(workspaceCode)}/list`, permanent: false, }, }); @@ -29,4 +29,4 @@ export const getServerSideProps: GetServerSideProps = ({ params }) => { const SupportWorkspaceIndexPage = () => null; -export default SupportWorkspaceIndexPage; \ No newline at end of file +export default SupportWorkspaceIndexPage; diff --git a/apps/web/src/pages/support/[workspaceCode]/list.tsx b/apps/web/src/pages/support/[workspaceCode]/list.tsx index 74c0d0c..0cd756d 100644 --- a/apps/web/src/pages/support/[workspaceCode]/list.tsx +++ b/apps/web/src/pages/support/[workspaceCode]/list.tsx @@ -28,7 +28,6 @@ import { import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui'; import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui'; -import serverSideTranslations from '@/server-side-translations'; import type { SupportTicketRecord, WorkspaceSummary, @@ -506,10 +505,13 @@ SupportListPage.getLayout = (page: React.ReactNode) => { return page; }; -export const getServerSideProps: GetServerSideProps = async ({ locale }) => { +export const getServerSideProps: GetServerSideProps = async ({ params }) => { + const workspaceCode = + typeof params?.workspaceCode === 'string' ? params.workspaceCode : 'EGBIM_DEMO'; + return { props: { - ...(await serverSideTranslations(locale)), + workspaceCode, }, }; }; diff --git a/apps/web/src/pages/support/[workspaceCode]/new.tsx b/apps/web/src/pages/support/[workspaceCode]/new.tsx index 429fd1e..3128414 100644 --- a/apps/web/src/pages/support/[workspaceCode]/new.tsx +++ b/apps/web/src/pages/support/[workspaceCode]/new.tsx @@ -21,6 +21,7 @@ import { useRouter } from 'next/router'; import { toast } from '@ufb/react'; import { + DEFAULT_SUPPORT_WORKSPACE_CODE, DescriptionTooltip, fetchWithAuthRefresh, } from '@/shared'; @@ -28,7 +29,10 @@ import type { NextPageWithLayout } from '@/shared/types'; import SupportPortalShell from '@/features/support-portal/ui/support-portal-shell.ui'; import serverSideTranslations from '@/server-side-translations'; -import type { WorkspaceFormTemplateResponse } from '@/server/support-types'; +import type { + WorkspaceFormField, + WorkspaceFormTemplateResponse, +} from '@/server/support-types'; interface TicketCreateResponse { ticket_id: number; @@ -39,6 +43,14 @@ interface TicketCreateResponse { message: string; } +interface ApiErrorResponse { + message?: string; + detail?: string; + error?: { + message?: string; + }; +} + interface AttachmentItem { id: string; name: string; @@ -90,6 +102,67 @@ const allowedFieldCodes = [ 'mac_address', ]; +const demoCompatibilityFields: WorkspaceFormField[] = [ + { + field_code: 'category', + label: '구분', + field_type: 'select', + required: true, + options: [ + { id: 1, key: 'ERROR_QNA', name: '오류 문의' }, + { id: 2, key: 'IMPROVEMENT_QNA', name: '개선 문의' }, + { id: 3, key: 'GENERAL_QNA', name: '일반 문의' }, + ], + }, + { + field_code: 'title', + label: '제목', + field_type: 'text', + required: true, + }, + { + field_code: 'description', + label: '내용', + field_type: 'textarea', + required: true, + }, + { + field_code: 'ip_address', + label: '사용자 IP 주소', + field_type: 'text', + required: false, + }, + { + field_code: 'mac_address', + label: 'MAC 주소', + field_type: 'text', + required: false, + }, +]; + +const normalizeDemoTemplate = ( + workspaceCode: string, + template: WorkspaceFormTemplateResponse, +) => { + if (workspaceCode !== DEFAULT_SUPPORT_WORKSPACE_CODE) return template; + + const fieldsByCode = new Map( + template.fields.map((field) => [field.field_code, field]), + ); + const normalizedFields = demoCompatibilityFields.map( + (fallbackField) => fieldsByCode.get(fallbackField.field_code) ?? fallbackField, + ); + const knownCodes = new Set(demoCompatibilityFields.map((field) => field.field_code)); + + return { + ...template, + fields: [ + ...normalizedFields, + ...template.fields.filter((field) => !knownCodes.has(field.field_code)), + ], + }; +}; + const fieldHelp: Record = { ip_address: 'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.', @@ -97,8 +170,6 @@ const fieldHelp: Record = { 'Windows: 명령 프롬프트에서 ipconfig /all을 실행한 뒤 Physical Address를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > 하드웨어에서 확인하세요.', }; -const TEST_PROJECT_NAME = 'Q&A_Platform'; - const SupportNewPage: NextPageWithLayout = () => { const router = useRouter(); const workspaceCode = @@ -153,7 +224,10 @@ const SupportNewPage: NextPageWithLayout = () => { ); } - const loadedTemplate = data as WorkspaceFormTemplateResponse; + const loadedTemplate = normalizeDemoTemplate( + workspaceCode, + data as WorkspaceFormTemplateResponse, + ); setTemplate(loadedTemplate); setFieldValues( loadedTemplate.fields.reduce>( @@ -275,7 +349,7 @@ const SupportNewPage: NextPageWithLayout = () => { } const response = await fetchWithAuthRefresh( - `/api/support/workspaces/${encodedWorkspaceCode}/tickets`, + `/api/support/workspaces/${encodedWorkspaceCode}/submit`, { method: 'POST', body: formData, @@ -284,25 +358,26 @@ const SupportNewPage: NextPageWithLayout = () => { const data = (await response.json()) as | TicketCreateResponse - | { message?: string }; + | ApiErrorResponse; if (!response.ok) { + const errorResponse = data as ApiErrorResponse; throw new Error( - 'message' in data && data.message ? - data.message - : '티켓 생성 요청에 실패했습니다.', + errorResponse.message || + errorResponse.detail || + errorResponse.error?.message || + '티켓 생성 요청에 실패했습니다.', ); } const created = data as TicketCreateResponse; setCreatedTicketId(created.ticket_id); setSubmitMessage( - `ticket_id=${created.ticket_id}, status=${created.status_code}, sync=${created.sync_status}`, + `등록이 완료되었습니다. 첨부파일 ${attachments.length}개가 포함되었습니다.`, ); toast.success('지원 요청이 생성되었습니다.'); - await router.push( - `/support/${encodedWorkspaceCode}/list`, - ); + await router.push(`/support/${encodedWorkspaceCode}/list`); + return; } catch (error) { const message = error instanceof Error ? @@ -317,8 +392,11 @@ const SupportNewPage: NextPageWithLayout = () => { return ( + typeof value === 'string' && value.trim() ? value.trim() : null; + +const asStringArray = (value: unknown) => + Array.isArray(value) + ? value.filter((item): item is string => Boolean(asString(item))) + : []; + +const configured = (name: string) => process.env[name]?.trim() ?? ''; +const getIssuer = () => configured('SSO_ISSUER') || 'https://sso.hmac.kr/oidc'; +const getEndpoint = (name: string, fallback: string) => configured(name) || fallback; + +export const getRedirectUri = (req: NextApiRequest) => { + const configuredUri = configured('SSO_REDIRECT_URI'); + if (configuredUri) return configuredUri; + + const forwardedProto = req.headers['x-forwarded-proto']; + const protocol = + typeof forwardedProto === 'string' ? forwardedProto.split(',')[0] : 'http'; + const host = req.headers.host; + if (!host) throw new Error('요청 호스트를 확인할 수 없습니다.'); + return `${protocol}://${host}/api/auth/baron-sso/callback`; +}; + +const makeCookie = ( + name: string, + value: string, + options: { maxAge?: number; httpOnly?: boolean; secure?: boolean } = {}, +) => { + const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'SameSite=Lax']; + if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); + if (options.httpOnly) parts.push('HttpOnly'); + if (options.secure) parts.push('Secure'); + return parts.join('; '); +}; + +const isSecureRequest = (req: NextApiRequest) => { + const forwardedProto = req.headers['x-forwarded-proto']; + return ( + (typeof forwardedProto === 'string' && forwardedProto.split(',')[0] === 'https') || + (process.env.NODE_ENV === 'production' && Boolean(process.env.COOKIE_SECURE)) + ); +}; + +const safeReturnTo = (value: unknown) => + typeof value === 'string' && /^\/support\/[^/]+\/(?:list|new)(?:\?.*)?$/.test(value) + ? value + : '/support/EGBIM_DEMO/list'; + +const requireClientConfig = () => { + const clientId = configured('SSO_CLIENT_ID'); + const clientSecret = configured('SSO_CLIENT_SECRET'); + if (!clientId || !clientSecret) { + throw new Error('SSO_CLIENT_ID와 SSO_CLIENT_SECRET이 설정되지 않았습니다.'); + } + return { clientId, clientSecret }; +}; + +export const buildLoginUrl = (req: NextApiRequest) => { + const { clientId } = requireClientConfig(); + const state = randomBytes(32).toString('base64url'); + const url = new URL( + getEndpoint('SSO_AUTHORIZATION_ENDPOINT', `${getIssuer()}/oauth2/auth`), + ); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', getRedirectUri(req)); + url.searchParams.set('scope', configured('SSO_SCOPE') || 'openid profile email'); + url.searchParams.set('state', state); + return { state, url: url.toString() }; +}; + +export const setLoginCookies = ( + req: NextApiRequest, + res: NextApiResponse, + state: string, + returnTo: unknown, +) => { + const secure = isSecureRequest(req); + res.setHeader('Set-Cookie', [ + makeCookie(STATE_COOKIE, state, { maxAge: 600, httpOnly: true, secure }), + makeCookie(RETURN_COOKIE, safeReturnTo(returnTo), { maxAge: 600, httpOnly: true, secure }), + ]); +}; + +const exchangeCode = async (req: NextApiRequest, code: string) => { + const { clientId, clientSecret } = requireClientConfig(); + const response = await fetch( + getEndpoint('SSO_TOKEN_ENDPOINT', `${getIssuer()}/oauth2/token`), + { + method: 'POST', + headers: { + // BARON-SSO requires confidential clients to authenticate at the + // token endpoint using HTTP Basic (RFC 6749 client_secret_basic). + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: getRedirectUri(req), + }), + }, + ); + const data = (await response.json()) as { + access_token?: unknown; + id_token?: unknown; + error_description?: unknown; + }; + if (!response.ok || typeof data.access_token !== 'string') { + throw new Error( + asString(data.error_description) ?? 'BARON-SSO 토큰 교환에 실패했습니다.', + ); + } + return { + accessToken: data.access_token, + idToken: asString(data.id_token) ?? undefined, + }; +}; + +const getUserInfo = async (accessToken: string): Promise => { + const response = await fetch( + getEndpoint('SSO_USERINFO_ENDPOINT', `${getIssuer()}/userinfo`), + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + const data = (await response.json()) as OidcUserInfo; + if (!response.ok) throw new Error('BARON-SSO 사용자 정보를 가져오지 못했습니다.'); + return data; +}; + +const signHs256 = (payload: Record, secret: string) => { + const encode = (value: string) => Buffer.from(value).toString('base64url'); + const header = encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = encode(JSON.stringify(payload)); + const unsigned = `${header}.${body}`; + const signature = createHmac('sha256', secret).update(unsigned).digest('base64url'); + return `${unsigned}.${signature}`; +}; + +const createSupportSession = (info: OidcUserInfo) => { + const jwtSecret = configured('JWT_SECRET'); + if (!jwtSecret) throw new Error('JWT_SECRET이 설정되지 않았습니다.'); + const subject = asString(info.sub) ?? asString(info.user_id) ?? asString(info.id); + const tenantId = + configured('SUPPORT_TENANT_ID') || + asString(info.tenant_id) || + asString(info.tenantId) || + asStringArray(info.tenant_ids ?? info.tenantIds)[0] || + ''; + if (!subject || !tenantId) { + throw new Error( + 'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.', + ); + } + const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds); + if (!tenantIds.includes(tenantId)) tenantIds.unshift(tenantId); + const now = Math.floor(Date.now() / 1000); + return signHs256( + { + sub: subject, + sso_sub: subject, + tenant_id: tenantId, + tenant_ids: tenantIds, + email: asString(info.email), + name: asString(info.name), + department: asString(info.department), + phone_number: asString(info.phone_number), + type: 'GENERAL', + iat: now, + exp: now + SESSION_MAX_AGE, + }, + jwtSecret, + ); +}; + +export const completeLogin = async (req: NextApiRequest, res: NextApiResponse) => { + const state = typeof req.query.state === 'string' ? req.query.state : ''; + const code = typeof req.query.code === 'string' ? req.query.code : ''; + if (!state || !code || !req.cookies[STATE_COOKIE] || state !== req.cookies[STATE_COOKIE]) { + throw new Error('BARON-SSO state가 유효하지 않습니다. 로그인부터 다시 시도하세요.'); + } + const tokens = await exchangeCode(req, code); + const userInfo = await getUserInfo(tokens.accessToken); + const sessionToken = createSupportSession(userInfo); + const secure = isSecureRequest(req); + res.setHeader('Set-Cookie', [ + makeCookie(SESSION_COOKIE, JSON.stringify({ accessToken: sessionToken, refreshToken: '' }), { + maxAge: SESSION_MAX_AGE, + httpOnly: true, + secure, + }), + makeCookie(STATE_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), + makeCookie(RETURN_COOKIE, '', { maxAge: 0, httpOnly: true, secure }), + ]); + return safeReturnTo(req.cookies[RETURN_COOKIE]); +}; + +export const clearSession = (req: NextApiRequest, res: NextApiResponse) => { + const secure = isSecureRequest(req); + res.setHeader('Set-Cookie', makeCookie(SESSION_COOKIE, '', { maxAge: 0, httpOnly: true, secure })); +}; diff --git a/apps/web/src/server/support-auth.ts b/apps/web/src/server/support-auth.ts index aa98eba..2187f7e 100644 --- a/apps/web/src/server/support-auth.ts +++ b/apps/web/src/server/support-auth.ts @@ -15,10 +15,9 @@ */ import type { NextApiRequest } from 'next'; -type StoredJwt = { accessToken?: string }; +import { supportExternalUrl } from './support-external'; -const supportApiBaseUrl = - process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010'; +type StoredJwt = { accessToken?: string }; export interface SupportPrincipal { user_id: string; @@ -88,7 +87,7 @@ export const getSupportPrincipal = async ( if (!headers.Authorization) return null; try { - const response = await fetch(`${supportApiBaseUrl}/api/access/me`, { + const response = await fetch(supportExternalUrl('/access'), { headers, }); if (!response.ok) return null; diff --git a/apps/web/src/server/support-external.ts b/apps/web/src/server/support-external.ts new file mode 100644 index 0000000..639c97c --- /dev/null +++ b/apps/web/src/server/support-external.ts @@ -0,0 +1,20 @@ +/** + * URL builder for the existing management console's support API. + * + * The console exposes Secretary through `/api/support`. A local development + * environment may instead provide the Secretary service directly at a base + * URL such as `http://127.0.0.1:8010`; both forms are supported here. + */ +const configuredBaseUrl = + process.env.SUPPORT_CONSOLE_API_BASE_URL ?? + process.env.SUPPORT_API_BASE_URL ?? + 'https://feedback.hmac.kr/api/support'; + +export const supportExternalUrl = (path: string) => { + const baseUrl = configuredBaseUrl.replace(/\/$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + return baseUrl.endsWith('/api/support') + ? `${baseUrl}${normalizedPath}` + : `${baseUrl}/api${normalizedPath}`; +}; diff --git a/apps/web/src/shared/constants/support-workspaces.ts b/apps/web/src/shared/constants/support-workspaces.ts index ee2b1db..bbbdd7a 100644 --- a/apps/web/src/shared/constants/support-workspaces.ts +++ b/apps/web/src/shared/constants/support-workspaces.ts @@ -22,21 +22,15 @@ export const supportWorkspaceChannelMap: Record< fieldKeys: string[]; } > = { - EGBIM: { - projectId: '1', - channelId: '1', - apiKey: '', - fieldKeys: ['title', 'contents'], - }, - 'Q&A_Platform': { - projectId: '1', - channelId: '1', + EGBIM_DEMO: { + projectId: '8', + channelId: '9', apiKey: '', fieldKeys: ['title', 'contents'], }, }; -export const DEFAULT_SUPPORT_WORKSPACE_CODE = 'EGBIM'; +export const DEFAULT_SUPPORT_WORKSPACE_CODE = 'EGBIM_DEMO'; export const getSupportWorkspaceCodeByProjectChannel = ( projectId: number | string, diff --git a/apps/web/src/shared/lib/fetch-with-auth-refresh.ts b/apps/web/src/shared/lib/fetch-with-auth-refresh.ts index 6019381..c7e5dfb 100644 --- a/apps/web/src/shared/lib/fetch-with-auth-refresh.ts +++ b/apps/web/src/shared/lib/fetch-with-auth-refresh.ts @@ -15,8 +15,6 @@ */ import { Path } from '@/shared/constants'; -import { env } from '@/env'; -import type { Jwt } from '../types/jwt.type'; import cookieStorage from './cookie-storage'; let refreshPromise: Promise | null = null; @@ -28,32 +26,14 @@ const refreshAccessToken = async (): Promise => { return false; } - const refreshUrl = new URL( - `${env.NEXT_PUBLIC_API_BASE_URL}/api/admin/auth/refresh`, - ); - refreshUrl.searchParams.set('_refresh', Date.now().toString()); - - const response = await fetch(refreshUrl, { - cache: 'no-store', - headers: { - Authorization: `Bearer ${currentJwt.refreshToken}`, - 'Cache-Control': 'no-cache', - }, - }); - - if (!response.ok) { - await cookieStorage.removeItem('jwt'); - return false; + // The feedback-only app has no management-console refresh endpoint. Its + // server-issued session is intentionally short-lived; expire it locally + // and require a fresh BARON-SSO login instead of contacting the console. + if (typeof window !== 'undefined') { + await fetch('/api/auth/sign-out', { method: 'POST' }); } - - const nextJwt = (await response.json()) as Jwt; - if (!nextJwt.accessToken || !nextJwt.refreshToken) { - await cookieStorage.removeItem('jwt'); - return false; - } - - await cookieStorage.setItem('jwt', nextJwt); - return true; + await cookieStorage.removeItem('jwt'); + return false; }; const tryRefreshAccessToken = () => { diff --git a/apps/web/src/shared/types/api.type.ts b/apps/web/src/shared/types/api.type.ts index 619ac2a..d3e867b 100644 --- a/apps/web/src/shared/types/api.type.ts +++ b/apps/web/src/shared/types/api.type.ts @@ -105,6 +105,7 @@ export interface paths { query?: { callback_url?: string; force_login?: string; + redirect_uri?: string; }; header?: never; path?: never; @@ -139,7 +140,10 @@ export interface paths { }; '/api/admin/auth/signIn/oauth': { parameters: { - query?: never; + query?: { + code?: string; + redirect_uri?: string; + }; header?: never; path?: never; cookie?: never; @@ -2668,6 +2672,7 @@ export interface operations { query?: { callback_url?: string; force_login?: string; + redirect_uri?: string; }; header?: never; path?: never; @@ -2710,6 +2715,7 @@ export interface operations { parameters: { query?: { code?: unknown; + redirect_uri?: string; }; header?: never; path?: never; diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml index bc1b84c..898f2e3 100644 --- a/docker/docker-compose.local.yml +++ b/docker/docker-compose.local.yml @@ -75,6 +75,7 @@ services: ABC_API_KEY: ${SECRETARY_ABC_API_KEY:-MASTER_API_KEY} UPLOAD_ROOT_DIR: /app/uploads UPLOAD_MAX_FILE_SIZE_MB: ${UPLOAD_MAX_FILE_SIZE_MB:-30} + DEFAULT_SUPPORT_WORKSPACE_CODE: ${DEFAULT_SUPPORT_WORKSPACE_CODE:-EGBIM_DEMO} ports: - "${SECRETARY_API_PORT:-8011}:8010" depends_on: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 60e2856..9e1c247 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,173 +1,40 @@ -name: abc-user-feedback-deploy +name: egbim-feedback-demo services: web: build: context: .. dockerfile: docker/web.dockerfile + args: + NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} + # Browser requests stay same-origin. Next.js proxies them server-side. + APP_NEXT_PUBLIC_API_BASE_URL: '' + APP_NEXT_PUBLIC_FEEDBACK_ONLY: 'true' environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - INTERNAL_API_BASE_URL: http://api:4000 - SUPPORT_API_BASE_URL: http://secretary-api:8010 - SECRETARY_ABC_API_KEY: ${SECRETARY_ABC_API_KEY:-} - ports: - - '${WEB_PORT:-3030}:3000' - depends_on: - api: - condition: service_started - secretary-api: - condition: service_started - restart: unless-stopped - - api: - build: - context: .. - dockerfile: docker/api.dockerfile - environment: - APP_ADDRESS: 0.0.0.0 - APP_PORT: 4000 - ADMIN_WEB_URL: ${ADMIN_WEB_URL} - BASE_URL: ${BASE_URL} - JWT_SECRET: ${JWT_SECRET} - INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${INITIAL_SUPER_ADMIN_PHONE_NUMBER:-} - ADMIN_CANDIDATE_EMAILS: ${ADMIN_CANDIDATE_EMAILS:-} - GITEA_API_URL: ${GITEA_API_URL:-https://gitea.hmac.kr/api/v1} - GITEA_API_TOKEN: ${GITEA_API_TOKEN:-} - GITHUB_API_TOKEN: ${GITHUB_API_TOKEN:-} - JIRA_API_EMAIL: ${JIRA_API_EMAIL:-} - JIRA_API_TOKEN: ${JIRA_API_TOKEN:-} - JIRA_WEBHOOK_SECRET: ${JIRA_WEBHOOK_SECRET:-} - JIRA_ISSUE_TYPE: ${JIRA_ISSUE_TYPE:-Task} - MYSQL_PRIMARY_URL: mysql://userfeedback:userfeedback@mysql:3306/userfeedback - SMTP_ENABLED: ${SMTP_ENABLED:-false} - SMTP_HOST: ${SMTP_HOST:-} - SMTP_PORT: ${SMTP_PORT:-25} - SMTP_SENDER: ${SMTP_SENDER:-} - SMTP_USERNAME: ${SMTP_USERNAME:-} - SMTP_PASSWORD: ${SMTP_PASSWORD:-} - SMTP_TLS: ${SMTP_TLS:-false} - SMTP_CIPHER_SPEC: ${SMTP_CIPHER_SPEC:-} - SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} - MASTER_API_KEY: ${MASTER_API_KEY:-} - SUPPORT_API_BASE_URL: http://secretary-api:8010 - ACCESS_TOKEN_EXPIRED_TIME: ${ACCESS_TOKEN_EXPIRED_TIME:-10m} - REFRESH_TOKEN_EXPIRED_TIME: ${REFRESH_TOKEN_EXPIRED_TIME:-1h} - AUTO_MIGRATION: ${AUTO_MIGRATION:-true} - OPENSEARCH_USE: ${OPENSEARCH_USE:-false} - OPENSEARCH_NODE: ${OPENSEARCH_NODE:-} - OPENSEARCH_USERNAME: ${OPENSEARCH_USERNAME:-} - OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD:-} - NAVER_WORKS_ENABLED: ${NAVER_WORKS_ENABLED:-false} - NAVER_WORKS_API_BASE_URL: ${NAVER_WORKS_API_BASE_URL:-https://www.worksapis.com/v1.0} - NAVER_WORKS_AUTH_URL: ${NAVER_WORKS_AUTH_URL:-https://auth.worksmobile.com/oauth2/v2.0/token} - NAVER_WORKS_ACCESS_TOKEN: ${NAVER_WORKS_ACCESS_TOKEN:-} - NAVER_WORKS_BOT_ID: ${NAVER_WORKS_BOT_ID:-} - NAVER_WORKS_DEFAULT_ROOM_ID: ${NAVER_WORKS_DEFAULT_ROOM_ID:-} - NAVER_WORKS_CLIENT_ID: ${NAVER_WORKS_CLIENT_ID:-} - NAVER_WORKS_CLIENT_SECRET: ${NAVER_WORKS_CLIENT_SECRET:-} - NAVER_WORKS_SERVICE_ACCOUNT: ${NAVER_WORKS_SERVICE_ACCOUNT:-} - NAVER_WORKS_PRIVATE_KEY: ${NAVER_WORKS_PRIVATE_KEY:-} - NAVER_WORKS_SCOPE: ${NAVER_WORKS_SCOPE:-bot.message bot} - NAVER_WORKS_MAX_RETRIES: ${NAVER_WORKS_MAX_RETRIES:-3} - NAVER_WORKS_MAX_MESSAGE_LENGTH: ${NAVER_WORKS_MAX_MESSAGE_LENGTH:-1000} - depends_on: - mysql: - condition: service_healthy - restart: unless-stopped - - secretary-api: - build: - context: .. - dockerfile: docker/secretary-api.dockerfile - environment: - APP_NAME: secretary-api - APP_ENV: ${APP_ENV:-staging} - APP_HOST: 0.0.0.0 - APP_PORT: 8010 - DOCS_ROOT_PATH: /secretary-docs - DATABASE_URL: mysql+pymysql://baron_support:baron_support@mysql-secretary:3306/baron_support - ABC_API_BASE_URL: http://api:4000 - ABC_API_KEY: ${SECRETARY_ABC_API_KEY:-} - MASTER_API_KEY: ${MASTER_API_KEY:-} - ADMIN_CANDIDATE_TENANT_ID: ${ADMIN_CANDIDATE_TENANT_ID:-} - INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${INITIAL_SUPER_ADMIN_PHONE_NUMBER:-} - SSO_ISSUER: ${SSO_ISSUER:-} + NODE_ENV: production + HOSTNAME: 0.0.0.0 + # Keep browser requests on 10.13.10.4:8864 to avoid CORS. + NEXT_PUBLIC_API_BASE_URL: '' + NEXT_PUBLIC_FEEDBACK_ONLY: 'true' + # Existing management console API. + SUPPORT_CONSOLE_API_BASE_URL: ${SUPPORT_CONSOLE_API_BASE_URL:-https://feedback.hmac.kr/api/support} + SSO_ISSUER: ${SSO_ISSUER:-https://sso.hmac.kr/oidc} + SSO_AUTHORIZATION_ENDPOINT: ${SSO_AUTHORIZATION_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/auth} + SSO_TOKEN_ENDPOINT: ${SSO_TOKEN_ENDPOINT:-https://sso.hmac.kr/oidc/oauth2/token} + SSO_USERINFO_ENDPOINT: ${SSO_USERINFO_ENDPOINT:-https://sso.hmac.kr/oidc/userinfo} + SSO_SCOPE: ${SSO_SCOPE:-openid profile email} + # These are server-only values. Do not prefix them with NEXT_PUBLIC_ and + # do not put them in the browser build. SSO_CLIENT_ID: ${SSO_CLIENT_ID:-} SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-} - JWT_SECRET: ${JWT_SECRET} - UPLOAD_ROOT_DIR: /app/uploads - UPLOAD_MAX_FILE_SIZE_MB: ${UPLOAD_MAX_FILE_SIZE_MB:-30} - depends_on: - api: - condition: service_started - mysql-secretary: - condition: service_healthy - volumes: - - ../uploads:/app/uploads - restart: unless-stopped - - mysql: - image: mysql:8.0 - command: - [ - '--default-authentication-plugin=mysql_native_password', - '--collation-server=utf8mb4_bin', - ] - environment: - MYSQL_ROOT_PASSWORD: userfeedback - MYSQL_DATABASE: userfeedback - MYSQL_USER: userfeedback - MYSQL_PASSWORD: userfeedback - TZ: UTC - # 스테이징 외부 DB 접속용 포트입니다. + JWT_SECRET: ${JWT_SECRET:-} + SUPPORT_TENANT_ID: ${SUPPORT_TENANT_ID:-} ports: - - '${MYSQL_PORT:-13306}:3306' + - '${WEB_PORT:-8864}:3000' healthcheck: - test: - [ - 'CMD-SHELL', - 'mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent', - ] - interval: 5s + test: ['CMD-SHELL', 'wget -q -O - http://127.0.0.1:3000/api/health >/dev/null'] + interval: 10s timeout: 5s - retries: 30 - start_period: 15s - volumes: - - mysql:/var/lib/mysql + retries: 12 + start_period: 20s restart: unless-stopped - - mysql-secretary: - hostname: mysql-secretary - image: mysql:8.0 - command: - [ - '--default-authentication-plugin=mysql_native_password', - '--collation-server=utf8mb4_bin', - '--default-time-zone=+09:00', - ] - environment: - MYSQL_ROOT_PASSWORD: baron_support - MYSQL_DATABASE: baron_support - MYSQL_USER: baron_support - MYSQL_PASSWORD: baron_support - TZ: Asia/Seoul - # 스테이징 외부 DB 접속용 포트입니다. - ports: - - '${MYSQL_SECRETARY_PORT:-13308}:3306' - healthcheck: - test: - [ - 'CMD-SHELL', - 'mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent', - ] - interval: 5s - timeout: 5s - retries: 30 - start_period: 15s - volumes: - - mysql-secretary:/var/lib/mysql - restart: unless-stopped - -volumes: - mysql: - mysql-secretary: diff --git a/docker/web.dockerfile b/docker/web.dockerfile index 352ee09..10973e1 100644 --- a/docker/web.dockerfile +++ b/docker/web.dockerfile @@ -1,6 +1,8 @@ # This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker. # Make sure you update both files! ARG NPM_REGISTRY=https://registry.npmjs.org +ARG APP_NEXT_PUBLIC_API_BASE_URL=https://feedback.hmac.kr +ARG APP_NEXT_PUBLIC_FEEDBACK_ONLY=true FROM node:24.14.1-alpine AS base ARG NPM_REGISTRY ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY} \ @@ -18,6 +20,8 @@ RUN turbo prune --scope=web --docker # Add lockfile and package.json's of isolated subworkspace FROM base AS installer +ARG APP_NEXT_PUBLIC_API_BASE_URL +ARG APP_NEXT_PUBLIC_FEEDBACK_ONLY RUN apk add --no-cache libc6-compat RUN apk --no-cache add --virtual .builds-deps build-base python3 @@ -37,7 +41,7 @@ COPY --from=builder /app/out/full/ . COPY turbo.json ./ -COPY --from=builder /app/apps/web/.env.build /app/apps/web/.env.production +RUN printf 'NEXT_PUBLIC_API_BASE_URL=%s\nNEXT_PUBLIC_FEEDBACK_ONLY=%s\n' "$APP_NEXT_PUBLIC_API_BASE_URL" "$APP_NEXT_PUBLIC_FEEDBACK_ONLY" > /app/apps/web/.env.production RUN SKIP_ENV_VALIDATION=true pnpm exec turbo run build --filter=web... FROM base AS runner