fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현

2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
This commit is contained in:
HeuJung
2026-09-07 17:08:14 +09:00
parent 72fb12f267
commit 50007d5cc6
98 changed files with 5815 additions and 231 deletions
@@ -104,6 +104,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
- [ ] `_admin_base.json` 슬롯 구조(`content` 슬롯 등) 변경 시 그 슬롯에 의존하는 모든 화면(145개 레이아웃 대다수) 영향 검토
- [ ] AdminSidebar 의 `MenuItem`/`AdminSidebarProps` 인터페이스 확장 시 이 문서의 §docs/components.md "AdminSidebar 상세" 동기화
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-admin_basic --force` (편집기는 활성 디렉토리만 읽는다)
- [ ] 로그인 화면의 2단계 인증 단계를 고쳤다면 1단계·2단계 `if` 의 상보성과 `login`/`loginTwoFactor` 의 상호배타 `if` 를 함께 확인 — 한쪽이 빠지면 인증번호 단계에서 Enter 가 새 challenge 를 발급한다
## 6. 금지 패턴
@@ -114,6 +115,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
| 필수 컴포넌트 목록 밖의 이 템플릿 전용 컴포넌트를 모듈 레이아웃에서 사용 | 필수 컴포넌트(config/template.php) 만 사용 | 다른 admin 템플릿으로 교체 시 그 화면만 깨진다 |
| 사이드바 접힘 상태를 레이아웃 `init_actions` 로 매번 복원 | 템플릿 부트스트랩(`src/index.ts`)에서 1회 복원 | `init_actions` 는 화면 진입마다 재실행되어 불필요한 반복 처리가 된다 |
| `_admin_base` 를 상속하는데 로그인 화면처럼 `initTheme`/메뉴 초기화를 다시 호출 | `_admin_base` 상속 화면은 이미 초기화된 전역 상태를 그대로 사용 | 중복 호출은 낭비이며, 두 초기화 지점의 결과가 어긋나면 화면 간 상태 불일치가 생긴다 |
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 자리에서는 `{{response.*}}` 만 읽는다 | 그 시점 컨텍스트는 아직 갱신 전이라 stale 값으로 조용히 분기한다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -122,9 +124,9 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 208개 | `vitest.config.ts` |
| Vitest | 210개 | `vitest.config.ts` |
| Playwright | 9개 | `tests/Playwright` |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
```bash
# Vitest (확장 디렉토리에서) (PowerShell)
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.9] - 2026-09-07
### Added
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 인증번호의 유효 시각도 함께 표시됩니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
### Changed
- 코어 최소 요구 버전을 7.0.11 로 상향했습니다.
### Fixed
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
## [1.0.8] - 2026-09-06
### Added
@@ -5,9 +5,9 @@
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.8-0066FF?style=flat-square" alt="version 1.0.8">
<img src="https://img.shields.io/badge/version-1.0.9-0066FF?style=flat-square" alt="version 1.0.9">
<img src="https://img.shields.io/badge/type-%ED%85%9C%ED%94%8C%EB%A6%BF-555555?style=flat-square" alt="type 템플릿">
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.11-1F883D?style=flat-square" alt="그누보드7 &gt;=7.0.11">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
@@ -68,7 +68,7 @@ flowchart TD
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 그누보드7 코어 | `>=7.0.10` |
| 그누보드7 코어 | `>=7.0.11` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
@@ -0,0 +1,337 @@
/**
* @file admin-login-two-factor-render.test.tsx
* @description 관리자 로그인 2단계 인증 단계 **렌더링** 회귀 테스트 (sirsoft-admin_basic)
*
* 형제 파일 `admin-login-two-factor-step.test.tsx` 는 레이아웃 JSON 의 구조를 단언한다.
* 이 파일은 그 JSON 을 실제로 렌더해 화면에 무엇이 나타나는지를 단언한다.
*
* 관리자가 들어갈 수 없으면 설정을 되돌릴 수단까지 사라지므로 사용자 화면보다 파급이 크다
* (공개 #133).
*
* @vitest-environment jsdom
* @since engine-v1.65.0
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createLayoutTest } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
import adminLogin from '../../layouts/admin_login.json';
// 공개 #133 의 원인은 레이아웃이 아니라 **응답 형태를 하나로 가정한 코어**였다. 상태를 직접
// 주입해 그린 화면만 단언하면 그 경로를 한 번도 태우지 않으므로, 코어가 다시 challenge 응답에서
// 던지더라도 이 파일은 초록으로 남는다. API 를 모킹해 login 액션을 실제로 통과시킨다.
const apiPost = vi.fn();
vi.mock('@core/api/ApiClient', async () => {
const actual = await vi.importActual<typeof import('@core/api/ApiClient')>(
'@core/api/ApiClient'
);
const stub = {
post: (...args: unknown[]) => apiPost(...args),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
getToken: vi.fn(() => null),
setToken: vi.fn(),
removeToken: vi.fn(),
setLocale: vi.fn(),
};
return { ...actual, getApiClient: () => stub, createApiClient: () => stub };
});
type Common = {
className?: string;
children?: React.ReactNode;
text?: string;
};
const TestDiv: React.FC<Common & { role?: string; id?: string }> = ({ className, children, role }) => (
<div className={className} role={role}>{children}</div>
);
const TestSpan: React.FC<Common> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestP: React.FC<Common & { role?: string }> = ({ className, children, text, role }) => (
<p className={className} role={role}>{children || text}</p>
);
const TestH1: React.FC<Common> = ({ className, children, text }) => (
<h1 className={className}>{children || text}</h1>
);
const TestLabel: React.FC<Common & { htmlFor?: string }> = ({ className, children, text, htmlFor }) => (
<label className={className} htmlFor={htmlFor}>{children || text}</label>
);
const TestForm: React.FC<Common & { onSubmit?: (e: React.FormEvent) => void }> = ({
className,
children,
onSubmit,
}) => <form className={className} onSubmit={onSubmit}>{children}</form>;
const TestButton: React.FC<Common & { type?: string; disabled?: boolean }> = ({
type,
className,
disabled,
children,
text,
}) => (
<button type={type as 'button' | 'submit'} className={className} disabled={disabled}>
{children || text}
</button>
);
const TestInput: React.FC<{
id?: string;
type?: string;
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
inputMode?: string;
autoComplete?: string;
maxLength?: number;
}> = ({ id, type, name, value, placeholder, disabled, className, inputMode, autoComplete, maxLength }) => (
<input
id={id}
type={type}
name={name}
defaultValue={value}
placeholder={placeholder}
disabled={disabled}
className={className}
inputMode={inputMode as any}
autoComplete={autoComplete}
maxLength={maxLength}
/>
);
const TestSelect: React.FC<{ className?: string; value?: string }> = ({ className }) => (
<select className={className} />
);
const TestImg: React.FC<{ className?: string; src?: string; alt?: string }> = ({ className, src, alt }) => (
<img className={className} src={src} alt={alt} />
);
const TestIcon: React.FC<{ className?: string }> = ({ className }) => <i className={className} />;
const TestToast: React.FC = () => <div data-testid="toast-host" />;
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
function setupRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Form: { component: TestForm, metadata: { name: 'Form', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
Img: { component: TestImg, metadata: { name: 'Img', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Toast: { component: TestToast, metadata: { name: 'Toast', type: 'composite' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/** init_actions 는 렌더 대상이 아니므로 제거하고 상태를 직접 준다. */
const layout = { ...(adminLogin as any), init_actions: [] };
const CHALLENGE_LOCAL = {
isLoggingIn: false,
loginError: null,
loginErrors: null,
loginForm: { email: '', password: '' },
twoFactor: {
required: true,
challenge_id: '9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40',
provider_id: 'g7:core.mail',
expires_at: '2026-09-07T14:03:00+09:00',
code: '',
error: null,
verifying: false,
resending: false,
resent: false,
},
};
describe('sirsoft-admin_basic 관리자 로그인 렌더링 — 2단계 인증', () => {
beforeEach(() => {
setupRegistry();
});
/**
* @scenario step=credentials, response=ok, action=submit
*
* @effects credential_step_hidden_on_challenge
*/
it('초기 상태에서는 이메일·비밀번호만 보이고 인증번호 입력은 없다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: { _local: { ...CHALLENGE_LOCAL, twoFactor: null } },
});
await t.render();
expect(document.querySelector('input[name="email"]')).not.toBeNull();
expect(document.querySelector('input[name="password"]')).not.toBeNull();
expect(document.querySelector('input[name="two_factor_code"]')).toBeNull();
t.cleanup();
});
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
*/
it('challenge 를 받으면 인증번호 입력으로 바뀌고 자격 증명 입력은 사라진다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: { _local: CHALLENGE_LOCAL },
});
await t.render();
const code = document.querySelector('input[name="two_factor_code"]');
expect(code, '챌린지 상태인데 인증번호 입력이 렌더되지 않았습니다').not.toBeNull();
expect(code?.getAttribute('inputmode')).toBe('numeric');
expect(code?.getAttribute('autocomplete')).toBe('one-time-code');
expect(document.querySelector('input[name="email"]')).toBeNull();
expect(document.querySelector('input[name="password"]')).toBeNull();
t.cleanup();
});
/**
* @scenario step=code, response=403, action=submit
*
* @effects no_raw_typeerror_text, admin_required_message_rendered_in_code_step
*/
it('관리자 아님(403) 문구가 인증번호 오류 자리에 표시되고 TypeError 원문은 없다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: {
_local: {
...CHALLENGE_LOCAL,
twoFactor: { ...CHALLENGE_LOCAL.twoFactor, error: '관리자 권한이 필요합니다.' },
},
},
});
await t.render();
const body = document.body.textContent ?? '';
expect(body).toContain('관리자 권한이 필요합니다.');
expect(body).not.toContain('Cannot read properties of undefined');
t.cleanup();
});
/**
* @scenario step=credentials, response=401, action=submit
*
* @effects toast_host_mounted_on_standalone_layout
*/
it('독립 레이아웃이므로 Toast 호스트가 함께 렌더된다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: { _local: { ...CHALLENGE_LOCAL, twoFactor: null } },
});
await t.render();
// 호스트가 없으면 안내가 성공으로 기록되고도 화면에 나타나지 않는다.
expect(document.querySelector('[data-testid="toast-host"]')).not.toBeNull();
t.cleanup();
});
/**
* 상태 주입이 아니라 **서버 응답**에서 출발한다. 이 레이아웃의 login 액션을 그대로 실행해
* 코어(AuthManager → 액션 반환값 → onSuccess 매핑)를 통과시킨 뒤 화면을 본다. 관리자 경로는
* 종전에 서버가 먼저 500 을 내던 자리라 사용자 경로와 같은 강도로 잠가 둔다.
*
* 자격 증명 값만 리터럴로 바꾼다 — 폼 입력 값(`{{form.*}}`)은 렌더러가 들고 있고 이 하네스가
* 관측하지 못하는 유일한 조각이기 때문이다.
*
* @scenario step=credentials, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge, no_raw_typeerror_text
*/
it('challenge 응답을 실제로 받으면 2단계로 넘어가고 오류 원문이 남지 않는다', async () => {
apiPost.mockReset();
apiPost.mockResolvedValue({
success: true,
data: {
two_factor_required: true,
challenge_id: '11111111-2222-3333-4444-555555555555',
provider_id: 'g7:core.mail',
expires_at: '2026-09-07T14:03:00+09:00',
},
});
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
await t.render();
// 이 레이아웃이 실제로 선언한 login 액션 — 자격 증명만 리터럴로 채운다.
const form = (adminLogin as any).components[1].children[0].children[1];
const loginAction = form.actions[0].actions.find(
(a: any) => a.handler === 'login'
);
expect(loginAction, 'login 액션이 제출 시퀀스에서 사라졌습니다').toBeDefined();
expect(loginAction.target, '관리자 로그인은 admin 대상이어야 합니다').toBe('admin');
await t.triggerAction({
...loginAction,
if: undefined,
params: { body: { email: 'admin@example.com', password: 'pw' } },
});
// 코어가 challenge 응답에서 던지면(=#133) 여기까지 오지 못한다.
expect(apiPost).toHaveBeenCalled();
const twoFactor = t.getState()._local?.twoFactor;
expect(twoFactor?.required, 'challenge 응답인데 2단계 상태가 서지 않았습니다').toBe(true);
expect(twoFactor?.challenge_id).toBe('11111111-2222-3333-4444-555555555555');
expect(twoFactor?.code).toBe('');
const body = document.body.textContent ?? '';
expect(body).not.toContain('Cannot read properties of undefined');
t.cleanup();
});
/**
* 응답이 아예 없었던 실패(네트워크 끊김)는 HTTP 오류가 아니다. 그 자리에서 axios 오류 원문이나
* 내부 식별 문구(`Failed to execute action: login`)가 새면 오류 박스에 영문이 그대로 실린다.
*
* @scenario step=credentials, response=network, action=submit
*
* @effects network_message_translated
*/
it('네트워크 실패는 영문 원문이 아니라 다국어 안내로 오류 박스에 실린다', async () => {
apiPost.mockReset();
apiPost.mockRejectedValue(Object.assign(new Error('Network Error'), {
code: 'ERR_NETWORK',
}));
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
await t.render();
const form = (adminLogin as any).components[1].children[0].children[1];
const loginAction = form.actions[0].actions.find((a: any) => a.handler === 'login');
await t.triggerAction({
...loginAction,
if: undefined,
params: { body: { email: 'admin@example.com', password: 'pw' } },
});
const loginError = String(t.getState()._local?.loginError ?? '');
expect(loginError).toContain('core.errors.network_request_failed');
expect(loginError).not.toContain('Failed to execute action');
expect(loginError).not.toBe('Network Error');
t.cleanup();
});
});
@@ -0,0 +1,190 @@
/**
* @file admin-login-two-factor-step.test.tsx
* @description 관리자 로그인 2단계 인증 단계 구조 회귀 테스트 (sirsoft-admin_basic)
*
* 2단계 인증이 켜진 사이트에서는 관리자 로그인도 challenge 를 받는다. 관리자가 들어갈 수
* 없으면 설정을 되돌릴 수단까지 사라지므로 사용자 화면보다 파급이 크다(공개 #133).
*
* 사용자 템플릿(sirsoft-basic)의 `login-two-factor-step.test.tsx` 와 평행한 검증이며,
* 네임스페이스만 `_local` 로 다르다 — 이 화면은 독립 레이아웃이라 전역 상태를 쓰지 않는다.
*
* @since engine-v1.65.0
*/
import { describe, it, expect } from 'vitest';
import adminLogin from '../../layouts/admin_login.json';
type Action = {
event?: string;
type?: string;
handler?: string;
target?: string;
if?: string;
params?: Record<string, any>;
actions?: Action[];
onSuccess?: Action[];
onError?: Action[];
};
type Node = {
id?: string;
name?: string;
if?: string;
props?: Record<string, any>;
children?: Node[] | string;
text?: string;
events?: Record<string, unknown>;
actions?: Action[];
};
function flatten(node: Node | Node[] | undefined): Node[] {
if (!node) return [];
if (Array.isArray(node)) return node.flatMap(flatten);
const children = Array.isArray(node.children) ? node.children.flatMap(flatten) : [];
return [node, ...children];
}
function flattenActions(actions: Action[] | undefined): Action[] {
if (!actions) return [];
return actions.flatMap((a) => [
a,
...flattenActions(a.actions),
...flattenActions(a.onSuccess),
...flattenActions(a.onError),
]);
}
const nodes = flatten((adminLogin as any).components as Node[]);
const form = nodes.find((n) => n.id === 'login_form_component');
const formActions = flattenActions(form?.actions);
describe('sirsoft-admin_basic 관리자 로그인 2단계 인증 단계', () => {
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
*/
it('1단계 입력과 2단계 블록의 조건이 상보적이다', () => {
const stepOne = nodes.filter((n) => n.if === '{{!_local.twoFactor?.required}}');
const stepTwo = nodes.filter((n) => n.if === '{{_local.twoFactor?.required}}');
expect(stepOne.length).toBeGreaterThanOrEqual(3);
expect(stepTwo.length).toBe(1);
expect(stepTwo[0]?.id).toBe('login_two_factor_step');
});
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_input_is_controlled_without_events_wrapper
*/
it('인증번호 입력이 controlled 이고 events 래퍼를 쓰지 않는다', () => {
const codeInput = nodes.find((n) => n.id === 'login_two_factor_input');
expect(codeInput?.props?.value).toBe("{{_local.twoFactor?.code ?? ''}}");
expect(codeInput?.props?.autoComplete).toBe('one-time-code');
expect(codeInput?.props?.inputMode).toBe('numeric');
expect(codeInput?.events).toBeUndefined();
const onChange = codeInput?.actions?.find((a) => a.event === 'onChange');
expect(onChange?.handler).toBe('setState');
expect(onChange?.params?.target).toBe('local');
expect(onChange?.params?.['twoFactor.code']).toBe('{{$event.target.value}}');
});
/**
* @scenario step=code, response=ok, action=enter_key
*
* @effects login_and_verify_are_mutually_exclusive, admin_required_message_rendered_in_code_step
*/
it('login 과 loginTwoFactor 가 상호배타 조건을 갖고 admin 을 대상으로 한다', () => {
const login = formActions.find((a) => a.handler === 'login');
const verify = formActions.find((a) => a.handler === 'loginTwoFactor');
expect(login?.if).toBe('{{!_local.twoFactor?.required}}');
expect(verify?.if).toBe('{{_local.twoFactor?.required}}');
expect(login?.target).toBe('admin');
expect(verify?.target).toBe('admin');
expect(verify?.params?.body?.challenge_id).toBe('{{_local.twoFactor?.challenge_id}}');
expect(verify?.params?.body?.code).toBe('{{_local.twoFactor?.code}}');
});
/**
* @scenario step=credentials, response=challenge, action=submit
*
* @effects no_raw_typeerror_text
*/
it('login 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다', () => {
const login = formActions.find((a) => a.handler === 'login');
const onSuccess = login?.onSuccess ?? [];
expect(onSuccess.length).toBeGreaterThan(0);
for (const action of onSuccess) {
expect(action.if, `후속 액션 ${action.handler} 에 조건이 없습니다`).toBeDefined();
}
const challengeBranch = onSuccess.find((a) => a.if === '{{response.two_factor_required}}');
expect(challengeBranch?.params?.target).toBe('local');
expect(challengeBranch?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
expect(JSON.stringify(challengeBranch?.params)).not.toContain('_local.twoFactor');
});
/**
* @scenario step=code, response=ok, action=resend
*
* @effects resend_clears_code_and_replaces_challenge
*/
it('재발송 성공 시 새 challenge 로 교체하고 입력값을 비운다', () => {
const resendButton = nodes.find((n) => n.id === 'login_two_factor_resend');
const resendAction = flattenActions(resendButton?.actions).find(
(a) => a.handler === 'loginTwoFactorResend'
);
expect(resendAction?.target).toBe('admin');
expect(resendAction?.params?.body?.challenge_id).toBe('{{_local.twoFactor?.challenge_id}}');
const success = resendAction?.onSuccess?.[0];
expect(success?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
expect(success?.params?.twoFactor?.code).toBe('');
expect(success?.params?.twoFactor?.resent).toBe(true);
});
/**
* @scenario step=credentials, response=ok, action=restart
*
* @effects init_actions_reset_two_factor_state, restart_resets_to_credential_step
*/
it('화면 진입 시 2단계 상태를 초기화한다', () => {
const init = (adminLogin as any).init_actions as Action[];
const reset = init.find(
(a) => a.handler === 'setState' && a.params?.target === 'local' && 'twoFactor' in (a.params ?? {})
);
expect(reset).toBeDefined();
expect(reset?.params?.twoFactor).toBeNull();
});
/**
* @scenario step=credentials, response=423, action=submit
*
* @effects locked_until_rendered
*/
it('계정 잠금 해제 시각을 렌더하는 지점이 있다', () => {
const lockedUntil = nodes.find((n) => n.id === 'login_error_locked_until');
const permanent = nodes.find((n) => n.id === 'login_error_locked_permanent');
expect(lockedUntil?.text).toContain('$t:auth.login.locked_until');
expect(permanent?.text).toBe('$t:auth.login.locked_permanent');
});
/**
* @scenario step=credentials, response=401, action=submit
*
* @effects toast_host_mounted_on_standalone_layout
*/
it('Toast 호스트가 마운트되어 있다', () => {
// 독립 레이아웃이라 베이스가 호스트를 주입하지 않는다 — 없으면 안내가 조용히 사라진다.
expect(nodes.some((n) => n.name === 'Toast')).toBe(true);
});
});
@@ -215,6 +215,30 @@
`admin_reset_password`)에서만 호출됩니다. 사이드바 접힘 상태 복원(`initSidebar`)은 레이아웃이
아니라 템플릿 부트스트랩(`src/index.ts`)에서 직접 호출됩니다. `_admin_base` 를 고칠 때 이
문서의 낡은 구조를 그대로 믿지 말고 실제 JSON 을 확인하세요.
### `admin_login.json` 의 2단계 인증(인증번호) 단계
보안 설정에서 2단계 인증을 켠 사이트에서는 관리자 로그인 응답도 두 형태로 갈립니다 — 정상
로그인과 인증번호 요구(challenge)입니다. `admin_login.json` 은 그 둘을 같은 카드 안에서 단계
전환으로 처리합니다. 이 화면은 `_admin_base` 를 상속하지 않는 독립 레이아웃이라 모달을 쓸 수
없고, Toast 호스트도 이 레이아웃이 직접 마운트합니다.
- 1단계(이메일·비밀번호) 블록은 `if: "{{!_local.twoFactor?.required}}"`, 2단계(인증번호) 블록은
그 부정형입니다. 두 `if` 는 언제나 상보여야 합니다. 상태는 `_global` 이 아니라 **`_local`** 입니다.
- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 같은 쌍으로 상호배타입니다. `loginTwoFactor` 쪽
`if` 가 빠지면 인증번호 단계에서 Enter 를 눌렀을 때 새 challenge 가 발급되어 흐름이 끊깁니다.
- 인증번호 입력은 controlled 입니다(`value` + `onChange` 의 `setState` 쌍). `events: {}` 래퍼는
쓰지 않습니다.
- 화면 진입 시 `init_actions` 의 `setState` 가 `twoFactor` 를 `null` 로 되돌립니다.
- 관리자가 아닌 계정이 인증번호를 맞춰도 서버가 403(`auth.admin_required`)으로 거부하고 이미
발급한 토큰을 회수합니다. 그 문구는 `loginTwoFactor` 의 `onError` 가 인증번호 오류 자리에 그대로
싣습니다 — 별도 분기를 두지 않습니다.
- 만료 시각은 정적 표기입니다(`| datetime`). 카운트다운을 쓰지 않습니다.
**금지 — 같은 시퀀스·onSuccess 안에서 방금 저장한 상태를 다시 읽지 않습니다**
`setState` 직후 형제 액션의 `if` 나 값으로 그 상태를 재독하면 갱신 이전 값을 읽습니다. 그 자리에서는
`{{response.*}}` 만 씁니다. 오류도 경고도 남지 않고 분기만 조용히 어긋납니다.
<!-- @intent END -->
## 라우트 매핑
@@ -8,6 +8,19 @@
"processing": "Processing...",
"remember": "Remember me",
"forgot": "Forgot your password?",
"two_factor": {
"sent": "We sent a verification code. Enter it to finish signing in.",
"resent": "We sent a new verification code. Enter the new code.",
"code_label": "Verification code",
"code_placeholder": "Enter the code you received",
"valid_until": "Valid until {{until}}",
"verify": "Verify code",
"verifying": "Verifying...",
"resend": "Resend code",
"restart": "Start over"
},
"locked_until": "Unlocks at {{until}}",
"locked_permanent": "Contact an administrator to unlock the account.",
"error": {
"email_required": "Please enter your email.",
"email_invalid": "Please enter a valid email address.",
@@ -8,6 +8,19 @@
"processing": "처리 중...",
"remember": "로그인 상태 유지",
"forgot": "비밀번호를 잊으셨나요?",
"two_factor": {
"sent": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
"resent": "인증번호를 다시 보냈습니다. 새로 받은 번호를 입력해주세요.",
"code_label": "인증번호",
"code_placeholder": "받은 인증번호를 입력하세요",
"valid_until": "유효시간 {{until}} 까지",
"verify": "인증번호 확인",
"verifying": "확인 중...",
"resend": "인증번호 다시 받기",
"restart": "처음부터"
},
"locked_until": "해제 예정: {{until}}",
"locked_permanent": "관리자에게 문의해 잠금을 해제해주세요.",
"error": {
"email_required": "이메일을 입력해주세요.",
"email_invalid": "올바른 이메일 주소를 입력해주세요.",
@@ -19,6 +19,7 @@
"isLoggingIn": false,
"loginError": null,
"loginErrors": null,
"twoFactor": null,
"loginForm": {
"email": "",
"password": ""
@@ -149,6 +150,27 @@
"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"
}
]
},
@@ -157,6 +179,7 @@
"id": "login_email_field",
"type": "basic",
"name": "Div",
"if": "{{!_local.twoFactor?.required}}",
"children": [
{
"id": "login_email_label",
@@ -200,6 +223,7 @@
"id": "login_password_field",
"type": "basic",
"name": "Div",
"if": "{{!_local.twoFactor?.required}}",
"children": [
{
"id": "login_password_label",
@@ -243,6 +267,7 @@
"id": "login_forgot_password",
"type": "basic",
"name": "Div",
"if": "{{!_local.twoFactor?.required}}",
"props": {
"className": "flex-center justify-end"
},
@@ -273,6 +298,7 @@
"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",
@@ -303,6 +329,241 @@
"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": [
@@ -320,8 +581,10 @@
}
},
{
"_comment": "1·2단계는 상호배타다. if 는 시퀀스 시작 시점 스냅샷으로 평가되므로 한 제출에 정확히 하나만 실행된다 — 이 if 가 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.",
"handler": "login",
"target": "admin",
"if": "{{!_local.twoFactor?.required}}",
"params": {
"body": {
"email": "{{_local.loginForm.email}}",
@@ -330,7 +593,28 @@
},
"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
@@ -338,6 +622,7 @@
},
{
"handler": "navigate",
"if": "{{!response.two_factor_required}}",
"params": {
"path": "{{query.redirect ?? '/admin'}}"
}
@@ -354,6 +639,45 @@
}
}
]
},
{
"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}}"
}
}
]
}
]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.8",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
"version": "1.0.8",
"version": "1.0.9",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.8",
"version": "1.0.9",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.8",
"version": "1.0.9",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -22,7 +22,7 @@
"url": "https://sirsoft.com"
},
"release_date": "2026-05-15",
"g7_version": ">=7.0.10",
"g7_version": ">=7.0.11",
"dependencies": {
"modules": {},
"plugins": {}
@@ -0,0 +1,42 @@
# audit:allow test-scenario-coverage reason: response 축(ok/challenge/401/403/423/429/503/network)은 서버 응답
# 종류를 열거한 것이라 레이아웃 구조 테스트가 케이스별로 나눠 단언할 실체가 아니다 — 구조 테스트는 화면이
# 그 응답들을 받을 수 있는 형태인지(상보 조건·controlled 입력·상호배타 액션)를 본다. 실제 회귀 가드는
# effects 이며 전부 @effects 로 귀속되어 있다(미검증 0건). 응답별 화면 실측은 Playwright spec 이 담당한다.
feature: 관리자 로그인 화면 2단계 인증 단계 (sirsoft-admin_basic)
description: |
관리자 로그인도 2단계 인증 대상이다. 관리자가 들어갈 수 없으면 설정을 되돌릴 수단까지
사라지므로 사용자 화면보다 파급이 크다(공개 #133).
사용자 템플릿과 평행한 구조이며 네임스페이스만 _local 로 다르다 — 이 화면은 독립 레이아웃이라
전역 상태를 쓰지 않는다. 관리자가 아닌 계정의 403 도 인증번호 오류 자리에 표시된다.
axes:
step: [credentials, code]
response: [ok, challenge, 401, 403, 423, 429, 503, network]
action: [submit, resend, restart, enter_key]
exclusions:
- { step: credentials, action: resend, reason: "재발송 버튼은 인증번호 단계에만 있다" }
- { step: credentials, action: restart, reason: "동일" }
- { step: code, response: challenge, reason: "challenge 는 비밀번호 단계의 응답이다" }
effects:
- no_raw_typeerror_text
- code_step_rendered_on_challenge
- credential_step_hidden_on_challenge
- login_and_verify_are_mutually_exclusive
- code_input_is_controlled_without_events_wrapper
- resend_clears_code_and_replaces_challenge
- restart_resets_to_credential_step
- locked_until_rendered
- init_actions_reset_two_factor_state
- admin_required_message_rendered_in_code_step
- toast_host_mounted_on_standalone_layout
- network_message_translated
test_files:
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-login-two-factor-step.test.tsx
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-login-two-factor-render.test.tsx
- tests/Playwright/specs/auth/two-factor-login.spec.ts
+4 -2
View File
@@ -135,6 +135,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
- [ ] TSX/TS 를 고쳤다면 `template:build --production` 후 `dist/` 동반 커밋 (`sourceMappingURL` 잔존 금지)
- [ ] 프론트엔드 변경은 Playwright spec 동반 — 단위 테스트만으로는 화면 회귀가 드러나지 않는다
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-basic --force` (편집기는 활성 디렉토리만 읽는다)
- [ ] 로그인 화면의 2단계 인증 단계를 고쳤다면 1단계·2단계 `if` 의 상보성과 `login`/`loginTwoFactor` 의 상호배타 `if` 를 함께 확인 — 한쪽이 빠지면 인증번호 단계에서 Enter 가 새 challenge 를 발급한다
## 6. 금지 패턴
@@ -150,6 +151,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
| 새 컴포넌트가 텍스트를 담는 prop 을 추가하면서 `seo-config.json` 을 그대로 두기 | `text_props` 에 그 prop 추가 | 봇 화면에서만 그 글자가 사라진다 — 사람 눈에는 정상이라 검색 노출이 줄어든 뒤에야 드러난다 |
| 레이아웃 JSON 에 빌드된 CSS 에 없는 Tailwind 클래스 사용 | 기존 레이아웃에 쓰인 클래스이거나 빌드 산출물에 존재하는지 확인 | 그 스타일만 조용히 빠져 화면이 어긋난다 |
| `dist/` 재빌드 없이 `src/` 만 고치고 커밋 | `template:build --production` 후 `dist/` 동반 커밋 | 브라우저가 받는 것은 커밋된 `dist/` 다 — 소스 수정이 사문화된다 |
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 자리에서는 `{{response.*}}` 만 읽는다 | 그 시점 컨텍스트는 아직 갱신 전이라 stale 값으로 조용히 분기한다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -158,9 +160,9 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 145개 | `vitest.config.ts` |
| Vitest | 147개 | `vitest.config.ts` |
| Playwright | 8개 | `tests/Playwright` |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
| 시나리오 매니페스트 | 4개 | `tests/scenarios` |
```bash
# Vitest (확장 디렉토리에서) (PowerShell)
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.4] - 2026-09-07
### Added
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 인증번호의 유효 시각도 함께 표시됩니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
### Changed
- 코어 최소 요구 버전을 7.0.11 로 상향했습니다.
### Fixed
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
## [1.1.3] - 2026-09-06
### Added
+3 -3
View File
@@ -5,9 +5,9 @@
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.1.3-0066FF?style=flat-square" alt="version 1.1.3">
<img src="https://img.shields.io/badge/version-1.1.4-0066FF?style=flat-square" alt="version 1.1.4">
<img src="https://img.shields.io/badge/type-%ED%85%9C%ED%94%8C%EB%A6%BF-555555?style=flat-square" alt="type 템플릿">
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.11-1F883D?style=flat-square" alt="그누보드7 &gt;=7.0.11">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
<img src="https://img.shields.io/badge/requires-sirsoft--board-BF8700?style=flat-square" alt="requires sirsoft-board">
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
@@ -87,7 +87,7 @@ flowchart LR
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 그누보드7 코어 | `>=7.0.10` |
| 그누보드7 코어 | `>=7.0.11` |
| PHP | `^8.2` |
| 의존 모듈 | `sirsoft-board` `>=1.0.0` |
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
@@ -0,0 +1,360 @@
/**
* @file login-two-factor-render.test.tsx
* @description 로그인 2단계 인증 단계 **렌더링** 회귀 테스트 (sirsoft-basic)
*
* 형제 파일 `login-two-factor-step.test.tsx` 는 레이아웃 JSON 의 구조를 단언한다.
* 이 파일은 그 JSON 을 **실제로 렌더해** 화면에 무엇이 나타나는지를 단언한다 — 구조가
* 맞아도 조건식이 어긋나면 두 단계가 함께 보이거나 아무것도 보이지 않을 수 있고,
* 그 차이는 구조 단언으로 드러나지 않는다.
*
* 핵심 회귀: challenge 응답을 받은 상태에서
* - 인증번호 입력이 화면에 있고 이메일·비밀번호는 사라진다
* - 오류 박스에 영문 TypeError 원문이 없다 (공개 #133 의 증상)
*
* @vitest-environment jsdom
* @since engine-v1.65.0
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createLayoutTest } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
import loginForm from '../../layouts/partials/auth/_login_form.json';
// 공개 #133 의 원인은 레이아웃이 아니라 **응답 형태를 하나로 가정한 코어**였다. 상태를 직접
// 주입해 그린 화면만 단언하면 그 경로를 한 번도 태우지 않으므로, 코어가 다시 challenge 응답에서
// 던지더라도 이 파일은 초록으로 남는다. API 를 모킹해 login 액션을 실제로 통과시킨다.
const apiPost = vi.fn();
vi.mock('@core/api/ApiClient', async () => {
const actual = await vi.importActual<typeof import('@core/api/ApiClient')>(
'@core/api/ApiClient'
);
const stub = {
post: (...args: unknown[]) => apiPost(...args),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
getToken: vi.fn(() => null),
setToken: vi.fn(),
removeToken: vi.fn(),
setLocale: vi.fn(),
};
return { ...actual, getApiClient: () => stub, createApiClient: () => stub };
});
// ========== 테스트용 컴포넌트 ==========
type Common = {
className?: string;
children?: React.ReactNode;
text?: string;
};
const TestDiv: React.FC<Common & { role?: string }> = ({ className, children, role }) => (
<div className={className} role={role}>{children}</div>
);
const TestSpan: React.FC<Common> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestP: React.FC<Common & { role?: string }> = ({ className, children, text, role }) => (
<p className={className} role={role}>{children || text}</p>
);
const TestLabel: React.FC<Common> = ({ className, children, text }) => (
<label className={className}>{children || text}</label>
);
const TestForm: React.FC<Common & { onSubmit?: (e: React.FormEvent) => void }> = ({
className,
children,
onSubmit,
}) => (
<form className={className} onSubmit={onSubmit}>{children}</form>
);
const TestButton: React.FC<Common & { type?: string; disabled?: boolean }> = ({
type,
className,
disabled,
children,
text,
}) => (
<button type={type as 'button' | 'submit'} className={className} disabled={disabled}>
{children || text}
</button>
);
const TestInput: React.FC<{
type?: string;
name?: string;
value?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
inputMode?: string;
autoComplete?: string;
maxLength?: number;
}> = ({ type, name, value, placeholder, disabled, className, inputMode, autoComplete, maxLength }) => (
<input
type={type}
name={name}
defaultValue={value}
placeholder={placeholder}
disabled={disabled}
className={className}
inputMode={inputMode as any}
autoComplete={autoComplete}
maxLength={maxLength}
/>
);
const TestPasswordInput: React.FC<{ name?: string; disabled?: boolean; className?: string }> = ({
name,
disabled,
className,
}) => <input type="password" name={name} disabled={disabled} className={className} />;
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
function setupRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Form: { component: TestForm, metadata: { name: 'Form', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
PasswordInput: { component: TestPasswordInput, metadata: { name: 'PasswordInput', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/** 실제 파셜을 단독 레이아웃으로 감싼다 — 컴포넌트 트리는 그대로다. */
const layout = {
version: '1.0.0',
layout_name: 'auth/login',
components: [loginForm as any],
};
/** 챌린지를 받은 뒤의 전역 상태 */
const CHALLENGE_STATE = {
_global: {
twoFactor: {
required: true,
challenge_id: '9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40',
provider_id: 'g7:core.mail',
expires_at: '2026-09-07T14:03:00+09:00',
code: '',
error: null,
verifying: false,
resending: false,
resent: false,
},
},
};
describe('sirsoft-basic 로그인 폼 렌더링 — 2단계 인증', () => {
beforeEach(() => {
setupRegistry();
});
/**
* @scenario step=credentials, response=ok, action=submit
*
* @effects credential_step_hidden_on_challenge
*/
it('초기 상태에서는 이메일·비밀번호만 보이고 인증번호 입력은 없다', async () => {
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
await t.render();
expect(document.querySelector('input[name="email"]')).not.toBeNull();
expect(document.querySelector('input[name="password"]')).not.toBeNull();
expect(document.querySelector('input[name="two_factor_code"]')).toBeNull();
t.cleanup();
});
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
*/
it('challenge 를 받으면 인증번호 입력으로 바뀌고 자격 증명 입력은 사라진다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: CHALLENGE_STATE,
});
await t.render();
const code = document.querySelector('input[name="two_factor_code"]');
expect(code, '챌린지 상태인데 인증번호 입력이 렌더되지 않았습니다').not.toBeNull();
expect(code?.getAttribute('inputmode')).toBe('numeric');
expect(code?.getAttribute('autocomplete')).toBe('one-time-code');
// 두 단계가 함께 보이면 사용자가 어느 쪽을 채워야 하는지 알 수 없다.
expect(document.querySelector('input[name="email"]')).toBeNull();
expect(document.querySelector('input[name="password"]')).toBeNull();
t.cleanup();
});
/**
* @scenario step=code, response=401, action=submit
*
* @effects no_raw_typeerror_text
*/
it('오류 문구 자리에 영문 TypeError 원문이 나타나지 않는다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: {
_global: {
...CHALLENGE_STATE._global,
twoFactor: {
...CHALLENGE_STATE._global.twoFactor,
error: '인증번호가 올바르지 않거나 유효시간이 지났습니다.',
},
},
},
});
await t.render();
const body = document.body.textContent ?? '';
expect(body).toContain('인증번호가 올바르지 않거나 유효시간이 지났습니다.');
// 공개 #133 의 증상 — 응답 형태를 하나로 가정했을 때 화면에 그대로 실리던 문구다.
expect(body).not.toContain('Cannot read properties of undefined');
expect(body).not.toContain('undefined is not an object');
t.cleanup();
});
/**
* @scenario step=credentials, response=423, action=submit
*
* @effects locked_until_rendered
*/
it('계정이 잠기면 해제 시각 줄이 함께 렌더된다', async () => {
const t = createLayoutTest(layout, {
componentRegistry: setupRegistry(),
initialState: {
_global: {
loginError: '로그인 시도 횟수 초과로 계정이 잠겼습니다.',
loginErrors: { locked_until: '2026-09-07T14:05:00+09:00', permanent: false },
},
},
});
await t.render();
// 잠금 안내와 별개로 해제 시각 줄이 존재해야 한다 (문구 자체는 다국어 키 해석 대상).
const paragraphs = Array.from(document.querySelectorAll('p'));
expect(paragraphs.length).toBeGreaterThanOrEqual(2);
expect(document.body.textContent ?? '').toContain('로그인 시도 횟수 초과로 계정이 잠겼습니다.');
t.cleanup();
});
/**
* 상태 주입이 아니라 **서버 응답**에서 출발한다. 이 레이아웃의 login 액션을 그대로 실행해
* 코어(AuthManager → 액션 반환값 → onSuccess 매핑)를 통과시킨 뒤 화면을 본다.
*
* 자격 증명 값만 리터럴로 바꾼다 — 폼 입력 값(`{{form.*}}`)은 렌더러가 들고 있고 이 하네스가
* 관측하지 못하는 유일한 조각이기 때문이다. 그 밖의 경로는 전부 실물이다.
*
* @scenario step=credentials, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge, no_raw_typeerror_text
*/
it('challenge 응답을 실제로 받으면 2단계로 넘어가고 오류 원문이 남지 않는다', async () => {
apiPost.mockReset();
apiPost.mockResolvedValue({
success: true,
data: {
two_factor_required: true,
challenge_id: '11111111-2222-3333-4444-555555555555',
provider_id: 'g7:core.mail',
expires_at: '2026-09-07T14:03:00+09:00',
},
});
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
await t.render();
// 이 레이아웃이 실제로 선언한 login 액션 — 자격 증명만 리터럴로 채운다.
const submitSequence = (loginForm as any).actions[0];
const loginAction = submitSequence.actions.find(
(a: any) => a.handler === 'login'
);
expect(loginAction, 'login 액션이 제출 시퀀스에서 사라졌습니다').toBeDefined();
await t.triggerAction({
...loginAction,
if: undefined,
params: { body: { email: 'user@example.com', password: 'pw' } },
});
// 코어가 challenge 응답에서 던지면(=#133) 여기까지 오지 못한다.
expect(apiPost).toHaveBeenCalled();
const twoFactor = t.getState()._global?.twoFactor;
expect(twoFactor?.required, 'challenge 응답인데 2단계 상태가 서지 않았습니다').toBe(true);
expect(twoFactor?.challenge_id).toBe('11111111-2222-3333-4444-555555555555');
expect(twoFactor?.code).toBe('');
await t.rerender();
expect(document.querySelector('input[name="two_factor_code"]')).not.toBeNull();
expect(document.querySelector('input[name="email"]')).toBeNull();
const body = document.body.textContent ?? '';
expect(body).not.toContain('Cannot read properties of undefined');
t.cleanup();
});
/**
* 응답이 아예 없었던 실패(네트워크 끊김)는 HTTP 오류가 아니다. 그 자리에서 axios 오류 원문이나
* 내부 식별 문구(`Failed to execute action: login`)가 새면 오류 박스에 영문이 그대로 실린다.
* 로그인 화면의 오류 문구는 사용자가 읽는 유일한 안내라 그 판정이 곧 화면 품질이다.
*
* @scenario step=credentials, response=network, action=submit
*
* @effects network_message_translated
*/
it('네트워크 실패는 영문 원문이 아니라 다국어 안내로 오류 박스에 실린다', async () => {
apiPost.mockReset();
apiPost.mockRejectedValue(Object.assign(new Error('Network Error'), {
code: 'ERR_NETWORK',
}));
const t = createLayoutTest(layout, { componentRegistry: setupRegistry() });
await t.render();
const loginAction = (loginForm as any).actions[0].actions.find(
(a: any) => a.handler === 'login'
);
await t.triggerAction({
...loginAction,
if: undefined,
params: { body: { email: 'user@example.com', password: 'pw' } },
});
const loginError = String(t.getState()._global?.loginError ?? '');
// 다국어 안내로 해석되는 값이어야 한다 — 번역기가 붙지 않은 하네스에서는 그 키가 남는다.
expect(loginError).toContain('core.errors.network_request_failed');
// 판정이 무너지면 이 두 형태 중 하나가 그대로 화면에 실린다.
expect(loginError).not.toContain('Failed to execute action');
expect(loginError).not.toBe('Network Error');
t.cleanup();
});
});
@@ -0,0 +1,211 @@
/**
* @file login-two-factor-step.test.tsx
* @description 로그인 2단계 인증 단계 구조 회귀 테스트 (sirsoft-basic)
*
* 2단계 인증이 켜진 사이트에서 서버는 로그인에 **두 가지 형태의 200** 을 돌려준다.
* 화면이 한 형태만 가정하면 인증번호 요구 응답에서 영문 오류가 노출되고 로그인이
* 불가능해진다(공개 #133).
*
* 검증 대상:
* 1. 1단계·2단계 블록의 `if` 가 상보적이다 (동시 노출 금지)
* 2. 인증번호 입력이 controlled — `value` + `onChange` 쌍, `events:{}` 래퍼 없음
* 3. `login` 과 `loginTwoFactor` 가 상호배타 `if` 를 갖는다
* 4. 재발송 onSuccess 가 새 challenge 로 교체하고 입력값을 비운다
* 5. `login` 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다
* 6. 로그인 화면 진입 시 2단계 상태가 초기화된다
*
* @since engine-v1.65.0
*/
import { describe, it, expect } from 'vitest';
import loginForm from '../../layouts/partials/auth/_login_form.json';
import loginPage from '../../layouts/auth/login.json';
type Action = {
event?: string;
type?: string;
handler?: string;
target?: string;
if?: string;
params?: Record<string, any>;
actions?: Action[];
onSuccess?: Action[];
onError?: Action[];
};
type Node = {
id?: string;
name?: string;
if?: string;
props?: Record<string, any>;
children?: Node[] | string;
text?: string;
events?: Record<string, unknown>;
actions?: Action[];
};
/** 트리를 평탄화한다. */
function flatten(node: Node | Node[] | undefined): Node[] {
if (!node) return [];
if (Array.isArray(node)) return node.flatMap(flatten);
const children = Array.isArray(node.children) ? node.children.flatMap(flatten) : [];
return [node, ...children];
}
/** 시퀀스를 평탄화해 모든 액션을 모은다. */
function flattenActions(actions: Action[] | undefined): Action[] {
if (!actions) return [];
return actions.flatMap((a) => [
a,
...flattenActions(a.actions),
...flattenActions(a.onSuccess),
...flattenActions(a.onError),
]);
}
const nodes = flatten(loginForm as unknown as Node);
const allActions = flattenActions((loginForm as unknown as Node).actions);
describe('sirsoft-basic 로그인 2단계 인증 단계', () => {
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_step_rendered_on_challenge, credential_step_hidden_on_challenge
*/
it('1단계 입력과 2단계 블록의 조건이 상보적이다', () => {
// 두 블록이 같은 조건을 쓰면 인증번호 단계에서 이메일·비밀번호가 함께 보인다.
const stepOne = nodes.filter((n) => n.if === '{{!_global.twoFactor?.required}}');
const stepTwo = nodes.filter((n) => n.if === '{{_global.twoFactor?.required}}');
expect(stepOne.length).toBeGreaterThanOrEqual(3);
expect(stepTwo.length).toBe(1);
});
/**
* @scenario step=code, response=challenge, action=submit
*
* @effects code_input_is_controlled_without_events_wrapper
*/
it('인증번호 입력이 controlled 이고 events 래퍼를 쓰지 않는다', () => {
const codeInput = nodes.find((n) => n.props?.name === 'two_factor_code');
expect(codeInput).toBeDefined();
expect(codeInput?.props?.value).toBe("{{_global.twoFactor?.code ?? ''}}");
expect(codeInput?.props?.autoComplete).toBe('one-time-code');
expect(codeInput?.props?.inputMode).toBe('numeric');
// 값의 소유자가 상태여야 재발송 시 입력값을 비울 수 있다.
expect(codeInput?.events).toBeUndefined();
const onChange = codeInput?.actions?.find((a) => a.event === 'onChange');
expect(onChange?.handler).toBe('setState');
expect(onChange?.params?.['twoFactor.code']).toBe('{{$event.target.value}}');
});
/**
* @scenario step=code, response=ok, action=enter_key
*
* @effects login_and_verify_are_mutually_exclusive
*/
it('login 과 loginTwoFactor 가 상호배타 조건을 갖는다', () => {
const login = allActions.find((a) => a.handler === 'login');
const verify = allActions.find((a) => a.handler === 'loginTwoFactor');
expect(login?.if).toBe('{{!_global.twoFactor?.required}}');
expect(verify?.if).toBe('{{_global.twoFactor?.required}}');
// 조건이 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급된다.
expect(login?.target).toBe('user');
expect(verify?.target).toBe('user');
});
/**
* @scenario step=code, response=ok, action=submit
*
* @effects no_raw_typeerror_text
*/
it('loginTwoFactor 가 challenge_id 와 code 를 함께 보낸다', () => {
const verify = allActions.find((a) => a.handler === 'loginTwoFactor');
expect(verify?.params?.body?.challenge_id).toBe('{{_global.twoFactor?.challenge_id}}');
expect(verify?.params?.body?.code).toBe('{{_global.twoFactor?.code}}');
});
/**
* @scenario step=credentials, response=challenge, action=submit
*
* @effects credential_step_hidden_on_challenge
*/
it('login 의 성공 후속 액션이 challenge 응답에서는 실행되지 않는다', () => {
const login = allActions.find((a) => a.handler === 'login');
const onSuccess = login?.onSuccess ?? [];
expect(onSuccess.length).toBeGreaterThan(0);
for (const action of onSuccess) {
// 조건이 없는 후속 액션이 하나라도 남으면 인증 전에 홈으로 이동하거나
// 빈 사용자 정보가 전역 상태에 실린다.
expect(action.if, `후속 액션 ${action.handler} 에 조건이 없습니다`).toBeDefined();
}
const challengeBranch = onSuccess.find((a) => a.if === '{{response.two_factor_required}}');
expect(challengeBranch?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
// 같은 시퀀스 안에서는 _global 이 아직 갱신 전이므로 응답 값만 읽어야 한다.
expect(JSON.stringify(challengeBranch?.params)).not.toContain('_global.twoFactor');
});
/**
* @scenario step=code, response=ok, action=resend
*
* @effects resend_clears_code_and_replaces_challenge
*/
it('재발송 성공 시 새 challenge 로 교체하고 입력값을 비운다', () => {
const resend = allActions.find((a) => a.handler === 'loginTwoFactorResend');
expect(resend).toBeUndefined();
// 재발송은 버튼 노드의 액션에 있다 — 폼 submit 시퀀스가 아니다.
const resendButton = nodes.find((n) =>
flattenActions(n.actions).some((a) => a.handler === 'loginTwoFactorResend')
);
const resendAction = flattenActions(resendButton?.actions).find(
(a) => a.handler === 'loginTwoFactorResend'
);
expect(resendAction?.target).toBe('user');
expect(resendAction?.params?.body?.challenge_id).toBe('{{_global.twoFactor?.challenge_id}}');
const success = resendAction?.onSuccess?.[0];
expect(success?.params?.twoFactor?.challenge_id).toBe('{{response.challenge_id}}');
// 이전 코드가 남아 있으면 새 코드를 받았는데 옛 코드로 제출된다.
expect(success?.params?.twoFactor?.code).toBe('');
expect(success?.params?.twoFactor?.resent).toBe(true);
});
/**
* @scenario step=credentials, response=ok, action=restart
*
* @effects init_actions_reset_two_factor_state, restart_resets_to_credential_step
*/
it('로그인 화면 진입 시 2단계 상태를 초기화한다', () => {
// 전역 상태라 화면을 떠나도 남는다 — 리셋이 없으면 다시 들어왔을 때 1단계가 보이지 않는다.
const init = (loginPage as any).init_actions as Action[];
const reset = init.find(
(a) => a.handler === 'setState' && a.params?.target === 'global' && 'twoFactor' in (a.params ?? {})
);
expect(reset).toBeDefined();
expect(reset?.params?.twoFactor).toBeNull();
expect(reset?.if).toBeUndefined();
});
/**
* @scenario step=credentials, response=423, action=submit
*
* @effects locked_until_rendered
*/
it('계정 잠금 해제 시각을 렌더하는 지점이 있다', () => {
const lockedUntil = nodes.find((n) => n.if === '{{_global.loginErrors?.locked_until}}');
const permanent = nodes.find((n) => n.if === '{{_global.loginErrors?.permanent === true}}');
expect(lockedUntil?.text).toContain('$t:auth.locked_until');
expect(permanent?.text).toBe('$t:auth.locked_permanent');
});
});
@@ -572,6 +572,30 @@ slots.content:
- `navigate` — 성공 후 홈/대시보드로 이동
- `setState` — 폼 에러, 로딩 상태
**2단계 인증(인증번호) 단계**
관리자가 보안 설정에서 2단계 인증을 켠 사이트에서는 로그인 응답이 두 형태로 갈립니다 —
정상 로그인과 인증번호 요구(challenge)입니다. `_login_form.json` 은 그 둘을 **같은 카드 안에서**
단계 전환으로 처리합니다.
- 1단계(이메일·비밀번호) 블록은 `if: "{{!_global.twoFactor?.required}}"`, 2단계(인증번호) 블록은
그 부정형을 답니다. 두 `if` 는 언제나 상보여야 합니다 — 한쪽만 고치면 두 단계가 겹쳐 보이거나
둘 다 사라집니다.
- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 같은 쌍으로 상호배타입니다. `loginTwoFactor` 쪽
`if` 가 빠지면 인증번호 단계에서 Enter 를 눌렀을 때 새 challenge 가 발급되어 흐름이 끊깁니다.
- 인증번호 입력은 controlled 입니다(`value` + `onChange` 의 `setState` 쌍). `events: {}` 래퍼는
쓰지 않습니다.
- 화면 진입 시 `auth/login.json` 의 `init_actions` 첫 액션이 `twoFactor` 를 `null` 로 되돌립니다.
전역 상태라 화면을 떠나도 남기 때문에, 이 리셋이 없으면 다시 들어왔을 때 1단계가 보이지 않습니다.
- 만료 시각은 정적 표기입니다(`| datetime`). 카운트다운을 쓰지 않습니다 — `startInterval` 은 등록
시점 컨텍스트를 고정하고 정지 호출처가 없어 화면을 떠난 뒤에도 남습니다.
**금지 — 같은 시퀀스·onSuccess 안에서 방금 저장한 상태를 다시 읽지 않습니다**
`setState target:global` 직후 형제 액션의 `if` 나 값으로 `_global.*` 를 재독하면 갱신 이전 값을
읽습니다(시퀀스 처리기는 `_local` 만 갱신합니다). 그 자리에서는 `{{response.*}}` 만 씁니다.
오류도 경고도 남지 않고 분기만 조용히 어긋나므로 화면을 보지 않으면 드러나지 않습니다.
---
#### 게시판 패턴
@@ -150,5 +150,18 @@
"minTypes": "At least {{count}} types (uppercase, lowercase, number, special)"
}
},
"two_factor": {
"sent": "We sent a verification code. Enter it to finish signing in.",
"resent": "We sent a new verification code. Enter the new code.",
"code_label": "Verification code",
"code_placeholder": "Enter the code you received",
"valid_until": "Valid until {{until}}",
"verify": "Verify code",
"verifying": "Verifying...",
"resend": "Resend code",
"restart": "Start over"
},
"locked_until": "Unlocks at {{until}}",
"locked_permanent": "Contact an administrator to unlock your account.",
"session_expired_toast": "Your session has expired. Please log in again."
}
@@ -150,5 +150,18 @@
"minTypes": "{{count}}가지 이상 조합 (대/소문자, 숫자, 특수문자)"
}
},
"two_factor": {
"sent": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
"resent": "인증번호를 다시 보냈습니다. 새로 받은 번호를 입력해주세요.",
"code_label": "인증번호",
"code_placeholder": "받은 인증번호를 입력하세요",
"valid_until": "유효시간 {{until}} 까지",
"verify": "인증번호 확인",
"verifying": "확인 중...",
"resend": "인증번호 다시 받기",
"restart": "처음부터"
},
"locked_until": "해제 예정: {{until}}",
"locked_permanent": "관리자에게 문의해 잠금을 해제해주세요.",
"session_expired_toast": "세션이 만료되었습니다. 다시 로그인해 주세요."
}
@@ -7,6 +7,11 @@
"guest_only": true
},
"init_actions": [
{
"_comment": "2단계 인증 단계 상태는 전역이라 화면을 떠나도 남는다 — 진입할 때마다 1단계로 되돌린다.",
"handler": "setState",
"params": { "target": "global", "twoFactor": null }
},
{
"if": "{{query?.reason === 'session_expired'}}",
"handler": "toast",
@@ -1,7 +1,7 @@
{
"meta": {
"is_partial": true,
"description": "로그인 폼 (이메일/비밀번호)"
"description": "로그인 폼 (1단계 이메일/비밀번호 → 2단계 인증번호)"
},
"type": "basic",
"name": "Form",
@@ -21,13 +21,29 @@
"name": "P",
"props": { "className": "text-sm text-red-600 dark:text-red-400" },
"text": "{{_global.loginError}}"
},
{
"comment": "계정 잠금 해제 시각 — 언제 다시 시도할 수 있는지 알려주지 않으면 사용자는 계속 눌러 보게 된다",
"type": "basic",
"name": "P",
"if": "{{_global.loginErrors?.locked_until}}",
"props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
"text": "$t:auth.locked_until|until={{_global.loginErrors?.locked_until | datetime}}"
},
{
"type": "basic",
"name": "P",
"if": "{{_global.loginErrors?.permanent === true}}",
"props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
"text": "$t:auth.locked_permanent"
}
]
},
{
"comment": "이메일 입력 필드",
"comment": "이메일 입력 필드 (1단계)",
"type": "basic",
"name": "Div",
"if": "{{!_global.twoFactor?.required}}",
"children": [
{
"type": "basic",
@@ -58,9 +74,10 @@
]
},
{
"comment": "비밀번호 입력 필드",
"comment": "비밀번호 입력 필드 (1단계)",
"type": "basic",
"name": "Div",
"if": "{{!_global.twoFactor?.required}}",
"children": [
{
"type": "basic",
@@ -84,8 +101,10 @@
]
},
{
"comment": "로그인 버튼 (1단계)",
"type": "basic",
"name": "Button",
"if": "{{!_global.twoFactor?.required}}",
"props": {
"type": "submit",
"className": "w-full py-3 mt-12 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2",
@@ -112,6 +131,223 @@
"text": "$t:auth.login_processing"
}
]
},
{
"comment": "2단계 인증 — 비밀번호 확인만 통과한 상태다. 인증번호를 확인해야 로그인이 완료된다.",
"type": "basic",
"name": "Div",
"if": "{{_global.twoFactor?.required}}",
"props": { "className": "space-y-6" },
"children": [
{
"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": [
{
"type": "basic",
"name": "P",
"if": "{{!_global.twoFactor?.resent}}",
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
"text": "$t:auth.two_factor.sent"
},
{
"type": "basic",
"name": "P",
"if": "{{_global.twoFactor?.resent}}",
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
"text": "$t:auth.two_factor.resent"
}
]
},
{
"comment": "인증번호 오류",
"type": "basic",
"name": "Div",
"if": "{{_global.twoFactor?.error}}",
"props": {
"className": "p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800",
"role": "alert"
},
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-red-600 dark:text-red-400" },
"text": "{{_global.twoFactor?.error ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3" },
"text": "$t:auth.two_factor.code_label"
},
{
"comment": "자동바인딩을 쓰지 않는다 — 재발송 시 입력값을 비워야 하므로 상태가 값을 소유한다",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "two_factor_code",
"value": "{{_global.twoFactor?.code ?? ''}}",
"inputMode": "numeric",
"autoComplete": "one-time-code",
"maxLength": 10,
"placeholder": "$t:auth.two_factor.code_placeholder",
"className": "w-full px-4 py-3 text-center text-xl tracking-widest font-mono border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200",
"disabled": "{{_global.isLoggingIn}}"
},
"actions": [
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"twoFactor.code": "{{$event.target.value}}",
"twoFactor.error": null
}
}
]
},
{
"comment": "유효시각 정적 표기 — 남은 시간 카운트다운은 SPA 이탈 후에도 타이머가 남아 쓰지 않는다",
"type": "basic",
"name": "P",
"if": "{{_global.twoFactor?.expires_at}}",
"props": { "className": "mt-2 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:auth.two_factor.valid_until|until={{_global.twoFactor?.expires_at | datetime}}"
}
]
},
{
"comment": "인증번호 확인 — submit 이라 Enter 키로도 제출된다",
"type": "basic",
"name": "Button",
"props": {
"type": "submit",
"className": "w-full py-3 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2",
"disabled": "{{!_global.twoFactor?.code || _global.twoFactor.code.length < 4 || _global.isLoggingIn}}"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_global.isLoggingIn}}",
"props": { "className": "w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" }
},
{
"type": "basic",
"name": "Span",
"if": "{{!_global.isLoggingIn}}",
"text": "$t:auth.two_factor.verify"
},
{
"type": "basic",
"name": "Span",
"if": "{{_global.isLoggingIn}}",
"text": "$t:auth.two_factor.verifying"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex flex-col sm:flex-row gap-2" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
"disabled": "{{_global.twoFactor?.resending || _global.isLoggingIn}}"
},
"text": "$t:auth.two_factor.resend",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": { "target": "global", "twoFactor.resending": true, "twoFactor.error": null }
},
{
"handler": "loginTwoFactorResend",
"target": "user",
"params": {
"body": { "challenge_id": "{{_global.twoFactor?.challenge_id}}" }
},
"onSuccess": [
{
"_comment": "같은 시퀀스 안에서는 _global 이 아직 갱신 전이므로 응답 값만 읽는다.",
"handler": "setState",
"params": {
"target": "global",
"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": "global",
"twoFactor.resending": false,
"twoFactor.error": "{{error.message}}"
}
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
"disabled": "{{_global.isLoggingIn}}"
},
"text": "$t:auth.two_factor.restart",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"twoFactor": null,
"loginError": null,
"loginErrors": null
}
}
]
}
]
}
]
}
],
"actions": [
@@ -136,8 +372,10 @@
}
},
{
"_comment": "1·2단계는 상호배타다. if 는 시퀀스 시작 시점 스냅샷으로 평가되므로 한 제출에 정확히 하나만 실행된다 — 이 if 가 빠지면 인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.",
"handler": "login",
"target": "user",
"if": "{{!_global.twoFactor?.required}}",
"params": {
"body": {
"email": "{{form.email}}",
@@ -145,17 +383,67 @@
}
},
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}" } },
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false } },
{
"_comment": "2단계 인증이 켜진 사이트는 아직 로그인이 끝나지 않았다 — 토큰도 사용자도 없다.",
"handler": "setState",
"if": "{{response.two_factor_required}}",
"params": {
"target": "global",
"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
},
"isLoggingIn": false
}
},
{ "handler": "setState", "if": "{{!response.two_factor_required}}", "params": { "target": "global", "currentUser": "{{response.user}}" } },
{ "handler": "setState", "if": "{{!response.two_factor_required}}", "params": { "target": "global", "isLoggingIn": false } },
{
"_comment": "로그인 직전에 비회원으로 발급받은 주문 조회 토큰은 회원 컨텍스트에 무효 — 잔존 시 동일 브라우저로 회원/비회원 페이지 동시 열람 가능. sessionStorage 3개 키 (token/orderNumber/expiresAt) 와 _global.guestOrderToken 을 한 번에 정리하는 모듈 커스텀 핸들러 사용.",
"handler": "clearGuestOrderToken"
"handler": "clearGuestOrderToken",
"if": "{{!response.two_factor_required}}"
},
{ "handler": "toast", "if": "{{!response.two_factor_required}}", "params": { "type": "success", "message": "$t:auth.login_success" } },
{ "handler": "navigate", "if": "{{!response.two_factor_required}}", "params": { "path": "{{query.redirect ?? '/'}}" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false, "loginError": "{{error.message}}", "loginErrors": "{{error.errors}}" } }
]
},
{
"handler": "loginTwoFactor",
"target": "user",
"if": "{{_global.twoFactor?.required}}",
"params": {
"body": {
"challenge_id": "{{_global.twoFactor?.challenge_id}}",
"code": "{{_global.twoFactor?.code}}"
}
},
"onSuccess": [
{ "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}", "isLoggingIn": false, "twoFactor": null } },
{ "handler": "clearGuestOrderToken" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:auth.login_success" } },
{ "handler": "navigate", "params": { "path": "{{query.redirect ?? '/'}}" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "global", "isLoggingIn": false, "loginError": "{{error.message}}", "loginErrors": "{{error.errors}}" } }
{
"_comment": "423 잠금은 1단계와 같은 문구 규칙을 따르도록 loginErrors 도 함께 싣는다.",
"handler": "setState",
"params": {
"target": "global",
"isLoggingIn": false,
"twoFactor.error": "{{error.message}}",
"loginErrors": "{{error.errors}}"
}
}
]
}
]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
"version": "1.1.3",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
"version": "1.1.3",
"version": "1.1.4",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.1.3",
"version": "1.1.4",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -166,10 +166,12 @@ const loginFormFixture = {
],
},
// 이메일 입력 필드
// 2단계 인증이 요구되면 자격 증명 입력은 숨는다 — 실제 `_login_form.json` 과 같은 조건.
{
id: 'email-field',
type: 'basic',
name: 'Div',
if: '{{!_global.twoFactor?.required}}',
props: { 'data-testid': 'email-field' },
children: [
{
@@ -197,6 +199,7 @@ const loginFormFixture = {
id: 'password-field',
type: 'basic',
name: 'Div',
if: '{{!_global.twoFactor?.required}}',
props: { 'data-testid': 'password-field' },
children: [
{
@@ -224,6 +227,7 @@ const loginFormFixture = {
id: 'submit-button',
type: 'basic',
name: 'Button',
if: '{{!_global.twoFactor?.required}}',
props: {
type: 'submit',
className: 'w-full py-3 mt-12 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2',
@@ -457,4 +461,48 @@ describe('로그인 폼 레이아웃 렌더링 (Issue #72)', () => {
testUtils.cleanup();
});
});
// 2단계 인증이 켜진 사이트에서는 로그인 응답이 인증번호 요구로 갈린다. 그때 자격 증명 입력이
// 남아 있으면 두 단계가 겹쳐 보이고, 사용자가 이메일·비밀번호를 다시 제출해 새 challenge 를
// 발급받는다. 이 fixture 의 `if` 는 실제 `_login_form.json` 과 같은 조건이므로, 한쪽이 바뀌면
// 이 케이스가 red 가 된다.
describe('2단계 인증 단계 전환', () => {
it('twoFactor 가 없으면 자격 증명 입력이 보인다', async () => {
// Given
const testUtils = createLayoutTest(loginFormFixture, {
componentRegistry: registry,
initialState: { _global: {} },
});
// When
await testUtils.render();
// Then
expect(screen.getByTestId('email-field')).toBeInTheDocument();
expect(screen.getByTestId('password-field')).toBeInTheDocument();
expect(screen.getByTestId('login-submit-btn')).toBeInTheDocument();
testUtils.cleanup();
});
it('인증번호가 요구되면 자격 증명 입력과 제출 버튼이 사라진다', async () => {
// Given
const testUtils = createLayoutTest(loginFormFixture, {
componentRegistry: registry,
initialState: {
_global: { twoFactor: { required: true, challenge_id: 'c-1', code: '' } },
},
});
// When
await testUtils.render();
// Then
expect(screen.queryByTestId('email-field')).not.toBeInTheDocument();
expect(screen.queryByTestId('password-field')).not.toBeInTheDocument();
expect(screen.queryByTestId('login-submit-btn')).not.toBeInTheDocument();
testUtils.cleanup();
});
});
});
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.1.3",
"version": "1.1.4",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",
@@ -22,7 +22,7 @@
"url": "https://sirsoft.com"
},
"release_date": "2026-01-07",
"g7_version": ">=7.0.10",
"g7_version": ">=7.0.11",
"dependencies": {
"modules": {
"sirsoft-board": ">=1.0.0",
@@ -0,0 +1,45 @@
# audit:allow test-scenario-coverage reason: response 축(ok/challenge/401/403/423/429/503/network)은 서버 응답
# 종류를 열거한 것이라 레이아웃 구조 테스트가 케이스별로 나눠 단언할 실체가 아니다 — 구조 테스트는 화면이
# 그 응답들을 받을 수 있는 형태인지(상보 조건·controlled 입력·상호배타 액션)를 본다. 실제 회귀 가드는
# effects 이며 전부 @effects 로 귀속되어 있다(미검증 0건). 응답별 화면 실측은 Playwright spec 이 담당한다.
feature: 로그인 화면 2단계 인증 단계 (sirsoft-basic)
description: |
2단계 인증이 켜진 사이트에서 로그인 화면은 비밀번호 단계와 인증번호 단계를 같은 카드 안에서
전환한다. 서버가 두 가지 형태의 200 을 돌려주므로, 화면이 한 형태만 가정하면 영문 오류가
노출되고 로그인이 불가능해진다(공개 #133).
핵심 동작:
- 1단계 블록과 2단계 블록의 조건이 상보적이다 (동시 노출 금지).
- 제출 시퀀스의 login 과 loginTwoFactor 가 상호배타 조건을 갖는다. 조건이 빠지면
인증번호 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨진다.
- 인증번호 입력은 상태가 값을 소유한다 (재발송 시 입력을 비워야 하므로).
- 인증 단계 상태는 전역이라 화면 진입 시 초기화한다.
axes:
step: [credentials, code]
response: [ok, challenge, 401, 423, 429, 503, network]
action: [submit, resend, restart, enter_key]
exclusions:
- { step: credentials, action: resend, reason: "재발송 버튼은 인증번호 단계에만 있다" }
- { step: credentials, action: restart, reason: "동일" }
- { step: code, response: challenge, reason: "challenge 는 비밀번호 단계의 응답이다" }
effects:
- no_raw_typeerror_text
- code_step_rendered_on_challenge
- credential_step_hidden_on_challenge
- login_and_verify_are_mutually_exclusive
- code_input_is_controlled_without_events_wrapper
- resend_clears_code_and_replaces_challenge
- restart_resets_to_credential_step
- locked_until_rendered
- init_actions_reset_two_factor_state
- network_message_translated
test_files:
- templates/_bundled/sirsoft-basic/__tests__/layouts/login-two-factor-step.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/login-two-factor-render.test.tsx
- tests/Playwright/specs/auth/two-factor-login.spec.ts