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