fix(M02): 재검증 흠 넷 — 같은 이름 토스트 · 쓸 수 없는 글자 막기(화면·서버 400) · 저장 안 한 고침 버리기 확인 · 덮어쓰기 확인과 개인·회사 지우기 단추

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
This commit is contained in:
2026-09-25 17:01:47 +09:00
co-authored by Claude Sonnet 5
parent 2aa4c80577
commit 6555313a22
8 changed files with 114 additions and 27 deletions
@@ -35,7 +35,8 @@ async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
...init,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (response.status === 409) throw new StaleError(String(body.detail ?? "409"));
if (response.status === 409)
throw new StaleError(typeof body.detail === "string" ? body.detail : "409");
if (!response.ok) {
const detail = body.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
@@ -82,6 +83,14 @@ export const saveTemplate = (
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
/** 개인·회사 층에서 지움 — 서버 길은 sub4 (없으면 404) */
export const deleteLayerTemplate = (
layer: Layer,
kind: Kind,
name: string,
): Promise<{ ok: boolean }> =>
call(`/layers/${layer}/templates/${kind}/${enc(name)}`, { method: "DELETE" });
/* --- 프로젝트 층 단추 다섯 --- */
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
const post = (path: string, body: object = {}): Promise<unknown> =>
@@ -34,6 +34,8 @@ export interface DrawingTemplateOptions {
layer?: string;
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
projectId?: string | null;
/** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */
onChanged?: (dirty: boolean) => void;
}
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
@@ -128,6 +130,7 @@ export function mountDrawingTemplate(
const areaInputs = AREA_LABELS.map((label, index) => {
const field = createInputField({ label, type: "number", value: String(start[index]) });
field.input.disabled = readOnly;
field.input.addEventListener("input", () => options.onChanged?.(true));
return field;
});
area.append(areaTitle, ...areaInputs.map((field) => field.root));
@@ -167,7 +170,10 @@ export function mountDrawingTemplate(
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
);
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
const cad = createCadHost<DrawingTemplateDoc>({
title: "도면 양식",
onChanged: (dirty) => options.onChanged?.(dirty),
});
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
cad.load(
@@ -18,7 +18,7 @@ from common_util.common_util_json import atomic_write_json
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈
_BAD_NAME = re.compile(r'[\/:*?"<>|\x00-\x1f]')
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
@@ -37,7 +37,7 @@ def _path(kind: str, name: str) -> Path:
if kind not in KINDS:
raise StoreError(404, f"없는 종류 「{kind}」")
if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name):
raise StoreError(422, f"쓸 수 없는 이름 「{name}」")
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
return FOLDER / kind / f"{name}.json"
@@ -4,7 +4,7 @@
* 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
readFilled,
@@ -48,6 +48,8 @@ export interface MainHandle {
root: HTMLElement;
open: (sel: Selection | null) => Promise<void>;
current: () => Selection | null;
/** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
confirmLeave: () => Promise<boolean>;
}
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
@@ -75,6 +77,21 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
let loaded: unknown = null;
let editor: EditorHandle | null = null;
let seq = 0;
let dirty = false;
const guard = (event: BeforeUnloadEvent): void => {
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", guard);
const confirmLeave = async (): Promise<boolean> => {
if (!dirty) return true;
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
dirty = false;
return true;
};
const showNotice = (nodes: (HTMLElement | string)[]): void => {
notice.replaceChildren(...nodes);
@@ -102,7 +119,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
"createSheet",
);
if (create) {
editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master" });
editor = create(host, doc, {
mode: at.layer === "project" ? "project" : "master",
onChange: () => (dirty = !readOnly),
});
return;
}
return empty(L("M02_NoSheet"));
@@ -118,6 +138,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
name: at.name,
layer: at.layer,
projectId: at.projectId,
onChanged: (d: boolean) => (dirty = d && !readOnly),
});
return;
}
@@ -126,6 +147,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
async function open(next: Selection | null): Promise<void> {
const mine = ++seq;
dirty = false;
drop();
showNotice([]);
sel = next;
@@ -166,6 +188,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
version = info.판;
loaded = doc;
dirty = false;
showNotice([]);
showToast(L("M02_Saved"), "success");
onSaved?.(); // 좌측 목록의 판을 새 판으로
@@ -182,8 +205,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
}
save.addEventListener("click", () => void doSave());
reload.addEventListener("click", () => sel && void open(sel));
reload.addEventListener("click", async () => {
if (sel && (await confirmLeave())) void open(sel);
});
bar();
empty("");
return { root, open, current: () => sel };
return { root, open, current: () => sel, confirmLeave };
}
@@ -20,9 +20,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const main = createMain(isAdmin, () => void side.refresh());
const side = buildSide({
isAdmin,
isCompanyAdmin: isAdmin || user?.role === "ADMIN" || !!user?.is_master,
userName: user?.name,
onOpen: (sel) => void main.open(sel),
getOpen: main.current,
confirmLeave: main.confirmLeave,
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -17,6 +17,7 @@ import { openModal } from "@ui/ui_template_modal";
import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import {
applyToProject,
deleteLayerTemplate,
deleteTemplate,
fetchSources,
listTemplates,
@@ -24,6 +25,7 @@ import {
resetProject,
saveProjectAs,
saveTemplate,
StaleError,
type Kind,
type Layer,
type TemplateInfo,
@@ -78,10 +80,14 @@ const store = {
export interface SideOptions {
isAdmin: boolean;
/** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */
isCompanyAdmin?: boolean;
/** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */
userName?: string;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
}
export interface SideHandle {
@@ -89,6 +95,10 @@ export interface SideHandle {
refresh: () => Promise<void>;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
@@ -144,7 +154,8 @@ export function buildSide(opt: SideOptions): SideHandle {
const layerButtons = LAYERS.map(({ layer: id, label }) => {
const button = createButton({ label: label(), variant: "ghost" });
button.disabled = id === "system" && !opt.isAdmin;
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
if (id !== layer && !(await opt.confirmLeave())) return;
layer = id;
store.set(LAYER_KEY, id);
opt.onOpen(null);
@@ -173,7 +184,11 @@ export function buildSide(opt: SideOptions): SideHandle {
};
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
projectField.select.addEventListener("change", () => {
projectField.select.addEventListener("change", async () => {
if (!(await opt.confirmLeave())) {
projectField.select.value = projectId;
return;
}
projectId = projectField.select.value;
remember(projectId);
opt.onOpen(null);
@@ -221,7 +236,7 @@ export function buildSide(opt: SideOptions): SideHandle {
text: info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => select(info));
b.addEventListener("click", () => void select(info));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
@@ -235,14 +250,19 @@ export function buildSide(opt: SideOptions): SideHandle {
layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer));
projectField.root.hidden = !isProject();
const editable = layer === "system" && canEdit(layer, opt.isAdmin);
editRow.hidden = layer !== "system";
const canDelete =
editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin);
editRow.hidden = isProject();
btnNew.hidden = btnCopy.hidden = layer !== "system";
btnNew.disabled = !editable;
btnCopy.disabled = btnDel.disabled = !editable || !currentInfo();
btnCopy.disabled = !editable || !currentInfo();
btnDel.disabled = !canDelete || !currentInfo();
projectRow.hidden = !isProject();
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
}
function select(info: TemplateInfo): void {
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
paintList();
paintButtons();
@@ -298,11 +318,16 @@ export function buildSide(opt: SideOptions): SideHandle {
ok.addEventListener("click", async () => {
const text = name.input.value.trim();
if (!text) return showToast(L("M02_NameNeeded"), "error");
if (badName(text)) return showToast(L("M02_BadName"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(kind.select.value as Kind, text);
close();
} catch (error) {
failed(error);
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
else failed(error);
} finally {
ok.disabled = false;
}
});
body.append(...(withKind ? [kind.root] : []), name.root, ok);
@@ -315,7 +340,7 @@ export function buildSide(opt: SideOptions): SideHandle {
showToast(L("M02_Created"), "success");
await refresh();
const info = items.find((i) => i.종류 === kind && i.이름 === name);
if (info) select(info);
if (info) await select(info);
};
btnNew.addEventListener("click", () =>
@@ -339,7 +364,8 @@ export function buildSide(opt: SideOptions): SideHandle {
if (!info) return;
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
try {
await deleteTemplate(info.종류, info.이름, info.판);
if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판);
else await deleteLayerTemplate(layer, info.종류, info.이름);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await refresh();
@@ -350,6 +376,7 @@ export function buildSide(opt: SideOptions): SideHandle {
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
const act = async (run: () => Promise<unknown>): Promise<void> => {
if (!(await opt.confirmLeave())) return;
try {
await run();
showToast(L("M02_Done"), "success");
@@ -364,9 +391,21 @@ export function buildSide(opt: SideOptions): SideHandle {
b.addEventListener("click", onClick);
projectRow.append(b);
};
const saveOpenAs = (to: "personal" | "company"): void => {
const saveOpenAs = async (to: "personal" | "company"): Promise<void> => {
const cur = opt.getOpen();
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
try {
const rows = await listTemplates(to, null);
const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name);
if (
same &&
!(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name)))
) {
return;
}
} catch (error) {
return failed(error);
}
void act(() => saveProjectAs(projectId, to, cur.kind, cur.name));
};
projectButton(L("M02_ProjectReset"), async () => {
@@ -407,8 +446,8 @@ export function buildSide(opt: SideOptions): SideHandle {
},
});
});
projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal"));
projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company"));
projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal"));
projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company"));
void refresh();
return { root, refresh };
+3 -7
View File
@@ -47,13 +47,9 @@ def test_new_save_reopen_delete_and_stale(client):
def test_bad_names_and_kinds(client):
assert (
client.put("/api/m02/templates/table/a..b", json={"판": "", "문서": {"열": []}}).status_code
== 200
)
assert (
client.put("/api/m02/templates/table/.숨김", json={"판": "", "문서": {}}).status_code == 422
)
for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"):
got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}})
assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"]
assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404
+10
View File
@@ -59,6 +59,16 @@ export const ui_locales_m2 = {
],
M02_ImportTitle: ["양식 가져오기", "Import template"],
M02_ImportNone: ["가져올 양식 없음", "Nothing to import"],
M02_NameExists: ["이미 있는 이름", "That name already exists"],
M02_BadName: [
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
'Invalid name — no / \\ : * ? " < > | or .. or leading dot',
],
M02_DiscardConfirm: [
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",
"Unsaved changes — discard and continue?",
],
M02_OverwriteConfirm: ["「{value}」 이(가) 이미 있음 — 덮을까?", "“{value}” exists — overwrite?"],
M02_Done: ["했음", "Done"],
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
} as const;