fix: support PKCE public client without client secret
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.sql
|
||||||
|
정적페이지 내 외부서비스 페이지 설계.png
|
||||||
|
qa-secrets.json
|
||||||
@@ -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 제거 여부를 결정하세요.
|
||||||
@@ -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 { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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(); });
|
||||||
|
})();
|
||||||
@@ -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.
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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&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&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&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&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
@@ -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&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&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&A)</a></div></nav>
|
||||||
|
<section class="page-shell">
|
||||||
|
<div class="section-heading"><div><h2>문의하기(Q&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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"SESSION_SECRET": "replace-with-openssl-rand-hex-32-output"
|
||||||
|
}
|
||||||
Executable
+37
@@ -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
@@ -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 }) });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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&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&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&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&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>
|
||||||
Reference in New Issue
Block a user