fix: enforce private feedback access across list and detail
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s
This commit is contained in:
1 parent
e2e70c13ac
commit
a5a5f24f92
10 files changed
+379
-24
No files matched your search
+45
-14
@@ -129,6 +129,7 @@
|
||||
createdAt: createdAt,
|
||||
status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])),
|
||||
secret: toBoolean(secret),
|
||||
accessRestricted: toBoolean(item.access_restricted || item.accessRestricted),
|
||||
completionRequested: toBoolean(completionRequested) || Boolean(completionRequestedAt),
|
||||
completionRequestedAt: completionRequestedAt,
|
||||
completionConfirmed: toBoolean(completionConfirmed) || Boolean(completionConfirmedAt),
|
||||
@@ -249,12 +250,14 @@
|
||||
}
|
||||
|
||||
function savePost(post) {
|
||||
if (post.secret) return;
|
||||
const posts = readJson(STORAGE_KEY, []);
|
||||
posts.unshift(post);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
|
||||
}
|
||||
|
||||
function updateStoredPost(post) {
|
||||
if (post.secret) { removeStoredPost(post.id); return; }
|
||||
const posts = readJson(STORAGE_KEY, []);
|
||||
const index = posts.findIndex(function (item) { return item.id === post.id; });
|
||||
if (index < 0) return;
|
||||
@@ -279,6 +282,10 @@
|
||||
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
|
||||
}
|
||||
|
||||
function isRestrictedPost(post, user) {
|
||||
return post.accessRestricted || (post.secret && !isPostOwner(post, user));
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
if (value && typeof value === "object") value = value.code || value.key || value.value || value.name || "";
|
||||
const raw = String(value || "").trim();
|
||||
@@ -496,6 +503,8 @@
|
||||
}
|
||||
|
||||
function bindGlobal() {
|
||||
// Discard legacy snapshots that could have been saved under another account.
|
||||
try { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(COMMENTS_KEY); } catch (error) {}
|
||||
renderAuth(getAuthUser());
|
||||
authReady = loadAuthUser().catch(function (error) {
|
||||
console.error('[QA] SSO session load failed:', error);
|
||||
@@ -544,7 +553,9 @@
|
||||
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(function () { return {}; });
|
||||
throw new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
|
||||
const error = new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.json().catch(function () { return {}; });
|
||||
}
|
||||
@@ -598,7 +609,8 @@
|
||||
const resultCount = qs("#resultCount");
|
||||
const pagination = qs("#pagination");
|
||||
let page = 1;
|
||||
let posts = getPosts();
|
||||
// A previous account's browser cache is never an authorization source.
|
||||
let posts = [];
|
||||
let remoteLoaded = false;
|
||||
let loadFailed = false;
|
||||
let receivedRemotePage = false;
|
||||
@@ -620,7 +632,11 @@
|
||||
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
|
||||
const onlyMine = qs("#onlyMine").checked;
|
||||
const user = getAuthUser();
|
||||
const filtered = searchPosts(posts.filter(function (post) {
|
||||
const visiblePosts = posts.map(function (post) {
|
||||
if (!isRestrictedPost(post, user)) return post;
|
||||
return Object.assign({}, post, { title: '비밀글입니다.', content: '', company: '-', department: '-', author: '-', requesterEmail: '', raw: {}, attachments: [] });
|
||||
});
|
||||
const filtered = searchPosts(visiblePosts.filter(function (post) {
|
||||
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
|
||||
const matchesMine = !onlyMine || isPostOwner(post, user);
|
||||
return matchesCategory && matchesMine;
|
||||
@@ -644,13 +660,17 @@
|
||||
const status = normalizeStatus(post.status);
|
||||
const statusClass = status === '완료' ? 'done' : status === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
|
||||
const company = getPostCompany(post, user);
|
||||
const restricted = isRestrictedPost(post, user);
|
||||
const subject = restricted ? '<span class="subject-link" aria-disabled="true"><span class="lock" aria-label="비밀글">🔒</span> 비밀글입니다.</span>' :
|
||||
'<a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a>';
|
||||
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
|
||||
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
|
||||
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
|
||||
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
|
||||
'<td class="inquiry-subject">' + subject + '</td>' +
|
||||
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
|
||||
}).join("");
|
||||
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
|
||||
if (!row.querySelector('a')) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
|
||||
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
|
||||
@@ -745,22 +765,32 @@
|
||||
const bottomBackButton = find('#backButton', detail);
|
||||
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
|
||||
const id = requestedId || new URLSearchParams(window.location.search).get("id") || "EG-1048";
|
||||
const storedPost = getPosts().find(function (item) { return item.id === id; });
|
||||
let post = storedPost;
|
||||
let post;
|
||||
let loadError;
|
||||
try {
|
||||
const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" });
|
||||
const remotePost = normalizeRemotePost(result.feedback || result.data || result);
|
||||
if (remotePost.id) post = Object.assign({}, storedPost || {}, remotePost);
|
||||
if (remotePost.id) post = remotePost;
|
||||
} catch (error) {
|
||||
loadError = error;
|
||||
console.warn("[QA] feedback detail load failed:", error.message);
|
||||
}
|
||||
const showUnavailable = function (restricted) {
|
||||
find('[data-detail-title]').textContent = restricted ? '비밀글입니다.' : '문의 내용을 찾을 수 없습니다.';
|
||||
find('[data-detail-content]').textContent = restricted ? '비밀글은 작성자만 조회할 수 있습니다.' : '문의 내용을 불러오지 못했습니다. 다시 시도해 주세요.';
|
||||
['.comment-box', '#commentForm', '#comments', '#editButton', '#deleteButton', '#editForm', '[data-detail-attachments]'].forEach(function (selector) {
|
||||
const element = find(selector);
|
||||
if (element) { element.hidden = true; }
|
||||
});
|
||||
};
|
||||
if (!post) {
|
||||
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
|
||||
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
|
||||
const commentBox = find('.comment-box');
|
||||
if (commentBox) commentBox.hidden = true;
|
||||
showUnavailable(loadError && loadError.status === 403);
|
||||
return;
|
||||
}
|
||||
// Private data must wait for verified identity before touching the DOM.
|
||||
if (!post.secret && !post.accessRestricted) find('[data-detail-title]').textContent = post.title;
|
||||
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
|
||||
if (isRestrictedPost(post, detailUser)) { showUnavailable(true); return; }
|
||||
find('[data-detail-category]').textContent = post.category;
|
||||
find('[data-detail-title]').textContent = post.title;
|
||||
const renderDetailStatus = function () {
|
||||
@@ -775,7 +805,6 @@
|
||||
renderDetailStatus();
|
||||
find('[data-detail-date]').textContent = post.dateTime || post.date;
|
||||
find('[data-detail-author]').textContent = post.author;
|
||||
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
|
||||
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
|
||||
find('[data-detail-department]').textContent = post.department;
|
||||
find('[data-detail-content]').textContent = post.content;
|
||||
@@ -979,11 +1008,13 @@
|
||||
return '<div class="comment" data-comment-id="' + escapeHtml(comment.id) + '"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div>' + content + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + (editing ? '' : actions) + '</div>';
|
||||
}).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
|
||||
const storedComments = getComments();
|
||||
storedComments[post.id] = comments;
|
||||
if (post.secret) delete storedComments[post.id];
|
||||
else storedComments[post.id] = comments;
|
||||
localStorage.setItem(COMMENTS_KEY, JSON.stringify(storedComments));
|
||||
return comments;
|
||||
}
|
||||
let comments = renderComments(getComments()[post.id] || []);
|
||||
// Refresh access on every open; cached comments may belong to another login.
|
||||
let comments = renderComments([]);
|
||||
const commentsPath = '/api/feedbacks/' + encodeURIComponent(post.id) + '/comments';
|
||||
requestJson(commentsPath, { method: 'GET' }).then(function (result) {
|
||||
comments = renderComments(result.comments || result.items || []);
|
||||
|
||||
Reference in new issue
Block a user