Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건. 설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라 화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이 끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동). 미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는 action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고 아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을 쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다. 같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어 함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다. 정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른 형태를 막고 있었으므로 두 미러를 함께 정정했다. 테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키 미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서 빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던 항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록, 관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가 "다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된 문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다). 재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지 테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
499 lines
18 KiB
JSON
499 lines
18 KiB
JSON
{
|
|
"version": "1.0.0",
|
|
"layout_name": "admin_login",
|
|
"meta": {
|
|
"title": "$t:auth.login.title",
|
|
"description": "그누보드7 관리자 로그인 페이지",
|
|
"auth_required": false
|
|
},
|
|
"data_sources": [],
|
|
"init_actions": [
|
|
{
|
|
"handler": "initTheme",
|
|
"target": "{{query.theme}}"
|
|
},
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"isLoggingIn": false,
|
|
"loginError": null,
|
|
"loginErrors": null,
|
|
"loginForm": {
|
|
"email": "",
|
|
"password": ""
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"components": [
|
|
{
|
|
"id": "global_toast",
|
|
"type": "composite",
|
|
"name": "Toast",
|
|
"props": {
|
|
"toasts": "{{_global.toasts}}",
|
|
"position": "bottom-center",
|
|
"duration": 5000
|
|
}
|
|
},
|
|
{
|
|
"id": "login_page_wrapper",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex-center min-h-screen justify-center bg-gray-50 dark:bg-gray-900 px-4 sm:px-6 lg:px-8"
|
|
},
|
|
"lifecycle": {
|
|
"onMount": [
|
|
{
|
|
"if": "{{query?.reason === 'session_expired'}}",
|
|
"handler": "toast",
|
|
"params": {
|
|
"type": "warning",
|
|
"message": "$t:auth.session_expired_toast"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "login_card_container",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "max-w-md w-full space-y-8"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "login_header",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "text-center"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "logo_image_wrapper",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_global.settings?.general?.site_logo_url}}",
|
|
"props": {
|
|
"className": "mb-4"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "logo_image",
|
|
"type": "basic",
|
|
"name": "Img",
|
|
"props": {
|
|
"src": "{{_global.settings?.general?.site_logo_url}}",
|
|
"alt": "{{_global.settings?.general?.site_name ?? '그누보드7'}}",
|
|
"className": "h-16 w-auto max-w-[200px] mx-auto object-contain"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "logo",
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"if": "{{!_global.settings?.general?.site_logo_url}}",
|
|
"props": {
|
|
"className": "text-primary text-4xl font-bold mb-2"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "logo_text",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"text": "{{_global.settings?.general?.site_name ?? '그누보드7'}}"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "subtitle",
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-label"
|
|
},
|
|
"text": "$t:auth.login.subtitle"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "login_form_component",
|
|
"type": "basic",
|
|
"name": "Form",
|
|
"dataKey": "loginForm",
|
|
"props": {
|
|
"className": "space-y-6 relative"
|
|
},
|
|
"children": [
|
|
{
|
|
"comment": "API 에러 메시지 표시",
|
|
"id": "login_error_message",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_local.loginError}}",
|
|
"props": {
|
|
"className": "alert-danger"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "login_error_text",
|
|
"type": "basic",
|
|
"name": "P",
|
|
"props": {
|
|
"className": "text-sm text-red-600 dark:text-red-400"
|
|
},
|
|
"text": "{{_local.loginError}}"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "이메일 입력 필드",
|
|
"id": "login_email_field",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"children": [
|
|
{
|
|
"id": "login_email_label",
|
|
"type": "basic",
|
|
"name": "Label",
|
|
"props": {
|
|
"htmlFor": "email",
|
|
"className": "form-label"
|
|
},
|
|
"text": "$t:auth.login.email"
|
|
},
|
|
{
|
|
"id": "login_email_input",
|
|
"type": "basic",
|
|
"name": "Input",
|
|
"props": {
|
|
"id": "email",
|
|
"type": "email",
|
|
"name": "email",
|
|
"placeholder": "$t:auth.login.email",
|
|
"className": "input py-3 {{_local.loginErrors?.email ? 'input-error' : ''}}",
|
|
"required": true,
|
|
"disabled": "{{_local.isLoggingIn}}"
|
|
}
|
|
},
|
|
{
|
|
"comment": "이메일 필드 에러 메시지",
|
|
"id": "login_email_error",
|
|
"type": "basic",
|
|
"name": "P",
|
|
"if": "{{_local.loginErrors?.email}}",
|
|
"props": {
|
|
"className": "mt-2 text-sm text-red-600 dark:text-red-400"
|
|
},
|
|
"text": "{{Array.isArray(_local.loginErrors?.email) ? _local.loginErrors.email[0] : _local.loginErrors?.email}}"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "비밀번호 입력 필드",
|
|
"id": "login_password_field",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"children": [
|
|
{
|
|
"id": "login_password_label",
|
|
"type": "basic",
|
|
"name": "Label",
|
|
"props": {
|
|
"htmlFor": "password",
|
|
"className": "form-label"
|
|
},
|
|
"text": "$t:auth.login.password"
|
|
},
|
|
{
|
|
"id": "login_password_input",
|
|
"type": "basic",
|
|
"name": "Input",
|
|
"props": {
|
|
"id": "password",
|
|
"type": "password",
|
|
"name": "password",
|
|
"placeholder": "$t:auth.login.password",
|
|
"className": "input py-3 {{_local.loginErrors?.password ? 'input-error' : ''}}",
|
|
"required": true,
|
|
"disabled": "{{_local.isLoggingIn}}"
|
|
}
|
|
},
|
|
{
|
|
"comment": "비밀번호 필드 에러 메시지",
|
|
"id": "login_password_error",
|
|
"type": "basic",
|
|
"name": "P",
|
|
"if": "{{_local.loginErrors?.password}}",
|
|
"props": {
|
|
"className": "mt-2 text-sm text-red-600 dark:text-red-400"
|
|
},
|
|
"text": "{{Array.isArray(_local.loginErrors?.password) ? _local.loginErrors.password[0] : _local.loginErrors?.password}}"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "비밀번호 찾기 링크",
|
|
"id": "login_forgot_password",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex-center justify-end"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "login_forgot_link",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "navigate",
|
|
"params": {
|
|
"path": "/admin/forgot-password"
|
|
}
|
|
}
|
|
],
|
|
"text": "$t:auth.login.forgot"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "로그인 버튼",
|
|
"id": "login_submit_button",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "submit",
|
|
"className": "flex-center w-full px-4 py-3 rounded-lg font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 justify-center gap-2",
|
|
"disabled": "{{_local.isLoggingIn}}"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "login_submit_spinner",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_local.isLoggingIn}}",
|
|
"props": {
|
|
"className": "w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
|
}
|
|
},
|
|
{
|
|
"id": "login_submit_text",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{!_local.isLoggingIn}}",
|
|
"text": "$t:auth.login.submit"
|
|
},
|
|
{
|
|
"id": "login_submit_processing",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{_local.isLoggingIn}}",
|
|
"text": "$t:auth.login.processing"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"actions": [
|
|
{
|
|
"type": "submit",
|
|
"handler": "sequence",
|
|
"actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"isLoggingIn": true,
|
|
"loginError": null,
|
|
"loginErrors": null
|
|
}
|
|
},
|
|
{
|
|
"handler": "login",
|
|
"target": "admin",
|
|
"params": {
|
|
"body": {
|
|
"email": "{{_local.loginForm.email}}",
|
|
"password": "{{_local.loginForm.password}}"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"isLoggingIn": false
|
|
}
|
|
},
|
|
{
|
|
"handler": "navigate",
|
|
"params": {
|
|
"path": "{{query.redirect ?? '/admin'}}"
|
|
}
|
|
}
|
|
],
|
|
"onError": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"isLoggingIn": false,
|
|
"loginError": "{{error.message}}",
|
|
"loginErrors": "{{error.errors}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "language_selector_container",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "mt-6"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "language_selector",
|
|
"type": "basic",
|
|
"name": "Select",
|
|
"props": {
|
|
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
|
"value": "{{$locale}}",
|
|
"options": "{{$locales ?? []}}"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "change",
|
|
"handler": "setLocale",
|
|
"target": "{{$event.target.value}}"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "theme_selector_container",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "mt-4 flex justify-center"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "theme_segmented_control",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "theme_light_button",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "{{_global.theme === 'light' ? 'p-2 rounded-md bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400 transition-all duration-200' : 'p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-all duration-200'}}",
|
|
"title": "$t:admin.theme.light"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "setTheme",
|
|
"target": "light"
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"id": "theme_light_icon",
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "sun",
|
|
"size": "sm"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "theme_dark_button",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "{{_global.theme === 'dark' ? 'p-2 rounded-md bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400 transition-all duration-200' : 'p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-all duration-200'}}",
|
|
"title": "$t:admin.theme.dark"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "setTheme",
|
|
"target": "dark"
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"id": "theme_dark_icon",
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "moon",
|
|
"size": "sm"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "theme_auto_button",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"type": "button",
|
|
"className": "{{_global.theme === 'auto' || !_global.theme ? 'p-2 rounded-md bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400 transition-all duration-200' : 'p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-all duration-200'}}",
|
|
"title": "$t:admin.theme.auto"
|
|
},
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "setTheme",
|
|
"target": "auto"
|
|
}
|
|
],
|
|
"children": [
|
|
{
|
|
"id": "theme_auto_icon",
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "desktop",
|
|
"size": "sm"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|