{ "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, "twoFactor": 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_error_locked_until", "type": "basic", "name": "P", "if": "{{_local.loginErrors?.locked_until}}", "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, "text": "$t:auth.login.locked_until|until={{_local.loginErrors?.locked_until | datetime}}" }, { "id": "login_error_locked_permanent", "type": "basic", "name": "P", "if": "{{_local.loginErrors?.permanent === true}}", "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, "text": "$t:auth.login.locked_permanent" } ] }, { "comment": "이메일 입력 필드", "id": "login_email_field", "type": "basic", "name": "Div", "if": "{{!_local.twoFactor?.required}}", "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", "if": "{{!_local.twoFactor?.required}}", "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", "if": "{{!_local.twoFactor?.required}}", "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", "if": "{{!_local.twoFactor?.required}}", "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" } ] }, { "comment": "2단계 인증 — 비밀번호 확인만 통과한 상태다. 인증번호를 확인해야 로그인이 완료된다.", "id": "login_two_factor_step", "type": "basic", "name": "Div", "if": "{{_local.twoFactor?.required}}", "props": { "className": "space-y-6" }, "children": [ { "id": "login_two_factor_notice", "type": "basic", "name": "Div", "props": { "className": "p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800", "role": "status", "aria-live": "polite" }, "children": [ { "id": "login_two_factor_notice_sent", "type": "basic", "name": "P", "if": "{{!_local.twoFactor?.resent}}", "props": { "className": "text-sm text-blue-700 dark:text-blue-300" }, "text": "$t:auth.login.two_factor.sent" }, { "id": "login_two_factor_notice_resent", "type": "basic", "name": "P", "if": "{{_local.twoFactor?.resent}}", "props": { "className": "text-sm text-blue-700 dark:text-blue-300" }, "text": "$t:auth.login.two_factor.resent" } ] }, { "id": "login_two_factor_error", "type": "basic", "name": "Div", "if": "{{_local.twoFactor?.error}}", "props": { "className": "alert-danger", "role": "alert" }, "children": [ { "id": "login_two_factor_error_text", "type": "basic", "name": "P", "props": { "className": "text-sm text-red-600 dark:text-red-400" }, "text": "{{_local.twoFactor?.error ?? ''}}" } ] }, { "id": "login_two_factor_field", "type": "basic", "name": "Div", "children": [ { "id": "login_two_factor_label", "type": "basic", "name": "Label", "props": { "htmlFor": "two_factor_code", "className": "form-label" }, "text": "$t:auth.login.two_factor.code_label" }, { "comment": "자동바인딩을 쓰지 않는다 — 재발송 시 입력값을 비워야 하므로 상태가 값을 소유한다", "id": "login_two_factor_input", "type": "basic", "name": "Input", "props": { "id": "two_factor_code", "type": "text", "name": "two_factor_code", "value": "{{_local.twoFactor?.code ?? ''}}", "inputMode": "numeric", "autoComplete": "one-time-code", "maxLength": 10, "placeholder": "$t:auth.login.two_factor.code_placeholder", "className": "input py-3 text-center text-xl tracking-widest font-mono", "disabled": "{{_local.isLoggingIn}}" }, "actions": [ { "event": "onChange", "handler": "setState", "params": { "target": "local", "twoFactor.code": "{{$event.target.value}}", "twoFactor.error": null } } ] }, { "comment": "유효시각 정적 표기 — 카운트다운은 SPA 이탈 후에도 타이머가 남아 쓰지 않는다", "id": "login_two_factor_valid_until", "type": "basic", "name": "P", "if": "{{_local.twoFactor?.expires_at}}", "props": { "className": "mt-2 text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:auth.login.two_factor.valid_until|until={{_local.twoFactor?.expires_at | datetime}}" } ] }, { "comment": "인증번호 확인 — submit 이라 Enter 키로도 제출된다", "id": "login_two_factor_submit", "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.twoFactor?.code || _local.twoFactor.code.length < 4 || _local.isLoggingIn}}" }, "children": [ { "id": "login_two_factor_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_two_factor_submit_text", "type": "basic", "name": "Span", "if": "{{!_local.isLoggingIn}}", "text": "$t:auth.login.two_factor.verify" }, { "id": "login_two_factor_submit_processing", "type": "basic", "name": "Span", "if": "{{_local.isLoggingIn}}", "text": "$t:auth.login.two_factor.verifying" } ] }, { "id": "login_two_factor_actions", "type": "basic", "name": "Div", "props": { "className": "flex flex-col sm:flex-row gap-2" }, "children": [ { "id": "login_two_factor_resend", "type": "basic", "name": "Button", "props": { "type": "button", "className": "flex-1 px-4 py-3 rounded-lg font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer", "disabled": "{{_local.twoFactor?.resending || _local.isLoggingIn}}" }, "text": "$t:auth.login.two_factor.resend", "actions": [ { "type": "click", "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "local", "twoFactor.resending": true, "twoFactor.error": null } }, { "handler": "loginTwoFactorResend", "target": "admin", "params": { "body": { "challenge_id": "{{_local.twoFactor?.challenge_id}}" } }, "onSuccess": [ { "_comment": "같은 시퀀스 안에서는 상태가 아직 갱신 전이므로 응답 값만 읽는다.", "handler": "setState", "params": { "target": "local", "twoFactor": { "required": true, "challenge_id": "{{response.challenge_id}}", "provider_id": "{{response.provider_id}}", "expires_at": "{{response.expires_at}}", "code": "", "error": null, "verifying": false, "resending": false, "resent": true } } } ], "onError": [ { "handler": "setState", "params": { "target": "local", "twoFactor.resending": false, "twoFactor.error": "{{error.message}}" } } ] } ] } ] }, { "id": "login_two_factor_restart", "type": "basic", "name": "Button", "props": { "type": "button", "className": "flex-1 px-4 py-3 rounded-lg font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 cursor-pointer", "disabled": "{{_local.isLoggingIn}}" }, "text": "$t:auth.login.two_factor.restart", "actions": [ { "type": "click", "handler": "setState", "params": { "target": "local", "twoFactor": null, "loginError": null, "loginErrors": null } } ] } ] } ] } ], "actions": [ { "type": "submit", "handler": "sequence", "actions": [ { "handler": "setState", "params": { "target": "local", "isLoggingIn": true, "loginError": null, "loginErrors": null } }, { "_comment": "1·2단계는 상호배타다. if 는 시퀀스 시작 시점 스냅샷으로 평가되므로 한 제출에 정확히 하나만 실행된다 — 이 if 가 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.", "handler": "login", "target": "admin", "if": "{{!_local.twoFactor?.required}}", "params": { "body": { "email": "{{_local.loginForm.email}}", "password": "{{_local.loginForm.password}}" } }, "onSuccess": [ { "_comment": "2단계 인증이 켜진 사이트는 아직 로그인이 끝나지 않았다 — 토큰도 사용자도 없다.", "handler": "setState", "if": "{{response.two_factor_required}}", "params": { "target": "local", "isLoggingIn": false, "twoFactor": { "required": true, "challenge_id": "{{response.challenge_id}}", "provider_id": "{{response.provider_id}}", "expires_at": "{{response.expires_at}}", "code": "", "error": null, "verifying": false, "resending": false, "resent": false } } }, { "handler": "setState", "if": "{{!response.two_factor_required}}", "params": { "target": "local", "isLoggingIn": false } }, { "handler": "navigate", "if": "{{!response.two_factor_required}}", "params": { "path": "{{query.redirect ?? '/admin'}}" } } ], "onError": [ { "handler": "setState", "params": { "target": "local", "isLoggingIn": false, "loginError": "{{error.message}}", "loginErrors": "{{error.errors}}" } } ] }, { "handler": "loginTwoFactor", "target": "admin", "if": "{{_local.twoFactor?.required}}", "params": { "body": { "challenge_id": "{{_local.twoFactor?.challenge_id}}", "code": "{{_local.twoFactor?.code}}" } }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "isLoggingIn": false, "twoFactor": null } }, { "handler": "navigate", "params": { "path": "{{query.redirect ?? '/admin'}}" } } ], "onError": [ { "_comment": "423 잠금은 1단계와 같은 문구 규칙을 따르도록 loginErrors 도 함께 싣는다. 403(관리자 아님)도 이 자리에 표시된다.", "handler": "setState", "params": { "target": "local", "isLoggingIn": false, "twoFactor.error": "{{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" } } ] } ] } ] } ] } ] } ] }