fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정
## 소스맵 노출 차단 (공개 이슈 )
배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.
- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
$productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
(--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).
실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).
## 관리자 환경설정 탭 지연 로딩
코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.
## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)
- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
(리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
(pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
컨벤션대로 skip 가드.
## 테스트
- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트
버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
This commit is contained in:
@@ -136,3 +136,10 @@ playwright-report/
|
||||
test-results/
|
||||
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
|
||||
.playwright-mcp/
|
||||
|
||||
# ===== 소스맵 =====
|
||||
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
|
||||
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
|
||||
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
|
||||
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
|
||||
*.map
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
|
||||
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
|
||||
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 나이스페이먼츠 결제 플러그인을 켜 두면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 결제 페이지에서만 요청합니다.
|
||||
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
|
||||
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
|
||||
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
|
||||
|
||||
@@ -94,9 +94,12 @@ class BuildCoreCommand extends Command
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
|
||||
$buildEnv = $this->buildEnv($productionMode);
|
||||
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
@@ -104,7 +107,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
@@ -114,7 +117,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
@@ -197,7 +200,13 @@ class BuildCoreCommand extends Command
|
||||
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$projectPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||
@@ -311,15 +320,30 @@ class BuildCoreCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -328,6 +352,11 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildModuleCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildModuleCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildModuleCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildPluginCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildPluginCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildPluginCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -181,8 +181,13 @@ class BuildTemplateCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -285,15 +290,30 @@ class BuildTemplateCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -302,6 +322,11 @@ class BuildTemplateCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -20,9 +20,6 @@ class AllowedModuleFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@ class AllowedPluginFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class ExtensionBundleService
|
||||
public function buildCssBundle(string $type): string
|
||||
{
|
||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||
$isProduction = app()->environment('production');
|
||||
$segments = [];
|
||||
|
||||
foreach ($ordered as $identifier => $paths) {
|
||||
@@ -197,7 +198,7 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $content;
|
||||
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
||||
'type' => $type,
|
||||
@@ -417,6 +418,29 @@ class ExtensionBundleService
|
||||
}, $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 소스맵 주석을 환경에 맞게 처리합니다.
|
||||
*
|
||||
* CSS 는 JS 와 주석 문법이 달라 소스맵 참조를 블록 주석으로 표기하므로
|
||||
* processJsSourceMap() 의 `//#` 패턴으로는 검출되지 않는다.
|
||||
*
|
||||
* prod: 주석 strip(맵 참조 제거). dev: 원본 유지.
|
||||
* 병합 번들에서는 개별 맵 URL 이 어차피 어긋나므로 dev rewrite 는 하지 않는다.
|
||||
*
|
||||
* @param string $content 원본 CSS 내용
|
||||
* @param bool $isProduction 프로덕션 여부
|
||||
* @return string 처리된 내용
|
||||
*/
|
||||
private function processCssSourceMap(string $content, bool $isProduction): string
|
||||
{
|
||||
if (! $isProduction) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// 구분자로 `~` 사용 — 패턴에 `/`, `#` 가 포함됨
|
||||
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -1043,7 +1043,6 @@ class ModuleService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -708,7 +708,6 @@ class PluginService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 환경설정 > 보안 화면의 "내부 네트워크 주소 호출 허용" 항목명·설명 일본어 번역 추가 — 새로 추가된 보안 설정이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
- 역할 관리 화면 새로고침 버튼의 일본어 번역 추가 — 일본어 로케일에서도 버튼 설명이 올바르게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
|
||||
@@ -1089,7 +1089,8 @@
|
||||
"errors": {
|
||||
"system_role_delete": "システムロールは削除できません。",
|
||||
"delete_failed": "ロール削除に失敗しました。"
|
||||
}
|
||||
},
|
||||
"refresh": "更新"
|
||||
},
|
||||
"schedules": {
|
||||
"page_title": "スケジュール管理",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- 게시판 설정 화면의 상단 고정 영역이 좌우 여백과 어긋나 내용이 가로로 밀려 보일 수 있던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/boards",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'basic_info'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file admin-board-settings-tab-scoped-datasources.test.tsx
|
||||
* @description 게시판 설정 데이터소스 탭 스코프 게이팅 회귀 가드
|
||||
*
|
||||
* 이커머스 환경설정과 동일 결함: 진입 시 전 탭의 데이터소스를 한꺼번에 fetch 했다.
|
||||
* 탭 본문은 `if` 로 게이트되어 비활성 탭은 렌더되지 않으므로 그 데이터는 아무도 읽지 않는다.
|
||||
* 엔진(DataSourceManager.filterByCondition + TemplateApp.updateQueryParams)이
|
||||
* 데이터소스 `if` 로 탭별 로딩을 지원하므로, 탭 전용 소스는 `if` 게이트를 가져야 한다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const layout = require('../../../layouts/admin/admin_board_settings.json');
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
const SHARED_SOURCE_IDS = ['settings'];
|
||||
|
||||
const TAB_SCOPED: Record<string, string[]> = {
|
||||
boards_list: ['basic_defaults'],
|
||||
roles: ['basic_defaults', 'report_policy'],
|
||||
board_types: ['basic_defaults'],
|
||||
boardNotificationDefinitions: ['notification_definitions', 'report_policy'],
|
||||
availableChannels: ['notification_definitions'],
|
||||
boardIdentityPolicies: ['identity_policies'],
|
||||
boardIdentityPurposes: ['identity_policies'],
|
||||
identityProviders: ['identity_policies'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
describe('admin_board_settings — 데이터소스 탭 스코프 게이팅', () => {
|
||||
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
|
||||
for (const id of SHARED_SOURCE_IDS) {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.if).toBeUndefined();
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s!.if).toBe('string');
|
||||
});
|
||||
|
||||
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
|
||||
const cond: string = byId(id)!.if;
|
||||
for (const tab of tabs as string[]) {
|
||||
expect(cond).toContain(`'${tab}'`);
|
||||
}
|
||||
expect(cond).toContain('query.tab');
|
||||
expect(cond).toContain('activeBoardSettingsTab');
|
||||
});
|
||||
|
||||
it('탭 전용 소스를 auto_fetch:false 로 죽여두지 않는다', () => {
|
||||
for (const id of Object.keys(TAB_SCOPED)) {
|
||||
expect(byId(id)!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('탭 전환은 navigate + replace:true 로 수행한다 (if 재평가 필요)', () => {
|
||||
const seqs: AnyJson[] = [];
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { n.forEach(walk); return; }
|
||||
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
|
||||
Object.values(n).forEach(walk);
|
||||
};
|
||||
walk(layout);
|
||||
|
||||
const tabSwitches = seqs.filter(s =>
|
||||
(s.params.actions as AnyJson[]).some(
|
||||
a => a?.handler === 'setState' && 'activeBoardSettingsTab' in (a.params ?? {})
|
||||
)
|
||||
);
|
||||
expect(tabSwitches.length).toBeGreaterThan(0);
|
||||
|
||||
for (const seq of tabSwitches) {
|
||||
const acts = seq.params.actions as AnyJson[];
|
||||
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
|
||||
const nav = acts.find(a => a.handler === 'navigate');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav!.params.replace).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -304,8 +304,11 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
|
||||
expect(innerActions[1].handler).toBe('setState');
|
||||
expect(innerActions[1].params.target).toBe('global');
|
||||
|
||||
// replaceUrl - URL 업데이트
|
||||
expect(innerActions[2].handler).toBe('replaceUrl');
|
||||
// navigate(replace:true) - URL 업데이트 + 데이터소스 `if` 재평가
|
||||
// replaceUrl 은 history 만 교체하므로 탭 전용 데이터소스가 로드되지 않는다.
|
||||
// 가드: admin-board-settings-tab-scoped-datasources.test.tsx
|
||||
expect(innerActions[2].handler).toBe('navigate');
|
||||
expect(innerActions[2].params.replace).toBe(true);
|
||||
expect(innerActions[2].params.query.tab).toBeDefined();
|
||||
|
||||
// scrollIntoView - 탭 변경 후 본문 상단으로 스크롤 (#369)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/boards?per_page=999&fields=id,title",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -94,6 +95,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/roles/active",
|
||||
"method": "GET",
|
||||
"if": "{{['basic_defaults', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -106,6 +108,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/board-types",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -118,6 +121,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-board",
|
||||
"method": "GET",
|
||||
"if": "{{['notification_definitions', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -145,6 +149,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-channels",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'notification_definitions'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -159,6 +164,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/policies",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -182,6 +188,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/identity/purposes",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -194,6 +201,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/providers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -243,7 +251,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "sticky-tab-nav-responsive"
|
||||
"className": "sticky-section-header"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -339,11 +347,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "replaceUrl",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/settings",
|
||||
"query": {
|
||||
"tab": "{{$args[0]}}"
|
||||
}
|
||||
},
|
||||
"replace": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ecommerce",
|
||||
"version": "1.0.0-beta.5",
|
||||
"version": "1.0.3",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file admin-ecommerce-settings-tab-scoped-datasources.test.tsx
|
||||
* @description 환경설정 데이터소스 탭 스코프 게이팅 회귀 가드
|
||||
*
|
||||
* 배경: 환경설정 진입 시 모든 탭의 데이터소스를 한꺼번에 fetch 했다(18건). 탭 본문은
|
||||
* `if` 로 게이트되어 비활성 탭은 렌더조차 되지 않으므로, 그 데이터는 아무도 읽지 않는
|
||||
* 순수 낭비다. 게다가 탭 전환(navigate replace:true → updateQueryParams)마다 전수 재fetch 되어
|
||||
* 요청이 2배(36건)로 불어나고 탭 클릭이 1.7초 blocking 됐다.
|
||||
*
|
||||
* 엔진의 기본 메커니즘: 데이터소스의 `if` 를 DataSourceManager.filterByCondition 이 평가해
|
||||
* fetch 대상에서 제외하고, TemplateApp.updateQueryParams 가 탭 전환 시 `if` 를 재평가해
|
||||
* 새 탭의 소스만 fetch 한다. 따라서 탭 전용 소스는 반드시 `if` 게이트를 가져야 하며,
|
||||
* 탭 전환은 navigate + replace:true 로 updateQueryParams 를 태워야 한다(replaceUrl 은
|
||||
* history 만 교체하므로 `if` 재평가가 일어나지 않음).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const layout = require('../../../layouts/admin/admin_ecommerce_settings.json');
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
/** 모든 탭이 공유하는 소스 (폼 본체) — 항상 fetch */
|
||||
const SHARED_SOURCE_IDS = ['settings'];
|
||||
|
||||
/** 탭 전용 소스 → 소유 탭 */
|
||||
const TAB_SCOPED: Record<string, string[]> = {
|
||||
seoStats: ['seo'],
|
||||
carriers: ['shipping'],
|
||||
ecommerceNotificationDefinitions: ['notification_definitions', 'mileage'],
|
||||
availableChannels: ['notification_definitions'],
|
||||
ecommerceIdentityPolicies: ['identity_policies'],
|
||||
ecommerceIdentityPurposes: ['identity_policies'],
|
||||
identityProviders: ['identity_policies'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
describe('admin_ecommerce_settings — 데이터소스 탭 스코프 게이팅', () => {
|
||||
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
|
||||
for (const id of SHARED_SOURCE_IDS) {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.if).toBeUndefined();
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s!.if).toBe('string');
|
||||
expect(s!.if.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
|
||||
const cond: string = byId(id)!.if;
|
||||
for (const tab of tabs as string[]) {
|
||||
expect(cond).toContain(`'${tab}'`);
|
||||
}
|
||||
// 활성 탭 판정에 query.tab 과 _global 을 모두 사용해야 딥링크/클릭 양쪽에서 동작
|
||||
expect(cond).toContain('query.tab');
|
||||
expect(cond).toContain('activeEcommerceSettingsTab');
|
||||
});
|
||||
|
||||
it('탭 전용 소스는 auto_fetch:false 로 죽여두지 않는다 (if 선택 시 fetch 되어야 함)', () => {
|
||||
for (const id of Object.keys(TAB_SCOPED)) {
|
||||
expect(byId(id)!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 전환은 updateQueryParams 를 태워 `if` 를 재평가해야 한다.
|
||||
// replaceUrl 은 history.replaceState 만 하므로 새 탭의 소스가 영원히 로드되지 않는다.
|
||||
it('탭 전환은 navigate + replace:true 로 수행한다 (replaceUrl 금지)', () => {
|
||||
const seqs: AnyJson[] = [];
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { n.forEach(walk); return; }
|
||||
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
|
||||
Object.values(n).forEach(walk);
|
||||
};
|
||||
walk(layout);
|
||||
|
||||
const tabSwitches = seqs.filter(s =>
|
||||
(s.params.actions as AnyJson[]).some(
|
||||
a => a?.handler === 'setState' && 'activeEcommerceSettingsTab' in (a.params ?? {})
|
||||
)
|
||||
);
|
||||
expect(tabSwitches.length).toBeGreaterThan(0);
|
||||
|
||||
for (const seq of tabSwitches) {
|
||||
const acts = seq.params.actions as AnyJson[];
|
||||
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
|
||||
const nav = acts.find(a => a.handler === 'navigate');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav!.params.replace).toBe(true);
|
||||
expect(nav!.params.query?.tab).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
+4
@@ -387,6 +387,9 @@ describe('유효기간 / 소멸 알림 카드', () => {
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
|
||||
// URL 갱신은 navigate + replace:true 로 수행한다 — updateQueryParams 를 거쳐야
|
||||
// 데이터소스의 `if` 가 재평가되어 알림 탭 전용 소스가 로드된다.
|
||||
// 가드: admin-ecommerce-settings-tab-scoped-datasources.test.tsx
|
||||
it('알림 설정으로 이동 버튼이 환경설정 알림 탭으로 전환한다', () => {
|
||||
const btn = findById(notificationCard, 'go_to_notification_settings');
|
||||
const seq = btn.actions[0];
|
||||
@@ -394,6 +397,7 @@ describe('유효기간 / 소멸 알림 카드', () => {
|
||||
const navigate = seq.params.actions.find((a: any) => a.handler === 'navigate');
|
||||
expect(navigate.params.path).toBe('/admin/ecommerce/settings');
|
||||
expect(navigate.params.query.tab).toBe('notification_definitions');
|
||||
expect(navigate.params.replace).toBe(true);
|
||||
const setGlobal = seq.params.actions.find((a: any) => a.handler === 'setState');
|
||||
expect(setGlobal.params.activeEcommerceSettingsTab).toBe('notification_definitions');
|
||||
});
|
||||
|
||||
+7
@@ -56,6 +56,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/admin/settings/seo-cache-info",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'seo'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true
|
||||
},
|
||||
@@ -65,6 +66,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/admin/shipping-carriers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'shipping'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"errorHandling": {
|
||||
@@ -82,6 +84,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-ecommerce",
|
||||
"method": "GET",
|
||||
"if": "{{['notification_definitions', 'mileage'].includes(query.tab || _global.activeEcommerceSettingsTab || 'basic_info')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -117,6 +120,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-channels",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'notification_definitions'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -131,6 +135,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/policies",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -155,6 +160,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/identity/purposes",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -167,6 +173,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/providers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
|
||||
@@ -20,7 +20,9 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
|
||||
// 소스맵 생성
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ckeditor5",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
|
||||
var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
|
||||
/* 다국어 탭 컨테이너 */
|
||||
.ckeditor5-locale-tabs {
|
||||
display: flex;
|
||||
@@ -369,5 +369,4 @@ var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ck
|
||||
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
|
||||
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
|
||||
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
|
||||
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G})({});
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,9 @@ export default defineConfig({
|
||||
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-daum_postcode",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,3 @@ var SirsoftDaumPostcode=function(m){"use strict";const y=window.G7Core?.createLo
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
`;const r=document.createElement("div");r.style.cssText="position: relative;",r.appendChild(n),r.appendChild(t),o.appendChild(r),document.body.appendChild(o);const s=()=>{o.parentNode&&o.parentNode.removeChild(o)};n.addEventListener("click",s),o.addEventListener("click",l=>{l.target===o&&s()});const i=l=>{l.key==="Escape"&&(s(),document.removeEventListener("keydown",i))};return document.addEventListener("keydown",i),a.log("[openPostcode] Layer created with size:",{width:e,height:c}),{overlay:o,layer:t,closeLayer:s}}function L(e,c,o){const t=window.G7Core;if(!t?.dispatch){a.error("[openPostcode] G7Core.dispatch not available");return}const n=o?.state||t?.state?.getLocal?.()||{},r={state:n,setState:o?.setState,data:{...o?.data||{},_local:n,$event:c},isolatedContext:o?.isolatedContext};a.log("[openPostcode] Dispatching with componentContext:",{hasLocalState:!!n,sourceType:o?.state?"handlerContext":"getLocal",localCheckout:n?.checkout,event:c});try{const s=Array.isArray(e)?e:[e];for(const i of s)i&&typeof i=="object"&&(a.log("[openPostcode] Dispatching action:",i),t.dispatch(i,{componentContext:r}))}catch(s){a.error("[openPostcode] callbackAction failed:",s)}}function S(e,c){const o=window.G7Core;if(!o?.state?.setLocal){a.error("[openPostcode] G7Core.state.setLocal not available");return}const t={};e.zipcode&&(t[e.zipcode]=c.zipcode),e.address&&(t[e.address]=c.address),e.region&&(t[e.region]=c.region),e.city&&(t[e.city]=c.city),a.log("[openPostcode] Setting local state:",t),o.state.setLocal(t)}const p={setFieldReadOnly:C,openPostcode:x},d="sirsoft-daum_postcode",u=window.G7Core?.createLogger?.(`Plugin:${d}`)??{log:(...e)=>console.log(`[Plugin:${d}]`,...e),warn:(...e)=>console.warn(`[Plugin:${d}]`,...e),error:(...e)=>console.error(`[Plugin:${d}]`,...e)};function P(e=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(p).forEach(([o,t])=>{const n=`${d}.${o}`;c.registerHandler(n,t,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(o=>`${d}.${o}`));else if(e){let o=0;const t=50,n=()=>{const r=window.G7Core?.getActionDispatcher?.();r?(Object.entries(p).forEach(([s,i])=>{const l=`${d}.${s}`;r.registerHandler(l,i,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(s=>`${d}.${s}`))):(o++,o<=t?(u.warn(`ActionDispatcher not found, retrying... (${o}/${t})`),setTimeout(n,100)):u.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};n()}else u.warn("ActionDispatcher not found, handlers not registered")}function w(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>P(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();P(!e)}}return w(),typeof window<"u"&&(window.__SirsoftDaumPostcode={identifier:d,handlers:Object.keys(p),initPlugin:w}),m.initPlugin=w,Object.defineProperty(m,Symbol.toStringTag,{value:"Module"}),m}({});
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,7 +20,9 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
|
||||
// 소스맵 생성
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-gdpr",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,7 +19,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-pay_kginicis",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+55
-18
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_NOTICE_ROW_ID = 'kginicis_test_mode_order_settings_notice';
|
||||
|
||||
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
|
||||
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
|
||||
|
||||
private const TEST_MODE_CONDITION = 'kginicis_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = "['point','deposit','free','dbank']";
|
||||
@@ -26,7 +29,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
private const KGINICIS_NO_PG_METHODS = "['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_naverpay','kginicis_lpay','kginicis_kakaopay','kginicis_japan_paypay','kginicis_japan_cvs']";
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
@@ -47,9 +52,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param int $templateId
|
||||
* @return array<string, mixed>
|
||||
* 이커머스 결제수단 설정 레이아웃에 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
{
|
||||
@@ -63,14 +70,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 레이아웃 노드를 재귀 순회하며 코어의 PG 불필요 결제수단 목록을 이니시스 목록으로 치환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,8 +97,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeWarning(array $layout): array
|
||||
{
|
||||
@@ -98,8 +110,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeDataSource(array $layout): array
|
||||
{
|
||||
@@ -119,6 +133,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
'type' => 'api',
|
||||
'endpoint' => '/api/plugins/sirsoft-pay_kginicis/admin/settings/test-mode-status',
|
||||
'method' => 'GET',
|
||||
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
|
||||
'auto_fetch' => true,
|
||||
'auth_required' => true,
|
||||
];
|
||||
@@ -129,8 +144,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 안내 노드가 삽입된 노드
|
||||
*/
|
||||
private function insertTestModeNotice(array $node): array
|
||||
{
|
||||
@@ -162,7 +179,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 찾을 노드 id
|
||||
* @return int|null 찾은 인덱스 (없으면 null)
|
||||
*/
|
||||
private function findChildIndexById(array $children, string $id): ?int
|
||||
{
|
||||
@@ -176,13 +197,23 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 확인할 노드 id
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function hasChildWithId(array $children, string $id): bool
|
||||
{
|
||||
return $this->findChildIndexById($children, $id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
|
||||
*
|
||||
* @param string $condition 기존 표시 조건식
|
||||
* @return string 테스트 모드 조건이 병합된 조건식
|
||||
*/
|
||||
private function mergeTestModeCondition(string $condition): string
|
||||
{
|
||||
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
|
||||
@@ -202,8 +233,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $notice
|
||||
* @return array<string, mixed>
|
||||
* 안내 컨테이너에 이니시스 테스트 모드 안내 행을 중복 없이 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $notice 안내 컨테이너 노드
|
||||
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
|
||||
*/
|
||||
private function appendTestModeNoticeRow(array $notice): array
|
||||
{
|
||||
@@ -219,7 +252,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 컨테이너 노드 정의
|
||||
*/
|
||||
private function testModeNoticeContainerNode(): array
|
||||
{
|
||||
@@ -238,7 +273,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* 이니시스 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 안내 행 노드 정의
|
||||
*/
|
||||
private function testModeNoticeRowNode(): array
|
||||
{
|
||||
|
||||
+24
-7
@@ -3,8 +3,10 @@
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
@@ -20,6 +22,21 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
|
||||
private const TEST_SIGN_KEY = 'SU5JTElURV9UUklQTEVERVNfS0VZU1RS';
|
||||
|
||||
/**
|
||||
* 애플리케이션 로그를 spy 로 감시하되 `activity` 채널은 실제 로거로 유지합니다.
|
||||
*
|
||||
* `Log::spy()` 단독 사용 시 `Log::channel('activity')` 가 null 을 반환하고,
|
||||
* 결제 완료 훅이 태우는 활동 로그가 null 에 `info()` 를 호출해 \Error 로 죽는다.
|
||||
* (`ResolvesActivityLogType::logActivity` 의 catch 는 \Exception 만 잡아 통과시킴)
|
||||
*/
|
||||
private function spyApplicationLogKeepingActivityChannel(): void
|
||||
{
|
||||
$activityChannel = Log::channel('activity');
|
||||
|
||||
Log::spy();
|
||||
Log::shouldReceive('channel')->with('activity')->andReturn($activityChannel);
|
||||
}
|
||||
|
||||
private function makeAuthorizeResponse(string $tid, string $moid, int $amount, string $resultCode = '0000', array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
@@ -97,11 +114,11 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
];
|
||||
|
||||
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
|
||||
$settingsMock = $this->createMock(PluginSettingsService::class);
|
||||
$settingsMock->method('get')
|
||||
->willReturn(array_merge($defaults, $overrides));
|
||||
|
||||
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
|
||||
$this->app->instance(PluginSettingsService::class, $settingsMock);
|
||||
}
|
||||
|
||||
private function makeCallbackParams(string $moid, int $totPrice, array $overrides = []): array
|
||||
@@ -316,7 +333,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$orderService->expects($this->once())
|
||||
->method('completePayment')
|
||||
->willReturnCallback(function (Order $callbackOrder, array $paymentData, ?int $paidAmount) use ($tid): void {
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
|
||||
DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
|
||||
$callbackOrder->forceFill([
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'total_paid_amount' => $paidAmount,
|
||||
@@ -477,7 +494,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$this->markVbankIssued($order, 'VBANK_TID_LOG', '1234567890');
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Log::spy();
|
||||
$this->spyApplicationLogKeepingActivityChannel();
|
||||
|
||||
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/vbank-notify', $this->makeVbankNotifyPayload([
|
||||
'no_tid' => 'VBANK_TID_LOG',
|
||||
@@ -569,7 +586,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$orderService->expects($this->once())
|
||||
->method('completePayment')
|
||||
->willReturnCallback(function (Order $callbackOrder, array $paymentData, ?int $paidAmount) use ($tid): void {
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
|
||||
DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
|
||||
$callbackOrder->forceFill([
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'total_paid_amount' => $paidAmount,
|
||||
@@ -660,7 +677,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$this->markVbankIssued($order, 'MOBILE_VBANK_TID_LOG', '1234567890');
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Log::spy();
|
||||
$this->spyApplicationLogKeepingActivityChannel();
|
||||
|
||||
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify', [
|
||||
'P_STATUS' => '02',
|
||||
|
||||
@@ -14,7 +14,9 @@ export default defineConfig({
|
||||
entryFileNames: 'js/plugin.iife.js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-pay_nhnkcp",
|
||||
"version": "1.0.0-beta.4",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+63
-18
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_NOTICE_ROW_ID = 'nhnkcp_test_mode_order_settings_notice';
|
||||
|
||||
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
|
||||
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
|
||||
|
||||
private const TEST_MODE_CONDITION = 'nhnkcp_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
|
||||
@@ -32,7 +35,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
@@ -46,14 +51,18 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param int $templateId
|
||||
* @return array<string, mixed>
|
||||
* 이커머스 결제수단 설정 레이아웃에 NHN KCP 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
{
|
||||
@@ -67,14 +76,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 NHN KCP 간편결제를 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -86,6 +98,12 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 NHN KCP 간편결제 수단을 덧붙입니다.
|
||||
*
|
||||
* @param string $expression 대상 표현식
|
||||
* @return string NHN KCP 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
|
||||
*/
|
||||
private function addNhnKcpMethodsToNoPgArray(string $expression): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
@@ -113,8 +131,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeWarning(array $layout): array
|
||||
{
|
||||
@@ -124,8 +144,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeDataSource(array $layout): array
|
||||
{
|
||||
@@ -145,6 +167,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
'type' => 'api',
|
||||
'endpoint' => '/api/plugins/sirsoft-pay_nhnkcp/admin/settings/test-mode-status',
|
||||
'method' => 'GET',
|
||||
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
|
||||
'auto_fetch' => true,
|
||||
'auth_required' => true,
|
||||
];
|
||||
@@ -155,8 +178,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 안내 노드가 삽입된 노드
|
||||
*/
|
||||
private function insertTestModeNotice(array $node): array
|
||||
{
|
||||
@@ -188,7 +213,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 찾을 노드 id
|
||||
* @return int|null 찾은 인덱스 (없으면 null)
|
||||
*/
|
||||
private function findChildIndexById(array $children, string $id): ?int
|
||||
{
|
||||
@@ -202,13 +231,23 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 확인할 노드 id
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function hasChildWithId(array $children, string $id): bool
|
||||
{
|
||||
return $this->findChildIndexById($children, $id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
|
||||
*
|
||||
* @param string $condition 기존 표시 조건식
|
||||
* @return string 테스트 모드 조건이 병합된 조건식
|
||||
*/
|
||||
private function mergeTestModeCondition(string $condition): string
|
||||
{
|
||||
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
|
||||
@@ -228,8 +267,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $notice
|
||||
* @return array<string, mixed>
|
||||
* 안내 컨테이너에 NHN KCP 테스트 모드 안내 행을 중복 없이 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $notice 안내 컨테이너 노드
|
||||
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
|
||||
*/
|
||||
private function appendTestModeNoticeRow(array $notice): array
|
||||
{
|
||||
@@ -245,7 +286,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 컨테이너 노드 정의
|
||||
*/
|
||||
private function testModeNoticeContainerNode(): array
|
||||
{
|
||||
@@ -264,7 +307,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* NHN KCP 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 안내 행 노드 정의
|
||||
*/
|
||||
private function testModeNoticeRowNode(): array
|
||||
{
|
||||
|
||||
@@ -39,6 +39,13 @@ class NhnKcpApiServiceTest extends PluginTestCase
|
||||
*/
|
||||
private function makeServiceWithStubbedCli(string $response, array $settingsOverrides = []): array
|
||||
{
|
||||
// CLI 스텁은 `#!/bin/sh` 스크립트라 Windows exec() 가 실행하지 못한다
|
||||
// (.exe 로 이름만 바꿔도 "not compatible with the version of Windows" 로 거부됨).
|
||||
// Windows 분기(executeCliWindows)는 별도 검증 대상이므로 여기서는 건너뛴다.
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
$this->markTestSkipped('Linux CLI 스텁 기반 테스트 — Windows 분기 별도 검증.');
|
||||
}
|
||||
|
||||
$service = $this->makeService($settingsOverrides);
|
||||
$dir = sys_get_temp_dir().'/nhnkcp_cli_'.uniqid('', true);
|
||||
$this->assertTrue(mkdir($dir), '임시 CLI 디렉터리 생성 실패');
|
||||
@@ -435,7 +442,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
|
||||
$method = (new \ReflectionClass($service))->getMethod('ensureCliExecutable');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->expectException(\Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException::class);
|
||||
$this->expectException(NhnKcpApiException::class);
|
||||
$this->expectExceptionMessageMatches('/CLI 바이너리/');
|
||||
|
||||
$method->invoke($service, '/tmp/nonexistent_kcp_cli_'.uniqid());
|
||||
|
||||
@@ -14,7 +14,9 @@ export default defineConfig({
|
||||
entryFileNames: 'js/plugin.iife.js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 결제 페이지에서만 요청합니다.
|
||||
|
||||
### Security
|
||||
|
||||
- SignData 발급 요청에 주문번호+IP 단위 rate limit과 구매자 이메일/전화번호 대조를 추가해 공개 서명 엔드포인트의 주문번호/금액 프로빙 가능성을 줄였습니다.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-pay_nicepayments",
|
||||
"version": "1.0.0-beta.4",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+74
@@ -14,6 +14,34 @@ function resetNicepayWindowFlags(): void {
|
||||
delete w.__sirsoftPgOriginalFetch;
|
||||
delete w.__sirsoftNicepayPaymentInProgress;
|
||||
delete w.__sirsoftNicepayCheckoutCache;
|
||||
delete w.__sirsoftNicepayAxiosCheckoutInterceptorInstalled;
|
||||
}
|
||||
|
||||
/** 현재 경로를 지정한다 (jsdom 은 history API 로만 pathname 변경 가능) */
|
||||
function setPathname(pathname: string): void {
|
||||
window.history.replaceState({}, '', pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* G7Core.api 스파이를 설치하고 get 스파이를 반환한다.
|
||||
*
|
||||
* 캐시 프리페치는 tryInstallAxiosCheckoutInterceptor 안에 있고, 이 함수는
|
||||
* `G7Core.api.client`(Axios)가 준비될 때까지 재시도하다가 준비되면 프리페치한다.
|
||||
* 따라서 client(=interceptors 를 가진 객체)를 함께 제공해야 실제 경로를 탄다.
|
||||
*/
|
||||
function installCoreApiSpy(): ReturnType<typeof vi.fn> {
|
||||
const getSpy = vi.fn().mockResolvedValue({});
|
||||
const axiosClient = {
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).G7Core = {
|
||||
api: { get: getSpy, client: axiosClient },
|
||||
};
|
||||
|
||||
return getSpy;
|
||||
}
|
||||
|
||||
function buildOrderResponse(order: Record<string, unknown>): Response {
|
||||
@@ -45,9 +73,55 @@ describe('installOrderResponseInterceptor', () => {
|
||||
afterEach(() => {
|
||||
window.fetch = originalFetch;
|
||||
resetNicepayWindowFlags();
|
||||
delete (window as unknown as Record<string, unknown>).G7Core;
|
||||
setPathname('/');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('체크아웃 캐시 프리페치 라우트 가드', () => {
|
||||
// 플러그인 번들은 모든 페이지에 로드된다. 캐시 프리페치는 체크아웃 페이지의
|
||||
// navigate-to-self 재초기화를 위한 것이므로, 다른 라우트에서 발화하면
|
||||
// 비회원에게 무의미한 422(cart_key_required) 왕복만 발생시킨다.
|
||||
it.each([
|
||||
['홈', '/'],
|
||||
['게시판', '/board'],
|
||||
['로그인', '/login'],
|
||||
['상품 상세', '/shop/products/1'],
|
||||
['장바구니', '/shop/cart'],
|
||||
])('%s 에서는 체크아웃을 프리페치하지 않는다', async (_label, pathname) => {
|
||||
setPathname(pathname);
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
await vi.waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('체크아웃 페이지에서는 캐시 프리페치가 동작한다', async () => {
|
||||
setPathname('/shop/checkout');
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getSpy).toHaveBeenCalledWith('/modules/sirsoft-ecommerce/checkout');
|
||||
});
|
||||
});
|
||||
|
||||
it('이미 캐시가 있으면 체크아웃 페이지에서도 재요청하지 않는다', async () => {
|
||||
setPathname('/shop/checkout');
|
||||
(window as unknown as Record<string, unknown>).__sirsoftNicepayCheckoutCache =
|
||||
JSON.stringify({ data: {} });
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
await vi.waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('간편결제 fallback pg_payment_data 금액은 total_due_amount를 우선 사용', async () => {
|
||||
fetchSpy.mockResolvedValue(buildOrderResponse({
|
||||
order_number: 'ORD-NICE-001',
|
||||
|
||||
@@ -23,6 +23,9 @@ const CHECKOUT_ENDPOINT_PATH = '/api/modules/sirsoft-ecommerce/checkout';
|
||||
const PAYMENT_IN_PROGRESS_FLAG = '__sirsoftNicepayPaymentInProgress';
|
||||
const CHECKOUT_CACHE_KEY = '__sirsoftNicepayCheckoutCache';
|
||||
|
||||
/** 체크아웃 페이지 경로 (캐시 프리페치를 이 경로에서만 수행) */
|
||||
const CHECKOUT_ROUTE_RE = /^\/shop\/checkout\/?$/;
|
||||
|
||||
const logger = {
|
||||
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
@@ -45,6 +48,11 @@ function isCheckoutEndpoint(url: string, method: string): boolean {
|
||||
return path === CHECKOUT_ENDPOINT_PATH || path.endsWith(CHECKOUT_ENDPOINT_PATH);
|
||||
}
|
||||
|
||||
/** 현재 브라우저 경로가 체크아웃 페이지인지 판정한다. */
|
||||
function isCheckoutRoute(): boolean {
|
||||
return CHECKOUT_ROUTE_RE.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function extractPaymentMethodFromBody(body: unknown): string | undefined {
|
||||
if (!body) return undefined;
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
@@ -311,7 +319,12 @@ function tryInstallAxiosCheckoutInterceptor(w: Record<string, unknown>, retries:
|
||||
logger.info('axios checkout interceptor installed');
|
||||
|
||||
// 인터셉터 설치 직후 GET checkout 즉시 캐싱 — navigate-to-self 재초기화 대비
|
||||
if (!w[CHECKOUT_CACHE_KEY]) {
|
||||
//
|
||||
// 체크아웃 페이지에서만 수행한다. 플러그인 번들은 모든 페이지에 로드되므로 경로를
|
||||
// 가리지 않으면 홈/게시판/로그인 등 어디서나 이 요청이 나가고, 장바구니가 없는
|
||||
// 비회원은 매 페이지 422(cart_key_required)를 받는다. 캐시는 체크아웃 페이지의
|
||||
// navigate-to-self 재초기화 대비용이므로 다른 경로에서는 만들 이유가 없다.
|
||||
if (isCheckoutRoute() && !w[CHECKOUT_CACHE_KEY]) {
|
||||
void (async () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
+61
-18
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_NOTICE_ROW_ID = 'nicepay_test_mode_order_settings_notice';
|
||||
|
||||
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
|
||||
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
|
||||
|
||||
private const TEST_MODE_CONDITION = 'nicepay_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
|
||||
@@ -35,7 +38,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
@@ -54,9 +59,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @param int $templateId
|
||||
* @return array<string, mixed>
|
||||
* 이커머스 결제수단 설정 레이아웃에 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
{
|
||||
@@ -70,14 +77,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 나이스페이먼츠 간편결제를 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -89,6 +99,12 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 나이스페이먼츠 간편결제 수단을 덧붙입니다.
|
||||
*
|
||||
* @param string $expression 대상 표현식
|
||||
* @return string 나이스페이먼츠 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
|
||||
*/
|
||||
private function addNicepayMethodsToNoPgArray(string $expression): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
@@ -116,8 +132,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeWarning(array $layout): array
|
||||
{
|
||||
@@ -127,8 +145,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
|
||||
*/
|
||||
private function ensureTestModeDataSource(array $layout): array
|
||||
{
|
||||
@@ -148,6 +168,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
'type' => 'api',
|
||||
'endpoint' => '/api/plugins/sirsoft-pay_nicepayments/admin/settings/test-mode-status',
|
||||
'method' => 'GET',
|
||||
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
|
||||
'auto_fetch' => true,
|
||||
'auth_required' => true,
|
||||
];
|
||||
@@ -158,8 +179,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 안내 노드가 삽입된 노드
|
||||
*/
|
||||
private function insertTestModeNotice(array $node): array
|
||||
{
|
||||
@@ -191,7 +214,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 찾을 노드 id
|
||||
* @return int|null 찾은 인덱스 (없으면 null)
|
||||
*/
|
||||
private function findChildIndexById(array $children, string $id): ?int
|
||||
{
|
||||
@@ -205,13 +232,23 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $children
|
||||
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
|
||||
*
|
||||
* @param array<int, mixed> $children 자식 노드 목록
|
||||
* @param string $id 확인할 노드 id
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function hasChildWithId(array $children, string $id): bool
|
||||
{
|
||||
return $this->findChildIndexById($children, $id) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
|
||||
*
|
||||
* @param string $condition 기존 표시 조건식
|
||||
* @return string 테스트 모드 조건이 병합된 조건식
|
||||
*/
|
||||
private function mergeTestModeCondition(string $condition): string
|
||||
{
|
||||
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
|
||||
@@ -231,8 +268,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $notice
|
||||
* @return array<string, mixed>
|
||||
* 안내 컨테이너에 나이스페이먼츠 테스트 모드 안내 행을 중복 없이 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $notice 안내 컨테이너 노드
|
||||
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
|
||||
*/
|
||||
private function appendTestModeNoticeRow(array $notice): array
|
||||
{
|
||||
@@ -248,7 +287,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 컨테이너 노드 정의
|
||||
*/
|
||||
private function testModeNoticeContainerNode(): array
|
||||
{
|
||||
@@ -267,7 +308,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* 나이스페이먼츠 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 안내 행 노드 정의
|
||||
*/
|
||||
private function testModeNoticeRowNode(): array
|
||||
{
|
||||
|
||||
+20
-4
@@ -4,9 +4,10 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderAddressFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
@@ -22,6 +23,21 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
private const TEST_MID = 'nicepay00m';
|
||||
|
||||
/**
|
||||
* 애플리케이션 로그를 spy 로 감시하되 `activity` 채널은 실제 로거로 유지합니다.
|
||||
*
|
||||
* `Log::spy()` 단독 사용 시 `Log::channel('activity')` 가 null 을 반환하고,
|
||||
* 결제 완료 훅이 태우는 활동 로그가 null 에 `info()` 를 호출해 \Error 로 죽는다.
|
||||
* (`ResolvesActivityLogType::logActivity` 의 catch 는 \Exception 만 잡아 통과시킴)
|
||||
*/
|
||||
private function spyApplicationLogKeepingActivityChannel(): void
|
||||
{
|
||||
$activityChannel = Log::channel('activity');
|
||||
|
||||
Log::spy();
|
||||
Log::shouldReceive('channel')->with('activity')->andReturn($activityChannel);
|
||||
}
|
||||
|
||||
private const TEST_MERCHANT_KEY = 'EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==';
|
||||
|
||||
private function makeAuthorizeResponse(string $tid, string $moid, int $amount, string $resultCode = '3001'): array
|
||||
@@ -96,11 +112,11 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
];
|
||||
|
||||
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
|
||||
$settingsMock = $this->createMock(PluginSettingsService::class);
|
||||
$settingsMock->method('get')
|
||||
->willReturn(array_merge($defaults, $overrides));
|
||||
|
||||
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
|
||||
$this->app->instance(PluginSettingsService::class, $settingsMock);
|
||||
}
|
||||
|
||||
private function makeSignature(string $authToken, string $mid, int $amt, string $merchantKey): string
|
||||
@@ -1034,7 +1050,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
|
||||
public function test_vbank_notify_success_log_does_not_include_depositor_or_full_account(): void
|
||||
{
|
||||
Log::spy();
|
||||
$this->spyApplicationLogKeepingActivityChannel();
|
||||
|
||||
$tid = 'VBANK_TID_LOG_SAFE';
|
||||
$order = $this->createTestOrder(30000, PaymentMethodEnum::VBANK);
|
||||
|
||||
@@ -14,7 +14,9 @@ export default defineConfig({
|
||||
entryFileNames: 'js/plugin.iife.js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-verification_kginicis",
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.1",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
var SirsoftVerificationKginicis=function(s){"use strict";const o="sirsoft-verification_kginicis",w="inicis",A="width=400,height=640,scrollbars=yes,resizable=yes",D="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${o}]`,...e),warn:(...e)=>console.warn(`[${o}]`,...e),error:(...e)=>console.error(`[${o}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${o}/plugin/inicis/callback`}function C(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",D),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const u={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(u)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const L={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},P="$t:sirsoft-verification_kginicis.errors.verify_failed",S=new Set(["NOT_ADULT"]);function T(e){return L[e]??P}function U(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function O(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const u="sa_popup",d=window.open("",u,A);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),C(t,u);let c=!1,r=null,f=null;const p=()=>{f&&(window.removeEventListener("message",f),f=null),r!==null&&(window.clearInterval(r),r=null)};f=y=>{if(y.origin!==window.location.origin)return;const n=y.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const b=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(S.has(n.identity_error)&&U(),g(T(n.identity_error),"error")),O(b)},window.addEventListener("message",f),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function E(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${o}.startAuth`,h,{category:"plugin",source:o}),l.info("startAuth handler registered"),!0)}function m(){if(E())return;let e=0;const i=window.setInterval(()=>{e++,(E()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}return m(),window.__SirsoftVerificationKginicis={identifier:o,init:m,startAuthHandler:h},s.MODAL_ID=I,s.PLUGIN_IDENTIFIER=o,s.PROVIDER_ID=w,s.init=m,s.startAuthHandler=h,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({});
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,7 +14,9 @@ export default defineConfig({
|
||||
entryFileNames: 'js/plugin.iife.js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
export * from './src/index'
|
||||
export {}
|
||||
@@ -0,0 +1 @@
|
||||
var Gnuboard7HelloAdminTemplate=(function(r,a,c){"use strict";const s=c.forwardRef(({children:e,className:t="",...n},o)=>a.jsx("div",{ref:o,className:t,...n,children:e}));s.displayName="Div";const i=c.forwardRef(({children:e,className:t="",type:n="button",...o},_)=>a.jsx("button",{ref:_,type:n,className:t,...o,children:e}));i.displayName="Button";const l=({children:e,className:t="",...n})=>a.jsx("h1",{className:t,...n,children:e}),m=({children:e,className:t="",...n})=>a.jsx("h2",{className:t,...n,children:e}),d=({children:e,className:t="",...n})=>a.jsx("h3",{className:t,...n,children:e}),p=({children:e,className:t="",...n})=>a.jsx("a",{className:t,...n,children:e}),g=({children:e,className:t="",...n})=>a.jsx("span",{className:t,...n,children:e}),u=({className:e="",alt:t="",...n})=>a.jsx("img",{className:e,alt:t,...n}),y={identifier:"gnuboard7-hello_admin_template",vendor:"gnuboard7",name:{ko:"Hello Admin Template",en:"Hello Admin Template"},version:"0.1.0",license:"MIT",description:{ko:"그누보드7 학습용 최소 Admin 템플릿 스켈레톤",en:"Gnuboard7 minimal admin template skeleton for learning"},type:"admin",hidden:!0,locales:["ko","en"],author:{name:"gnuboard7",email:"contact@gnuboard.com",url:"https://gnuboard.com"},release_date:"2026-04-21",g7_version:">=7.0.0",dependencies:{modules:{},plugins:{}},assets:{js:["dist/components.js"]},components:{basic:["a","button","div","h1","h2","h3","img","span"],composite:[],layout:[]},error_config:{layouts:{401:"errors/401",403:"errors/403",404:"errors/404",500:"errors/500",503:"errors/503",maintenance:"errors/maintenance"}},externals:[{id:"fontawesome",type:"style",url:"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",preconnect:"https://cdnjs.cloudflare.com"}]},b=window.G7Core?.createLogger?.("Template:gnuboard7-hello_admin_template")??{log:(...e)=>console.log("[Template:gnuboard7-hello_admin_template]",...e),warn:(...e)=>console.warn("[Template:gnuboard7-hello_admin_template]",...e),error:(...e)=>console.error("[Template:gnuboard7-hello_admin_template]",...e)};function f(){if(typeof window>"u")return;let e=0;const t=50,n=()=>{const o=window.G7Core?.getComponentRegistry?.()??window.__G7_COMPONENTS__;if(o&&typeof o.register=="function"){o.register("Div",s,{type:"basic",name:"Div"}),o.register("Button",i,{type:"basic",name:"Button"}),o.register("H1",l,{type:"basic",name:"H1"}),o.register("H2",m,{type:"basic",name:"H2"}),o.register("H3",d,{type:"basic",name:"H3"}),o.register("A",p,{type:"basic",name:"A"}),o.register("Span",g,{type:"basic",name:"Span"}),o.register("Img",u,{type:"basic",name:"Img"}),b.log("8 basic component(s) registered");return}e++,e<=t?setTimeout(n,100):b.error("Failed to register components: ComponentRegistry not available after maximum retries")};document.readyState==="complete"?n():window.addEventListener("load",n)}return f(),r.A=p,r.Button=i,r.Div=s,r.H1=l,r.H2=m,r.H3=d,r.Img=u,r.Span=g,r.initTemplate=f,r.templateMetadata=y,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r})({},ReactJSXRuntime,React);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface AProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 a(링크) 컴포넌트
|
||||
*/
|
||||
export declare const A: React.FC<AProps>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 버튼 컴포넌트 (type="button" 기본값으로 submit 방지)
|
||||
*/
|
||||
export declare const Button: React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { default as React } from 'react';
|
||||
export interface DivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
/**
|
||||
* 기본 div 컴포넌트
|
||||
*/
|
||||
export declare const Div: React.ForwardRefExoticComponent<Omit<DivProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H1Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h1 제목 컴포넌트
|
||||
*/
|
||||
export declare const H1: React.FC<H1Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H2Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h2 제목 컴포넌트
|
||||
*/
|
||||
export declare const H2: React.FC<H2Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H3Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h3 제목 컴포넌트
|
||||
*/
|
||||
export declare const H3: React.FC<H3Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 이미지 컴포넌트
|
||||
*/
|
||||
export declare const Img: React.FC<ImgProps>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface SpanProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 span 컴포넌트
|
||||
*/
|
||||
export declare const Span: React.FC<SpanProps>;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 기본 HTML 매핑 컴포넌트 Export
|
||||
*/
|
||||
export { Div, type DivProps } from './Div';
|
||||
export { Button, type ButtonProps } from './Button';
|
||||
export { H1, type H1Props } from './H1';
|
||||
export { H2, type H2Props } from './H2';
|
||||
export { H3, type H3Props } from './H3';
|
||||
export { A, type AProps } from './A';
|
||||
export { Span, type SpanProps } from './Span';
|
||||
export { Img, type ImgProps } from './Img';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { default as templateMetadata } from '../template.json';
|
||||
/**
|
||||
* Gnuboard7 Hello Admin Template
|
||||
*
|
||||
* 학습용 최소 Admin 템플릿 스켈레톤 — Basic 8개 컴포넌트만 포함
|
||||
*/
|
||||
export * from './components/basic';
|
||||
export { templateMetadata };
|
||||
/**
|
||||
* 템플릿 초기화 함수
|
||||
*
|
||||
* 코어 엔진의 ComponentRegistry 에 Basic 8개 컴포넌트를 등록합니다.
|
||||
*/
|
||||
export declare function initTemplate(): void;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,9 @@ export default defineConfig({
|
||||
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface AProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 a(링크) 컴포넌트
|
||||
*/
|
||||
export declare const A: React.FC<AProps>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 버튼 컴포넌트
|
||||
*/
|
||||
export declare const Button: React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { default as React } from 'react';
|
||||
export interface DivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
/**
|
||||
* 기본 div 컴포넌트
|
||||
*/
|
||||
export declare const Div: React.ForwardRefExoticComponent<Omit<DivProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H1Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h1 제목 컴포넌트
|
||||
*/
|
||||
export declare const H1: React.FC<H1Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H2Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h2 제목 컴포넌트
|
||||
*/
|
||||
export declare const H2: React.FC<H2Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface H3Props extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 h3 제목 컴포넌트
|
||||
*/
|
||||
export declare const H3: React.FC<H3Props>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 이미지 컴포넌트
|
||||
*/
|
||||
export declare const Img: React.FC<ImgProps>;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
export interface SpanProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
}
|
||||
/**
|
||||
* 기본 span 컴포넌트
|
||||
*/
|
||||
export declare const Span: React.FC<SpanProps>;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Gnuboard7 Hello User Template
|
||||
*
|
||||
* 학습용 최소 샘플 사용자 템플릿.
|
||||
* Basic 8개 컴포넌트만 포함 (Div, Button, H1, H2, H3, A, Span, Img).
|
||||
*/
|
||||
export { Div, type DivProps } from './components/basic/Div';
|
||||
export { Button, type ButtonProps } from './components/basic/Button';
|
||||
export { H1, type H1Props } from './components/basic/H1';
|
||||
export { H2, type H2Props } from './components/basic/H2';
|
||||
export { H3, type H3Props } from './components/basic/H3';
|
||||
export { A, type AProps } from './components/basic/A';
|
||||
export { Span, type SpanProps } from './components/basic/Span';
|
||||
export { Img, type ImgProps } from './components/basic/Img';
|
||||
@@ -0,0 +1 @@
|
||||
var Gnuboard7HelloUserTemplate=(function(a,r,c){"use strict";const l=c.forwardRef(({children:e,className:t="",...n},g)=>r.jsx("div",{ref:g,className:t,...n,children:e}));l.displayName="Div";const s=c.forwardRef(({children:e,className:t="",...n},g)=>r.jsx("button",{ref:g,className:t,...n,children:e}));s.displayName="Button";const m=({children:e,className:t="",...n})=>r.jsx("h1",{className:t,...n,children:e}),i=({children:e,className:t="",...n})=>r.jsx("h2",{className:t,...n,children:e}),d=({children:e,className:t="",...n})=>r.jsx("h3",{className:t,...n,children:e}),p=({children:e,className:t="",...n})=>r.jsx("a",{className:t,...n,children:e}),u=({children:e,className:t="",...n})=>r.jsx("span",{className:t,...n,children:e}),b=({className:e="",alt:t="",...n})=>r.jsx("img",{className:e,alt:t,...n}),H=window.G7Core?.createLogger?.("Template:gnuboard7-hello_user_template")??{log:(...e)=>console.log("[Template:gnuboard7-hello_user_template]",...e),warn:(...e)=>console.warn("[Template:gnuboard7-hello_user_template]",...e),error:(...e)=>console.error("[Template:gnuboard7-hello_user_template]",...e)},o=window.G7Core?.templateEngine?.ComponentRegistry?.getInstance?.();if(o){const e=t=>({name:t,type:"basic"});o.register({component:l,metadata:e("Div")}),o.register({component:s,metadata:e("Button")}),o.register({component:m,metadata:e("H1")}),o.register({component:i,metadata:e("H2")}),o.register({component:d,metadata:e("H3")}),o.register({component:p,metadata:e("A")}),o.register({component:u,metadata:e("Span")}),o.register({component:b,metadata:e("Img")}),H.log("Registered 8 basic components")}else H.warn("ComponentRegistry not available — skipping auto-registration");return a.A=p,a.Button=s,a.Div=l,a.H1=m,a.H2=i,a.H3=d,a.Img=b,a.Span=u,Object.defineProperty(a,Symbol.toStringTag,{value:"Module"}),a})({},ReactJSXRuntime,React);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ export default defineConfig({
|
||||
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- 권한 관리 화면의 새로고침 버튼이 다른 관리자 화면의 새로고침 버튼과 모양이 달랐던 문제를 수정했습니다.
|
||||
- 환경설정 화면의 탭 줄과 게시판 설정 화면의 상단 고정 영역이 화면 여백과 어긋나 가로로 밀려 보일 수 있던 문제를 정리했습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @file admin-settings-tab-scoped-datasources.test.tsx
|
||||
* @description 코어 환경설정 데이터소스 탭 스코프 게이팅 회귀 가드
|
||||
*
|
||||
* 이커머스/게시판 환경설정과 동일 결함: 진입 시 전 탭의 데이터소스를 한꺼번에 fetch 했다.
|
||||
* 이 레이아웃은 이미 language_packs / policies 두 소스에만 `if` 게이트를 적용하고 있었다
|
||||
* (엔진의 기본 패턴). 나머지 탭 전용 소스에도 동일 게이트를 적용한다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const layout = require('../../layouts/admin_settings.json');
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
const SHARED_SOURCE_IDS = ['settings'];
|
||||
|
||||
/** 모달 상세용 지연 소스 — 탭이 아니라 _local.detailId 로 트리거 (auto_fetch:false 유지) */
|
||||
const LAZY_DETAIL_IDS = ['language_pack_detail', 'language_pack_changelog'];
|
||||
|
||||
const TAB_SCOPED: Record<string, string[]> = {
|
||||
language_packs: ['language_packs'],
|
||||
systemInfo: ['info'],
|
||||
appKey: ['security'],
|
||||
notificationDefinitions: ['notification_definitions'],
|
||||
availableChannels: ['notification_definitions'],
|
||||
identityProviders: ['identity'],
|
||||
identityPurposes: ['identity'],
|
||||
identityMessages: ['identity'],
|
||||
adminIdentityPolicies: ['identity'],
|
||||
policies: ['identity'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
describe('admin_settings — 데이터소스 탭 스코프 게이팅', () => {
|
||||
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
|
||||
for (const id of SHARED_SOURCE_IDS) {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.if).toBeUndefined();
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('모달 상세 소스는 auto_fetch:false 지연 로딩을 유지한다', () => {
|
||||
for (const id of LAZY_DETAIL_IDS) {
|
||||
expect(byId(id)!.auto_fetch).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s!.if).toBe('string');
|
||||
});
|
||||
|
||||
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
|
||||
const cond: string = byId(id)!.if;
|
||||
for (const tab of tabs as string[]) {
|
||||
expect(cond).toContain(`'${tab}'`);
|
||||
}
|
||||
expect(cond).toContain('query.tab');
|
||||
expect(cond).toContain('activeSettingsTab');
|
||||
});
|
||||
|
||||
it('탭 전용 소스를 auto_fetch:false 로 죽여두지 않는다', () => {
|
||||
for (const id of Object.keys(TAB_SCOPED)) {
|
||||
expect(byId(id)!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('탭 전환은 navigate + replace:true 로 수행한다 (if 재평가 필요)', () => {
|
||||
const seqs: AnyJson[] = [];
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { n.forEach(walk); return; }
|
||||
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
|
||||
Object.values(n).forEach(walk);
|
||||
};
|
||||
walk(layout);
|
||||
|
||||
const tabSwitches = seqs.filter(s =>
|
||||
(s.params.actions as AnyJson[]).some(
|
||||
a => a?.handler === 'setState' && 'activeSettingsTab' in (a.params ?? {})
|
||||
)
|
||||
);
|
||||
expect(tabSwitches.length).toBeGreaterThan(0);
|
||||
|
||||
for (const seq of tabSwitches) {
|
||||
const acts = seq.params.actions as AnyJson[];
|
||||
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
|
||||
const nav = acts.find(a => a.handler === 'navigate');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav!.params.replace).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user