@@ -1,6 +1,8 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
.git
|
||||
.gitignore
|
||||
**/.env*
|
||||
!apps/web/.env.build
|
||||
docker
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=<local RP client id>
|
||||
SSO_CLIENT_SECRET=<local RP client secret>
|
||||
JWT_SECRET=<the same HS256 secret used by the support API>
|
||||
SUPPORT_TENANT_ID=<optional; use this when userinfo does not include 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=<local RP client id>
|
||||
SSO_CLIENT_SECRET=<local RP 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를 삭제하지 않는다.
|
||||
@@ -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=
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
NEXT_PUBLIC_FEEDBACK_ONLY=APP_NEXT_PUBLIC_FEEDBACK_ONLY
|
||||
|
||||
@@ -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=
|
||||
|
||||
+3
-60
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
<CommentImageGallery
|
||||
urls={(comment.attachments ?? []).map(
|
||||
(attachment) =>
|
||||
attachment.download_url ??
|
||||
`/api/support/tickets/${supportTicketId ?? feedbackId}/attachments/${attachment.attachment_id}?workspaceCode=${encodeURIComponent(resolvedWorkspaceCode)}&abcFeedbackId=${feedbackId}`,
|
||||
)}
|
||||
names={(comment.attachments ?? []).map(
|
||||
|
||||
@@ -34,8 +34,10 @@ const TenantGuard: React.FC<IProps> = ({ 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') ||
|
||||
|
||||
@@ -61,7 +61,10 @@ interface Action {
|
||||
email: string;
|
||||
password: string;
|
||||
}) => Promise<void>;
|
||||
signInWithOAuth: (input: { code: string }) => Promise<void>;
|
||||
signInWithOAuth: (input: {
|
||||
code: string;
|
||||
redirectUri?: string;
|
||||
}) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (jwt?: Jwt) => Promise<void>;
|
||||
_signIn: (jwt: Jwt) => Promise<void>;
|
||||
@@ -79,10 +82,10 @@ export const useUserStore = create<State & Action>((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<State & Action>((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<State & Action>((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<JwtPayload & {
|
||||
email?: string;
|
||||
name?: string | null;
|
||||
department?: string | null;
|
||||
phone_number?: string | null;
|
||||
}>(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<State & Action>((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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<IProps> = () => {
|
||||
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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
disabled={!data?.url}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (callback_url) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_CALLBACK_URL_STORAGE_KEY,
|
||||
callback_url,
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
|
||||
}
|
||||
if (force_login) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_FORCE_LOGIN_STORAGE_KEY,
|
||||
'true',
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
|
||||
}
|
||||
window.location.assign(data?.url ?? '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/google.svg"
|
||||
alt="Google"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Google {t('button.sign-in')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
disabled={!data?.url}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (callback_url) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_CALLBACK_URL_STORAGE_KEY,
|
||||
callback_url,
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
|
||||
}
|
||||
if (force_login) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_FORCE_LOGIN_STORAGE_KEY,
|
||||
'true',
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
|
||||
}
|
||||
window.location.assign(data?.url ?? '');
|
||||
}
|
||||
window.location.assign(loginUrl);
|
||||
}}
|
||||
>
|
||||
{tenant?.oauthConfig?.loginButtonName ??
|
||||
`OAuth 2.0 ${t('button.sign-in')}`}
|
||||
<Image src="/assets/images/google.svg" alt="" width={20} height={20} />
|
||||
BARON-SSO {t('button.sign-in')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
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)}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
query.set(key, value[0] ?? '');
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
|
||||
return res.redirect(302, `/auth/oauth-callback${suffix}`);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
}
|
||||
|
||||
@@ -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 설정이 올바르지 않습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>, 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,99 +14,38 @@
|
||||
* 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<ReturnType<typeof listSupportAbcFields>>,
|
||||
) => {
|
||||
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에서 피드백 양식을 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<string, string | string[]>;
|
||||
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<string, string | string[]>,
|
||||
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<string, string | string[]>,
|
||||
files: FormidableFile[],
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof signInWithEmailSchema>;
|
||||
|
||||
const SignInPage: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { tenant, refetchTenant } = useTenantStore();
|
||||
const { signInWithEmail } = useUserStore();
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
|
||||
const { handleSubmit, register, formState, setError } = useForm<FormType>({
|
||||
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 (
|
||||
<AnonymousTemplate
|
||||
@@ -84,60 +37,10 @@ const SignInPage: NextPageWithLayout = () => {
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{!tenant && (
|
||||
<div className="rounded-16 border border-[#e4dfd4] bg-[#faf7f0] p-4 text-sm leading-6 text-[#5e5544]">
|
||||
로컬 미리보기에서는 tenant 설정 API(`/api/admin/tenants`)가 없어 로그인 입력칸이 표시되지 않습니다.
|
||||
<br />
|
||||
지원 화면 확인은 `/support/INTRA_BOOK_REQUEST/new`, `/ops`, `/admin/issues` 같은 공개 미리보기 경로를 직접 사용해 주세요.
|
||||
</div>
|
||||
)}
|
||||
{tenant?.useOAuth && <SignInWithOAuthButton />}
|
||||
{tenant?.useOAuth && tenant.useEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
|
||||
<span className="text-neutral-tertiary">or With Email</span>
|
||||
<div className="border-neutral-tertiary flex-1 border-b-[1px]" />
|
||||
</div>
|
||||
)}
|
||||
{tenant?.useEmail && (
|
||||
<form id="sign-in" onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
type="email"
|
||||
{...register('email')}
|
||||
error={formState.errors.email?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
type="password"
|
||||
{...register('password')}
|
||||
error={formState.errors.password?.message}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{tenant?.useEmail && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
size="medium"
|
||||
type="submit"
|
||||
loading={loginLoading}
|
||||
form="sign-in"
|
||||
disabled={!formState.isDirty}
|
||||
>
|
||||
{t('button.sign-in')}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link href="/auth/reset-password" className="text-center underline">
|
||||
{t('link.reset-password.title')}
|
||||
</Link>
|
||||
<Link href="/auth/sign-up" className="text-center underline">
|
||||
{t('button.sign-up')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SignInWithOAuthButton />
|
||||
<p className="text-center text-sm text-neutral-secondary">
|
||||
BARON-SSO 로그인 후 EGBIM_DEMO 피드백 목록으로 이동합니다.
|
||||
</p>
|
||||
</AnonymousTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
<CommentImageGallery
|
||||
urls={(comment.attachments ?? []).map(
|
||||
(attachment) =>
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const getServerSideProps: GetServerSideProps = ({ params }) => {
|
||||
|
||||
return Promise.resolve({
|
||||
redirect: {
|
||||
destination: `/support/${workspaceCode}/list`,
|
||||
destination: `/support/${encodeURIComponent(workspaceCode)}/list`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string, string> = {
|
||||
ip_address:
|
||||
'Windows: 명령 프롬프트에서 ipconfig를 실행한 뒤 IPv4 주소를 확인하세요.\nmacOS: 시스템 설정 > 네트워크 > 연결된 네트워크 > 세부사항 > TCP/IP에서 확인하세요.',
|
||||
@@ -97,8 +170,6 @@ const fieldHelp: Record<string, string> = {
|
||||
'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<Record<string, string>>(
|
||||
@@ -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 (
|
||||
<SupportPortalShell
|
||||
workspaceCode={workspaceCode || 'Q&A_Platform'}
|
||||
workspaceName={TEST_PROJECT_NAME}
|
||||
workspaceCode={workspaceCode || DEFAULT_SUPPORT_WORKSPACE_CODE}
|
||||
workspaceName={
|
||||
template?.workspace_name ??
|
||||
(workspaceCode || DEFAULT_SUPPORT_WORKSPACE_CODE)
|
||||
}
|
||||
currentTab="new"
|
||||
eyebrow="Q&A Write"
|
||||
title="문의등록"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const STATE_COOKIE = 'baron_sso_state';
|
||||
const RETURN_COOKIE = 'baron_sso_return_to';
|
||||
const SESSION_COOKIE = 'jwt';
|
||||
const SESSION_MAX_AGE = 60 * 60;
|
||||
|
||||
interface OidcUserInfo {
|
||||
sub?: unknown;
|
||||
user_id?: unknown;
|
||||
id?: unknown;
|
||||
email?: unknown;
|
||||
name?: unknown;
|
||||
department?: unknown;
|
||||
phone_number?: unknown;
|
||||
tenant_id?: unknown;
|
||||
tenantId?: unknown;
|
||||
tenant_ids?: unknown;
|
||||
tenantIds?: unknown;
|
||||
}
|
||||
|
||||
const asString = (value: unknown) =>
|
||||
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<OidcUserInfo> => {
|
||||
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<string, unknown>, 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 }));
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean> | null = null;
|
||||
@@ -28,32 +26,14 @@ const refreshAccessToken = async (): Promise<boolean> => {
|
||||
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) {
|
||||
// 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' });
|
||||
}
|
||||
await cookieStorage.removeItem('jwt');
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const tryRefreshAccessToken = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
+27
-160
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user