fix: show post and comment actions only to the verified author
Deploy EG-BIM QA Gateway / deploy (push) Successful in 40s

This commit is contained in:
root committed 2026-10-30 15:58:41 +09:00
1 parent 688d16555e
commit dbf5dc3611
8 files changed
+89 -30

No files matched your search

+2
View File
@@ -33,6 +33,8 @@ python3 -m http.server 4173
상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다.
게시글과 댓글의 수정·삭제 버튼은 로그인 사용자의 기본 requester UUID와 작성자 ID가 일치할 때만 표시합니다. 이름·이메일이 같다는 이유로 표시하지 않으며, 작성자 ID가 없거나 로그인 확인 중·만료·조회 실패 상태이면 숨깁니다. 다른 사람의 게시글에서도 본인이 작성한 댓글의 버튼은 표시합니다. 공통 버튼 CSS는 `hidden` 속성을 덮어쓰지 않습니다.
목록 검색은 제목·내용·회사·부서·작성자를 대상으로 오타, 띄어쓰기·대소문자 차이, 두 글자 이상의 한글 초성을 처리합니다. 예를 들어 `로그임`, `ㄹㄱㅇ`으로 `로그인`을, `파일첨부`로 `파일 첨부`를 찾을 수 있습니다. 검색 버튼이나 Enter로 실행하며, 여러 단어를 입력하면 각 단어가 모두 일치해야 합니다. 정확한 제목 일치를 우선하고 같은 점수는 기존 목록 순서를 유지합니다. 분류와 `내가 작성한 글` 조건도 함께 적용합니다.
너무 짧은 검색어의 과도한 일치를 줄이기 위해 1~2글자는 정확한 포함 검색을 사용합니다. 오타는 3~5글자에 1회, 6~32글자에 최대 2회의 삽입·삭제·치환·인접 문자 교환을 허용합니다. 긴 단어는 정확한 포함 검색을 사용합니다. 목록은 API가 반환하는 페이지를 순서대로 수집하므로 첫 100건 이후의 글도 검색합니다. 로딩 중과 일부 페이지 조회 실패는 화면에 표시하며, 조회하지 못한 글까지 검색했다고 안내하지 않습니다.
+1
View File
@@ -210,6 +210,7 @@ button { cursor: pointer; }
.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[hidden] { display: none !important; }
.button-primary { background: var(--navy); color: #fff; }
.button-primary:hover { background: #0e315f; }
.button-outline { border-color: #bfc5cb; background: #fff; color: #555; }
+25 -20
View File
@@ -266,13 +266,16 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function isPostOwner(post, user) {
if (!post || !user) return false;
function matchesAuthorId(authorId, user) {
if (!user) return false;
const normalizeId = function (id) { return String(id || '').trim().toLowerCase(); };
const postIds = [post.requesterId, post.requesterUuid].map(normalizeId).filter(Boolean);
const userIds = [user.requesterId, user.ssoSubject, user.userUuid].map(normalizeId).filter(Boolean);
if (postIds.length && userIds.length) return postIds.some(function (id) { return userIds.indexOf(id) > -1; });
return Boolean(post.author && [user.name, user.loginId, user.email].filter(Boolean).indexOf(post.author) > -1);
const ownerId = normalizeId(authorId);
const userId = normalizeId(user.requesterId || user.ssoSubject || user.userUuid);
return Boolean(ownerId && userId && ownerId === userId);
}
function isPostOwner(post, user) {
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
}
function normalizeStatus(value) {
@@ -405,15 +408,15 @@
if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId);
const primaryTenant = tenantList.find(function (tenant) { return typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId; }) || tenantList[0] || {};
const tenantName = raw.tenantName || raw.tenant_name || custom.tenantName || custom.tenant_name || claims.tenantName || claims.tenant_name || getTenantName(primaryTenant);
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 ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.requesterId || raw.userUuid || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || ssoSubject || sessionStorage.getItem('descopeUserId') || '';
const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || '';
const email = raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '';
const company = raw.company || custom.company || claims.company || sessionStorage.getItem('company') || getCompanyNameFromEmail(email) || '';
return {
userUuid: userUuid,
ssoSubject: ssoSubject,
requesterId: ssoSubject,
requesterId: raw.requesterId || ssoSubject,
tenantId: tenantId,
requesterTenantId: tenantId,
tenantIds: tenantIds,
@@ -463,7 +466,12 @@
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 || {});
const rawUser = body.user || body;
if (body.authenticated === false || !rawUser || !(rawUser.requesterId || rawUser.ssoSubject || rawUser.userUuid || rawUser.userId || rawUser.subject)) {
resolvedAuthUser = null;
return null;
}
const remoteUser = normalizeAuthUser(rawUser, body.claims || body.scope || {});
if (remoteUser.loginId || remoteUser.ssoSubject) {
resolvedAuthUser = remoteUser;
return remoteUser;
@@ -471,8 +479,8 @@
} catch (error) {
console.warn('[QA] SSO session bridge unavailable:', error.message);
}
resolvedAuthUser = localUser;
return localUser;
resolvedAuthUser = null;
return null;
}
function renderAuth(user) {
@@ -727,7 +735,7 @@
renderDetailStatus();
qs('[data-detail-date]').textContent = post.dateTime || post.date;
qs('[data-detail-author]').textContent = post.author;
let detailUser = getAuthUser();
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
@@ -747,20 +755,19 @@
const completionDoneNotice = qs("#completionDoneNotice");
const completionButton = qs("#completionButton");
const renderCompletionNotice = function () {
const owner = isPostOwner(post, detailUser || getAuthUser());
const owner = isPostOwner(post, detailUser);
const completed = normalizeStatus(post.status) === "완료" || post.completionConfirmed;
const visible = owner && !completed && post.completionRequested;
if (completionNotice) completionNotice.hidden = !visible;
if (completionDoneNotice) completionDoneNotice.hidden = !(owner && completed);
};
const ownerActions = function () {
const owner = isPostOwner(post, detailUser || getAuthUser());
const owner = isPostOwner(post, detailUser);
if (editButton) editButton.hidden = !owner;
if (deleteButton) deleteButton.hidden = !owner;
renderCompletionNotice();
};
ownerActions();
if (authReady) authReady.then(function (user) { detailUser = user || detailUser; ownerActions(); renderCompletionNotice(); comments = renderComments(comments); });
if (completionButton) completionButton.addEventListener("click", async function () {
if (!post.completionRequested || post.completionConfirmed) return;
completionButton.disabled = true;
@@ -890,15 +897,13 @@
let editingCommentId = null;
function isCommentOwner(comment, user) {
const authorId = String(comment.authorId || '').trim().toLowerCase();
const userIds = [user && user.requesterId, user && user.ssoSubject, user && user.userUuid].map(function (id) { return String(id || '').trim().toLowerCase(); }).filter(Boolean);
return Boolean(authorId && userIds.indexOf(authorId) !== -1);
return matchesAuthorId(comment.authorId, user);
}
function renderComments(comments) {
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
qs('#commentCount').textContent = comments.length;
const user = detailUser || getAuthUser();
const user = detailUser;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) {
const owner = Boolean(comment.id) && isCommentOwner(comment, user);
const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner;
+2 -2
View File
@@ -1,8 +1,8 @@
<!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?v=20261030-search-1"></head>
<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?v=20261030-owner-1"></head>
<body>
<main class="egbim-page"><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><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></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><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div id="completionNotice" class="completion-notice" hidden><div><strong>처리 완료 안내</strong><p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p></div><button class="button button-primary" id="completionButton" type="button">완료 확인</button></div><div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body></html>
+2 -2
View File
@@ -5,7 +5,7 @@
<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?v=20261030-search-1">
<link rel="stylesheet" href="../assets/styles.css?v=20261030-owner-1">
</head>
<body>
<main class="qa-list-page">
@@ -24,6 +24,6 @@
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body>
</html>
+2 -2
View File
@@ -1,8 +1,8 @@
<!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?v=20261030-search-1"></head>
<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?v=20261030-owner-1"></head>
<body>
<main class="egbim-page"><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><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<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">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 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><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body></html>
+34 -4
View File
@@ -21,13 +21,13 @@ function element() {
};
}
async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false } = {}) {
async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false, user = USER, postOwner = USER.requesterId } = {}) {
const elements = new Map([
'#detail', '[data-detail-category]', '[data-detail-title]', '[data-detail-status]',
'[data-detail-date]', '[data-detail-author]', '[data-detail-company]',
'[data-detail-department]', '[data-detail-content]', '[data-detail-secret]',
'#comments', '#commentCount', '#commentForm', '#commentContent',
'#commentError', '#commentSubmit', '[data-comment-edit-input]'
'#commentError', '#commentSubmit', '[data-comment-edit-input]', '#editButton', '#deleteButton'
].map((selector) => [selector, element()]));
const storage = new Map([[COMMENTS_KEY, JSON.stringify({ [POST]: cachedComments })]]);
const requests = [];
@@ -45,7 +45,7 @@ async function detailPage({ comments = [], cachedComments = [], createResponse =
else if (method === 'PATCH') body = patchResponse;
else if (method === 'DELETE') body = { ok: true };
else if (path.endsWith('/comments')) body = { comments };
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: USER.requesterId };
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: postOwner, requester_name: USER.name };
return new Response(JSON.stringify(body));
}
});
@@ -54,7 +54,7 @@ async function detailPage({ comments = [], cachedComments = [], createResponse =
resolvedAuthUser = user;
if (refresh) authReady = Promise.resolve(user);
} }; })();`), context);
context.testPage.setUser(USER, authRefresh);
context.testPage.setUser(user, authRefresh);
await context.testPage.initDetail();
await new Promise((resolve) => setImmediate(resolve));
return {
@@ -141,3 +141,33 @@ test('a cached comment without an ID remains read-only when loading the page', a
assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete|data-comment-save/);
assert.equal(page.requests.some((request) => ['PATCH', 'DELETE'].includes(request.method)), false);
});
test('another author post hides both post buttons while the viewer own comment keeps its actions', async () => {
const page = await detailPage({ postOwner: 'another-user', comments: [comment(), comment({ id: SECOND, author_id: 'another-user' })] });
assert.equal(page.elements.get('#editButton').hidden, true);
assert.equal(page.elements.get('#deleteButton').hidden, true);
assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"'));
assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"'));
});
test('a matching display name never shows post or comment buttons when author IDs are missing', async () => {
const page = await detailPage({ postOwner: '', comments: [comment({ author_id: '' })] });
assert.equal(page.elements.get('#editButton').hidden, true);
assert.equal(page.elements.get('#deleteButton').hidden, true);
assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete/);
});
test('post and comment actions follow only the current primary identity and disappear without a session', async () => {
const page = await detailPage({
user: { ...USER, ssoSubject: 'stale-owner', userUuid: 'stale-owner' },
comments: [comment(), comment({ id: SECOND, author_id: 'stale-owner' })]
});
assert.equal(page.elements.get('#editButton').hidden, false);
assert.equal(page.elements.get('#deleteButton').hidden, false);
assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"'));
assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"'));
const anonymous = await detailPage({ user: null, authRefresh: true, comments: [comment()] });
assert.equal(anonymous.elements.get('#editButton').hidden, true);
assert.equal(anonymous.elements.get('#deleteButton').hidden, true);
assert.doesNotMatch(anonymous.html(), /data-comment-edit|data-comment-delete/);
});
+21
View File
@@ -42,3 +42,24 @@ test('own-post filtering follows canonical identity even after company and depar
assert.equal(page.isPostOwner({ requesterId: 'fixture-owner', company: '이전 회사' }, user), true);
assert.equal(page.isPostOwner({ requesterId: 'another-owner', author: '동명이인', company: user.company }, user), false);
});
test('a matching display name, login or email never grants post ownership without an author ID', () => {
const user = { requesterId: 'fixture-owner', name: '동명이인', loginId: 'same@example.test', email: 'same@example.test' };
for (const author of [user.name, user.loginId, user.email]) {
assert.equal(page.isPostOwner({ author }, user), false);
}
assert.equal(page.isPostOwner({ requesterId: 'fixture-owner' }, null), false);
});
test('the signed primary identity takes precedence over stale alternate browser IDs', () => {
const user = { requesterId: 'current-owner', ssoSubject: 'old-owner', userUuid: 'old-owner' };
assert.equal(page.isPostOwner({ requesterId: 'old-owner' }, user), false);
assert.equal(page.isPostOwner({ requesterId: 'current-owner' }, user), true);
});
test('server requester and user UUID fields remain the author identity when normalizing a session', () => {
const user = page.normalizeAuthUser({ requesterId: 'current-owner', userUuid: 'current-owner', email: 'fixture@example.test' }, {});
assert.equal(user.requesterId, 'current-owner');
assert.equal(user.ssoSubject, 'current-owner');
assert.equal(user.userUuid, 'current-owner');
});