feat(project): 업무·프로젝트 삭제를 '만든 사람'만 허용 + 경고
- 서버: api_delete_task/api_delete_project 가 created_by 일치자만 허용 (슈퍼관리자 예외, created_by 없는 과거 데이터는 관리자 허용). 403 반환. - 클라이언트: 삭제 버튼 클릭 시 만든 사람이 아니면 경고창 후 중단 (created_by 를 트리 삭제버튼·홈 카드·업무 객체로 전달) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,23 @@ def _require_manage(request: Request, st: Any, user: dict[str, Any], project_id:
|
|||||||
raise HTTPException(status_code=403, detail="이 프로젝트에 대한 권한이 없습니다.")
|
raise HTTPException(status_code=403, detail="이 프로젝트에 대한 권한이 없습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_delete(user: dict[str, Any], created_by: str | None) -> bool:
|
||||||
|
"""삭제는 '만든 사람'만. 슈퍼관리자는 예외 허용.
|
||||||
|
생성자 정보가 없는 과거 데이터는 관리자에게 허용(잠김 방지)."""
|
||||||
|
from app.store import SUPER_ADMIN_EMAIL, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
me = store.norm_str(user.get("email", ""), lower=True)
|
||||||
|
cb = store.norm_str(created_by or "", lower=True)
|
||||||
|
if me == SUPER_ADMIN_EMAIL:
|
||||||
|
return True
|
||||||
|
if not cb:
|
||||||
|
return is_admin(user)
|
||||||
|
return me == cb
|
||||||
|
|
||||||
|
|
||||||
|
_DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다."
|
||||||
|
|
||||||
|
|
||||||
def _admin_emails(request: Request) -> list[str]:
|
def _admin_emails(request: Request) -> list[str]:
|
||||||
"""알림 수신 관리자 이메일 목록.
|
"""알림 수신 관리자 이메일 목록.
|
||||||
|
|
||||||
@@ -406,6 +423,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
|
|||||||
"id": p["id"],
|
"id": p["id"],
|
||||||
"name": p["name"],
|
"name": p["name"],
|
||||||
"color": p.get("color") or "#4573d2",
|
"color": p.get("color") or "#4573d2",
|
||||||
|
"created_by": p.get("created_by") or "",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": t["id"], "title": t["title"],
|
{"id": t["id"], "title": t["title"],
|
||||||
"color": t.get("project_color") or p.get("color") or "#4573d2",
|
"color": t.get("project_color") or p.get("color") or "#4573d2",
|
||||||
@@ -558,23 +576,14 @@ async def api_update_project(
|
|||||||
|
|
||||||
@router.delete("/api/projects/{project_id}")
|
@router.delete("/api/projects/{project_id}")
|
||||||
async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
|
async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
|
||||||
"""프로젝트 삭제 — 서브프로젝트는 멤버도, 최상위는 관리자만.
|
"""프로젝트 삭제 — '만든 사람'만(슈퍼관리자 예외). 안의 업무도 CASCADE."""
|
||||||
|
|
||||||
아사나 요구사항: 배정된 사용자는 '서브프로젝트' 추가/삭제 가능.
|
|
||||||
최상위 프로젝트 삭제는 관리자 전용.
|
|
||||||
"""
|
|
||||||
from app.store import is_admin # noqa: WPS433
|
|
||||||
|
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
st = _db_or_503(request)
|
st = _db_or_503(request)
|
||||||
project = st.get_project(project_id=project_id)
|
project = st.get_project(project_id=project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||||
is_sub = project.get("parent_id") is not None
|
if not _can_delete(user, project.get("created_by")):
|
||||||
if is_sub:
|
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
|
||||||
_require_manage(request, st, user, project_id)
|
|
||||||
elif not is_admin(user):
|
|
||||||
raise HTTPException(status_code=403, detail="최상위 프로젝트 삭제는 관리자만 가능합니다.")
|
|
||||||
st.delete_project(project_id=project_id)
|
st.delete_project(project_id=project_id)
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
@@ -831,7 +840,8 @@ async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
|
|||||||
existing = st.get_task(task_id=task_id)
|
existing = st.get_task(task_id=task_id)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||||
_require_manage(request, st, user, existing["project_id"])
|
if not _can_delete(user, existing.get("created_by")):
|
||||||
|
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
|
||||||
st.delete_task(task_id=task_id)
|
st.delete_task(task_id=task_id)
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260625u" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625v" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}">
|
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}"
|
||||||
|
data-is-super="{{ 'true' if user.is_super_admin else 'false' }}"
|
||||||
|
data-me="{{ user.email }}">
|
||||||
|
|
||||||
<div class="pj-home">
|
<div class="pj-home">
|
||||||
<!-- 프로젝트 목록 -->
|
<!-- 프로젝트 목록 -->
|
||||||
@@ -50,7 +52,8 @@
|
|||||||
data-start="{{ p.start_date or '' }}"
|
data-start="{{ p.start_date or '' }}"
|
||||||
data-due="{{ p.due_date or '' }}"
|
data-due="{{ p.due_date or '' }}"
|
||||||
data-color="{{ p.color }}"
|
data-color="{{ p.color }}"
|
||||||
data-status="{{ p.status or 'active' }}">
|
data-status="{{ p.status or 'active' }}"
|
||||||
|
data-creator="{{ p.created_by or '' }}">
|
||||||
<a class="pj-project-cardlink" href="/project/p/{{ p.id }}">
|
<a class="pj-project-cardlink" href="/project/p/{{ p.id }}">
|
||||||
<span class="pj-project-dot"></span>
|
<span class="pj-project-dot"></span>
|
||||||
<div class="pj-project-body">
|
<div class="pj-project-body">
|
||||||
@@ -164,5 +167,5 @@
|
|||||||
<script>
|
<script>
|
||||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/project.js?v=20260625u" defer></script>
|
<script src="/static/project.js?v=20260625v" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/project.css?v=20260625u" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625v" />
|
||||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
<!-- 타임라인 vis-timeline — self-host -->
|
<!-- 타임라인 vis-timeline — self-host -->
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
data-project-id="{{ project.id }}"
|
data-project-id="{{ project.id }}"
|
||||||
data-can-manage="{{ 'true' if can_manage else 'false' }}"
|
data-can-manage="{{ 'true' if can_manage else 'false' }}"
|
||||||
data-is-admin="{{ 'true' if is_admin else 'false' }}"
|
data-is-admin="{{ 'true' if is_admin else 'false' }}"
|
||||||
|
data-is-super="{{ 'true' if user.is_super_admin else 'false' }}"
|
||||||
data-me="{{ user.email }}">
|
data-me="{{ user.email }}">
|
||||||
|
|
||||||
<div class="pj-layout">
|
<div class="pj-layout">
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<span class="pj-tree-acts">
|
<span class="pj-tree-acts">
|
||||||
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" title="프로젝트 삭제">×</button>
|
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" data-creator="{{ p.created_by }}" title="프로젝트 삭제">×</button>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,5 +290,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||||
<script src="/static/project.js?v=20260625u" defer></script>
|
<script src="/static/project.js?v=20260625v" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+24
-1
@@ -62,6 +62,14 @@
|
|||||||
function initHome() {
|
function initHome() {
|
||||||
const grid = el("pj-project-grid");
|
const grid = el("pj-project-grid");
|
||||||
if (!grid && !el("pj-toggle-done")) return; // 홈 아님
|
if (!grid && !el("pj-toggle-done")) return; // 홈 아님
|
||||||
|
const wrap = document.querySelector(".pj-wrap");
|
||||||
|
const me = ((wrap && wrap.dataset.me) || "").toLowerCase();
|
||||||
|
const isSuper = !!(wrap && wrap.dataset.isSuper === "true");
|
||||||
|
function canDelete(createdBy) {
|
||||||
|
if (isSuper) return true;
|
||||||
|
const cb = (createdBy || "").toLowerCase();
|
||||||
|
return !!cb && cb === me;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ──
|
// ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ──
|
||||||
const toggleBtn = el("pj-toggle-done");
|
const toggleBtn = el("pj-toggle-done");
|
||||||
@@ -140,6 +148,8 @@
|
|||||||
});
|
});
|
||||||
el("pj-delete-project").addEventListener("click", async function () {
|
el("pj-delete-project").addEventListener("click", async function () {
|
||||||
const id = el("pj-e-id").value;
|
const id = el("pj-e-id").value;
|
||||||
|
const card = grid.querySelector('.pj-project-card[data-id="' + id + '"]');
|
||||||
|
if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; }
|
||||||
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
|
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
|
||||||
try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
|
try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
|
||||||
catch (e) { alert("삭제 실패: " + e.message); }
|
catch (e) { alert("삭제 실패: " + e.message); }
|
||||||
@@ -156,6 +166,15 @@
|
|||||||
const projectId = parseInt(root.dataset.projectId, 10);
|
const projectId = parseInt(root.dataset.projectId, 10);
|
||||||
const canManage = root.dataset.canManage === "true";
|
const canManage = root.dataset.canManage === "true";
|
||||||
const isAdmin = root.dataset.isAdmin === "true";
|
const isAdmin = root.dataset.isAdmin === "true";
|
||||||
|
const me = (root.dataset.me || "").toLowerCase();
|
||||||
|
const isSuper = root.dataset.isSuper === "true";
|
||||||
|
// 삭제는 만든 사람만(슈퍼관리자 예외). created_by 없으면 서버가 최종 판정.
|
||||||
|
function canDelete(createdBy) {
|
||||||
|
if (isSuper) return true;
|
||||||
|
const cb = (createdBy || "").toLowerCase();
|
||||||
|
return !!cb && cb === me;
|
||||||
|
}
|
||||||
|
const DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다.";
|
||||||
|
|
||||||
const dataEl = el("pj-data");
|
const dataEl = el("pj-data");
|
||||||
const data = JSON.parse(dataEl.textContent);
|
const data = JSON.parse(dataEl.textContent);
|
||||||
@@ -918,7 +937,10 @@
|
|||||||
|
|
||||||
el("pj-delete-task").addEventListener("click", async function () {
|
el("pj-delete-task").addEventListener("click", async function () {
|
||||||
const id = el("pj-t-id").value;
|
const id = el("pj-t-id").value;
|
||||||
if (!id || !confirm("이 업무를 삭제할까요?")) return;
|
if (!id) return;
|
||||||
|
const t = tasks.find(function (x) { return String(x.id) === String(id); });
|
||||||
|
if (t && !canDelete(t.created_by)) { alert(DELETE_DENIED); return; }
|
||||||
|
if (!confirm("이 업무를 삭제할까요?")) return;
|
||||||
try {
|
try {
|
||||||
await api("DELETE", "/project/api/tasks/" + id);
|
await api("DELETE", "/project/api/tasks/" + id);
|
||||||
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
|
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
|
||||||
@@ -976,6 +998,7 @@
|
|||||||
document.querySelectorAll(".pj-tree-del").forEach(function (b) {
|
document.querySelectorAll(".pj-tree-del").forEach(function (b) {
|
||||||
b.addEventListener("click", async function (e) {
|
b.addEventListener("click", async function (e) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; }
|
||||||
if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
|
if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
|
||||||
try {
|
try {
|
||||||
await api("DELETE", "/project/api/projects/" + b.dataset.id);
|
await api("DELETE", "/project/api/projects/" + b.dataset.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user