fix: support PKCE public client without client secret
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s

This commit is contained in:
root
2026-09-18 19:59:06 +09:00
commit a420407c18
17 changed files with 1310 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Deploy EG-BIM QA Gateway
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Validate required secrets
shell: bash
run: |
set -euo pipefail
test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; }
test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; }
- name: Install dependencies
run: npm install --no-fund --no-audit
- name: Check source
run: npm run check
- name: Register Worker session secret
shell: bash
run: |
set -euo pipefail
printf '%s' "$SESSION_SECRET" | npx wrangler secret put SESSION_SECRET --name baron-qa-gateway-test
- name: Upload static files to R2
run: npm run r2:upload
- name: Deploy Worker
run: npx wrangler deploy --name baron-qa-gateway-test
+8
View File
@@ -0,0 +1,8 @@
.DS_Store
node_modules/
.wrangler/
dist/
.env
*.sql
정적페이지 내 외부서비스 페이지 설계.png
qa-secrets.json
+89
View File
@@ -0,0 +1,89 @@
# EG-BIM Q&A static pages
`index.html`, `write.html`, `detail.html` 세 페이지로 구성한 EG-BIM Q&A UI입니다. 원본 `egbim_homepage`의 Q&A 화면 구성과 패키지 S/W 메뉴 방향을 참고해, PHP/그누보드/DB 의존성 없이 정적 호스팅에서 동작하도록 분리했습니다.
## 로컬 확인
정적 파일 서버에서 루트를 열면 됩니다.
```bash
python3 -m http.server 4173
```
그 다음 `http://localhost:4173/`에 접속합니다. 브라우저 보안 정책 때문에 `file://` 직접 열기보다 정적 서버를 사용하는 편이 안전합니다.
## feedback.hmac.kr DB 기준 연동
덤프의 EG-BIM 운영 대상은 다음 값으로 고정했습니다.
- `workspaces.id = 6`, `workspace_code = EGBIM`
- `channels.name = Q&A`, `channels.id = 01a0ae40-51c2-7647-a93c-0249b3759777`
- 카테고리: `ERROR_QNA`, `IMPROVEMENT_QNA`, `GENERAL_QNA`
- 신규 상태: `support_tickets.status_code = RECEIVED`, `feedback_status = NEW`
- 첨부 저장소: `storage_bucket = qa_cdn`
작성 시 브라우저가 UUID를 하나 생성합니다. 같은 UUID를 `feedback.id`, `feedback.source_record_id`, `support_tickets.idempotency_key`에 사용하므로 중복 제출을 판별할 수 있습니다. API 서버는 envelope를 받아 다음 DB 레코드를 하나의 트랜잭션으로 생성해야 합니다.
- `feedbacks`: `id`, `channel_id`, `source_namespace`, `source_record_id`, `data`
- `support_tickets`: 작성자/테넌트/제목/내용/분류/상태/`idempotency_key`
- `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id``qa_cdn` 메타데이터
- `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터
현재 작성 페이지에는 댓글 입력 UI가 없으므로 `comments``commentAttachments`는 빈 배열로 보냅니다. 관리 페이지에서 댓글과 이미지를 작성할 때 같은 규칙으로 `feedback_comments`/`feedback_comment_attachments``ticket_comments`/`support_attachments`를 생성할 수 있도록 envelope를 열어 두었습니다.
## 연동 지점
- SSO: 헤더 로그인 링크와 작성 페이지의 로그인 가이드는 `https://test.baroncs.co.kr/`로 연결됩니다. `baron_user`, `baron_claims`, Descope 쿠키, JWT payload와 `sessionStorage`를 우선 읽고, `ssoSessionEndpoint`가 설정되면 `credentials: include`로 세션 bridge를 호출합니다. 브라우저에서 읽은 JWT claim은 표시/전송용 힌트일 뿐이며, feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다.
- 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id``requester_tenant_id`가 없으면 제출을 차단합니다.
- API: `assets/config.js``apiBaseUrl`에 API origin을 넣으면 `POST {apiBaseUrl}/v1/qa/uploads/presign`으로 업로드 URL을 받고, 파일을 `qa_cdn`에 직접 업로드한 뒤 `POST {apiBaseUrl}/v1/qa/feedbacks`로 DB용 envelope를 보냅니다. 두 엔드포인트의 인증/응답 규격은 실제 feedback 서버에 맞춰야 합니다.
- presign 응답: `{ "uploads": [{ "uploadUrl": "...", "storageKey": "...", "storageBucket": "qa_cdn", "headers": {} }] }` 형태를 기대합니다. R2 access key/secret은 정적 페이지에 넣지 않습니다.
- API 주소가 비어 있으면 테스트를 위해 브라우저 `localStorage`에만 저장하며, 첨부파일은 `local-preview/...` 메타데이터만 생성합니다.
## Cloudflare R2
`wrangler.toml``src/index.js`를 추가해 Worker가 R2 정적 파일을 제공하도록 구성했습니다. `/``/index.html`은 공개 목록, `/egbim/``/write.html`, `/detail.html`은 로그인 보호 경로입니다. 업로드 스크립트는 루트와 `/egbim/` 경로에 현재 EG-BIM 파일을 함께 올려, 기존 링크와 향후 제품별 prefix 확장을 모두 지원합니다.
```bash
npm install
npm run check
npm run r2:upload
npm run deploy
```
`wrangler deploy`는 Worker와 R2 binding을 배포하고, `npm run r2:upload`는 정적 파일을 R2 bucket에 올립니다. `npx wrangler r2 object put`은 각 파일을 개별 업로드하므로 업로드 결과를 확인하기 쉽습니다.
## Wrangler secret 등록
실제 secret은 저장소에 만들지 않습니다. `qa-secrets.json.example`을 복사해 `qa-secrets.json`을 만들고 값을 채운 뒤 등록합니다.
```bash
cp qa-secrets.json.example qa-secrets.json
openssl rand -hex 32
npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test
```
`AUTH_CLIENT_ID`, `AUTH_AUTHORIZE_URL`, `AUTH_TOKEN_URL`, 선택적인 `AUTH_USERINFO_URL``wrangler.toml`에 실제 SSO 값으로 설정해야 합니다. 이 RP는 PKCE Public Client이므로 `AUTH_CLIENT_SECRET`은 사용하지 않으며, `SESSION_SECRET`만 secret으로 등록합니다.
Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다.
## Gitea Actions 등록값
저장소 Settings → Actions → Secrets에 아래 2개를 등록합니다.
| 이름 | 종류 | 값 |
|---|---|---|
| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token |
| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 |
`CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml``81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다.
Cloudflare API Token에는 최소한 다음 권한이 필요합니다.
- Account → Workers Scripts → Edit
- Account → Workers R2 Storage → Edit
- Account → Account Settings → Read
- Custom Domain route를 Actions에서 변경할 경우 Zone → Workers Routes → Edit
`.gitea/workflows/deploy.yml``main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다.
현재 화면은 API 설정 전에도 QA 흐름을 확인할 수 있도록 샘플 글과 로컬 테스트 저장을 포함합니다. 운영 반영 시 `apiBaseUrl`과 feedback API endpoint를 설정하고 로컬 fallback 제거 여부를 결정하세요.
+386
View File
@@ -0,0 +1,386 @@
(function () {
'use strict';
const config = window.QA_CONFIG || { product: 'egbim', apiBaseUrl: '', ssoUrl: 'https://test.baroncs.co.kr/' };
const STORAGE_KEY = 'baron.qa.posts.egbim';
const COMMENTS_KEY = 'baron.qa.comments.egbim';
const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' };
let authReady;
const seedPosts = [
{ id: 'EG-1048', category: '오류문의', company: '바론컨설턴트', department: '기술연구팀', author: '홍길동', title: '도면 내보내기 중 프로그램이 종료됩니다.', date: '2026-09-17', status: '문의접수', secret: false, content: '도면 내보내기 버튼을 누르면 프로그램이 종료되는 현상이 있습니다.\n재현 절차와 사용 중인 버전을 함께 확인 부탁드립니다.' },
{ id: 'EG-1047', category: '개선문의', company: '한맥기술', department: 'BIM팀', author: '김민지', title: '층별 도면을 한 번에 선택할 수 있으면 좋겠습니다.', date: '2026-09-16', status: '문의검토', secret: false, content: '층별 도면을 일괄 선택하여 내보낼 수 있는 기능을 제안드립니다.' },
{ id: 'EG-1046', category: '일반문의', company: '삼안', department: '설계1팀', author: '이도윤', title: 'EG-BIM 라이선스와 설치 환경을 문의드립니다.', date: '2026-09-15', status: '답변완료', secret: true, content: '설치 가능한 운영체제와 라이선스 정책을 확인하고 싶습니다.' },
{ id: 'EG-1045', category: '오류문의', company: '바론컨설턴트', department: '디지털전환팀', author: '박서준', title: '프로젝트 파일을 열 때 로딩이 오래 걸립니다.', date: '2026-09-12', status: '정밀검토', secret: false, content: '최근 프로젝트 파일을 열 때 약 2분 이상 로딩이 지속됩니다.' },
{ id: 'EG-1044', category: '공지사항', company: '관리자', department: '-', author: '관리자', title: 'EG-BIM Q&A 게시판 운영 안내', date: '2026-09-10', status: '', secret: false, content: 'EG-BIM 사용 중 궁금한 점이나 개선 의견을 남겨주세요.' },
{ id: 'EG-1043', category: '개선문의', company: '한맥기술', department: '사업관리팀', author: '최유진', title: '검색 결과에서 도면 미리보기를 제공해주세요.', date: '2026-09-09', status: '문의접수', secret: false, content: '검색 결과에 도면 미리보기 썸네일이 있으면 확인이 더 편리할 것 같습니다.' },
{ id: 'EG-1042', category: '일반문의', company: '외부 사용자', department: '-', author: '정하늘', title: '교육 자료를 어디서 확인할 수 있나요?', date: '2026-09-08', status: '답변완료', secret: false, content: 'EG-BIM 기본 교육 자료와 사용 가이드 위치를 알려주세요.' }
];
function qs(selector, root) { return (root || document).querySelector(selector); }
function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, function (char) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;' }[char];
});
}
function readJson(key, fallback) {
try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch (error) { return fallback; }
}
function getPosts() {
const saved = readJson(STORAGE_KEY, []);
return saved.concat(seedPosts.filter(function (seed) {
return !saved.some(function (post) { return post.id === seed.id; });
}));
}
function savePost(post) {
const posts = readJson(STORAGE_KEY, []);
posts.unshift(post);
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function getComments() { return readJson(COMMENTS_KEY, {}); }
function parseJson(value, fallback) {
if (!value) return fallback;
try { return typeof value === 'string' ? JSON.parse(value) : value; } catch (error) { return fallback; }
}
function decodeCookieJson(value) {
if (!value) return null;
try {
const binary = window.atob(value);
const bytes = Array.prototype.map.call(binary, function (char) { return '%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2); }).join('');
return JSON.parse(decodeURIComponent(bytes));
} catch (error) {
try { return JSON.parse(window.atob(value)); } catch (fallbackError) { return null; }
}
}
function readCookie(name) {
const item = document.cookie.split('; ').find(function (part) { return part.indexOf(name + '=') === 0; });
return item ? item.slice(name.length + 1) : '';
}
function decodeJwtPayload(token) {
if (!token || token.split('.').length < 2) return {};
try {
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(decodeURIComponent(escape(window.atob(encoded + '='.repeat((4 - encoded.length % 4) % 4)))));
} catch (error) { return {}; }
}
function collectSsoClaims() {
const storedClaims = parseJson(
sessionStorage.getItem('ssoClaims') || sessionStorage.getItem('baronClaims') || sessionStorage.getItem('claims'),
decodeCookieJson(readCookie('baron_claims')) || {}
);
return Object.assign({}, decodeJwtPayload(sessionStorage.getItem('sessionJwt') || ''), storedClaims);
}
function normalizeAuthUser(raw, claims) {
raw = raw || {};
claims = claims || {};
const custom = raw.customAttributes || raw.custom_attributes || {};
const claimProfile = claims.profile || {};
const tenants = raw.tenants || raw.tenantIds || custom.tenants || claims.tenants || claims.tenantIds || [];
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map(function (key) {
return typeof tenants[key] === 'object' ? Object.assign({ id: key }, tenants[key]) : { id: key, name: tenants[key] };
});
const tenantId = raw.tenantId || raw.tenant_id || custom.tenantId || custom.tenant_id || claims.tenantId || claims.tenant_id || (tenantList[0] && (tenantList[0].id || tenantList[0].tenantId)) || '';
const tenantIds = tenantList.map(function (tenant) { return typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || ''); }).filter(Boolean);
if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId);
const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || '';
const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || '';
return {
userUuid: userUuid,
ssoSubject: ssoSubject,
requesterId: ssoSubject,
tenantId: tenantId,
requesterTenantId: tenantId,
tenantIds: tenantIds,
scope: raw.scope || claims.scope || sessionStorage.getItem('ssoScope') || '',
roles: raw.roles || raw.roleNames || claims.roles || claims.roleNames || [],
loginId: loginId,
email: raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '',
name: raw.name || claims.name || claimProfile.name || sessionStorage.getItem('userName') || readCookie('descope_user_name') || '',
phone: raw.phone || raw.phoneNumber || claims.phone || claims.phone_number || sessionStorage.getItem('phone') || '',
company: custom.company || raw.company || claims.company || sessionStorage.getItem('company') || '',
familyCompany: custom.familyCompany || custom.family_company || raw.familyCompany || sessionStorage.getItem('familyCompany') || '',
department: custom.team || custom.department || raw.department || claims.department || sessionStorage.getItem('team') || '',
rawClaims: claims
};
}
function getAuthUser() {
const sessionUser = normalizeAuthUser({
loginId: sessionStorage.getItem('loginId') || '',
userId: sessionStorage.getItem('descopeUserId') || '',
name: sessionStorage.getItem('userName') || '',
company: sessionStorage.getItem('company') || '',
familyCompany: sessionStorage.getItem('familyCompany') || '',
department: sessionStorage.getItem('team') || '',
tenantId: sessionStorage.getItem('tenantId') || sessionStorage.getItem('tenant_id') || '',
tenantIds: parseJson(sessionStorage.getItem('tenantIds'), [])
}, collectSsoClaims());
if (sessionUser.loginId || sessionUser.ssoSubject) return sessionUser;
const baronUser = decodeCookieJson(readCookie('baron_user'));
const descopeUser = baronUser || {
loginId: readCookie('descope_login_id'), userId: readCookie('descope_user_id'), name: readCookie('descope_user_name'),
email: readCookie('descope_user_email'), phone: readCookie('descope_user_phone'),
customAttributes: decodeCookieJson(readCookie('descope_custom_attributes')) || {}
};
return descopeUser && (descopeUser.loginId || descopeUser.userId || descopeUser.email) ? normalizeAuthUser(descopeUser, collectSsoClaims()) : null;
}
async function loadAuthUser() {
const localUser = getAuthUser();
if (localUser && localUser.tenantId) return localUser;
if (!config.ssoSessionEndpoint) return localUser;
try {
const response = await fetch(config.ssoSessionEndpoint, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('SSO 세션 확인에 실패했습니다.');
const body = await response.json();
const remoteUser = normalizeAuthUser(body.user || body, body.claims || body.scope || {});
if (remoteUser.loginId || remoteUser.ssoSubject) return remoteUser;
} catch (error) {
console.warn('[QA] SSO session bridge unavailable:', error.message);
}
return localUser;
}
function renderAuth(user) {
user = user || getAuthUser();
qsa('.login-link').forEach(function (element) { element.href = config.ssoUrl || element.href; });
qsa('[data-auth-name]').forEach(function (element) { element.textContent = user ? (user.name || user.loginId) : '로그인'; });
qsa('[data-auth-state]').forEach(function (element) {
element.textContent = user ? (user.name || user.loginId) + '님으로 로그인됨' : '글 작성은 로그인 후 이용할 수 있습니다.';
});
qsa('[data-auth-required]').forEach(function (element) { element.hidden = Boolean(user); });
qsa('[data-auth-user]').forEach(function (element) { element.hidden = !user; });
}
function bindGlobal() {
renderAuth(getAuthUser());
authReady = loadAuthUser().then(function (user) { renderAuth(user); return user; });
const menuButton = qs('.menu-button');
const nav = qs('.global-nav');
if (menuButton && nav) {
menuButton.addEventListener('click', function () {
nav.classList.toggle('mobile-open');
});
}
}
function showToast(message) {
const toast = qs('#toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('show');
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(function () { toast.classList.remove('show'); }, 2600);
}
async function requestJson(path, options) {
if (!config.apiBaseUrl) return { local: true };
const response = await fetch(config.apiBaseUrl.replace(/\/$/, '') + path, Object.assign({ credentials: 'include' }, options));
if (!response.ok) {
const errorBody = await response.json().catch(function () { return {}; });
throw new Error(errorBody.message || 'API 요청에 실패했습니다. (' + response.status + ')');
}
return response.json().catch(function () { return {}; });
}
async function postToApi(path, payload) {
return requestJson(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
}
function makeUuid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) {
const random = Math.random() * 16 | 0;
const value = char === 'x' ? random : (random & 3 | 8);
return value.toString(16);
});
}
async function sha256(file) {
if (!window.crypto || !window.crypto.subtle) return '';
const digest = await window.crypto.subtle.digest('SHA-256', await file.arrayBuffer());
return Array.prototype.map.call(new Uint8Array(digest), function (byte) { return ('00' + byte.toString(16)).slice(-2); }).join('');
}
async function uploadFiles(files, feedbackId, purpose) {
files = Array.prototype.slice.call(files || []);
if (!files.length) return [];
const descriptors = await Promise.all(files.map(async function (file) {
return { clientId: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file) };
}));
if (!config.apiBaseUrl) {
return descriptors.map(function (item) { return Object.assign(item, { id: makeUuid(), storageBucket: config.storageBucket, storageKey: 'local-preview/' + feedbackId + '/' + item.clientId + '-' + item.originalFileName, purpose: purpose }); });
}
const presign = await postToApi(config.presignPath, { feedbackId: feedbackId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, bucket: config.storageBucket, purpose: purpose, files: descriptors });
const uploads = presign.uploads || presign.items || [];
if (uploads.length !== descriptors.length) throw new Error('첨부파일 업로드 URL을 모두 받지 못했습니다.');
return Promise.all(uploads.map(async function (upload, index) {
const file = files[index];
const headers = Object.assign({}, upload.headers || {});
if (!headers['Content-Type'] && file.type) headers['Content-Type'] = file.type;
const response = await fetch(upload.uploadUrl, { method: upload.method || 'PUT', headers: headers, body: file });
if (!response.ok) throw new Error(file.name + ' 업로드에 실패했습니다.');
if (!upload.storageKey) throw new Error(file.name + '의 storageKey를 받지 못했습니다.');
return Object.assign(descriptors[index], upload.metadata || {}, {
id: upload.id || makeUuid(), storageBucket: upload.storageBucket || config.storageBucket,
storageKey: upload.storageKey, purpose: purpose
});
}));
}
function buildFeedbackPayload(post, user, feedbackId, attachments) {
const categoryCode = CATEGORY_CODES[post.category];
const source = { namespace: config.sourceNamespace, recordId: feedbackId };
const imageMetadata = attachments.filter(function (item) { return item.purpose === 'FEEDBACK_ATTACHMENT'; }).map(function (item) {
return { id: item.id, original_file_name: item.originalFileName, storage_bucket: item.storageBucket, storage_key: item.storageKey, mime_type: item.mimeType, file_size: item.fileSize, checksum_sha256: item.checksumSha256 };
});
const data = {
id: feedbackId,
createdAt: post.createdAt,
title: post.title,
contents: post.content,
priority: 'MEDIUM',
Category: categoryCode,
requester_id: user.requesterId,
requester_tenant_id: user.requesterTenantId,
requester_email: user.email,
requester_name: user.name,
requester_department: user.department,
is_secret: post.secret ? 1 : 0,
feedback_status: 'NEW',
images: imageMetadata
};
return {
schemaVersion: 'feedback.hmac.kr/qa.v1',
source: source,
feedback: { id: feedbackId, channelId: config.channelId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, sourceNamespace: source.namespace, sourceRecordId: source.recordId, data: data },
supportTicket: {
workspace_id: config.workspaceId, requester_id: user.requesterId, requester_tenant_id: user.requesterTenantId, ticket_type: 'QNA', source_system: 'EGBIM', title: post.title, category_code: categoryCode, status_code: 'RECEIVED', is_secret: post.secret ? 1 : 0, priority: 'NORMAL', description: post.content, approval_status: 'NOT_REQUIRED', sync_status: 'PENDING', issue_link_status: 'NOT_REQUIRED', feedback_status: 'NEW', idempotency_key: feedbackId, requester_email: user.email, requester_name: user.name, requester_department: user.department, requester_phone_number: user.phone, extra_fields: { feedback_id: feedbackId, channel_id: config.channelId }
},
attachments: attachments.map(function (item) { return Object.assign({}, item, { sourceNamespace: source.namespace, sourceRecordId: source.recordId }); }),
comments: [],
commentAttachments: [],
author: { userUuid: user.userUuid, ssoSubject: user.ssoSubject, requesterId: user.requesterId, tenantId: user.tenantId, tenantIds: user.tenantIds, scope: user.scope, roles: user.roles, email: user.email, name: user.name, department: user.department, phone: user.phone }
};
}
function initList() {
const tableBody = qs('#qaRows');
if (!tableBody) return;
const searchForm = qs('#searchForm');
const empty = qs('#tableEmpty');
const resultCount = qs('#resultCount');
const pagination = qs('#pagination');
let page = 1;
function render() {
const query = (qs('#query').value || '').trim().toLowerCase();
const checkedCategories = qsa('input[name="category"]:checked').map(function (input) { return input.value; });
const onlyMine = qs('#onlyMine').checked;
const user = getAuthUser();
const filtered = getPosts().filter(function (post) {
const matchesCategory = !checkedCategories.length || checkedCategories.indexOf(post.category) > -1;
const haystack = [post.title, post.content, post.company, post.department, post.author].join(' ').toLowerCase();
const matchesQuery = !query || haystack.indexOf(query) > -1;
const matchesMine = !onlyMine || (user && (post.author === user.name || post.author === user.loginId));
return matchesCategory && matchesQuery && matchesMine;
});
const pageSize = config.pageSize || 10;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, totalPages);
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
resultCount.textContent = '총 ' + filtered.length + '건';
empty.style.display = rows.length ? 'none' : 'block';
tableBody.innerHTML = rows.map(function (post, index) {
const isNotice = post.category === '공지사항';
const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index);
const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : '');
return '<tr class="' + (isNotice ? 'notice-row' : '') + '" data-id="' + escapeHtml(post.id) + '">' +
'<td>' + number + '</td><td><span class="category ' + (isNotice ? 'notice' : '') + '">' + escapeHtml(post.category.replace('문의', '')) + '</span></td>' +
'<td>' + escapeHtml(post.company) + '</td><td>' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="subject"><span class="subject-link">' + (post.secret ? '<span class="lock">🔒</span>' : '') + escapeHtml(post.title) + '</span>' + (!isNotice && post.date === '2026-09-17' ? '<span class="new-mark">N</span>' : '') + '</td>' +
'<td>' + escapeHtml(post.date) + '</td><td>' + (post.status ? '<span class="status ' + statusClass + '">' + escapeHtml(post.status) + '</span>' : '-') + '</td></tr>';
}).join('');
qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); });
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return '<button type="button" class="' + (i + 1 === page ? 'active' : '') + '" data-page="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('');
qsa('#pagination button').forEach(function (button) { button.addEventListener('click', function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: 'smooth' }); }); });
}
searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); });
qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); });
render();
}
function initWrite() {
const form = qs('#qaForm');
if (!form) return;
const user = getAuthUser();
const errorBox = qs('#formError');
if (!user) {
qs('#writeFields').setAttribute('aria-disabled', 'true');
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
errorBox.style.display = 'none';
const currentUser = await (authReady || Promise.resolve(getAuthUser()));
if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; }
if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; }
const title = qs('#title').value.trim();
const content = qs('#content').value.trim();
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const feedbackId = makeUuid();
const createdAt = new Date().toISOString();
const post = { id: feedbackId, feedbackId: feedbackId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
const submitButton = qs('#submitButton');
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
try {
const attachments = await uploadFiles(qs('#attachment').files, feedbackId, 'FEEDBACK_ATTACHMENT');
const payload = buildFeedbackPayload(post, currentUser, feedbackId, attachments);
const result = await postToApi(config.createFeedbackPath, payload);
if (result.local) savePost(post);
showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.');
window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
} catch (error) {
errorBox.textContent = error.message || '등록 중 오류가 발생했습니다.';
errorBox.style.display = 'block';
submitButton.disabled = false;
submitButton.textContent = '문의 등록';
}
});
}
function initDetail() {
const detail = qs('#detail');
if (!detail) return;
const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048';
const post = getPosts().find(function (item) { return item.id === id; }) || seedPosts[0];
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-status]').textContent = post.status || '공지';
qs('[data-detail-date]').textContent = post.date;
qs('[data-detail-author]').textContent = post.author;
qs('[data-detail-company]').textContent = post.company;
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
const comments = getComments()[post.id] || [];
qs('#commentCount').textContent = comments.length;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div></div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; });
}
document.addEventListener('DOMContentLoaded', function () { bindGlobal(); initList(); initWrite(); initDetail(); });
})();
+15
View File
@@ -0,0 +1,15 @@
/* Static deployment configuration. Keep credentials and R2 secrets out of this file. */
window.QA_CONFIG = Object.assign({
product: 'egbim',
apiBaseUrl: '',
ssoUrl: '/auth/login',
ssoSessionEndpoint: '/auth/session',
presignPath: '/v1/qa/uploads/presign',
createFeedbackPath: '/v1/qa/feedbacks',
workspaceId: 6,
workspaceCode: 'EGBIM',
channelId: '01a0ae40-51c2-7647-a93c-0249b3759777',
sourceNamespace: 'baron_qa_write',
storageBucket: 'qa_cdn',
pageSize: 10
}, window.QA_CONFIG || {});
Binary file not shown.
Binary file not shown.
Binary file not shown.
+342
View File
@@ -0,0 +1,342 @@
@font-face {
font-family: 'Noto Sans KR';
src: url('./fonts/notokr-regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans KR';
src: url('./fonts/notokr-medium.woff2') format('woff2');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans KR';
src: url('./fonts/notokr-bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
}
:root {
--navy: #173f78;
--ink: #171717;
--muted: #707070;
--line: #e5e5e5;
--soft: #f7f8fa;
--accent: #005bff;
--green: #0c7857;
--header-height: 88px;
}
* { box-sizing: border-box; }
html { min-width: 320px; scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
background: #fff;
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
.site-header {
position: relative;
z-index: 10;
height: var(--header-height);
border-bottom: 1px solid #ededed;
background: rgba(255, 255, 255, .98);
}
.header-inner {
display: flex;
align-items: center;
height: 100%;
max-width: 1840px;
padding: 0 32px;
margin: 0 auto;
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
color: var(--navy);
font-size: 17px;
font-weight: 700;
white-space: nowrap;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 24px;
border: 2px solid var(--navy);
border-radius: 50%;
font-size: 14px;
font-style: italic;
letter-spacing: -1px;
}
.global-nav {
display: flex;
align-items: stretch;
height: 100%;
gap: clamp(22px, 2.5vw, 52px);
margin-left: auto;
margin-right: 38px;
}
.nav-item {
position: relative;
display: inline-flex;
align-items: center;
color: #3d3d3d;
font-size: 16px;
white-space: nowrap;
}
.nav-item.package::after {
content: '';
position: absolute;
right: -13px;
bottom: 25px;
left: -13px;
height: 2px;
background: var(--navy);
transform: scaleX(0);
transition: transform .2s ease;
}
.nav-item.package:hover::after,
.nav-item.package:focus-within::after { transform: scaleX(1); }
.mega-menu {
position: absolute;
top: calc(100% - 1px);
left: 50%;
display: grid;
grid-template-columns: 116px 1fr;
width: 360px;
padding: 22px 20px;
border-radius: 0 0 10px 10px;
background: #fff;
box-shadow: 0 15px 36px rgba(0, 0, 0, .13);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -6px);
transition: opacity .2s ease, transform .2s ease;
}
.nav-item.package:hover .mega-menu,
.nav-item.package:focus-within .mega-menu {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.mega-label { color: var(--accent); font-weight: 500; padding-top: 5px; }
.mega-links { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; }
.mega-links a { color: #555; font-size: 14px; }
.mega-links a:hover, .mega-links a[aria-current='page'] { color: var(--accent); }
.header-tools { display: flex; align-items: center; gap: 22px; flex: 0 0 auto; }
.locale { font-weight: 700; }
.locale span { color: #c9c9c9; margin: 0 8px; }
.locale a:last-child { color: #9a9a9a; font-weight: 400; }
.login-link { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; }
.user-icon { width: 20px; height: 20px; border: 1.8px solid #111; border-radius: 50%; position: relative; margin-top: -9px; }
.user-icon::after { content: ''; position: absolute; top: 17px; left: -5px; width: 26px; height: 12px; border: 1.8px solid #111; border-bottom: 0; border-radius: 16px 16px 0 0; }
.menu-button { display: inline-flex; flex-direction: column; gap: 6px; border: 0; background: none; padding: 6px 0 6px 8px; }
.menu-button span { display: block; width: 24px; height: 1.5px; background: #111; }
.page-hero {
display: flex;
min-height: 248px;
align-items: center;
justify-content: center;
color: #fff;
text-align: center;
background: linear-gradient(115deg, #11342b 0%, #10221f 50%, #07110f 100%);
}
.hero-inner { padding: 42px 24px; }
.hero-kicker { display: block; color: #b9d9ca; font-size: 14px; letter-spacing: .06em; }
.page-hero h1 { margin: 5px 0 5px; font-size: 42px; letter-spacing: -.05em; }
.page-hero p { margin: 0; color: #d5dfdc; font-size: 16px; }
.sub-nav { background: rgba(8, 30, 24, .96); color: #fff; }
.sub-nav-inner { display: flex; max-width: 1280px; margin: 0 auto; }
.sub-nav a { flex: 1; padding: 17px 12px; text-align: center; color: #cbd6d2; }
.sub-nav a:hover, .sub-nav a[aria-current='page'] { color: #fff; background: #1c7056; font-weight: 700; }
.page-shell { max-width: 1280px; padding: 66px 32px 110px; margin: 0 auto; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 30px; }
.section-heading h2 { margin: 0; font-size: 28px; letter-spacing: -.05em; }
.section-heading p { margin: 3px 0 0; color: var(--muted); }
.filter-panel { padding: 22px 24px; margin-bottom: 25px; border: 1px solid var(--line); background: var(--soft); }
.filter-row { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
.filter-row + .filter-row { padding-top: 17px; margin-top: 17px; border-top: 1px solid var(--line); }
.filter-label { min-width: 48px; font-weight: 700; }
.chip-group { display: flex; gap: 7px; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 7px 13px; border: 1px solid #d7d7d7; border-radius: 3px; background: #fff; color: #666; font-size: 13px; }
.chip:has(input:checked) { border-color: var(--navy); background: var(--navy); color: #fff; }
.chip input { position: absolute; opacity: 0; pointer-events: none; }
.filter-spacer { flex: 1; }
.search-form { display: flex; gap: 8px; min-width: min(100%, 400px); }
.search-form input { width: 100%; min-width: 0; height: 40px; padding: 0 13px; border: 1px solid #d2d2d2; background: #fff; outline: 0; }
.search-form input:focus { border-color: var(--navy); }
.button { display: inline-flex; align-items: center; justify-content: center; height: 42px; padding: 0 21px; border: 1px solid transparent; border-radius: 3px; font-weight: 500; white-space: nowrap; }
.button-primary { background: var(--navy); color: #fff; }
.button-primary:hover { background: #0e315f; }
.button-outline { border-color: #bfc5cb; background: #fff; color: #555; }
.button-outline:hover { border-color: var(--navy); color: var(--navy); }
.button-accent { background: var(--accent); color: #fff; }
.button-accent:hover { background: #0049cc; }
.table-wrap { overflow-x: auto; border-top: 2px solid #252525; }
.qa-table { width: 100%; min-width: 850px; border-collapse: collapse; table-layout: fixed; }
.qa-table th { height: 48px; padding: 0 12px; border-bottom: 1px solid #252525; font-weight: 500; }
.qa-table td { height: 62px; padding: 0 12px; border-bottom: 1px solid var(--line); color: #5c5c5c; text-align: center; }
.qa-table tbody tr { cursor: pointer; transition: background .15s ease; }
.qa-table tbody tr:hover { background: #fafafa; }
.qa-table .subject { overflow: hidden; color: #222; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.subject-link { font-weight: 500; }
.notice-row td { background: #fcfcf8; font-weight: 500; }
.category { display: inline-flex; align-items: center; justify-content: center; min-width: 66px; padding: 3px 7px; border-radius: 2px; background: #f0f4fb; color: var(--navy); font-size: 12px; }
.category.notice { background: #fff6e7; color: #9b6915; }
.lock { display: inline-block; margin-right: 6px; color: #888; font-size: 12px; }
.new-mark { margin-left: 4px; color: var(--accent); font-size: 11px; font-weight: 700; }
.status { display: inline-flex; justify-content: center; min-width: 76px; padding: 3px 7px; border: 1px solid #dadada; border-radius: 2px; color: #666; font-size: 12px; }
.status.done { border-color: #b3ddce; background: #effaf6; color: var(--green); }
.status.review { border-color: #c5d8ef; background: #f2f7fd; color: #356594; }
.table-empty { display: none; padding: 70px 20px; border-bottom: 1px solid var(--line); color: var(--muted); text-align: center; }
.table-actions { display: flex; align-items: center; justify-content: space-between; padding-top: 23px; }
.result-count { color: var(--muted); font-size: 13px; }
.pagination { display: flex; justify-content: center; gap: 5px; margin: 38px 0 0; }
.pagination button { width: 32px; height: 32px; border: 0; background: none; color: #555; }
.pagination button:hover { color: var(--navy); }
.pagination button.active { border-radius: 50%; background: var(--navy); color: #fff; }
.form-card, .detail-card { border-top: 2px solid #242424; }
.form-row { display: grid; grid-template-columns: 145px 1fr; min-height: 68px; border-bottom: 1px solid var(--line); }
.form-label { display: flex; align-items: center; padding: 17px 18px; background: #fafafa; font-weight: 500; }
.required { color: #d74444; }
.form-control { display: flex; align-items: center; min-width: 0; padding: 13px 17px; }
.form-control.column { align-items: stretch; flex-direction: column; gap: 8px; }
.input, .select, .textarea { width: 100%; padding: 10px 12px; border: 1px solid #d5d5d5; border-radius: 2px; background: #fff; outline: 0; }
.input, .select { height: 42px; }
.textarea { min-height: 260px; resize: vertical; line-height: 1.7; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--navy); box-shadow: 0 0 0 2px rgba(23, 63, 120, .08); }
.select { max-width: 230px; }
.check { display: inline-flex; align-items: center; gap: 8px; color: #555; }
.check input { width: 17px; height: 17px; accent-color: var(--navy); }
.help-text { color: #888; font-size: 12px; }
.file-input { padding: 8px 0; }
.form-notice { padding: 18px; margin-bottom: 22px; border: 1px solid #d9e3ef; background: #f6f9fd; color: #49617a; }
.form-notice strong { display: block; margin-bottom: 3px; color: #294867; }
.form-error { display: none; padding: 14px 16px; margin-bottom: 18px; border: 1px solid #f0b2b2; background: #fff7f7; color: #b42318; }
.form-footer { display: flex; justify-content: center; gap: 8px; padding-top: 26px; }
.detail-card { padding-bottom: 24px; }
.detail-head { padding: 23px 22px 20px; border-bottom: 1px solid var(--line); }
.detail-title-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.detail-title-line h2 { margin: 0; font-size: 22px; letter-spacing: -.04em; }
.detail-meta { display: flex; gap: 14px; flex-wrap: wrap; padding-top: 15px; color: #888; font-size: 13px; }
.detail-meta span + span::before { content: ''; display: inline-block; width: 1px; height: 11px; margin: 0 14px 0 0; background: #ddd; vertical-align: -1px; }
.detail-body { min-height: 260px; padding: 38px 22px 56px; white-space: pre-wrap; word-break: break-word; }
.attachments { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafafa; }
.attachments h3 { margin: 0 0 8px; font-size: 14px; }
.attachments a { color: var(--navy); text-decoration: underline; }
.detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; }
.comment-box { margin-top: 54px; }
.comment-box h3 { padding-bottom: 15px; margin: 0; border-bottom: 2px solid #222; font-size: 17px; }
.comment { padding: 19px 10px; border-bottom: 1px solid var(--line); }
.comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; }
.comment-content { white-space: pre-wrap; }
.comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; }
.site-footer { padding: 38px 32px; background: #111; color: #b9b9b9; }
.footer-inner { display: flex; align-items: flex-start; justify-content: space-between; max-width: 1280px; margin: 0 auto; gap: 40px; }
.footer-brand { color: #fff; }
.footer-copy { margin: 5px 0 0; color: #777; font-size: 12px; }
.footer-links { display: flex; gap: 23px; color: #d9d9d9; font-size: 13px; }
.toast { position: fixed; right: 26px; bottom: 26px; z-index: 30; padding: 14px 18px; border-radius: 4px; background: #1c1c1c; color: #fff; box-shadow: 0 8px 24px rgba(0,0,0,.2); opacity: 0; pointer-events: none; transform: translateY(10px); transition: opacity .2s ease, transform .2s ease; }
.toast.show { opacity: 1; transform: translateY(0); }
.mobile-menu { display: none; }
@media (max-width: 1100px) {
.global-nav { gap: 18px; margin-right: 20px; }
.nav-item { font-size: 14px; }
.header-tools { gap: 12px; }
.brand { font-size: 15px; }
}
@media (max-width: 820px) {
:root { --header-height: 68px; }
.header-inner { padding: 0 18px; }
.global-nav, .locale { display: none; }
.global-nav.mobile-open { position: absolute; top: var(--header-height); right: 0; left: 0; display: flex; flex-direction: column; align-items: stretch; height: auto; gap: 0; padding: 8px 18px 18px; margin: 0; border-bottom: 1px solid var(--line); background: #fff; box-shadow: 0 12px 25px rgba(0, 0, 0, .08); }
.global-nav.mobile-open .nav-item { padding: 13px 0; }
.global-nav.mobile-open .mega-menu { position: static; display: none; width: auto; padding: 10px 0 10px 16px; box-shadow: none; opacity: 1; transform: none; }
.global-nav.mobile-open .package:focus-within .mega-menu, .global-nav.mobile-open .package:hover .mega-menu { display: grid; }
.header-tools { margin-left: auto; }
.mobile-menu { display: block; }
.page-hero { min-height: 185px; }
.page-hero h1 { font-size: 33px; }
.sub-nav-inner { overflow-x: auto; }
.sub-nav a { min-width: 130px; padding: 14px 10px; font-size: 13px; }
.page-shell { padding: 43px 18px 75px; }
.section-heading { display: block; margin-bottom: 22px; }
.section-heading h2 { font-size: 24px; }
.filter-panel { padding: 16px; }
.filter-row { gap: 13px; }
.filter-row + .filter-row { margin-top: 14px; padding-top: 14px; }
.filter-spacer { display: none; }
.search-form { width: 100%; min-width: 0; }
.table-actions { padding-top: 17px; }
.form-row { grid-template-columns: 90px 1fr; }
.form-label, .form-control { padding: 12px 10px; font-size: 13px; }
.detail-title-line { display: block; }
.detail-title-line .status { margin-top: 12px; }
.detail-head, .detail-body, .attachments, .detail-actions { padding-right: 14px; padding-left: 14px; }
.detail-actions { gap: 8px; }
.detail-actions .button { flex: 1; padding: 0 10px; }
.footer-inner { display: block; }
.footer-links { margin-top: 18px; }
}
@media (max-width: 480px) {
.brand-mark { width: 31px; height: 21px; font-size: 12px; }
.brand { gap: 5px; font-size: 13px; }
.login-link { font-size: 0; }
.login-link .user-icon { margin-right: 8px; }
.button { padding: 0 14px; }
.form-footer { justify-content: stretch; }
.form-footer .button { flex: 1; }
}
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="assets/styles.css"></head>
<body>
<header class="site-header"><div class="header-inner"><a class="brand" href="index.html"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a><nav class="global-nav" aria-label="주 메뉴"><a class="nav-item" href="#">바론 컨설턴트</a><a class="nav-item" href="#">디지털전환</a><div class="nav-item package"><a href="#">패키지 S/W</a><div class="mega-menu"><strong class="mega-label">EG-BIM</strong><div class="mega-links"><a href="#">소개</a><a href="#">인터페이스</a><a href="#">주요기능</a><a href="index.html" aria-current="page">Q&amp;A</a><a href="#">for BIM</a><a href="#">구매하기</a></div></div></div><a class="nav-item" href="#">서비스 S/W</a><a class="nav-item" href="#">빅룸</a><a class="nav-item" href="#">D/X 체험</a><a class="nav-item" href="#">홍보센터</a></nav><div class="header-tools"><span class="locale"><a href="#">◎ KR</a><span>|</span><a href="#">EN</a></span><a class="login-link" href="https://test.baroncs.co.kr/"><span class="user-icon" aria-hidden="true"></span><span data-auth-name>로그인</span></a><button class="menu-button mobile-menu" type="button" aria-label="메뉴"><span></span><span></span><span></span></button></div></div></header>
<main><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div></section><nav class="sub-nav"><div class="sub-nav-inner"><a href="#">EG-BIM 소개</a><a href="#">주요기능</a><a href="#">사용가이드</a><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a></div></nav>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div><a class="button button-primary" href="write.html">문의 등록</a></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><span class="help-text">첨부된 파일이 없습니다.</span></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a><a class="button button-outline" href="write.html">문의 등록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><footer class="site-footer"><div class="footer-inner"><div><a class="brand footer-brand" href="index.html"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a><p class="footer-copy">© BARON Consultants Co., Ltd. All Rights Reserved.</p></div><div class="footer-links"><a href="#">개인정보 처리방침</a><a href="#">이용약관</a></div></div></footer><script src="assets/config.js"></script><script src="app.js"></script>
</body></html>
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="EG-BIM Q&A 문의 게시판">
<title>EG-BIM Q&A | 바론컨설턴트</title>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a class="brand" href="index.html" aria-label="바론컨설턴트 홈"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a>
<nav class="global-nav" aria-label="주 메뉴">
<a class="nav-item" href="#">바론 컨설턴트</a><a class="nav-item" href="#">디지털전환</a>
<div class="nav-item package"><a href="#">패키지 S/W</a><div class="mega-menu" aria-label="패키지 소프트웨어 메뉴"><strong class="mega-label">EG-BIM</strong><div class="mega-links"><a href="#">소개</a><a href="#">인터페이스</a><a href="#">주요기능</a><a href="index.html" aria-current="page">Q&amp;A</a><a href="#">for BIM</a><a href="#">구매하기</a></div></div></div>
<a class="nav-item" href="#">서비스 S/W</a><a class="nav-item" href="#">빅룸</a><a class="nav-item" href="#">D/X 체험</a><a class="nav-item" href="#">홍보센터</a>
</nav>
<div class="header-tools"><span class="locale"><a href="#">◎ KR</a><span>|</span><a href="#">EN</a></span><a class="login-link" href="https://test.baroncs.co.kr/"><span class="user-icon" aria-hidden="true"></span><span data-auth-name>로그인</span></a><button class="menu-button mobile-menu" type="button" aria-label="메뉴"><span></span><span></span><span></span></button></div>
</div>
</header>
<main>
<section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div></section>
<nav class="sub-nav" aria-label="EG-BIM 메뉴"><div class="sub-nav-inner"><a href="#">EG-BIM 소개</a><a href="#">주요기능</a><a href="#">사용가이드</a><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a></div></nav>
<section class="page-shell">
<div class="section-heading"><div><h2>문의하기(Q&amp;A)</h2><p>EG-BIM 사용 중 궁금한 점과 개선 의견을 남겨주세요.</p></div><a class="button button-primary" href="write.html">문의 등록</a></div>
<form class="filter-panel" id="searchForm">
<div class="filter-row"><span class="filter-label">구분</span><div class="chip-group"><label class="chip"><input type="checkbox" name="category" value="오류문의">오류</label><label class="chip"><input type="checkbox" name="category" value="개선문의">개선</label><label class="chip"><input type="checkbox" name="category" value="일반문의">일반</label><label class="chip"><input type="checkbox" name="category" value="공지사항">공지</label></div><span class="filter-spacer"></span><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label></div>
<div class="filter-row"><span class="filter-label">검색</span><div class="search-form"><input id="query" type="search" placeholder="제목, 내용, 회사명으로 검색" aria-label="게시글 검색"><button class="button button-primary" type="submit">검색</button></div></div>
</form>
<div class="table-actions"><span class="result-count" id="resultCount">총 0건</span><span class="help-text">문의 내용에 개인정보를 입력하지 마세요.</span></div>
<div class="table-wrap"><table class="qa-table"><caption class="sr-only">EG-BIM 문의 목록</caption><colgroup><col style="width:6%"><col style="width:10%"><col style="width:15%"><col style="width:12%"><col style="width:9%"><col><col style="width:12%"><col style="width:11%"></colgroup><thead><tr><th>번호</th><th>구분</th><th>회사</th><th>부서</th><th>작성자</th><th>제목</th><th>등록일</th><th>상태</th></tr></thead><tbody id="qaRows"></tbody></table><div class="table-empty" id="tableEmpty">검색 조건에 맞는 문의가 없습니다.</div></div>
<div class="pagination" id="pagination" aria-label="페이지 이동"></div>
</section>
</main>
<footer class="site-footer"><div class="footer-inner"><div><a class="brand footer-brand" href="index.html"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a><p class="footer-copy">© BARON Consultants Co., Ltd. All Rights Reserved.</p></div><div class="footer-links"><a href="#">개인정보 처리방침</a><a href="#">이용약관</a><a href="https://test.baroncs.co.kr/">로그인</a></div></div></footer>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="assets/config.js"></script><script src="app.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "baron-qa-gateway-test",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"r2:upload": "bash scripts/upload-r2.sh",
"check": "node --check src/index.js && node --check app.js"
},
"devDependencies": {
"wrangler": "4.134.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"SESSION_SECRET": "replace-with-openssl-rand-hex-32-output"
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
bucket="${1:-baron-qa-pages}"
upload() {
local file="$1"
local key="$2"
local content_type="$3"
npx wrangler r2 object put "$bucket/$key" --file "$file" --content-type "$content_type" --remote
}
upload index.html index.html text/html
upload write.html write.html text/html
upload detail.html detail.html text/html
upload app.js app.js application/javascript
upload index.html egbim/index.html text/html
upload write.html egbim/write.html text/html
upload detail.html egbim/detail.html text/html
upload app.js egbim/app.js application/javascript
while IFS= read -r file; do
key="${file#./}"
case "$file" in
*.css) content_type="text/css" ;;
*.js) content_type="application/javascript" ;;
*.woff2) content_type="font/woff2" ;;
*.svg) content_type="image/svg+xml" ;;
*.png) content_type="image/png" ;;
*.jpg|*.jpeg) content_type="image/jpeg" ;;
*) content_type="application/octet-stream" ;;
esac
upload "$file" "$key" "$content_type"
upload "$file" "egbim/$key" "$content_type"
done < <(find ./assets -type f -not -name '*.map' -print | sort)
echo "Uploaded static files to R2 bucket: $bucket"
+274
View File
@@ -0,0 +1,274 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === '/auth/login') return startLogin(request, env);
if (url.pathname === '/auth/callback') return finishLogin(request, env);
if (url.pathname === '/auth/logout') return logout(request, env);
if (url.pathname === '/auth/session') return sessionResponse(request, env);
if (request.method !== 'GET' && request.method !== 'HEAD') {
return json({ error: 'method_not_allowed' }, 405);
}
if (matchesConfiguredPath(url.pathname, env.INTERNAL_ONLY_PREFIXES) && !isInternalRequest(request, env)) {
return json({ error: 'not_found' }, 404);
}
if (isProtectedPath(url.pathname, env) && !(await getSession(request, env))) {
const returnUrl = url.pathname + url.search;
return Response.redirect(new URL('/auth/login?return_url=' + encodeURIComponent(returnUrl), url), 302);
}
if (url.pathname === '/egbim' || url.pathname === '/tova' || url.pathname === '/gaia') {
return Response.redirect(new URL(url.pathname + '/', url), 301);
}
const objectKey = objectKeyForPath(url.pathname);
let object = await env.QA_BUCKET.get(objectKey);
if (!object && objectKey.endsWith('/index.html')) {
object = await env.QA_BUCKET.get(objectKey.replace(/\/index\.html$/, 'index.html'));
}
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('x-content-type-options', 'nosniff');
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
headers.set('cache-control', url.pathname.endsWith('.html') ? 'no-cache' : 'public, max-age=3600');
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
} catch (error) {
console.error(error);
return json({ error: 'internal_error' }, 500);
}
}
};
function csv(value) {
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
}
function pathMatches(pathname, values) {
return csv(values).some((value) => value.endsWith('/') ? pathname.startsWith(value) : pathname === value);
}
function matchesConfiguredPath(pathname, value) {
return pathMatches(pathname, value);
}
function isProtectedPath(pathname, env) {
return pathMatches(pathname, env.PROTECTED_EXACT_PATHS) || pathMatches(pathname, env.PROTECTED_PREFIXES);
}
function isInternalRequest(request, env) {
const expected = env.INTERNAL_SHARED_SECRET;
return Boolean(expected && request.headers.get('x-internal-secret') === expected);
}
function objectKeyForPath(pathname) {
const normalized = pathname.replace(/^\/+/, '');
if (normalized === '') return 'index.html';
if (normalized.endsWith('/')) return normalized + 'index.html';
return normalized;
}
function json(payload, status = 200, extraHeaders = {}) {
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extraHeaders });
return new Response(JSON.stringify(payload), { status, headers });
}
function redirect(url, headers = {}) {
const responseHeaders = new Headers();
Object.entries(headers).forEach(([name, value]) => {
if (Array.isArray(value)) value.forEach((item) => responseHeaders.append(name, item));
else responseHeaders.set(name, value);
});
responseHeaders.set('location', url);
return new Response(null, { status: 302, headers: responseHeaders });
}
function base64url(value) {
const bytes = value instanceof Uint8Array ? value : encoder.encode(value);
let binary = '';
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64url(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function sha256(value) {
return crypto.subtle.digest('SHA-256', typeof value === 'string' ? encoder.encode(value) : value);
}
async function hmac(value, secret) {
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
return crypto.subtle.sign('HMAC', key, encoder.encode(value));
}
async function signValue(value, secret) {
return value + '.' + base64url(new Uint8Array(await hmac(value, secret)));
}
async function verifyValue(signed, secret) {
if (!signed || !secret) return null;
const separator = signed.lastIndexOf('.');
if (separator < 1) return null;
const value = signed.slice(0, separator);
const signature = signed.slice(separator + 1);
const expected = base64url(new Uint8Array(await hmac(value, secret)));
if (signature.length !== expected.length) return null;
let difference = 0;
for (let index = 0; index < signature.length; index += 1) difference |= signature.charCodeAt(index) ^ expected.charCodeAt(index);
return difference === 0 ? value : null;
}
function randomBytes(size = 32) {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytes;
}
function randomString(size = 32) {
return base64url(randomBytes(size));
}
function getCookie(request, name) {
const header = request.headers.get('cookie') || '';
const match = header.split(';').map((part) => part.trim()).find((part) => part.startsWith(name + '='));
return match ? match.slice(name.length + 1) : '';
}
function cookie(name, value, maxAge, options = {}) {
const parts = [`${name}=${value}`, `Max-Age=${maxAge}`, 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax'];
if (options.delete) parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
return parts.join('; ');
}
function parseJwt(token) {
if (!token || token.split('.').length < 2) return {};
try { return JSON.parse(decoder.decode(fromBase64url(token.split('.')[1]))); } catch (error) { return {}; }
}
function requiredAuthConfig(env) {
const keys = ['AUTH_CLIENT_ID', 'AUTH_AUTHORIZE_URL', 'AUTH_TOKEN_URL', 'AUTH_REDIRECT_URI', 'SESSION_SECRET'];
return keys.filter((key) => !env[key]);
}
function normalizeClaims(claims) {
const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {};
const custom = claims.customAttributes || claims.custom_attributes || {};
const tenants = claims.tenants || claims.tenantIds || custom.tenants || [];
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => ({ id: key, ...(tenants[key] || {}) }));
const tenantId = claims.tenant_id || claims.tenantId || custom.tenant_id || custom.tenantId || tenantList[0]?.id || tenantList[0]?.tenantId || '';
const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean);
if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
const subject = claims.sub || claims.subject || claims.userId || claims.user_id || '';
return {
userUuid: claims.userId || claims.user_id || subject,
ssoSubject: subject,
requesterId: subject,
tenantId,
requesterTenantId: tenantId,
tenantIds,
scope: claims.scope || '',
roles: claims.roles || claims.roleNames || [],
loginId: claims.email || claims.loginId || claims.preferred_username || profile.email || '',
email: claims.email || profile.email || '',
name: claims.name || profile.name || claims.display_name || '',
phone: claims.phone || claims.phone_number || '',
company: custom.company || claims.company || claims.organization || '',
familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || '',
department: custom.team || custom.department || claims.department || ''
};
}
function safeReturnUrl(value, origin) {
try {
const target = new URL(value || '/', origin);
return target.origin === origin ? target.pathname + target.search : '/';
} catch (error) { return '/'; }
}
async function startLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const returnUrl = safeReturnUrl(url.searchParams.get('return_url'), url.origin);
const state = randomString(24);
const verifier = randomString(48);
const nonce = randomString(24);
const statePayload = base64url(JSON.stringify({ state, verifier, nonce, returnUrl, createdAt: Date.now() }));
const signedState = await signValue(statePayload, env.SESSION_SECRET);
const challenge = base64url(new Uint8Array(await sha256(verifier)));
const authorize = new URL(env.AUTH_AUTHORIZE_URL);
authorize.searchParams.set('client_id', env.AUTH_CLIENT_ID);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('redirect_uri', env.AUTH_REDIRECT_URI);
authorize.searchParams.set('scope', env.AUTH_SCOPE || 'openid profile email');
authorize.searchParams.set('state', state);
authorize.searchParams.set('nonce', nonce);
authorize.searchParams.set('code_challenge', challenge);
authorize.searchParams.set('code_challenge_method', 'S256');
return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) });
}
async function finishLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const signedState = getCookie(request, env.OAUTH_COOKIE_NAME);
const statePayload = await verifyValue(signedState, env.SESSION_SECRET);
if (!statePayload) return json({ error: 'invalid_oauth_state' }, 400);
let state;
try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return json({ error: 'invalid_oauth_state' }, 400); }
if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return json({ error: 'invalid_oauth_state' }, 400);
const code = url.searchParams.get('code');
if (!code) return json({ error: 'oauth_code_missing', detail: url.searchParams.get('error_description') || url.searchParams.get('error') || '' }, 400);
const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier });
const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams });
const tokens = await tokenResponse.json().catch(() => ({}));
if (!tokenResponse.ok) return json({ error: 'oauth_token_exchange_failed', detail: tokens.error_description || tokens.error || '' }, 502);
let claims = parseJwt(tokens.id_token || tokens.access_token);
if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return json({ error: 'invalid_oauth_nonce' }, 400);
if (env.AUTH_USERINFO_URL && tokens.access_token) {
const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } });
const userInfo = await userResponse.json().catch(() => ({}));
if (userResponse.ok) claims = { ...claims, ...userInfo };
}
const user = normalizeClaims(claims);
if (!user.ssoSubject) return json({ error: 'sso_subject_missing' }, 502);
const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 }));
const session = await signValue(sessionPayload, env.SESSION_SECRET);
return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] });
}
async function getSession(request, env) {
const signedSession = getCookie(request, env.SESSION_COOKIE_NAME);
const value = await verifyValue(signedSession, env.SESSION_SECRET);
if (!value) return null;
try {
const payload = JSON.parse(decoder.decode(fromBase64url(value)));
return payload.expiresAt > Date.now() ? payload.user : null;
} catch (error) { return null; }
}
async function sessionResponse(request, env) {
const user = await getSession(request, env);
return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' });
}
function logout(request, env) {
const url = new URL(request.url);
const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
}
+37
View File
@@ -0,0 +1,37 @@
name = "baron-qa-gateway-test"
main = "src/index.js"
compatibility_date = "2026-09-18"
account_id = "81fa2d48964d31dd0da9558f9ce601d1"
preview_urls = false
routes = [
{ pattern = "qa-test.baroncs.co.kr", custom_domain = true }
]
[vars]
AUTH_REDIRECT_URI = "https://qa-test.baroncs.co.kr/auth/callback"
AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa-test.baroncs.co.kr/"
AUTH_SCOPE = "openid tenants profile email"
AUTH_CLIENT_ID = "e16898a7-acd2-420a-a224-cdcd89ff66cd"
AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth"
AUTH_TOKEN_URL = "https://sso.hmac.kr/oidc/oauth2/token"
AUTH_USERINFO_URL = "https://sso.hmac.kr/oidc/userinfo"
SESSION_COOKIE_NAME = "baron_qa_session"
OAUTH_COOKIE_NAME = "baron_qa_oauth"
SESSION_TTL_SECONDS = "3600"
PUBLIC_EXACT_PATHS = "/,/index.html,/favicon.ico"
PUBLIC_PREFIXES = "/egbim/,/tova/,/gaia/,/shared/,/assets/,/auth/"
PROTECTED_PREFIXES = "/egbim/,/tova/,/gaia/"
PROTECTED_EXACT_PATHS = "/write.html,/detail.html"
INTERNAL_ONLY_PREFIXES = "/protected/"
[[r2_buckets]]
binding = "QA_BUCKET"
bucket_name = "baron-qa-pages"
[observability]
enabled = true
[observability.logs]
enabled = true
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 등록 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="assets/styles.css"></head>
<body>
<header class="site-header"><div class="header-inner"><a class="brand" href="index.html"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a><nav class="global-nav" aria-label="주 메뉴"><a class="nav-item" href="#">바론 컨설턴트</a><a class="nav-item" href="#">디지털전환</a><div class="nav-item package"><a href="#">패키지 S/W</a><div class="mega-menu"><strong class="mega-label">EG-BIM</strong><div class="mega-links"><a href="#">소개</a><a href="#">인터페이스</a><a href="#">주요기능</a><a href="index.html">Q&amp;A</a><a href="#">for BIM</a><a href="#">구매하기</a></div></div></div><a class="nav-item" href="#">서비스 S/W</a><a class="nav-item" href="#">빅룸</a><a class="nav-item" href="#">D/X 체험</a><a class="nav-item" href="#">홍보센터</a></nav><div class="header-tools"><span class="locale"><a href="#">◎ KR</a><span>|</span><a href="#">EN</a></span><a class="login-link" href="https://test.baroncs.co.kr/"><span class="user-icon" aria-hidden="true"></span><span data-auth-name>로그인</span></a><button class="menu-button mobile-menu" type="button" aria-label="메뉴"><span></span><span></span><span></span></button></div></div></header>
<main><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div></section><nav class="sub-nav"><div class="sub-nav-inner"><a href="#">EG-BIM 소개</a><a href="#">주요기능</a><a href="#">사용가이드</a><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a></div></nav>
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 presigned URL을 통해 qa_cdn 버킷에 저장됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
</main><footer class="site-footer"><div class="footer-inner"><div><a class="brand footer-brand" href="index.html"><span class="brand-mark">BR</span><span>(주)바론컨설턴트</span></a><p class="footer-copy">© BARON Consultants Co., Ltd. All Rights Reserved.</p></div><div class="footer-links"><a href="#">개인정보 처리방침</a><a href="#">이용약관</a></div></div></footer><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="assets/config.js"></script><script src="app.js"></script>
</body></html>