diff --git a/README.md b/README.md index fe105cd..2639632 100644 --- a/README.md +++ b/README.md @@ -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건 이후의 글도 검색합니다. 로딩 중과 일부 페이지 조회 실패는 화면에 표시하며, 조회하지 못한 글까지 검색했다고 안내하지 않습니다. diff --git a/assets/styles.css b/assets/styles.css index 67f43f2..30827b6 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -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; } diff --git a/egbim/app.js b/egbim/app.js index 708d8c5..0391b5d 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -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; diff --git a/egbim/detail.html b/egbim/detail.html index bbc0e4d..c718c34 100644 --- a/egbim/detail.html +++ b/egbim/detail.html @@ -1,8 +1,8 @@ -문의 상세 | EG-BIM Q&A +문의 상세 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.
목록

답변 0

-
+ diff --git a/egbim/index.html b/egbim/index.html index 472c282..a6b5587 100644 --- a/egbim/index.html +++ b/egbim/index.html @@ -5,7 +5,7 @@ EG-BIM Q&A | 바론컨설턴트 - +
@@ -24,6 +24,6 @@
- + diff --git a/egbim/write.html b/egbim/write.html index 4394243..ae7d7d8 100644 --- a/egbim/write.html +++ b/egbim/write.html @@ -1,8 +1,8 @@ -문의 등록 | EG-BIM Q&A +문의 등록 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

문의 등록

로그인한 사용자 정보로 문의가 등록됩니다.

로그인 상태를 확인하고 있습니다.
공개 설정
개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.
파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB
-
+
diff --git a/tests/page-comments.test.js b/tests/page-comments.test.js index 1179189..e51dbc3 100644 --- a/tests/page-comments.test.js +++ b/tests/page-comments.test.js @@ -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/); +}); diff --git a/tests/page-organization.test.js b/tests/page-organization.test.js index fd9a362..40a7073 100644 --- a/tests/page-organization.test.js +++ b/tests/page-organization.test.js @@ -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'); +});