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:
@@ -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 >=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 >=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 -->
|
||||
|
||||
|
||||
+337
@@ -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();
|
||||
});
|
||||
});
|
||||
+190
@@ -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
@@ -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": {}
|
||||
|
||||
+42
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 >=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 >=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
@@ -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
|
||||
Reference in New Issue
Block a user