diff --git a/.env.example b/.env.example index 49db91b8..25f1be5e 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.10 +APP_VERSION=7.0.11 # 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 # 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). diff --git a/.env.testing.example b/.env.testing.example index dea1ab52..b5706dca 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.10 +APP_VERSION=7.0.11 # 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 # 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). diff --git a/AGENTS.md b/AGENTS.md index 2b914a09..fc2c03e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ | 대상 | 진입점 | 문서/엔드포인트 | |------|--------|----------------| -| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 325 | +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 328 | ### 확장 API 레퍼런스 (14개 확장, 자동 스캔) @@ -476,6 +476,24 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 > 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md) +### 서버가 조건에 따라 다른 형태의 200 을 돌려주는 엔드포인트 + +같은 엔드포인트가 설정·상태에 따라 **다른 형태의 2xx** 를 낸다면, 프론트는 형태를 판별한 뒤에 읽어야 한다. 한 형태만 가정하면 다른 형태에서 필드 접근이 그 자리에서 던지고, 그 원문이 오류 박스에 영문으로 노출된다. 서버는 정상 응답했으므로 **서버 로그에는 흔적이 없다** — 깨진 것은 클라이언트뿐이다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 응답 타입을 한 형태로 고정 선언하고 `response.data.user.*` 를 바로 읽기 | 판별 유니온으로 두 형태를 표현 (`LoginResult` = `{status:'authenticated', user}` \| `{status:'two_factor_required', challenge}`) | +| 저장 지점에 형태 가드 없이 `setToken(response.data.token)` | 비어 있지 않은 문자열만 저장 — `localStorage` 는 무엇을 넣든 문자열로 바꾸므로 `undefined` 가 `"undefined"`(truthy)로 남아 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 된다 | +| 대체 형태 분기를 사용자 경로에만 두고 관리자 경로는 그대로 | 관리자 경로가 먼저 500 이 되면 설정을 되돌릴 수단까지 사라진다 — 두 경로 동시 적용 | +| `onSuccess` 후속 액션에 조건 없이 성공 처리를 나열 | 대체 형태에서 실행되면 안 되는 액션마다 `if:"{{!response.대체형태플래그}}"` | +| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 시점 컨텍스트는 아직 갱신 전이다 — `{{response.*}}` 만 읽는다 (`onSuccess` 결과는 `handleSequence` 의 상태 동기화 대상이 아니다) | +| 서버가 제공하는 기능의 프론트 화면 부재를 "미사용" 으로 간주 | 토글을 켠 사이트에서만 드러나는 미구현이다 — 서버 토글 ↔ 화면 존재를 전수 대조 | + +착수 전 전수조사 축은 **"서버가 대체 형태 2xx 를 내는 엔드포인트 ↔ 프론트 처리 여부"** 다. 그리고 **"서버 토글 ON 시 프론트 화면 존재 여부"** 를 함께 본다 — 2단계 인증은 도입 후 여러 버전 동안 입력 화면이 없었고, 그 토글을 켠 사이트에서만 전원 로그인 불가로 나타났다(공개 #133). + +> 상세: [auth-system.md "2단계 인증 로그인"](docs/frontend/auth-system.md) +> 정적 검사로는 잡히지 않는다 — 응답 변종은 서버 분기의 의미 판정이므로 코드 리뷰에서 확인한다. + ### 제3자 라이브러리는 쓰기 경로를 지정받는다 제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125). diff --git a/CHANGELOG.md b/CHANGELOG.md index f393be09..00d9638b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.11] - 2026-09-07 + +### Added + +- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 관리자 로그인 화면도 같은 흐름으로 동작합니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) + +### Changed + +- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) + +### Fixed + +- 2단계 인증을 켠 사이트에서 로그인이 되지 않던 문제를 수정했습니다. 로그인 화면에 영문 오류(`Cannot read properties of undefined`)가 표시되고 그 뒤로는 진행할 방법이 없었으며, 관리자 로그인은 서버 오류(500)가 되어 설정을 되돌릴 수단까지 사라졌습니다. 이제 인증번호 입력 단계가 표시되고 관리자도 같은 흐름으로 로그인할 수 있습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) +- 로그인 응답을 잘못 해석해 사용할 수 없는 인증 정보가 브라우저에 저장되던 문제를 수정했습니다. 그 뒤로는 화면을 새로 열 때마다 「세션이 만료되었습니다」 안내와 함께 로그인 화면으로 되돌아갔습니다. +- 본인인증 화면에서 로그인용 인증 요청을 처리할 수 있어, 그 요청으로는 다시 로그인할 수 없게 되던 문제를 수정했습니다. 로그인용 인증 요청은 이제 로그인 화면에서만 처리됩니다. +- 로그인 시도가 많아 잠시 차단될 때 영문 안내(`Too Many Attempts.`)가 그대로 표시되던 문제를 수정했습니다. 이제 다시 시도할 수 있는 시각과 함께 사이트 언어로 안내합니다. +- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다. +- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다. +- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다. + ## [7.0.10] - 2026-09-06 ### Added diff --git a/INSTALL.md b/INSTALL.md index aedfdf78..e443a788 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -289,7 +289,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.10 g7 +# (필요 시) mv g7-7.0.11 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.ko.md b/README.ko.md index 28e085cf..264d663c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ A modern, extensible CMS platform built with Laravel + React
-
+
@@ -521,6 +521,7 @@ cp .env.example .env
+
diff --git a/README.md b/README.md
index e6ded2ed..27983fa9 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
The next generation of Gnuboard — Korea's most widely used open-source CMS
- Ze?(ut=Ne,Ne=null):ut=Ne.sibling;var wt=J(K,Ne,Y[Ze],oe);if(wt===null){Ne===null&&(Ne=ut);break}t&&Ne&&wt.alternate===null&&a(K,Ne),F=g(wt,F,Ze),St===null?Be=wt:St.sibling=wt,St=wt,Ne=ut}if(Ze===Y.length)return o(K,Ne),ht&&ya(K,Ze),Be;if(Ne===null){for(;Ze
+
@@ -535,6 +535,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
+
diff --git a/app/Console/Commands/PlaywrightSeedTwoFactor.php b/app/Console/Commands/PlaywrightSeedTwoFactor.php
new file mode 100644
index 00000000..e5060f90
--- /dev/null
+++ b/app/Console/Commands/PlaywrightSeedTwoFactor.php
@@ -0,0 +1,242 @@
+ --code=135790
+ */
+class PlaywrightSeedTwoFactor extends Command
+{
+ protected $signature = 'playwright:seed-two-factor
+ {--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
+ {--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
+ {--admin : --ensure-user 계정에 admin 역할을 부여한다}
+ {--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
+ {--code=135790 : --plant 가 심을 인증 코드}
+ {--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
+ {--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
+
+ protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
+
+ /** 테스트 전용 계정 이메일 접두사 */
+ private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
+
+ public function handle(): int
+ {
+ // ① CLI 한정 — production 웹 요청에서 절대 도달 불가
+ if (php_sapi_name() !== 'cli') {
+ $this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
+
+ return self::FAILURE;
+ }
+
+ // ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
+ // 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
+ // 환경변수이므로 config:cache 의 영향을 받지 않는다.
+ if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
+ $this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
+
+ return self::FAILURE;
+ }
+
+ // ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
+ Config::set('app.debug', true);
+
+ $gcHours = (int) $this->option('gc-hours');
+ if ($gcHours > 0) {
+ $this->pruneStaleTestUsers($gcHours);
+ }
+
+ $did = false;
+
+ // 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
+ // 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
+ if ($this->option('purge-users')) {
+ $this->purgeTestUsers();
+ $did = true;
+ }
+
+ if ($suffix = $this->option('ensure-user')) {
+ $this->ensureUser((string) $suffix);
+ $did = true;
+ }
+
+ if ($challengeId = $this->option('plant')) {
+ if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
+ return self::FAILURE;
+ }
+ $did = true;
+ }
+
+ if (! $did) {
+ $this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
+
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
+ *
+ * @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
+ */
+ private function ensureUser(string $suffix): void
+ {
+ $email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
+ $password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
+
+ $user = User::where('email', $email)->first();
+
+ if ($user === null) {
+ $user = User::factory()->create([
+ 'email' => $email,
+ 'password' => Hash::make($password),
+ 'status' => UserStatus::Active->value,
+ ]);
+ } else {
+ // 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
+ // 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
+ // 잠금 상태가 다음 실행으로 새지 않게 한다.
+ $user->forceFill([
+ 'password' => Hash::make($password),
+ 'status' => UserStatus::Active->value,
+ 'locked_until' => null,
+ 'failed_login_attempts' => 0,
+ ])->save();
+ }
+
+ if ($this->option('admin')) {
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ [
+ 'name' => ['ko' => '관리자', 'en' => 'Admin'],
+ 'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
+ 'extension_type' => ExtensionOwnerType::Core,
+ 'extension_identifier' => 'core',
+ 'type' => 'admin',
+ 'is_active' => true,
+ ]
+ );
+
+ if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
+ $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+ }
+ } else {
+ // 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
+ // 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
+ $user->roles()->detach();
+ }
+
+ $this->line($email);
+ }
+
+ /**
+ * challenge 에 알려진 인증 코드의 해시를 심습니다.
+ *
+ * @param string $challengeId challenge UUID
+ * @param string $code 심을 인증 코드
+ * @return bool 성공 여부
+ */
+ private function plantCode(string $challengeId, string $code): bool
+ {
+ $log = IdentityVerificationLog::find($challengeId);
+
+ if ($log === null) {
+ $this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
+
+ return false;
+ }
+
+ $metadata = $log->metadata ?? [];
+ $metadata['code_hash'] = Hash::make($code);
+
+ $log->metadata = $metadata;
+ $log->save();
+
+ $this->line($code);
+
+ return true;
+ }
+
+ /**
+ * playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
+ *
+ * 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
+ * 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
+ */
+ private function purgeTestUsers(): void
+ {
+ $removed = 0;
+
+ User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
+ ->chunkById(100, function ($chunk) use (&$removed) {
+ foreach ($chunk as $user) {
+ $user->tokens()->delete();
+ $user->roles()->detach();
+ $user->delete();
+ $removed++;
+ }
+ });
+
+ $this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
+ }
+
+ /**
+ * 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
+ *
+ * chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
+ * chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
+ *
+ * @param int $hours 이 시간보다 오래된 계정만 정리
+ */
+ private function pruneStaleTestUsers(int $hours): void
+ {
+ $threshold = now()->subHours($hours);
+ $removed = 0;
+
+ User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
+ ->where('created_at', '<', $threshold)
+ ->chunkById(100, function ($chunk) use (&$removed) {
+ foreach ($chunk as $user) {
+ $user->tokens()->delete();
+ $user->roles()->detach();
+ $user->delete();
+ $removed++;
+ }
+ });
+
+ if ($removed > 0) {
+ $this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
+ }
+ }
+}
diff --git a/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php b/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php
new file mode 100644
index 00000000..e973a070
--- /dev/null
+++ b/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php
@@ -0,0 +1,25 @@
+middleware(['auth:sanctum', 'admin'])->except([
+ 'login',
+ 'verifyTwoFactor',
+ 'resendTwoFactor',
+ ]);
}
/**
@@ -33,10 +48,21 @@ class AuthController extends AdminBaseController
$request->validated()['password']
);
+ // 2단계 인증이 켜져 있으면 아직 토큰도 사용자도 없다 — 관리자 판정은 코드 확인
+ // 뒤로 미룬다. 이 분기가 없으면 $data['user'] 가 없어 500 이 되고, 관리자까지
+ // 로그인할 수 없어 설정을 되돌릴 수단이 사라진다.
+ if ($data['two_factor_required'] ?? false) {
+ return $this->success('auth.two_factor_required', $data);
+ }
+
$user = $data['user'];
// 관리자 권한 확인
if (! $user->isAdmin()) {
+ // 이 시점에는 이미 토큰과 web 세션이 발급되어 있다 — 거절하면서 남겨 두면
+ // 관리자가 아닌 사용자가 응답만 403 을 받을 뿐 세션은 그대로 유효해진다.
+ $this->authService->revokeIssuedSession($user, $data['token']);
+
return $this->forbidden('auth.admin_required');
}
@@ -45,22 +71,92 @@ class AuthController extends AdminBaseController
return $this->success('auth.admin_login_success', $data);
} catch (AccountLockedException $e) {
- // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
+ /**
+ * 관리자의 2단계 인증 코드를 확인하고 로그인을 완료합니다.
+ *
+ * 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
+ * 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
+ *
+ * @param TwoFactorChallengeRequest $request challenge 확인 요청
+ * @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
+ */
+ public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+
+ try {
+ $data = $this->authService->completeTwoFactor(
+ $validated['challenge_id'],
+ ['code' => $validated['code']]
+ );
+
+ $user = $data['user'];
+
+ if (! $user->isAdmin()) {
+ // completeTwoFactor() 는 코드 확인에 성공한 시점에 토큰을 발급한다.
+ // 관리자 판정으로 거절하면서 그 발급분을 회수하지 않으면 관리자가 아닌
+ // 사용자가 유효한 세션을 손에 쥔 채 응답만 403 을 받는다.
+ $this->authService->revokeIssuedSession($user, $data['token']);
+
+ return $this->forbidden('auth.admin_required');
+ }
+
+ $data['user'] = new UserResource($user);
+
+ return $this->success('auth.admin_login_success', $data);
+ } catch (AccountLockedException $e) {
+ // 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다.
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->unauthorized('auth.two_factor_failed');
+ }
+ }
+
+ /**
+ * 관리자의 2단계 인증 코드를 재발송합니다.
+ *
+ * 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
+ * 더 이상 통하지 않습니다.
+ *
+ * @param TwoFactorResendRequest $request challenge 재발송 요청
+ * @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
+ */
+ public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
+ {
+ try {
+ $resolvedUser = null;
+
+ $data = $this->authService->resendTwoFactorChallenge(
+ $request->validated()['challenge_id'],
+ $resolvedUser
+ );
+
+ // 이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다 — 다만 완료할 수 없는
+ // 상대에게 새 인증번호를 계속 보내지는 않는다.
+ if ($resolvedUser === null || ! $resolvedUser->isAdmin()) {
+ return $this->forbidden('auth.admin_required');
+ }
+
+ return $this->success('auth.two_factor_required', $data);
+ } catch (AccountLockedException $e) {
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
+ }
+ }
+
/**
* 관리자를 로그아웃시킵니다.
*
@@ -113,16 +209,7 @@ class AuthController extends AdminBaseController
} catch (AccountLockedException $e) {
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ return $this->lockedResponse($e);
} catch (ValidationException $e) {
return $this->unauthorized('auth.unauthenticated');
}
diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php
index d66a9007..d5085011 100644
--- a/app/Http/Controllers/Api/Auth/AuthController.php
+++ b/app/Http/Controllers/Api/Auth/AuthController.php
@@ -3,13 +3,16 @@
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
+use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
+use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
+use App\Http\Requests\Auth\TwoFactorResendRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
@@ -18,6 +21,8 @@ use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
{
+ use BuildsAuthFailureResponses;
+
public function __construct(
private AuthService $authService
) {
@@ -27,8 +32,9 @@ class AuthController extends AuthBaseController
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
- // 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
+ // 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
+ 'resendTwoFactor',
'register',
'forgotPassword',
'resetPassword',
@@ -61,6 +67,8 @@ class AuthController extends AuthBaseController
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
@@ -92,32 +100,37 @@ class AuthController extends AuthBaseController
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
- * 계정 잠금 응답(423)을 구성합니다.
+ * 2단계 인증 코드를 재발송합니다.
*
- * 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다.
+ * 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
+ * 더 이상 통하지 않습니다.
*
- * @param AccountLockedException $e 잠금 예외
- * @return JsonResponse 423 응답
+ * @param TwoFactorResendRequest $request challenge 재발송 요청
+ * @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
*/
- private function lockedResponse(AccountLockedException $e): JsonResponse
+ public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
{
- // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ try {
+ $data = $this->authService->resendTwoFactorChallenge(
+ $request->validated()['challenge_id']
+ );
+
+ return $this->success('auth.two_factor_required', $data);
+ } catch (AccountLockedException $e) {
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
+ }
}
/**
diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
index 89fc494e..573f6336 100644
--- a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
+++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Identity;
use App\Enums\IdentityOriginType;
+use App\Enums\IdentityVerificationPurpose;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Identity\CancelChallengeRequest;
@@ -96,6 +97,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
+ // 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
+ // 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
+ // 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
+ if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
+ return $this->error('identity.errors.purpose_not_allowed', 403, [
+ 'failure_code' => 'PURPOSE_NOT_ALLOWED',
+ ]);
+ }
+
$result = $this->service->verify(
challengeId: $challenge->id,
input: $request->validated(),
@@ -142,6 +152,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
+ // 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
+ // 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
+ // 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
+ if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
+ return $this->error('identity.errors.purpose_not_allowed', 403, [
+ 'failure_code' => 'PURPOSE_NOT_ALLOWED',
+ ]);
+ }
+
$ok = $this->service->cancel($challenge->id);
if (! $ok) {
diff --git a/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php b/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php
new file mode 100644
index 00000000..f5588287
--- /dev/null
+++ b/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php
@@ -0,0 +1,53 @@
+error(
+ $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
+ 423,
+ [
+ 'locked_until' => $e->lockedUntil?->toIso8601String(),
+ 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
+ 'permanent' => $e->isPermanent(),
+ ],
+ ['minutes' => $e->remainingMinutes]
+ );
+ }
+
+ /**
+ * 인증번호 발송 실패 응답(503)을 구성합니다.
+ *
+ * 자격 증명은 올바르므로 401 로 답하지 않는다 — 사용자는 비밀번호를 의심하며 같은
+ * 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
+ *
+ * @return JsonResponse 503 응답
+ */
+ protected function deliveryFailedResponse(): JsonResponse
+ {
+ return $this->error('auth.two_factor_delivery_failed', 503);
+ }
+}
diff --git a/app/Http/Requests/Auth/TwoFactorResendRequest.php b/app/Http/Requests/Auth/TwoFactorResendRequest.php
new file mode 100644
index 00000000..8ba3f3cf
--- /dev/null
+++ b/app/Http/Requests/Auth/TwoFactorResendRequest.php
@@ -0,0 +1,54 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'challenge_id' => ['required', 'string', 'uuid'],
+ ];
+
+ // 확장이 자체 2단계 수단을 붙일 때 재발송 입력을 추가할 수 있도록 개방한다
+ return HookManager::applyFilters('core.auth.two_factor_resend_validation_rules', $rules, $this);
+ }
+
+ /**
+ * 검증 실패 메시지를 반환합니다.
+ *
+ * @return array{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ru(e){return JSON.stringify(Rs(e),null,2)}function Rs(e){if(Array.isArray(e))return e.map(Rs);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Rs(t[o]);return n}return e}const L$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},P$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},O$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},B$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},W$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},j$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},ep={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},H$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function F$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const tp={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function K$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ru(n),p=Ru(o);return N$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:L$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:P$,children:[i.jsxs("button",{type:"button",onClick:s,style:O$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:B$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:W$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:j$,children:d?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:H$,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,b)=>{const g=F$(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${b}`,children:[i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},b)})]},p))})]})}const V$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},U$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},q$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},G$={flex:1,minHeight:0,overflowY:"auto",padding:12},np={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},Y$={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},X$={display:"flex",flexDirection:"column",gap:8},J$={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},Z$={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},Q$={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},R$={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},eE={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},op={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function rp(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function tE(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function nE({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,isLoadingMore:l,error:d,restoringId:c,hasMore:p,loadVersions:f,loadMore:b,restore:g,loadVersionDetail:m}=T$(e,t,o),[h,v]=u.useState(null),[y,_]=u.useState(null),[w,k]=u.useState(null);u.useEffect(()=>{f()},[f]);const x=u.useCallback(async C=>{v(null),k(C.version);try{const T=await m(C.version);if(T.kind!=="success"){v(T.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):T.message);return}const E=C.version-1,A=s.some(D=>D.version===E);let N={};if(A){const D=await m(E);if(D.kind==="success")N=rp(D.detail);else if(D.kind==="network_error"){v(D.message);return}}_({oldVersion:A?E:0,newVersion:C.version,oldContent:N,newContent:rp(T.detail)})}finally{k(null)}},[m,s,n]),$=u.useCallback(async C=>{v(null);const T=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(T):!0))return;const A=await g(C);A.kind==="success"?r():A.kind==="not_found"?v(n("layout_editor.version_history.restore_not_found")):v(A.message)},[g,r,n]),S=h??d;return y?i.jsx(K$,{oldVersion:y.oldVersion,newVersion:y.newVersion,oldContent:y.oldContent,newContent:y.newContent,t:n,onBack:()=>_(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:V$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:U$,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:q$,"data-testid":"g7le-version-history-close",children:"✕"})]}),S&&i.jsx("div",{style:Y$,"data-testid":"g7le-version-history-error",children:S}),i.jsx("div",{style:G$,children:a?i.jsx("div",{style:np,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:np,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsxs("div",{"data-testid":"g7le-version-history-list",style:X$,children:[s.map((C,T)=>{const E=C.changes_summary,A=E?.added_count??0,N=E?.removed_count??0,D=E?.char_diff??0,B=tE(C.created_at),M=c===C.id,O=T===0;return i.jsxs("div",{style:J$,"data-testid":`g7le-version-row-${C.id}`,children:[i.jsxs("span",{style:Z$,"data-testid":`g7le-version-badge-${C.id}`,children:["v",C.version]}),i.jsxs("div",{style:eE,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[B&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:B}),O&&i.jsx("span",{style:Q$,"data-testid":`g7le-version-latest-${C.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:R$,"data-testid":`g7le-version-summary-${C.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${C.id}`,children:n("layout_editor.version_history.added",{count:A})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${C.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${C.id}`,children:n("layout_editor.version_history.chars",{diff:`${D>=0?"+":""}${D}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${C.id}`,children:n("layout_editor.version_history.saved_by",{name:C.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>{x(C)},disabled:w!==null,style:{...op,background:"#f8fafc",opacity:w!==null?.6:1},"data-testid":`g7le-version-compare-${C.id}`,children:w===C.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!O&&i.jsx("button",{type:"button",onClick:()=>{$(C.id)},disabled:M||c!==null,style:{...op,opacity:M||c!==null?.6:1},"data-testid":`g7le-version-restore-${C.id}`,children:n(M?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},C.id)}),p&&i.jsx("button",{type:"button","data-testid":"g7le-version-history-load-more",style:oE,disabled:l,onClick:()=>{b()},children:n(l?"layout_editor.version_history.loading":"layout_editor.version_history.load_more")})]})})]})}const oE={width:"100%",marginTop:8,padding:"8px 12px",fontSize:12,color:"#475569",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,cursor:"pointer"};function Po(e){return`/api/admin/extensions/${encodeURIComponent(e.type)}/${encodeURIComponent(e.identifier)}/custom-assets`}function Oo(e,t){const n=e?.message;return typeof n=="string"&&n!==""?n:`HTTP ${t}`}function rE(e){const[t,n]=u.useState([]),[o,r]=u.useState(null),[s,a]=u.useState(!1),[l,d]=u.useState(!1),[c,p]=u.useState(null),f=u.useCallback(async()=>{a(!0),p(null);try{const v=await fetch(Po(e),{credentials:"same-origin",headers:Ne()}),y=await v.json().catch(()=>null);if(v.status===401||v.status===403){d(!0),n([]);return}if(d(!1),!v.ok){p(Oo(y,v.status)),n([]);return}const _=y?.data??{},w=_.files;n(Array.isArray(w)?w:[]),r({editable_extensions:_.editable_extensions??[],uploadable_extensions:_.uploadable_extensions??[],max_text_bytes:_.max_text_bytes??0,max_upload_bytes:_.max_upload_bytes??0})}catch(v){p(v instanceof Error?v.message:"network error"),n([])}finally{a(!1)}},[e.type,e.identifier]),b=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}/content?path=${encodeURIComponent(v)}`,_=await fetch(y,{credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);if(!_.ok)return p(Oo(w,_.status)),null;const k=w?.data?.content;return typeof k=="string"?k:""}catch(y){return p(y instanceof Error?y.message:"network error"),null}},[e.type,e.identifier]),g=u.useCallback(async(v,y)=>{p(null);try{const _=await fetch(`${Po(e)}/content`,{method:"PUT",credentials:"same-origin",headers:Ne({"Content-Type":"application/json"}),body:JSON.stringify({path:v,content:y})}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),m=u.useCallback(async(v,y)=>{p(null);try{const _=new FormData;_.append("file",v),y&&_.append("directory",y);const w=await fetch(`${Po(e)}/upload`,{method:"POST",credentials:"same-origin",headers:Ne(),body:_}),k=await w.json().catch(()=>null);return w.ok?(await f(),!0):(p(Oo(k,w.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),h=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}?path=${encodeURIComponent(v)}`,_=await fetch(y,{method:"DELETE",credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(y){return p(y instanceof Error?y.message:"network error"),!1}},[e.type,e.identifier,f]);return{files:t,meta:o,isLoading:s,forbidden:l,error:c,reload:f,read:b,save:g,upload:m,remove:h}}const iE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},sE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc",fontWeight:600,color:"#0f172a"},aE={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},lE={flex:1,minHeight:0,display:"flex"},dE={width:260,flexShrink:0,borderRight:"1px solid #e2e8f0",overflowY:"auto",padding:8},cE={flex:1,minWidth:0,display:"flex",flexDirection:"column",padding:12,gap:8},ip={display:"flex",alignItems:"center",gap:6,width:"100%",padding:"6px 8px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",fontSize:12,color:"#334155",cursor:"pointer",textAlign:"left"},uE={...ip,backgroundColor:"#eff6ff",borderColor:"#bfdbfe",color:"#1d4ed8",fontWeight:600},pE={margin:16,padding:16,borderRadius:8,border:"1px solid #fcd34d",background:"#fffbeb",color:"#92400e",fontSize:13,lineHeight:1.7},fE={padding:"8px 12px",borderRadius:6,border:"1px solid #fecaca",background:"#fef2f2",color:"#b91c1c",fontSize:12},gE={flex:1,minHeight:220,width:"100%",boxSizing:"border-box",padding:10,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",fontFamily:"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",fontSize:12,lineHeight:1.6,resize:"vertical"},sp={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},io={padding:"6px 12px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",backgroundColor:"#ffffff",color:"#334155",fontSize:12,cursor:"pointer"},hE={...io,backgroundColor:"#2563eb",borderColor:"#2563eb",color:"#ffffff",fontWeight:600},yE={...io,borderColor:"#fca5a5",color:"#b91c1c"},bE={...io,cursor:"text",minWidth:200},mE={...io,cursor:"pointer",minWidth:220};function vE(e,t){if(typeof e=="string"&&e!=="")return e;if(e&&typeof e=="object"){const n=e,o=window?.G7Config?.locale??"ko",r=n[o]??n.ko??n.en??Object.values(n)[0];if(typeof r=="string"&&r!=="")return r}return t}function _E(e){const t=(typeof window<"u"?window.G7Config:null)??{},n=[{type:"template",identifier:e,label:e}];for(const[o,r]of[["module",t.activeModules],["plugin",t.activePlugins]])if(Array.isArray(r))for(const s of r){const a=typeof s?.identifier=="string"?s.identifier:"";a&&n.push({type:o,identifier:a,label:vE(s?.display_name,a)})}return n}function xE(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function wE(e){const{templateIdentifier:t,t:n,onClose:o}=e,r=u.useMemo(()=>_E(t),[t]),[s,a]=u.useState(`template:${t}`),l=u.useMemo(()=>r.find(S=>`${S.type}:${S.identifier}`===s)??r[0],[r,s]),d=rE({type:l.type,identifier:l.identifier}),{reload:c}=d,[p,f]=u.useState(null),[b,g]=u.useState(""),[m,h]=u.useState(""),[v,y]=u.useState(!1),_=u.useRef(null);u.useEffect(()=>{c()},[c]),u.useEffect(()=>{f(null),g(""),h("")},[s]);const w=u.useCallback(async S=>{if(f(S),!S.editable){g("");return}y(!0);const C=await d.read(S.path);y(!1),g(C??"")},[d]),k=u.useCallback(async()=>{const S=p?.path??m.trim();if(!S)return;y(!0);const C=await d.save(S,b);y(!1),C&&!p&&(h(""),f({path:S,name:S.split("/").pop()??S,extension:(S.split(".").pop()??"").toLowerCase(),size:b.length,modified_at:new Date().toISOString(),editable:!0,loaded:!0}))},[d,p,m,b]),x=u.useCallback(async()=>{if(!p||typeof window<"u"&&!window.confirm(n("layout_editor.custom_assets.delete_confirm")))return;y(!0);const S=await d.remove(p.path);y(!1),S&&(f(null),g(""))},[d,p,n]),$=u.useCallback(async S=>{const C=S.target.files?.[0];C&&(y(!0),await d.upload(C),y(!1),_.current&&(_.current.value=""))},[d]);return i.jsxs("div",{className:"g7le-custom-assets",style:iE,"data-testid":"g7le-custom-assets-modal",children:[i.jsxs("div",{style:sE,children:[i.jsxs("span",{children:["🎨 ",n("layout_editor.custom_assets.title")]}),i.jsx("select",{value:s,onChange:S=>a(S.target.value),style:mE,"aria-label":n("layout_editor.custom_assets.target_label"),"data-testid":"g7le-custom-assets-target",children:r.map(S=>i.jsxs("option",{value:`${S.type}:${S.identifier}`,children:[n(`layout_editor.custom_assets.type_${S.type}`)," · ",S.label]},`${S.type}:${S.identifier}`))}),i.jsx("button",{type:"button",style:aE,onClick:o,"aria-label":n("layout_editor.custom_assets.close"),"data-testid":"g7le-custom-assets-close",children:"✕"})]}),d.forbidden?i.jsx("div",{style:pE,"data-testid":"g7le-custom-assets-forbidden",children:n("layout_editor.custom_assets.forbidden")}):i.jsxs("div",{style:lE,children:[i.jsxs("div",{style:dE,"data-testid":"g7le-custom-assets-list",children:[d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.loading")}):null,!d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.empty")}):null,d.files.map(S=>i.jsxs("button",{type:"button",style:p?.path===S.path?uE:ip,onClick:()=>{w(S)},"data-testid":`g7le-custom-asset-row-${S.path}`,"data-loaded":S.loaded?"true":"false",children:[i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:S.path}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:xE(S.size)})]},S.path))]}),i.jsxs("div",{style:cE,children:[d.error?i.jsx("div",{style:fE,"data-testid":"g7le-custom-assets-error",children:d.error}):null,i.jsxs("div",{style:sp,children:[i.jsx("input",{type:"text",value:p?p.path:m,readOnly:!!p,onChange:S=>h(S.target.value),placeholder:n("layout_editor.custom_assets.new_path_placeholder"),style:bE,"aria-label":n("layout_editor.custom_assets.path_label"),"data-testid":"g7le-custom-assets-path"}),p?i.jsx("button",{type:"button",style:io,onClick:()=>{f(null),g("")},"data-testid":"g7le-custom-assets-new",children:n("layout_editor.custom_assets.new_file")}):null]}),p&&!p.editable?i.jsx("div",{style:{color:"#64748b",fontSize:12,lineHeight:1.7},children:n("layout_editor.custom_assets.binary_hint",{extension:p.extension})}):i.jsx("textarea",{value:b,onChange:S=>g(S.target.value),style:gE,spellCheck:!1,"aria-label":n("layout_editor.custom_assets.content_label"),"data-testid":"g7le-custom-assets-content"}),i.jsxs("div",{style:sp,children:[i.jsx("button",{type:"button",style:hE,disabled:v||(p?!p.editable:m.trim()===""),onClick:()=>{k()},"data-testid":"g7le-custom-assets-save",children:n("layout_editor.custom_assets.save")}),i.jsx("button",{type:"button",style:io,disabled:v,onClick:()=>_.current?.click(),"data-testid":"g7le-custom-assets-upload",children:n("layout_editor.custom_assets.upload")}),i.jsx("input",{ref:_,type:"file",style:{display:"none"},onChange:S=>{$(S)},"data-testid":"g7le-custom-assets-file-input"}),i.jsx("button",{type:"button",style:yE,disabled:v||!p,onClick:()=>{x()},"data-testid":"g7le-custom-assets-delete",children:n("layout_editor.custom_assets.delete")}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:n("layout_editor.custom_assets.apply_hint")})]})]})]})]})}const ap=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function kE(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function SE(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function CE(e){for(const t of ap)if(t.combos.some(n=>SE(n,e)))return t;return null}function $E(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(EE(e.key)),n.join(" + ")}function EE(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const IE=["clipboard","element","history","view","document"],AE={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function TE(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function DE({t:e,onClose:t,isMac:n}){const o=n??TE(),r=u.useMemo(()=>{const s=new Map;for(const a of ap){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:zE,children:[i.jsxs("div",{style:NE,children:[i.jsx("span",{style:ME,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:LE,children:"✕"})]}),i.jsx("div",{style:PE,children:IE.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:OE,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:BE,children:e(AE[s])}),i.jsx("table",{style:WE,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:jE,children:e(l.labelKey)}),i.jsx("td",{style:HE,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:KE,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:FE,children:$E(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:VE,children:e("layout_editor.shortcuts.footnote")})]})}const zE={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},NE={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},ME={fontSize:15,fontWeight:700,color:"#0f172a"},LE={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},PE={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},OE={display:"flex",flexDirection:"column",gap:6},BE={fontSize:12,fontWeight:700,color:"#2563eb"},WE={width:"100%",borderCollapse:"collapse"},jE={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},HE={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},FE={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},KE={fontSize:11,color:"#94a3b8",margin:"0 4px"},VE={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function UE(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function qE(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function lp(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(UE(s.target))return;const a=kE(s),l=CE(a);if(!l||qE()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function GE(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=tt();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(Bo,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:cp,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(Bo,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:cp,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_no_document"?i.jsx(Bo,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:ZE,children:i.jsxs("div",{style:QE,children:[i.jsx("h2",{style:RE,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:e7,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:t7,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:n7,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:o7,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:r7,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:i7,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function Bo(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=YE[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...XE,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:JE,children:"×"})]})}const YE={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},XE={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},JE={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},dp={margin:"8px 0 0",paddingLeft:20,fontSize:12},cp={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},ZE={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},QE={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},RE={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},e7={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},t7={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},n7={display:"flex",gap:8,justifyContent:"flex-end"},o7={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},r7={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},i7={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function up(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function ea(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function pp(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function fp(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function s7(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const a7="g7le-editor-route",l7=new Set;function ta(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function gp(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=ta(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const b of l.slice(d+1).split("|")){const g=b.indexOf("=");g!==-1&&(p[b.slice(0,g)]=b.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const b={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,b,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const b=window.G7Core?.t;if(typeof b=="function")try{const g=b(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const b=e.path;if(b&&b!=="/"){const g=b.split("/").filter(Boolean);return g[g.length-1]??b}return b==="/"?"/":c}return f}function d7(e){return e.trim().toLocaleLowerCase()}function hp(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ru(e){return JSON.stringify(Rs(e),null,2)}function Rs(e){if(Array.isArray(e))return e.map(Rs);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Rs(t[o]);return n}return e}const L$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},P$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},O$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},B$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},W$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},j$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},ep={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},H$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function F$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const tp={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function K$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ru(n),p=Ru(o);return N$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:L$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:P$,children:[i.jsxs("button",{type:"button",onClick:s,style:O$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:B$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:W$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:j$,children:d?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:H$,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,b)=>{const g=F$(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${b}`,children:[i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},b)})]},p))})]})}const V$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},U$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},q$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},G$={flex:1,minHeight:0,overflowY:"auto",padding:12},np={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},Y$={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},X$={display:"flex",flexDirection:"column",gap:8},J$={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},Z$={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},Q$={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},R$={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},eE={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},op={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function rp(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function tE(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function nE({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,isLoadingMore:l,error:d,restoringId:c,hasMore:p,loadVersions:f,loadMore:b,restore:g,loadVersionDetail:m}=T$(e,t,o),[h,v]=u.useState(null),[y,_]=u.useState(null),[w,k]=u.useState(null);u.useEffect(()=>{f()},[f]);const x=u.useCallback(async C=>{v(null),k(C.version);try{const T=await m(C.version);if(T.kind!=="success"){v(T.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):T.message);return}const E=C.version-1,A=s.some(D=>D.version===E);let N={};if(A){const D=await m(E);if(D.kind==="success")N=rp(D.detail);else if(D.kind==="network_error"){v(D.message);return}}_({oldVersion:A?E:0,newVersion:C.version,oldContent:N,newContent:rp(T.detail)})}finally{k(null)}},[m,s,n]),$=u.useCallback(async C=>{v(null);const T=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(T):!0))return;const A=await g(C);A.kind==="success"?r():A.kind==="not_found"?v(n("layout_editor.version_history.restore_not_found")):v(A.message)},[g,r,n]),S=h??d;return y?i.jsx(K$,{oldVersion:y.oldVersion,newVersion:y.newVersion,oldContent:y.oldContent,newContent:y.newContent,t:n,onBack:()=>_(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:V$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:U$,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:q$,"data-testid":"g7le-version-history-close",children:"✕"})]}),S&&i.jsx("div",{style:Y$,"data-testid":"g7le-version-history-error",children:S}),i.jsx("div",{style:G$,children:a?i.jsx("div",{style:np,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:np,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsxs("div",{"data-testid":"g7le-version-history-list",style:X$,children:[s.map((C,T)=>{const E=C.changes_summary,A=E?.added_count??0,N=E?.removed_count??0,D=E?.char_diff??0,B=tE(C.created_at),M=c===C.id,O=T===0;return i.jsxs("div",{style:J$,"data-testid":`g7le-version-row-${C.id}`,children:[i.jsxs("span",{style:Z$,"data-testid":`g7le-version-badge-${C.id}`,children:["v",C.version]}),i.jsxs("div",{style:eE,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[B&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:B}),O&&i.jsx("span",{style:Q$,"data-testid":`g7le-version-latest-${C.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:R$,"data-testid":`g7le-version-summary-${C.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${C.id}`,children:n("layout_editor.version_history.added",{count:A})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${C.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${C.id}`,children:n("layout_editor.version_history.chars",{diff:`${D>=0?"+":""}${D}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${C.id}`,children:n("layout_editor.version_history.saved_by",{name:C.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>void x(C),disabled:w!==null,style:{...op,background:"#f8fafc",opacity:w!==null?.6:1},"data-testid":`g7le-version-compare-${C.id}`,children:w===C.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!O&&i.jsx("button",{type:"button",onClick:()=>void $(C.id),disabled:M||c!==null,style:{...op,opacity:M||c!==null?.6:1},"data-testid":`g7le-version-restore-${C.id}`,children:n(M?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},C.id)}),p&&i.jsx("button",{type:"button","data-testid":"g7le-version-history-load-more",style:oE,disabled:l,onClick:()=>{b()},children:n(l?"layout_editor.version_history.loading":"layout_editor.version_history.load_more")})]})})]})}const oE={width:"100%",marginTop:8,padding:"8px 12px",fontSize:12,color:"#475569",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,cursor:"pointer"};function Po(e){return`/api/admin/extensions/${encodeURIComponent(e.type)}/${encodeURIComponent(e.identifier)}/custom-assets`}function Oo(e,t){const n=e?.message;return typeof n=="string"&&n!==""?n:`HTTP ${t}`}function rE(e){const[t,n]=u.useState([]),[o,r]=u.useState(null),[s,a]=u.useState(!1),[l,d]=u.useState(!1),[c,p]=u.useState(null),f=u.useCallback(async()=>{a(!0),p(null);try{const v=await fetch(Po(e),{credentials:"same-origin",headers:Ne()}),y=await v.json().catch(()=>null);if(v.status===401||v.status===403){d(!0),n([]);return}if(d(!1),!v.ok){p(Oo(y,v.status)),n([]);return}const _=y?.data??{},w=_.files;n(Array.isArray(w)?w:[]),r({editable_extensions:_.editable_extensions??[],uploadable_extensions:_.uploadable_extensions??[],max_text_bytes:_.max_text_bytes??0,max_upload_bytes:_.max_upload_bytes??0})}catch(v){p(v instanceof Error?v.message:"network error"),n([])}finally{a(!1)}},[e.type,e.identifier]),b=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}/content?path=${encodeURIComponent(v)}`,_=await fetch(y,{credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);if(!_.ok)return p(Oo(w,_.status)),null;const k=w?.data?.content;return typeof k=="string"?k:""}catch(y){return p(y instanceof Error?y.message:"network error"),null}},[e.type,e.identifier]),g=u.useCallback(async(v,y)=>{p(null);try{const _=await fetch(`${Po(e)}/content`,{method:"PUT",credentials:"same-origin",headers:Ne({"Content-Type":"application/json"}),body:JSON.stringify({path:v,content:y})}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),m=u.useCallback(async(v,y)=>{p(null);try{const _=new FormData;_.append("file",v),y&&_.append("directory",y);const w=await fetch(`${Po(e)}/upload`,{method:"POST",credentials:"same-origin",headers:Ne(),body:_}),k=await w.json().catch(()=>null);return w.ok?(await f(),!0):(p(Oo(k,w.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),h=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}?path=${encodeURIComponent(v)}`,_=await fetch(y,{method:"DELETE",credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(y){return p(y instanceof Error?y.message:"network error"),!1}},[e.type,e.identifier,f]);return{files:t,meta:o,isLoading:s,forbidden:l,error:c,reload:f,read:b,save:g,upload:m,remove:h}}const iE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},sE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc",fontWeight:600,color:"#0f172a"},aE={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},lE={flex:1,minHeight:0,display:"flex"},dE={width:260,flexShrink:0,borderRight:"1px solid #e2e8f0",overflowY:"auto",padding:8},cE={flex:1,minWidth:0,display:"flex",flexDirection:"column",padding:12,gap:8},ip={display:"flex",alignItems:"center",gap:6,width:"100%",padding:"6px 8px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",fontSize:12,color:"#334155",cursor:"pointer",textAlign:"left"},uE={...ip,backgroundColor:"#eff6ff",borderColor:"#bfdbfe",color:"#1d4ed8",fontWeight:600},pE={margin:16,padding:16,borderRadius:8,border:"1px solid #fcd34d",background:"#fffbeb",color:"#92400e",fontSize:13,lineHeight:1.7},fE={padding:"8px 12px",borderRadius:6,border:"1px solid #fecaca",background:"#fef2f2",color:"#b91c1c",fontSize:12},gE={flex:1,minHeight:220,width:"100%",boxSizing:"border-box",padding:10,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",fontFamily:"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",fontSize:12,lineHeight:1.6,resize:"vertical"},sp={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},io={padding:"6px 12px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",backgroundColor:"#ffffff",color:"#334155",fontSize:12,cursor:"pointer"},hE={...io,backgroundColor:"#2563eb",borderColor:"#2563eb",color:"#ffffff",fontWeight:600},yE={...io,borderColor:"#fca5a5",color:"#b91c1c"},bE={...io,cursor:"text",minWidth:200},mE={...io,cursor:"pointer",minWidth:220};function vE(e,t){if(typeof e=="string"&&e!=="")return e;if(e&&typeof e=="object"){const n=e,o=window?.G7Config?.locale??"ko",r=n[o]??n.ko??n.en??Object.values(n)[0];if(typeof r=="string"&&r!=="")return r}return t}function _E(e){const t=(typeof window<"u"?window.G7Config:null)??{},n=[{type:"template",identifier:e,label:e}];for(const[o,r]of[["module",t.activeModules],["plugin",t.activePlugins]])if(Array.isArray(r))for(const s of r){const a=typeof s?.identifier=="string"?s.identifier:"";a&&n.push({type:o,identifier:a,label:vE(s?.display_name,a)})}return n}function xE(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function wE(e){const{templateIdentifier:t,t:n,onClose:o}=e,r=u.useMemo(()=>_E(t),[t]),[s,a]=u.useState(`template:${t}`),l=u.useMemo(()=>r.find(S=>`${S.type}:${S.identifier}`===s)??r[0],[r,s]),d=rE({type:l.type,identifier:l.identifier}),{reload:c}=d,[p,f]=u.useState(null),[b,g]=u.useState(""),[m,h]=u.useState(""),[v,y]=u.useState(!1),_=u.useRef(null);u.useEffect(()=>{c()},[c]),u.useEffect(()=>{f(null),g(""),h("")},[s]);const w=u.useCallback(async S=>{if(f(S),!S.editable){g("");return}y(!0);const C=await d.read(S.path);y(!1),g(C??"")},[d]),k=u.useCallback(async()=>{const S=p?.path??m.trim();if(!S)return;y(!0);const C=await d.save(S,b);y(!1),C&&!p&&(h(""),f({path:S,name:S.split("/").pop()??S,extension:(S.split(".").pop()??"").toLowerCase(),size:b.length,modified_at:new Date().toISOString(),editable:!0,loaded:!0}))},[d,p,m,b]),x=u.useCallback(async()=>{if(!p||typeof window<"u"&&!window.confirm(n("layout_editor.custom_assets.delete_confirm")))return;y(!0);const S=await d.remove(p.path);y(!1),S&&(f(null),g(""))},[d,p,n]),$=u.useCallback(async S=>{const C=S.target.files?.[0];C&&(y(!0),await d.upload(C),y(!1),_.current&&(_.current.value=""))},[d]);return i.jsxs("div",{className:"g7le-custom-assets",style:iE,"data-testid":"g7le-custom-assets-modal",children:[i.jsxs("div",{style:sE,children:[i.jsxs("span",{children:["🎨 ",n("layout_editor.custom_assets.title")]}),i.jsx("select",{value:s,onChange:S=>a(S.target.value),style:mE,"aria-label":n("layout_editor.custom_assets.target_label"),"data-testid":"g7le-custom-assets-target",children:r.map(S=>i.jsxs("option",{value:`${S.type}:${S.identifier}`,children:[n(`layout_editor.custom_assets.type_${S.type}`)," · ",S.label]},`${S.type}:${S.identifier}`))}),i.jsx("button",{type:"button",style:aE,onClick:o,"aria-label":n("layout_editor.custom_assets.close"),"data-testid":"g7le-custom-assets-close",children:"✕"})]}),d.forbidden?i.jsx("div",{style:pE,"data-testid":"g7le-custom-assets-forbidden",children:n("layout_editor.custom_assets.forbidden")}):i.jsxs("div",{style:lE,children:[i.jsxs("div",{style:dE,"data-testid":"g7le-custom-assets-list",children:[d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.loading")}):null,!d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.empty")}):null,d.files.map(S=>i.jsxs("button",{type:"button",style:p?.path===S.path?uE:ip,onClick:()=>void w(S),"data-testid":`g7le-custom-asset-row-${S.path}`,"data-loaded":S.loaded?"true":"false",children:[i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:S.path}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:xE(S.size)})]},S.path))]}),i.jsxs("div",{style:cE,children:[d.error?i.jsx("div",{style:fE,"data-testid":"g7le-custom-assets-error",children:d.error}):null,i.jsxs("div",{style:sp,children:[i.jsx("input",{type:"text",value:p?p.path:m,readOnly:!!p,onChange:S=>h(S.target.value),placeholder:n("layout_editor.custom_assets.new_path_placeholder"),style:bE,"aria-label":n("layout_editor.custom_assets.path_label"),"data-testid":"g7le-custom-assets-path"}),p?i.jsx("button",{type:"button",style:io,onClick:()=>{f(null),g("")},"data-testid":"g7le-custom-assets-new",children:n("layout_editor.custom_assets.new_file")}):null]}),p&&!p.editable?i.jsx("div",{style:{color:"#64748b",fontSize:12,lineHeight:1.7},children:n("layout_editor.custom_assets.binary_hint",{extension:p.extension})}):i.jsx("textarea",{value:b,onChange:S=>g(S.target.value),style:gE,spellCheck:!1,"aria-label":n("layout_editor.custom_assets.content_label"),"data-testid":"g7le-custom-assets-content"}),i.jsxs("div",{style:sp,children:[i.jsx("button",{type:"button",style:hE,disabled:v||(p?!p.editable:m.trim()===""),onClick:()=>void k(),"data-testid":"g7le-custom-assets-save",children:n("layout_editor.custom_assets.save")}),i.jsx("button",{type:"button",style:io,disabled:v,onClick:()=>_.current?.click(),"data-testid":"g7le-custom-assets-upload",children:n("layout_editor.custom_assets.upload")}),i.jsx("input",{ref:_,type:"file",style:{display:"none"},onChange:S=>void $(S),"data-testid":"g7le-custom-assets-file-input"}),i.jsx("button",{type:"button",style:yE,disabled:v||!p,onClick:()=>void x(),"data-testid":"g7le-custom-assets-delete",children:n("layout_editor.custom_assets.delete")}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:n("layout_editor.custom_assets.apply_hint")})]})]})]})]})}const ap=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function kE(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function SE(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function CE(e){for(const t of ap)if(t.combos.some(n=>SE(n,e)))return t;return null}function $E(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(EE(e.key)),n.join(" + ")}function EE(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const IE=["clipboard","element","history","view","document"],AE={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function TE(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function DE({t:e,onClose:t,isMac:n}){const o=n??TE(),r=u.useMemo(()=>{const s=new Map;for(const a of ap){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:zE,children:[i.jsxs("div",{style:NE,children:[i.jsx("span",{style:ME,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:LE,children:"✕"})]}),i.jsx("div",{style:PE,children:IE.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:OE,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:BE,children:e(AE[s])}),i.jsx("table",{style:WE,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:jE,children:e(l.labelKey)}),i.jsx("td",{style:HE,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:KE,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:FE,children:$E(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:VE,children:e("layout_editor.shortcuts.footnote")})]})}const zE={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},NE={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},ME={fontSize:15,fontWeight:700,color:"#0f172a"},LE={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},PE={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},OE={display:"flex",flexDirection:"column",gap:6},BE={fontSize:12,fontWeight:700,color:"#2563eb"},WE={width:"100%",borderCollapse:"collapse"},jE={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},HE={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},FE={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},KE={fontSize:11,color:"#94a3b8",margin:"0 4px"},VE={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function UE(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function qE(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function lp(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(UE(s.target))return;const a=kE(s),l=CE(a);if(!l||qE()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function GE(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=tt();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(Bo,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:cp,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(Bo,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:cp,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_no_document"?i.jsx(Bo,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:ZE,children:i.jsxs("div",{style:QE,children:[i.jsx("h2",{style:RE,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:e7,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:t7,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:n7,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:o7,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:r7,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:i7,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function Bo(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=YE[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...XE,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:JE,children:"×"})]})}const YE={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},XE={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},JE={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},dp={margin:"8px 0 0",paddingLeft:20,fontSize:12},cp={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},ZE={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},QE={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},RE={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},e7={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},t7={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},n7={display:"flex",gap:8,justifyContent:"flex-end"},o7={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},r7={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},i7={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function up(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function ea(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function pp(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function fp(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function s7(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const a7="g7le-editor-route",l7=new Set;function ta(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function gp(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=ta(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const b of l.slice(d+1).split("|")){const g=b.indexOf("=");g!==-1&&(p[b.slice(0,g)]=b.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const b={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,b,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const b=window.G7Core?.t;if(typeof b=="function")try{const g=b(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const b=e.path;if(b&&b!=="/"){const g=b.split("/").filter(Boolean);return g[g.length-1]??b}return b==="/"?"/":c}return f}function d7(e){return e.trim().toLocaleLowerCase()}function hp(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r=0?(a.push(s.containerIndex),l=Bn(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=Bn(l);for(let m=0;m=0?(a.push(s.containerIndex),l=Bn(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=Bn(l);for(let m=0;mee?(Q.sortIndex=De,e(h,Q),n(f)===null&&Q===n(h)&&(x?(O(z),z=-1):x=!0,Et(B,De-ee))):(Q.sortIndex=me,e(f,Q),_||S||(_=!0,q||(q=!0,Ie()))),Q},s.unstable_shouldYield=_e,s.unstable_wrapCallback=function(Q){var ue=v;return function(){var De=v;v=ue;try{return Q.apply(this,arguments)}finally{v=De}}}})(Rd)),Rd}var jg;function s0(){return jg||(jg=1,kd.exports=i0()),kd.exports}var Dd={exports:{}},An={};var Hg;function o0(){if(Hg)return An;Hg=1;var s=xd();function e(f){var h="https://react.dev/errors/"+f;if(1