Skip to content

권한 없음 모달 (PermissionDeniedModal) ​

권한이 없어 막혔을 때 뜨는 전역 팝업입니다. 어떻게 띄우는지와 왜 다른 모달 위에 서는지를 확인해 보세요.

Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마

접근 권한이 필요해요

이 기능을 사용하려면 관리자에게 권한을 요청하세요.

필요 권한:payroll:write
급여 편집
급여 시트를 수정하고 확정할 수 있습니다.
실제 partial 마크업 그대로예요. 전체 화면 백드롭은 재현하지 않았습니다 — 실제로는 .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" }));

옵션은 descriptions 하나뿐이고 나머지는 부를 때 넘기는 권한 코드와 진행 상태입니다. 그래서 코드 탭에는 마운트와 띄우는 법을 함께 뒀습니다 — 이 컴포넌트에서 가장 자주 막히는 것이 「어떻게 띄우나」이기 때문입니다.

규칙 (강제) · 코딩 에이전트용

PermissionDeniedModal 강제 규칙. 상세는 아래 본문.

마크업 / 마운트

  • 앱마다 한 번만 마운트합니다 — x-data="permissionDeniedModal({ descriptions })" + packages/components/html/permission-denied-modal.html include.
  • 띄우는 것은 컴포넌트 인스턴스를 붙잡는 게 아니라 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 이벤트 한 줄이면 어디서든 뜹니다.

js
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 가 두 단계로 찾습니다.

  1. 앱이 넘긴 descriptions[코드] 가 있으면 그것
  2. 없으면 스토어의 tPermission(코드) 번역 라벨 (예: 「스케줄 조회 (본인)」)

둘 다 없으면 「권한」이라는 밋밋한 이름이 뜹니다. 그래서 앱마다 descriptions 를 넘기는 편이 좋습니다 — 사용자가 무슨 권한인지 알아야 사유를 쓸 수 있으니까요.

ESC 를 나눠 쓰는 문제 ​

새 패널을 만들면 양보 확인을 반드시 넣으세요

이 팝업과 패널이 둘 다 @keydown.escape.window 를 씁니다. 리스너 실행 순서는 Alpine 이 x-data 를 만나는 순서(= DOM 순서)라 화면에 늦게 뜬 것과 무관합니다. 그래서 ESC 한 번에 팝업과 그 아래 패널이 같이 닫히는 일이 실제로 있었습니다.

패널의 escapeClose() 는 맨 처음에 이렇게 양보해야 합니다(AddEmployeePanel · EditEmployeePanel 가 이렇게 되어 있습니다).

ts
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없음 (원본 그대로)
전송 완료--successic-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) 인라인 stylebox-shadow 토큰
최대 폭max-width: 420px 인라인 style카드 크기 클래스
버튼.ds-mini-modal__btn + 색 유틸.ds-btn + 변형

값 자체는 패널 모달과 같게 맞춰 둔 것이라 화면은 어긋나지 않습니다. 문제는 규격이 마크업에 흩어져 있다는 점이고, 마이그레이션 대상입니다.

마운트 ​

앱마다 한 번만 둡니다. 보통 dashboard.html 맨 아래입니다.

html
<!-- 공용 권한 요청 팝업. window "permission-denied" 이벤트를 듣는다 -->
<div x-data="permissionDeniedModal({ descriptions: permissionDescriptions })">
  <!-- include "../../packages/components/html/permission-denied-modal.html" -->
</div>

descriptions 는 resource:action 코드로 키를 겁니다.

ts
const permissionDescriptions = {
  "payroll:write": {
    name: "급여 편집",
    description: "급여 시트를 수정하고 확정할 수 있습니다.",
  },
};

띄우기 ​

js
// 어디서든 — 가장 흔한 방법
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타입기본값설명
descriptionsRecord<string, { name, description }>{}권한 코드 → 사람이 읽는 설명. 키는 resource:action

상태 ​

멤버설명
isOpen열림 여부
blockedPermission막힌 권한 코드. 비면 권한 상세 notice 가 숨습니다
reason요청 사유 입력값
submitting · sent전송 중 · 전송 완료
errorMessage인라인 오류 문구
infodescriptions[코드] → 없으면 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 partialpackages/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-08PermissionDeniedModal 페이지 신규 작성 — Playground(권한 코드 · 설명 맵 · 사유 × 상태 4종) + 모아보기 7상태. 403 → 팝업 사슬 다이어그램과 ESC 양보 규칙 포함