권한 없음 모달 (PermissionDeniedModal)
권한이 없어 막혔을 때 뜨는 전역 팝업입니다. 어떻게 띄우는지와 왜 다른 모달 위에 서는지를 확인해 보세요.
Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용
Interactive playground
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write.ds-modal-backdrop--system(z 75)이 화면을 덮고, 그 z 값이 컨펌 다이얼로그(70)보다도 위입니다. 카드만 보고 있다고 생각하세요. <!-- 앱마다 한 번만 마운트한다. 권한 코드 → 사람이 읽는 설명 맵을 넘긴다 -->
<div x-data="permissionDeniedModal({ descriptions: permissionDescriptions })">
<!-- include "../../packages/components/html/permission-denied-modal.html" -->
</div>
// 앱이 갖는 맵 — 키는 tRPC 경로가 아니라 `resource:action` 코드다
const permissionDescriptions = {
"payroll:write": {
name: "급여 편집",
description: "급여 시트를 수정하고 확정할 수 있습니다.",
},
};
// 띄우는 법 — 어디서든 window 이벤트 한 줄이면 된다
window.dispatchEvent(new CustomEvent("permission-denied", { detail: "payroll:write" }));기본 — 권한 코드 + 앱이 넘긴 설명. 사유가 비어 있어 요청 버튼이 죽어 있습니다
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write설명을 안 넘긴 앱 — tPermission(코드) 라벨로 떨어집니다(「급여 쓰기 (전체)」)
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write권한 코드 없이 열린 경우 — 안내 notice 가 통째로 사라집니다(x-show="blockedPermission")
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
사유 입력 후 — 요청 버튼이 살아납니다
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write전송 중 — 버튼 문구만 바뀌고 스피너는 없습니다(원본 그대로)
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write전송 완료 — 사유 칸과 요청 버튼이 사라지고, 취소가 「닫기」로 바뀝니다
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write⚠️ 오류 — .ds-notice--danger 인데 아이콘이 없습니다. 다른 화면의 오류 notice 와 모양이 다릅니다
접근 권한이 필요해요
이 기능을 사용하려면 관리자에게 권한을 요청하세요.
payroll:write--info · 전송 완료 --success · 오류 --danger. 오류만 아이콘이 빠져 있는 것은 의도가 아니라 누락으로 보입니다(검토 필요 사항). 옵션은 descriptions 하나뿐이고 나머지는 부를 때 넘기는 권한 코드와 진행 상태입니다. 그래서 코드 탭에는 마운트와 띄우는 법을 함께 뒀습니다 — 이 컴포넌트에서 가장 자주 막히는 것이 「어떻게 띄우나」이기 때문입니다.
규칙 (강제) · 코딩 에이전트용
PermissionDeniedModal 강제 규칙. 상세는 아래 본문.
마크업 / 마운트
- 앱마다 한 번만 마운트합니다 —
x-data="permissionDeniedModal({ descriptions })"+packages/components/html/permission-denied-modal.htmlinclude. - 띄우는 것은 컴포넌트 인스턴스를 붙잡는 게 아니라 window 이벤트입니다:
window.dispatchEvent(new CustomEvent("permission-denied", { detail: "payroll:write" })).
⚠️ 트랩 ① 타이틀에 헤딩 태그를 쓰지 마세요tokens.css 의 unlayered h2 가 text 유틸을 덮습니다. 그래서 이 팝업의 제목은 <h2> 가 아니라 <p class="text-h5">(20px) 이고, 본문 <p> 는 DS 가드(.ds-modal-backdrop p.text-*)로 크기를 되찾습니다. 헤딩으로 「정리」하면 크기가 조용히 틀어집니다.
⚠️ 트랩 ② ESC 를 다른 팝업과 나눠 쓰면 같이 닫힙니다 이 팝업과 패널이 둘 다 @keydown.escape.window 를 씁니다. 그래서 open()/close() 가 esc-guard.ts 의 모듈 상태를 기록하고, 패널의 escapeClose() 는 맨 처음에 permissionModalOwnsEsc() 를 확인해 양보해야 합니다. 새 패널을 만들면 이 확인을 반드시 넣으세요 — 빠뜨리면 ESC 한 번에 팝업과 패널이 함께 닫힙니다. 「막 닫힌 직후 500ms」도 양보 대상입니다.
⚠️ 트랩 ③ 이 팝업은 컨펌 다이얼로그보다 위에 섭니다 백드롭이 .ds-modal-backdrop--system(z 75)이라 일반 모달(50) · 신고(68) · 컨펌 다이얼로그(70) 전부보다 위입니다. 전역 팝업은 항상 마지막에 뜨므로 그게 맞습니다 — z 를 낮추지 마세요.
⚠️ 트랩 ④ 권한 코드는 tRPC 경로가 아니라 resource:action 입니다 앱의 descriptions 맵은 resource:action 코드로 키를 겁니다. SDK 가 FORBIDDEN 메시지 꼬리에서 코드를 뽑아 넘기는 것도 그래서입니다 — 경로(nurseScheduler.profile.get)를 넘기면 맵이 안 걸려 원시 RPC 이름이 그대로 노출됩니다.
DS 3종 모달 밖입니다 (레거시) 이 팝업은 모달 3종 어디에도 속하지 않고 카드를 유틸로 직접 조립합니다(버튼도 레거시 .ds-mini-modal__btn). 새 모달을 이걸 보고 만들지 마세요 — 마이그레이션 대상입니다.
- 다크 모드(2026-10-19) — 모달 면은
bg-elevated, 안의 안내 박스 · 코드 배지 · 입력칸은 시멘틱 토큰(팔레트 유틸 10곳 중 8곳 이관, 남은 2곳은 브랜드 주황). 확인: Playground 「미리보기 테마 → 다크」.
어떻게 띄우나
컴포넌트를 직접 붙잡지 않습니다. window 이벤트 한 줄이면 어디서든 뜹니다.
window.dispatchEvent(new CustomEvent("permission-denied", { detail: "payroll:write" }));detail 은 세 가지를 받습니다 — 문자열(권한 코드), { permission } 객체, 그리고 아무것도 없을 때(코드 없이 열림). 컴포넌트가 init() 에서 window 리스너를 걸어 두므로 앱 어느 구석에서 쏘든 걸립니다.
대부분은 SDK 가 알아서 띄웁니다
직접 쏘는 일보다 자동으로 뜨는 경우가 더 많습니다. 서버가 403(FORBIDDEN)을 돌려주면 SDK 가 이 사슬을 탑니다.
팝업이 안 뜬다면 앱이 이벤트를 먹은 것입니다
직원 앱은 hereby:permission-error 에서 e.preventDefault() 를 부르고 토스트로만 알립니다(모바일에서 전체 모달이 과하다는 판단). 그 한 줄이 이 팝업의 유일한 진입 경로를 닫습니다. 모달로 되돌리려면 그 줄을 지우고 마운트를 되살리되, descriptions 맵을 함께 넘겨야 합니다 — 없으면 원시 코드가 그대로 노출됩니다.
권한 이름은 어디서 오나
info getter 가 두 단계로 찾습니다.
- 앱이 넘긴
descriptions[코드]가 있으면 그것 - 없으면 스토어의
tPermission(코드)번역 라벨 (예: 「스케줄 조회 (본인)」)
둘 다 없으면 「권한」이라는 밋밋한 이름이 뜹니다. 그래서 앱마다 descriptions 를 넘기는 편이 좋습니다 — 사용자가 무슨 권한인지 알아야 사유를 쓸 수 있으니까요.
ESC 를 나눠 쓰는 문제
새 패널을 만들면 양보 확인을 반드시 넣으세요
이 팝업과 패널이 둘 다 @keydown.escape.window 를 씁니다. 리스너 실행 순서는 Alpine 이 x-data 를 만나는 순서(= DOM 순서)라 화면에 늦게 뜬 것과 무관합니다. 그래서 ESC 한 번에 팝업과 그 아래 패널이 같이 닫히는 일이 실제로 있었습니다.
패널의 escapeClose() 는 맨 처음에 이렇게 양보해야 합니다(AddEmployeePanel · EditEmployeePanel 가 이렇게 되어 있습니다).
import { permissionModalOwnsEsc } from "../utils/esc-guard";
escapeClose() {
if (permissionModalOwnsEsc()) return; // 이번 ESC 는 권한 팝업 몫
// …패널 닫기
}이 헬퍼는 packages/components 안에서만 씁니다
esc-guard.ts 는 패키지 index 로 내보내지지 않습니다. 그래서 위 상대경로 import 는 packages/components 안의 컴포넌트에서만 가능하고, 앱 안에 직접 만든 패널은 양보할 방법이 없습니다. 앱 쪽에 ESC 로 닫히는 패널을 새로 만든다면 검토 필요 사항을 먼저 보세요.
permissionModalOwnsEsc() 는 열려 있을 때뿐 아니라 「막 닫힌 직후 500ms」도 true 입니다. 팝업이 먼저 같은 키다운을 처리해 이미 닫혔을 수 있고, ESC 연타·키리피트도 함께 흡수하기 위해서입니다.
새로 만드는 팝업은 이 방식 대신 registerEscLayer() 로 등록하는 쪽이 낫습니다 — window 캡처 단계에서 z-index 가 가장 높은 것 하나만 닫아 주므로 순서 싸움 자체가 없어집니다. (등록하면 마크업의 @keydown.escape.window 는 빼야 합니다 — 두 벌이 되면 다시 순서 싸움입니다.)
접근성
- 닫기 버튼에
title과aria-label이 모두 붙습니다. - 요청 버튼은 사유가 비면
disabled라 키보드 포커스에서도 빠집니다. - 팝업이 열려도 포커스를 가두지 않습니다(focus trap 없음) — 검토 필요 사항 참고.
상태
| 상태 | 화면 |
|---|---|
| 입력 중 | 제목 + 권한 상세(--info) + 사유 textarea + 취소/요청 |
| 사유 비어 있음 | 요청 버튼이 bg-orange-300 + cursor-not-allowed 로 죽음 |
| 전송 중 | 버튼 문구가 「요청 중...」으로. 스피너는 없습니다 |
| 전송 완료 | 사유 칸·요청 버튼이 사라지고 --success notice, 취소가 **「닫기」**로 |
| 오류 | 버튼 위 --danger notice. ⚠️ 아이콘이 없습니다 |
| 권한 코드 없음 | 권한 상세 notice 가 통째로 숨음(x-show="blockedPermission") |
notice 세 변형이 다 쓰입니다
| 자리 | 변형 | 아이콘 |
|---|---|---|
| 권한 상세 | --info | 없음 (원본 그대로) |
| 전송 완료 | --success | ic-check-circle ✅ |
| 오류 | --danger | 없음 ⚠️ |
성공에만 아이콘이 있고 오류에는 없습니다. notice 규격은 아이콘을 독립 축(어느 변형에서나 켜고 끌 수 있음)으로 정의하므로 규칙 위반은 아니지만, 같은 팝업 안에서 성공은 아이콘이 있고 오류는 없는 것은 일관성이 떨어집니다.
카드는 DS 3종 모달이 아닙니다
이 팝업을 보고 새 모달을 만들지 마세요
모달 3종(컨펌 다이얼로그 · 일반 모달 · 패널 모달) 어디에도 속하지 않습니다. .ds-modal 클래스가 admin 전용이던 시절에 공유 컴포넌트에서 같은 모양을 유틸로 재현한 것이고, 버튼도 레거시 .ds-mini-modal__btn 입니다.
그래서 이런 것들이 마크업에 직접 박혀 있습니다.
| 항목 | 이 팝업 | DS 일반 모달 |
|---|---|---|
| 카드 여백 | pt-11 px-10 pb-9 (44 / 40 / 36) 유틸 | .ds-modal 이 소유 |
| 그림자 | filter: var(--ds-drop-shadow-modal) 인라인 style | box-shadow 토큰 |
| 최대 폭 | max-width: 420px 인라인 style | 카드 크기 클래스 |
| 버튼 | .ds-mini-modal__btn + 색 유틸 | .ds-btn + 변형 |
값 자체는 패널 모달과 같게 맞춰 둔 것이라 화면은 어긋나지 않습니다. 문제는 규격이 마크업에 흩어져 있다는 점이고, 마이그레이션 대상입니다.
마운트
앱마다 한 번만 둡니다. 보통 dashboard.html 맨 아래입니다.
<!-- 공용 권한 요청 팝업. window "permission-denied" 이벤트를 듣는다 -->
<div x-data="permissionDeniedModal({ descriptions: permissionDescriptions })">
<!-- include "../../packages/components/html/permission-denied-modal.html" -->
</div>descriptions 는 resource:action 코드로 키를 겁니다.
const permissionDescriptions = {
"payroll:write": {
name: "급여 편집",
description: "급여 시트를 수정하고 확정할 수 있습니다.",
},
};띄우기
// 어디서든 — 가장 흔한 방법
window.dispatchEvent(new CustomEvent("permission-denied", { detail: "payroll:write" }));
// 팝업의 x-data 스코프 안에서
$root.open("payroll:write");
// 밖에서 인스턴스를 직접 잡아야 할 때
Alpine.$data(modalEl).open("payroll:write");함께 필요한 것
hereby스토어 — 문구(t()),tPermission(),sdk.permissionRequest.create().sdk.permissionRequest.create— 없으면 「권한 요청 기능을 사용할 수 없습니다.」가 인라인 오류로 뜹니다.- 아이콘 레지스트리 —
ic-close·ic-check-circle.
API
permissionDeniedModal(opts) Alpine.data factory.
옵션
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
descriptions | Record<string, { name, description }> | {} | 권한 코드 → 사람이 읽는 설명. 키는 resource:action |
상태
| 멤버 | 설명 |
|---|---|
isOpen | 열림 여부 |
blockedPermission | 막힌 권한 코드. 비면 권한 상세 notice 가 숨습니다 |
reason | 요청 사유 입력값 |
submitting · sent | 전송 중 · 전송 완료 |
errorMessage | 인라인 오류 문구 |
info | descriptions[코드] → 없으면 tPermission(코드) 라벨 (getter) |
메서드
| 멤버 | 설명 |
|---|---|
init() | window permission-denied 리스너 등록 (+ alpine:removed 에서 해제) |
open(key?) | 상태를 초기화하고 열기. 이미 열려 있으면 아무것도 하지 않습니다(재시도로 다시 쏴도 입력이 안 날아갑니다) |
close() | 닫고 상태 비우기 |
submit() | 사유가 비면 무시. sdk.permissionRequest.create({ reason, requestedPath }) |
requestedPath 는 권한 코드가 있으면 그 코드, 없으면 현재 window.location.pathname 이 들어갑니다.
z-index
| 층 | 값 | 토큰 |
|---|---|---|
| 일반 모달 | 50 | — |
| 신고 모달 | 68 | --ds-z-modal-report |
| 컨펌 다이얼로그 | 70 | --ds-z-modal-alert |
| 권한 팝업 | 75 | --ds-z-modal-system |
| 토스트 | 80 | --ds-z-toast |
전역 팝업은 항상 마지막에 뜨므로 컨펌 다이얼로그보다도 위입니다. 낮추지 마세요.
사용처
| 앱 | 마운트 | descriptions |
|---|---|---|
| hr-system | ✅ | ✅ permissionDescriptions |
| admin | ✅ | — |
| team-manager | ✅ | — |
| management-dashboard | ✅ | — |
| module-manager | ✅ | — |
| employee-app | ❌ 안 함 | — (hereby:permission-error 를 토스트로 소비) |
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/PermissionDeniedModal.ts |
| HTML partial | packages/components/html/permission-denied-modal.html |
| ESC 양보 | packages/components/src/utils/esc-guard.ts |
| 403 → 이벤트 | packages/sdk/src/client/HerebyClient.ts |
| z 토큰 | --ds-z-modal-system (design-system.css) |
검토 필요 사항
- DS 3종 모달 밖입니다 (위). 카드 여백·그림자·최대 폭이 유틸과 인라인
style로 흩어져 있고 버튼은 레거시.ds-mini-modal__btn입니다. 값은 패널 모달과 맞춰 둬서 화면은 어긋나지 않지만, 규격이 마크업에 있는 상태입니다. - 오류 notice 에만 아이콘이 없습니다 (위). 같은 팝업 안에서 성공은
ic-check-circle이 있고 오류는 없습니다. notice 규격상 위반은 아니지만 일관성이 떨어집니다. - 전송 중에 스피너가 없습니다. 버튼 문구만 「요청 중...」으로 바뀝니다. 스피너 규격의 「버튼 안 아이콘 스피너」를 넣을지 검토가 필요합니다.
- 포커스를 가두지 않습니다. z 75 로 화면 맨 위에 뜨는데 focus trap 이 없어, 탭 키로 뒤 화면 요소에 갈 수 있습니다.
- ESC 양보 헬퍼를 앱에서 쓸 수 없습니다.
esc-guard.ts의permissionModalOwnsEsc()·registerEscLayer()가 패키지 index 로 내보내지지 않아, 지금 양보할 수 있는 것은packages/components안의 패널 둘뿐입니다. 앱 안에 ESC 로 닫히는 패널을 만들면 같은 사고가 다시 납니다 — 내보낼지 결정이 필요합니다. - 직원 앱만 이 팝업을 안 씁니다. 토스트로 대체하는 것이 모바일에서는 맞는 판단이지만, 그 결과 권한 요청을 보낼 방법이 직원 앱에는 없습니다. 의도된 것인지 확인이 필요합니다.
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-09-08 | PermissionDeniedModal 페이지 신규 작성 — Playground(권한 코드 · 설명 맵 · 사유 × 상태 4종) + 모아보기 7상태. 403 → 팝업 사슬 다이어그램과 ESC 양보 규칙 포함 |