v7.0.0-beta.5 release
This commit is contained in:
1 parent
1db039ff34
commit
eb467aca79
159 files changed
+7599
-218
No files matched your search
+6
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.4
|
||||
APP_VERSION=7.0.0-beta.5
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
@@ -103,3 +103,8 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
|
||||
G7_UPDATE_GITHUB_TOKEN=
|
||||
G7_UPDATE_PENDING_PATH=
|
||||
|
||||
# spawn 자식 프로세스 실패 시 동작 모드 (기본: abort)
|
||||
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
|
||||
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
|
||||
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.4
|
||||
APP_VERSION=7.0.0-beta.5
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -4,6 +4,95 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.0-beta.5] - 2026-05-12
|
||||
|
||||
### Upgrade Notice
|
||||
|
||||
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다.
|
||||
|
||||
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
|
||||
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
|
||||
|
||||
beta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.
|
||||
|
||||
업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.
|
||||
|
||||
- `modules/`
|
||||
- `plugins/`
|
||||
- `templates/`
|
||||
- `lang-packs/`
|
||||
|
||||
업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.
|
||||
|
||||
#### 3가지 사용자군별 업그레이드 절차
|
||||
|
||||
- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.
|
||||
|
||||
- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.
|
||||
|
||||
- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:
|
||||
|
||||
```text
|
||||
beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)
|
||||
beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)
|
||||
```
|
||||
|
||||
각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.
|
||||
|
||||
#### 자동 롤백 후 `public/storage` 회복
|
||||
|
||||
업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.
|
||||
|
||||
**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:
|
||||
|
||||
- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)
|
||||
- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성
|
||||
- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)
|
||||
|
||||
**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:
|
||||
|
||||
```bash
|
||||
rm -rf public/storage
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
beta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.
|
||||
|
||||
#### spawn 자식 프로세스 실패 시 동작 모드
|
||||
|
||||
beta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:
|
||||
|
||||
1. `proc_open` 비활성 (일부 공유 호스팅)
|
||||
2. `proc_open` 자원 생성 실패
|
||||
3. 자식 비정상 종료 (uncaught exception)
|
||||
4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)
|
||||
|
||||
abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.
|
||||
|
||||
### Added
|
||||
|
||||
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
|
||||
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
|
||||
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단
|
||||
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드)
|
||||
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
|
||||
|
||||
### Changed
|
||||
|
||||
#### Breaking
|
||||
|
||||
- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요
|
||||
|
||||
### Fixed
|
||||
|
||||
- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내
|
||||
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
|
||||
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
|
||||
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
|
||||
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함)
|
||||
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
|
||||
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
|
||||
|
||||
## [7.0.0-beta.4] - 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.0-beta.4 g7
|
||||
# (필요 시) mv g7-7.0.0-beta.5 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.5-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -661,9 +661,12 @@ class CoreUpdateCommand extends Command
|
||||
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$log('proc_open 비활성 — spawn 스킵, in-process fallback 진행');
|
||||
|
||||
return false;
|
||||
return $this->failSpawnWithMode(
|
||||
'proc_open 비활성',
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', PHP_BINARY);
|
||||
@@ -708,17 +711,21 @@ class CoreUpdateCommand extends Command
|
||||
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
$log('spawn 실패 — proc_open 자원 생성 실패');
|
||||
|
||||
return false;
|
||||
return $this->failSpawnWithMode(
|
||||
'proc_open 자원 생성 실패',
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
|
||||
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
|
||||
// 단, [HANDOFF] 접두사 라인은 상위 콘솔로 노출하지 않고 페이로드만 보관한다
|
||||
// (exit=UpgradeHandoffException::EXIT_CODE 감지 시 UpgradeHandoffException 재구성용).
|
||||
// 단, [HANDOFF] / [STEPS_EXECUTED] 접두사 라인은 상위 콘솔로 노출하지 않고
|
||||
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
|
||||
$handoffPayload = null;
|
||||
$stepsExecuted = null;
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line !== false) {
|
||||
@@ -730,19 +737,30 @@ class CoreUpdateCommand extends Command
|
||||
if (str_starts_with($trimmed, '[HANDOFF] ')) {
|
||||
$json = substr($trimmed, strlen('[HANDOFF] '));
|
||||
$decoded = json_decode($json, true);
|
||||
// resumeCommand 는 null 허용 (자식이 null 로 전달한 경우 부모가
|
||||
// CoreUpdateCommand catch 분기에서 from/to 기반으로 자동 생성)
|
||||
if (is_array($decoded)
|
||||
&& array_key_exists('afterVersion', $decoded)
|
||||
&& array_key_exists('reason', $decoded)
|
||||
&& array_key_exists('resumeCommand', $decoded)
|
||||
) {
|
||||
// 페이로드 값 검증 — array_key_exists 만으로는 비정상 입력(빈 문자열·
|
||||
// shell metacharacter·과도 길이) 이 통과한다. resumeCommand 는 null
|
||||
// 허용 (자식이 null 로 전달한 경우 부모가 from/to 기반 자동 생성).
|
||||
if ($this->isValidHandoffPayload($decoded)) {
|
||||
$handoffPayload = $decoded;
|
||||
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
|
||||
|
||||
continue;
|
||||
}
|
||||
// 구조가 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
|
||||
// 구조/값이 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
|
||||
if (is_array($decoded)) {
|
||||
$log('[spawn] 핸드오프 페이로드 검증 실패 — 정상 출력으로 처리: '.json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, '[STEPS_EXECUTED] ')) {
|
||||
$json = substr($trimmed, strlen('[STEPS_EXECUTED] '));
|
||||
$decoded = json_decode($json, true);
|
||||
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
|
||||
$stepsExecuted = $decoded['count'];
|
||||
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line($trimmed);
|
||||
@@ -768,16 +786,133 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
if ($exitCode === 0) {
|
||||
$log('spawn 완료 (exit=0)');
|
||||
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
|
||||
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
|
||||
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
|
||||
if ($stepsExecuted === null) {
|
||||
return $this->failSpawnWithMode(
|
||||
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
}
|
||||
|
||||
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
|
||||
return $this->failSpawnWithMode(
|
||||
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
}
|
||||
|
||||
$log("spawn 완료 (exit=0, steps={$stepsExecuted})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$log("spawn 비정상 종료 (exit={$exitCode}) — fallback 진행");
|
||||
return $this->failSpawnWithMode(
|
||||
"spawn 비정상 종료 (exit={$exitCode})",
|
||||
$log,
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식 프로세스 실패 시 `app.update.spawn_failure_mode` 에 따라 분기합니다.
|
||||
*
|
||||
* - 'abort' (기본): `UpgradeHandoffException` 을 throw 하여 부모의 in-process
|
||||
* fallback 진입을 차단. 부모 catch 블록이 cleanup 후 운영자에게
|
||||
* `core:execute-upgrade-steps` 수동 명령을 안내한다.
|
||||
* - 'fallback' (호환): 기존 동작 — log 한 줄 남기고 false 반환. 호출자가
|
||||
* in-process fallback 으로 진행. 부모 메모리 stale 시 fatal 위험.
|
||||
*
|
||||
* @param string $reason 실패 사유 (로그/안내 메시지에 포함)
|
||||
* @param \Closure $log 로그 엔트리 수집 콜백
|
||||
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
|
||||
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
|
||||
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
|
||||
*
|
||||
* @throws UpgradeHandoffException mode=abort 일 때
|
||||
*/
|
||||
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
|
||||
{
|
||||
$mode = config('app.update.spawn_failure_mode', 'fallback');
|
||||
|
||||
if ($mode === 'abort') {
|
||||
$resumeCommand = sprintf(
|
||||
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
);
|
||||
|
||||
$log("spawn 자식 실패 — {$reason}. fail-fast 모드 abort.");
|
||||
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: $fromVersion,
|
||||
reason: "spawn 자식 실패 — {$reason}. fail-fast 모드. 수동 재개로 진행하세요.",
|
||||
resumeCommand: $resumeCommand,
|
||||
);
|
||||
}
|
||||
|
||||
$log("{$reason} — in-process fallback 진행 (stale 메모리 위험)");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* [HANDOFF] 페이로드의 값을 검증합니다.
|
||||
*
|
||||
* spawn 자식의 stdout 을 부모가 그대로 신뢰하면 (a) 빈 afterVersion 으로 Step 11
|
||||
* fromVersion 해석 혼란, (b) shell injection 문자열이 운영자 안내에 노출, (c) 과도한
|
||||
* reason 길이로 로그 폭증 위험. 본 메서드는 다음 4가지 키/값을 동시에 검증한다:
|
||||
*
|
||||
* - `afterVersion`: 정규식 `/^\d+\.\d+\.\d+/` 매치 + 비어있지 않음
|
||||
* - `reason`: string + 길이 < 500
|
||||
* - `resumeCommand`: null 허용. string 일 때 길이 < 1000 + shell metacharacter(`;&|`$()<>`) 부재
|
||||
*
|
||||
* @param mixed $payload json_decode 결과
|
||||
*/
|
||||
private function isValidHandoffPayload($payload): bool
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['afterVersion', 'reason', 'resumeCommand'] as $key) {
|
||||
if (! array_key_exists($key, $payload)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$afterVersion = $payload['afterVersion'];
|
||||
if (! is_string($afterVersion) || $afterVersion === '' || ! preg_match('/^\d+\.\d+\.\d+/', $afterVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reason = $payload['reason'];
|
||||
if (! is_string($reason) || strlen($reason) >= 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resumeCommand = $payload['resumeCommand'];
|
||||
if ($resumeCommand !== null) {
|
||||
if (! is_string($resumeCommand)) {
|
||||
return false;
|
||||
}
|
||||
if (strlen($resumeCommand) >= 1000) {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('/[;&|`$()<>]/', $resumeCommand)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 로그를 파일로 저장합니다.
|
||||
*
|
||||
|
||||
@@ -78,7 +78,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
$service->ensureWritableDirectories(
|
||||
$writablePaths,
|
||||
function (string $level, string $msg): void {
|
||||
// 콘솔 + upgrade 로그 채널 동시 출력 — PO 가 단일 파일(upgrade.log)에서
|
||||
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
|
||||
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
|
||||
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
|
||||
\Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg);
|
||||
@@ -91,11 +91,16 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
]);
|
||||
}
|
||||
|
||||
$stepsExecuted = 0;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps(
|
||||
$from,
|
||||
$to,
|
||||
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
|
||||
function (string $version) use (&$stepsExecuted): void {
|
||||
$this->info("upgrade step 실행: {$version}");
|
||||
$stepsExecuted++;
|
||||
},
|
||||
$force,
|
||||
);
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
@@ -134,6 +139,16 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는
|
||||
// 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로
|
||||
// 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw,
|
||||
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
|
||||
$this->line('[STEPS_EXECUTED] '.json_encode([
|
||||
'count' => $stepsExecuted,
|
||||
'fromVersion' => $from,
|
||||
'toVersion' => $to,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,17 @@ interface IdentityPolicyRepositoryInterface
|
||||
*/
|
||||
public function deleteByKey(string $key): bool;
|
||||
|
||||
/**
|
||||
* 정책 모델을 영속화합니다 (Eloquent save 위임).
|
||||
*
|
||||
* upsert/updateByKey 가 다루지 않는 부분 갱신 경로 (예: user_overrides 부분 복원) 에서 사용합니다.
|
||||
* Service 가 모델 객체에 직접 변경을 적용한 뒤 본 메서드로 저장 — Service-Repository 경유 패턴 유지.
|
||||
*
|
||||
* @param IdentityPolicy $policy 영속화할 모델
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function save(IdentityPolicy $policy): bool;
|
||||
|
||||
/**
|
||||
* source_type+source_identifier 에 속하지 않은 stale 정책을 제거합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\Upgrade\DataSnapshot;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* 버전별 데이터 스냅샷 규약을 따르는 업그레이드 스텝 기본 클래스.
|
||||
*
|
||||
* 7.0.0-beta.5 부터 신규 업그레이드 스텝은 본 추상 클래스를 상속해야 한다.
|
||||
* runUpgradeSteps 가 인스턴스 검증 직후 상속 여부를 확인하고 미상속 시 fatal.
|
||||
*
|
||||
* 격리 원칙 ("각 스텝별 동작 100% 동일 보장"):
|
||||
* - 카탈로그 시드는 data/{version}/*.delta.json 으로 동결
|
||||
* - Applier 는 data/{version}/appliers/ 안에 버전 namespace 로 동결
|
||||
* - 변환 / 단발성 핫픽스는 data/{version}/migrations/ 안에 버전 namespace 로 동결
|
||||
* - 스텝 파일 자체는 본 추상 클래스를 상속한 빈 껍데기 (모든 비즈니스 로직은 data/ 로 위임)
|
||||
*
|
||||
* 사용 예:
|
||||
*
|
||||
* class Upgrade_7_0_0_beta_5 extends AbstractUpgradeStep
|
||||
* {
|
||||
* // 모든 default 사용 — data/7.0.0-beta.5/ 의 manifest/appliers/migrations 가 SSoT
|
||||
* }
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §12
|
||||
*/
|
||||
abstract class AbstractUpgradeStep implements UpgradeStepInterface
|
||||
{
|
||||
/**
|
||||
* data/{currentStep}/manifest.json 을 로드 + Applier 동적 로드.
|
||||
*
|
||||
* 카탈로그 변동이 없는 스텝은 manifest 파일 없이 자동 빈 스냅샷 반환.
|
||||
*/
|
||||
protected function dataSnapshot(UpgradeContext $context): DataSnapshot
|
||||
{
|
||||
return DataSnapshot::fromManifest($this->dataDir($context), $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* data/{currentStep}/migrations/*.php 를 require_once + 버전 namespace 의 DataMigration
|
||||
* 구현체를 인스턴스화하여 배열로 반환. 디렉토리 부재 시 빈 배열.
|
||||
*
|
||||
* 파일명은 정렬용 prefix (예: `01_RecoverActiveExtensionDirs.php`, `02_...php`) 를 허용한다.
|
||||
* prefix 는 정규식 `^\d{2,}_` 로 클래스명 매핑 시 제거되며, 클래스명 자체는 prefix 없이
|
||||
* 선언한다 (PHP 식별자 제약). 파일 정렬 결과가 실행 순서 — 명시적 순서가 필요하면 prefix 사용.
|
||||
*
|
||||
* @return array<int, DataMigration>
|
||||
*/
|
||||
protected function dataMigrations(UpgradeContext $context): array
|
||||
{
|
||||
$dir = $this->dataDir($context).'/migrations';
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($dir.'/*.php') ?: [];
|
||||
sort($files);
|
||||
|
||||
$migrations = [];
|
||||
$namespace = DataSnapshot::versionedNamespace($context, $dir).'\\Migrations';
|
||||
|
||||
foreach ($files as $file) {
|
||||
require_once $file;
|
||||
$baseName = basename($file, '.php');
|
||||
$className = preg_replace('/^\d{2,}_/', '', $baseName);
|
||||
$fqcn = $namespace.'\\'.$className;
|
||||
|
||||
if (class_exists($fqcn) && is_subclass_of($fqcn, DataMigration::class)) {
|
||||
$migrations[] = new $fqcn;
|
||||
}
|
||||
}
|
||||
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카탈로그/변환 외 프레임워크 레벨 부수 작업 (선택).
|
||||
*
|
||||
* 본 PR 의 beta.5 재작성에서는 사용 안 함 — 모든 비즈니스 로직은 data/ 로 격리.
|
||||
* 미래에 *모든 버전 공통* 인 부수 작업이 필요하면 override.
|
||||
*
|
||||
* default: no-op.
|
||||
*/
|
||||
protected function postRun(UpgradeContext $context): void {}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝 실행 — 순서: dataSnapshot → dataMigrations → postRun.
|
||||
*
|
||||
* 본 메서드는 final — 하위 클래스가 순서 무력화 못 함.
|
||||
*/
|
||||
final public function run(UpgradeContext $context): void
|
||||
{
|
||||
$snapshot = $this->dataSnapshot($context);
|
||||
if ($snapshot->appliers !== []) {
|
||||
$context->logger->info(sprintf(
|
||||
'[%s] DataSnapshot 적용 — Applier %d개',
|
||||
$context->currentStep,
|
||||
count($snapshot->appliers),
|
||||
));
|
||||
}
|
||||
$snapshot->apply($context);
|
||||
|
||||
$migrations = $this->dataMigrations($context);
|
||||
if ($migrations !== []) {
|
||||
$context->logger->info(sprintf(
|
||||
'[%s] DataMigration 실행 — %d개',
|
||||
$context->currentStep,
|
||||
count($migrations),
|
||||
));
|
||||
}
|
||||
foreach ($migrations as $migration) {
|
||||
$context->logger->info(sprintf(
|
||||
'[%s] migration: %s',
|
||||
$context->currentStep,
|
||||
$migration->name(),
|
||||
));
|
||||
$migration->run($context);
|
||||
}
|
||||
|
||||
$this->postRun($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* data/{currentStep}/ 절대 경로 계산. 스텝 파일 위치 기반 — 코어/확장 무관.
|
||||
*/
|
||||
protected function dataDir(UpgradeContext $context): string
|
||||
{
|
||||
$stepFile = (new ReflectionClass($this))->getFileName();
|
||||
|
||||
return dirname($stepFile).'/data/'.$context->currentStep;
|
||||
}
|
||||
}
|
||||
@@ -927,6 +927,11 @@ PHP;
|
||||
$env['COMPOSER_HOME'] = $composerHome;
|
||||
$env['HOME'] = $composerHome;
|
||||
|
||||
// root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한
|
||||
// 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응)
|
||||
$env['COMPOSER_ALLOW_SUPERUSER'] = '1';
|
||||
$env['COMPOSER_NO_INTERACTION'] = '1';
|
||||
|
||||
// Windows: TEMP 디렉토리가 없거나 쓰기 불가능하면 storage/temp 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* 확장(모듈/플러그인) 업그레이드 스텝의 AbstractUpgradeStep 상속 의무 가드.
|
||||
*
|
||||
* 코어 7.0.0-beta.5 에서 도입된 "버전별 데이터 스냅샷 규약" (AbstractUpgradeStep 의무) 을
|
||||
* 확장 측에 동일 수준으로 적용한다.
|
||||
*
|
||||
* 의무 시작 버전 결정 규칙:
|
||||
* - 확장의 manifest g7_version 제약이 7.0.0-beta.5 미만의 코어를 허용한다면 legacy
|
||||
* (가드 미발동) — 그 확장은 본 인프라가 없던 코어를 인지하는 작성본이라 호환 보장.
|
||||
* - 제약 최소 버전이 7.0.0-beta.5 이상이면 확장이 본 인프라를 인지함 → 그 확장의 현재
|
||||
* working version 부터 step 작성 시 AbstractUpgradeStep 상속 의무.
|
||||
*
|
||||
* 즉 확장 작성자가 g7_version 을 7.0.0-beta.5 이상으로 상향하는 시점이 본 규약의 자동
|
||||
* 적용 첫 버전이 된다.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §13
|
||||
*/
|
||||
class ExtensionUpgradeGuardHelper
|
||||
{
|
||||
/**
|
||||
* AbstractUpgradeStep 의무가 도입된 코어 버전.
|
||||
*/
|
||||
public const ABSTRACT_INTRODUCED_AT = '7.0.0-beta.5';
|
||||
|
||||
/**
|
||||
* 확장의 AbstractUpgradeStep 의무 시작 버전을 계산합니다.
|
||||
*
|
||||
* @param string|null $g7VersionConstraint manifest 의 g7_version (예: ">=7.0.0-beta.5")
|
||||
* @param string $extensionWorkingVersion manifest 의 version (예: "1.0.0-beta.5")
|
||||
* @return string|null 의무 시작 버전 또는 null (가드 미발동 — legacy)
|
||||
*/
|
||||
public static function resolveSinceVersion(
|
||||
?string $g7VersionConstraint,
|
||||
string $extensionWorkingVersion,
|
||||
): ?string {
|
||||
if ($g7VersionConstraint === null || trim($g7VersionConstraint) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minVersion = self::extractMinimumVersion($g7VersionConstraint);
|
||||
if ($minVersion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (version_compare($minVersion, self::ABSTRACT_INTRODUCED_AT, '<')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $extensionWorkingVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 step 이 본 가드를 통과하는지 검증합니다.
|
||||
*
|
||||
* stepVersion < sinceVersion 인 step (확장 버전 이전의 legacy step) 은 미발동.
|
||||
* stepVersion >= sinceVersion 인 step 이 AbstractUpgradeStep 미상속이면 fatal.
|
||||
*
|
||||
* @param string $stepVersion 실행 중인 step 의 버전
|
||||
* @param string $sinceVersion 의무 시작 버전 (resolveSinceVersion 결과; null 아님)
|
||||
* @param UpgradeStepInterface $step step 인스턴스
|
||||
* @param string $extensionType 확장 타입 ("module" / "plugin")
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
*
|
||||
* @throws \RuntimeException stepVersion >= sinceVersion 인데 AbstractUpgradeStep 미상속 시
|
||||
*/
|
||||
public static function assertAbstractInheritance(
|
||||
string $stepVersion,
|
||||
string $sinceVersion,
|
||||
UpgradeStepInterface $step,
|
||||
string $extensionType,
|
||||
string $extensionIdentifier,
|
||||
): void {
|
||||
if (version_compare($stepVersion, $sinceVersion, '<')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($step instanceof AbstractUpgradeStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Extension %s "%s" upgrade step %s must extend App\\Extension\\AbstractUpgradeStep '
|
||||
.'(required from %s; introduced in core %s).',
|
||||
$extensionType,
|
||||
$extensionIdentifier,
|
||||
$stepVersion,
|
||||
$sinceVersion,
|
||||
self::ABSTRACT_INTRODUCED_AT,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Versioning 제약 문자열에서 최소 버전을 추출합니다.
|
||||
*
|
||||
* 지원 형식:
|
||||
* - ">=X.Y.Z[-suffix]" / ">X.Y.Z" / "X.Y.Z"
|
||||
* - "^X.Y.Z" / "~X.Y.Z"
|
||||
* - "A|B" (OR) — 첫 후보 기준
|
||||
*
|
||||
* 파싱 실패 시 null 반환 (보수적으로 가드 미발동).
|
||||
*/
|
||||
private static function extractMinimumVersion(string $constraint): ?string
|
||||
{
|
||||
$constraint = trim($constraint);
|
||||
|
||||
$pipeIdx = strpos($constraint, '|');
|
||||
if ($pipeIdx !== false) {
|
||||
$constraint = trim(substr($constraint, 0, $pipeIdx));
|
||||
}
|
||||
|
||||
if (preg_match(
|
||||
'/^(?:>=|>|\^|~|=)?\s*([0-9]+\.[0-9]+\.[0-9]+(?:[\-\.][a-zA-Z0-9\.\-]+)?)/',
|
||||
$constraint,
|
||||
$m,
|
||||
)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ class FilePermissionHelper
|
||||
|
||||
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
|
||||
|
||||
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
|
||||
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
|
||||
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
|
||||
if ($item->isLink()) {
|
||||
if (static::copySymlink($item->getPathname(), $destPath)) {
|
||||
continue;
|
||||
}
|
||||
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
|
||||
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
|
||||
}
|
||||
|
||||
if ($item->isDir()) {
|
||||
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans);
|
||||
} else {
|
||||
@@ -62,6 +73,50 @@ class FilePermissionHelper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* symlink 자체를 보존 복사합니다 (target 미추적).
|
||||
*
|
||||
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
|
||||
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
|
||||
*
|
||||
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
|
||||
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
|
||||
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
|
||||
* 명령으로 symlink 를 회복할 수 있다.
|
||||
*
|
||||
* @param string $source 소스 symlink 경로
|
||||
* @param string $destination 대상 symlink 경로
|
||||
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
|
||||
*/
|
||||
protected static function copySymlink(string $source, string $destination): bool
|
||||
{
|
||||
$target = @readlink($source);
|
||||
if ($target === false) {
|
||||
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기존 dest 정리 — symlink/파일 은 unlink, 디렉토리는 deleteDirectory
|
||||
if (is_link($destination) || is_file($destination)) {
|
||||
@unlink($destination);
|
||||
} elseif (is_dir($destination)) {
|
||||
File::deleteDirectory($destination);
|
||||
}
|
||||
|
||||
if (! @symlink($target, $destination)) {
|
||||
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'target' => $target,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스에 없고 대상에만 있는 파일/디렉토리를 삭제합니다.
|
||||
*
|
||||
@@ -90,7 +145,12 @@ class FilePermissionHelper
|
||||
|
||||
// 소스에 존재하지 않는 항목만 삭제
|
||||
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
|
||||
if ($destItem->isDir()) {
|
||||
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
|
||||
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
|
||||
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
|
||||
if (is_link($destItem->getPathname())) {
|
||||
@unlink($destItem->getPathname());
|
||||
} elseif ($destItem->isDir()) {
|
||||
File::deleteDirectory($destItem->getPathname());
|
||||
} else {
|
||||
File::delete($destItem->getPathname());
|
||||
|
||||
@@ -190,6 +190,7 @@ class IdentityVerificationManager
|
||||
* 특정 purpose 에 사용할 프로바이더를 해석합니다.
|
||||
*
|
||||
* 해석 순서:
|
||||
* 0. 명시 $providerId 가 등록 + supportsPurpose 통과 → 사용 (호출자 명시 우선)
|
||||
* 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용
|
||||
* 2. 기본 프로바이더가 purpose 지원 → 사용
|
||||
* 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용
|
||||
@@ -198,13 +199,21 @@ class IdentityVerificationManager
|
||||
* 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는
|
||||
* 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다.
|
||||
*
|
||||
* $providerId 가 등록되지 않았거나 purpose 미지원이면 silent fallback (1~4 단계 진행).
|
||||
* 운영자가 정책에 명시한 provider 가 비활성화/미설치된 상태에서도 가입 흐름이 중단되지 않도록 한다.
|
||||
*
|
||||
* @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등)
|
||||
* @param string|null $providerId 호출자 명시 provider id (Mode A controller 요청 / Mode B 정책)
|
||||
* @return IdentityVerificationInterface 해석된 provider
|
||||
*/
|
||||
public function resolveForPurpose(string $purpose): IdentityVerificationInterface
|
||||
public function resolveForPurpose(string $purpose, ?string $providerId = null): IdentityVerificationInterface
|
||||
{
|
||||
$providers = $this->all();
|
||||
|
||||
if ($providerId !== null && $providerId !== '' && isset($providers[$providerId]) && $providers[$providerId]->supportsPurpose($purpose)) {
|
||||
return $providers[$providerId];
|
||||
}
|
||||
|
||||
$explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", '');
|
||||
if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) {
|
||||
return $providers[$explicitId];
|
||||
|
||||
@@ -24,6 +24,7 @@ use App\Extension\Helpers\ExtensionMenuSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionPendingHelper;
|
||||
use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Providers\CoreServiceProvider;
|
||||
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
|
||||
@@ -772,7 +773,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
|
||||
}
|
||||
|
||||
@@ -867,7 +868,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
if ($result) {
|
||||
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
|
||||
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
|
||||
// PO 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
||||
// 운영 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
||||
if ($deleteData) {
|
||||
$this->removeModulePermissionsAndMenus($module);
|
||||
|
||||
@@ -4044,6 +4045,20 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$stepContext->logger->info("Upgrading {$module->getIdentifier()} step {$stepVersion}...");
|
||||
|
||||
if ($step instanceof UpgradeStepInterface) {
|
||||
$sinceVersion = ExtensionUpgradeGuardHelper::resolveSinceVersion(
|
||||
$module->getRequiredCoreVersion(),
|
||||
$module->getVersion(),
|
||||
);
|
||||
if ($sinceVersion !== null) {
|
||||
ExtensionUpgradeGuardHelper::assertAbstractInheritance(
|
||||
(string) $stepVersion,
|
||||
$sinceVersion,
|
||||
$step,
|
||||
'module',
|
||||
$module->getIdentifier(),
|
||||
);
|
||||
}
|
||||
|
||||
$step->run($stepContext);
|
||||
} elseif (is_callable($step)) {
|
||||
$step($stepContext);
|
||||
@@ -4457,7 +4472,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
/**
|
||||
* --force 시 업데이트 소스를 결정합니다.
|
||||
*
|
||||
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
|
||||
*
|
||||
* 우선순위:
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Extension\Helpers\IdentityMessageSyncHelper;
|
||||
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
||||
use App\Extension\Helpers\NotificationSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Providers\CoreServiceProvider;
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
@@ -756,7 +757,7 @@ class PluginManager implements PluginManagerInterface
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
|
||||
}
|
||||
|
||||
@@ -887,7 +888,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
if ($result) {
|
||||
// 권한/역할은 $deleteData=true 시에만 삭제.
|
||||
// PO 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
||||
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
||||
if ($deleteData) {
|
||||
$this->removePluginPermissions($plugin);
|
||||
|
||||
@@ -4208,7 +4209,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
if (empty($filteredSteps)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 버전순 정렬
|
||||
uksort($filteredSteps, 'version_compare');
|
||||
@@ -4226,6 +4227,20 @@ class PluginManager implements PluginManagerInterface
|
||||
$stepContext->logger->info("Upgrading {$plugin->getIdentifier()} step {$stepVersion}...");
|
||||
|
||||
if ($step instanceof UpgradeStepInterface) {
|
||||
$sinceVersion = ExtensionUpgradeGuardHelper::resolveSinceVersion(
|
||||
$plugin->getRequiredCoreVersion(),
|
||||
$plugin->getVersion(),
|
||||
);
|
||||
if ($sinceVersion !== null) {
|
||||
ExtensionUpgradeGuardHelper::assertAbstractInheritance(
|
||||
(string) $stepVersion,
|
||||
$sinceVersion,
|
||||
$step,
|
||||
'plugin',
|
||||
$plugin->getIdentifier(),
|
||||
);
|
||||
}
|
||||
|
||||
$step->run($stepContext);
|
||||
} elseif (is_callable($step)) {
|
||||
$step($stepContext);
|
||||
@@ -4637,7 +4652,7 @@ class PluginManager implements PluginManagerInterface
|
||||
/**
|
||||
* --force 시 업데이트 소스를 결정합니다.
|
||||
*
|
||||
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
|
||||
*
|
||||
* 우선순위:
|
||||
|
||||
@@ -672,7 +672,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
HookManager::doAction('core.templates.after_deactivate', $templateName);
|
||||
|
||||
Log::info(__('templates.messages.template_deactivated'), [
|
||||
@@ -3116,7 +3116,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
/**
|
||||
* --force 시 업데이트 소스를 결정합니다.
|
||||
*
|
||||
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
|
||||
*
|
||||
* 우선순위:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
/**
|
||||
* 카탈로그 JSON 으로 표현 불가한 imperative 변환 / 단발성 핫픽스 단위.
|
||||
*
|
||||
* 각 버전이 자기 시점의 변환 로직을 본 인터페이스 구현체로
|
||||
* `upgrades/data/{version}/migrations/*.php` 에 작성한다.
|
||||
*
|
||||
* 적용 대상:
|
||||
* - 컬럼 의미 변경 / 데이터 변환 (예: dot-path 변환)
|
||||
* - 카탈로그 rename 의 충돌 경로 (Applier 가 처리하지 못하는 분기)
|
||||
* - 단발성 파일시스템 핫픽스 (활성 디렉토리 복구, symlink 복구 등)
|
||||
* - 그 버전 고유의 DB 보정 로직
|
||||
*
|
||||
* 구현 의무 (Applier 와 동일):
|
||||
* - **idempotent**: 두 번 실행되어도 동일 결과
|
||||
* - **V-1 안전**: 로컬 헬퍼 + `Illuminate\Support\Facades\*` 만 사용
|
||||
* - **버전 격리**: 다른 버전 namespace 참조 금지
|
||||
*
|
||||
* 인스턴스화: `new {Migration}()` (인자 없음).
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §12
|
||||
*/
|
||||
interface DataMigration
|
||||
{
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자 (예: "IdentityPermissionPivotMerge")
|
||||
*/
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* 변환 / 핫픽스 적용. idempotent 의무.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
|
||||
* @return void
|
||||
*
|
||||
* @throws \Throwable 적용 실패 시 — runUpgradeSteps 가 상위에서 백업 복원 처리
|
||||
*/
|
||||
public function run(UpgradeContext $context): void;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 한 업그레이드 스텝이 적용할 카탈로그 delta 의 모음.
|
||||
*
|
||||
* `data/{version}/manifest.json` 을 읽어 각 kind 의 delta JSON 과 그에 대응하는
|
||||
* 버전 namespace 의 Applier 를 동적 로드 + 인스턴스화하여 `SnapshotApplier` 배열로 보유.
|
||||
*
|
||||
* 사용:
|
||||
* $snapshot = DataSnapshot::fromManifest($dataDir, $context);
|
||||
* $snapshot->apply($context);
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §12
|
||||
*/
|
||||
final class DataSnapshot
|
||||
{
|
||||
/** @var array<int, SnapshotApplier> */
|
||||
public readonly array $appliers;
|
||||
|
||||
/**
|
||||
* @param array<int, SnapshotApplier> $appliers
|
||||
*/
|
||||
public function __construct(array $appliers)
|
||||
{
|
||||
$this->appliers = $appliers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 스냅샷 (카탈로그 변동이 없는 스텝용).
|
||||
*
|
||||
* @return self 빈 Applier 배열을 보유한 DataSnapshot
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* data/{version}/manifest.json 을 로드하여 DataSnapshot 인스턴스 생성.
|
||||
*
|
||||
* manifest 부재 시 빈 스냅샷 반환 (V-1 호환 — 디렉토리 없어도 동작).
|
||||
*
|
||||
* @param string $dataDir upgrades/data/{version} 절대 경로
|
||||
* @param UpgradeContext $context 현재 스텝 버전 정보 (namespace 계산용)
|
||||
* @return self manifest 의 kind 별로 Applier 를 로드한 DataSnapshot
|
||||
*
|
||||
* @throws RuntimeException manifest JSON 파싱 실패 / Applier 클래스 부재 시
|
||||
*/
|
||||
public static function fromManifest(string $dataDir, UpgradeContext $context): self
|
||||
{
|
||||
$manifestPath = $dataDir.'/manifest.json';
|
||||
if (! is_file($manifestPath)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
$manifest = json_decode(
|
||||
file_get_contents($manifestPath),
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$appliers = [];
|
||||
foreach ($manifest['files'] ?? [] as $entry) {
|
||||
$kind = $entry['kind'] ?? null;
|
||||
$file = $entry['file'] ?? null;
|
||||
if (! is_string($kind) || ! is_string($file)) {
|
||||
throw new RuntimeException(
|
||||
"Invalid manifest entry in {$manifestPath}: kind/file required"
|
||||
);
|
||||
}
|
||||
|
||||
$applierClass = $entry['applier_class'] ?? self::kindToClassName($kind);
|
||||
$appliers[] = self::makeApplier(
|
||||
applierClass: $applierClass,
|
||||
kind: $kind,
|
||||
dataDir: $dataDir,
|
||||
jsonPath: $dataDir.'/'.$file,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
return new self($appliers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보유한 모든 Applier 를 순차 실행.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
|
||||
*/
|
||||
public function apply(UpgradeContext $context): void
|
||||
{
|
||||
foreach ($this->appliers as $applier) {
|
||||
$applier->apply($context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 namespace 의 Applier 클래스를 require_once + 인스턴스화.
|
||||
*
|
||||
* 예: kind="permissions", context->currentStep="7.0.0-beta.5"
|
||||
* → 클래스 "App\Upgrades\Data\V7_0_0_beta_5\Appliers\PermissionsApplier"
|
||||
* → 파일 "{dataDir}/appliers/PermissionsApplier.php"
|
||||
*
|
||||
* @throws RuntimeException Applier 파일/클래스 부재 또는 인터페이스 미구현 시
|
||||
*/
|
||||
private static function makeApplier(
|
||||
string $applierClass,
|
||||
string $kind,
|
||||
string $dataDir,
|
||||
string $jsonPath,
|
||||
UpgradeContext $context,
|
||||
): SnapshotApplier {
|
||||
$applierFile = $dataDir.'/appliers/'.$applierClass.'.php';
|
||||
if (! is_file($applierFile)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Applier file not found: %s (kind=%s)',
|
||||
$applierFile,
|
||||
$kind,
|
||||
));
|
||||
}
|
||||
|
||||
require_once $applierFile;
|
||||
|
||||
$fqcn = self::versionedNamespace($context, $dataDir).'\\Appliers\\'.$applierClass;
|
||||
if (! class_exists($fqcn)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Applier class not declared in expected namespace: %s (file=%s)',
|
||||
$fqcn,
|
||||
$applierFile,
|
||||
));
|
||||
}
|
||||
if (! is_subclass_of($fqcn, SnapshotApplier::class)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Applier class must implement SnapshotApplier: %s',
|
||||
$fqcn,
|
||||
));
|
||||
}
|
||||
|
||||
return new $fqcn($jsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 스텝 버전의 namespace prefix 계산.
|
||||
*
|
||||
* 코어:
|
||||
* "7.0.0-beta.5" → "App\Upgrades\Data\V7_0_0_beta_5"
|
||||
* 번들/외부 모듈:
|
||||
* path 가 `.../modules/.../upgrades/data/{ver}/` → "App\Upgrades\Data\Ext\Modules\{StudlyIdentifier}\V{token}"
|
||||
* 번들/외부 플러그인:
|
||||
* path 가 `.../plugins/.../upgrades/data/{ver}/` → "App\Upgrades\Data\Ext\Plugins\{StudlyIdentifier}\V{token}"
|
||||
*
|
||||
* 확장 식별자 segment 가 namespace 에 포함되어 두 다른 확장의 같은 step 버전 + 같은
|
||||
* 클래스명 이 *별개* FQCN 으로 격리된다. 이로써 PHP compile-time fatal
|
||||
* ("Cannot declare class ... because the name is already in use") 회귀가 차단된다.
|
||||
*
|
||||
* sourceLocation 미지정 시 코어 namespace 로 폴백 (기존 동작 보존).
|
||||
*
|
||||
* @param UpgradeContext $context 현재 스텝 버전 정보 보유
|
||||
* @param string|null $sourceLocation step 파일 또는 dataDir 의 절대 경로 (코어/확장 분기 결정)
|
||||
* @return string PSR-4 호환 namespace prefix (Applier/Migration 으로 이어짐)
|
||||
*/
|
||||
public static function versionedNamespace(UpgradeContext $context, ?string $sourceLocation = null): string
|
||||
{
|
||||
$token = 'V'.str_replace(['.', '-'], '_', $context->currentStep);
|
||||
$base = 'App\\Upgrades\\Data';
|
||||
|
||||
if ($sourceLocation !== null) {
|
||||
$normalized = str_replace('\\', '/', $sourceLocation);
|
||||
if (preg_match(
|
||||
'#(?:^|/)(modules|plugins)(?:/_bundled)?/([^/]+)/upgrades(?:/data)?/#',
|
||||
$normalized,
|
||||
$match,
|
||||
)) {
|
||||
$type = $match[1] === 'modules' ? 'Modules' : 'Plugins';
|
||||
$identifier = self::studlyIdentifier($match[2]);
|
||||
|
||||
return "{$base}\\Ext\\{$type}\\{$identifier}\\{$token}";
|
||||
}
|
||||
}
|
||||
|
||||
return "{$base}\\{$token}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 식별자(`sirsoft-ecommerce` 등) 를 PHP namespace-safe StudlyCase 로 변환.
|
||||
*
|
||||
* "sirsoft-ecommerce" → "SirsoftEcommerce"
|
||||
* "vendor_module_v2" → "VendorModuleV2"
|
||||
*/
|
||||
private static function studlyIdentifier(string $identifier): string
|
||||
{
|
||||
$parts = preg_split('/[-_]+/', $identifier) ?: [$identifier];
|
||||
$studly = '';
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '') {
|
||||
continue;
|
||||
}
|
||||
$studly .= ucfirst($part);
|
||||
}
|
||||
|
||||
return $studly !== '' ? $studly : 'Anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
* snake_case kind → StudlyCase Applier 클래스명.
|
||||
*
|
||||
* permissions → PermissionsApplier
|
||||
* role_permissions → RolePermissionsApplier
|
||||
* notification_definitions → NotificationDefinitionsApplier
|
||||
*/
|
||||
private static function kindToClassName(string $kind): string
|
||||
{
|
||||
$studly = str_replace(' ', '', ucwords(str_replace('_', ' ', $kind)));
|
||||
|
||||
return $studly.'Applier';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
/**
|
||||
* 카탈로그 delta JSON 한 파일을 DB 에 idempotent 하게 적용하는 단위.
|
||||
*
|
||||
* 각 버전이 자기가 사용하는 kind 별로 본 인터페이스 구현체를
|
||||
* `upgrades/data/{version}/appliers/{Kind}Applier.php` 에 작성한다.
|
||||
*
|
||||
* 구현 의무:
|
||||
* - **raw JSON 만 read**: 시더 클래스(`Database\Seeders\*`) 호출 금지
|
||||
* - **idempotent**: `Schema::hasColumn` / `where->exists()` 가드 동반
|
||||
* - **V-1 안전**: 로컬 SQL/Eloquent 만 사용, `app(*Service|*Manager|*Repository::class)` 금지
|
||||
* - **버전 격리**: 다른 버전 namespace 의 클래스 참조 금지
|
||||
*
|
||||
* 인스턴스화: `new {Kind}Applier($absoluteJsonPath)`.
|
||||
* 생성자는 delta JSON 파일의 절대 경로를 받아 `apply()` 시 read 한다.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §12
|
||||
*/
|
||||
interface SnapshotApplier
|
||||
{
|
||||
/**
|
||||
* delta JSON 을 read 하여 DB 에 적용.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
|
||||
* @return void
|
||||
*
|
||||
* @throws \Throwable 적용 실패 시 — runUpgradeSteps 가 상위에서 백업 복원 처리
|
||||
*/
|
||||
public function apply(UpgradeContext $context): void;
|
||||
}
|
||||
+41
-1
@@ -24,6 +24,8 @@ class EnvironmentDetector
|
||||
|
||||
/**
|
||||
* proc_open() 함수 사용 가능 여부.
|
||||
*
|
||||
* @return bool proc_open 호출 가능 여부 (disable_functions 차단 시 false)
|
||||
*/
|
||||
public function hasProcOpen(): bool
|
||||
{
|
||||
@@ -38,6 +40,8 @@ class EnvironmentDetector
|
||||
|
||||
/**
|
||||
* shell_exec() 함수 사용 가능 여부.
|
||||
*
|
||||
* @return bool shell_exec 호출 가능 여부 (disable_functions 차단 시 false)
|
||||
*/
|
||||
public function hasShellExec(): bool
|
||||
{
|
||||
@@ -52,6 +56,8 @@ class EnvironmentDetector
|
||||
|
||||
/**
|
||||
* ZipArchive 클래스 사용 가능 여부.
|
||||
*
|
||||
* @return bool ext-zip 활성화 여부
|
||||
*/
|
||||
public function hasZipArchive(): bool
|
||||
{
|
||||
@@ -63,6 +69,9 @@ class EnvironmentDetector
|
||||
*
|
||||
* 우선순위: hint → config(process.composer_binary) → $_ENV['COMPOSER_BINARY']
|
||||
* → PATH 검색 → composer.phar
|
||||
*
|
||||
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
*/
|
||||
public function findComposerBinary(?string $hint = null): ?string
|
||||
{
|
||||
@@ -108,6 +117,9 @@ class EnvironmentDetector
|
||||
* composer 실행 가능 여부 종합 판단.
|
||||
*
|
||||
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
|
||||
*
|
||||
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
*/
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
@@ -135,7 +147,7 @@ class EnvironmentDetector
|
||||
],
|
||||
$pipes,
|
||||
null,
|
||||
null,
|
||||
self::buildComposerEnv(),
|
||||
['bypass_shell' => true]
|
||||
);
|
||||
|
||||
@@ -163,6 +175,9 @@ class EnvironmentDetector
|
||||
|
||||
/**
|
||||
* 지정 경로에 쓰기 가능한지 확인.
|
||||
*
|
||||
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
*/
|
||||
public function canWriteVendor(string $targetPath): bool
|
||||
{
|
||||
@@ -174,6 +189,7 @@ class EnvironmentDetector
|
||||
/**
|
||||
* 인스톨러 Step 2 요구사항 체크용 종합 리포트.
|
||||
*
|
||||
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
|
||||
* @return array{
|
||||
* proc_open: bool,
|
||||
* shell_exec: bool,
|
||||
@@ -210,6 +226,30 @@ class EnvironmentDetector
|
||||
$this->cachedComposerBinary = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_open() 5번째 인자용 composer 환경변수 배열을 구성합니다.
|
||||
*
|
||||
* root/super user 환경 + 비대화형(웹) 컨텍스트에서 composer 가 interactive
|
||||
* 경고를 출력하며 비정상 종료하는 문제를 차단합니다.
|
||||
*
|
||||
* 현재 프로세스의 환경변수(getenv() + $_ENV)에 두 composer 전용 변수를 마지막에
|
||||
* 덮어쓰기 — 외부에서 0 으로 설정되어 있어도 강제로 1.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function buildComposerEnv(): array
|
||||
{
|
||||
$current = getenv();
|
||||
if (! is_array($current)) {
|
||||
$current = [];
|
||||
}
|
||||
|
||||
return array_merge($current, $_ENV, [
|
||||
'COMPOSER_ALLOW_SUPERUSER' => '1',
|
||||
'COMPOSER_NO_INTERACTION' => '1',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 실행 명령 문자열 구성.
|
||||
*
|
||||
|
||||
+11
-2
@@ -68,6 +68,8 @@ class VendorBundler
|
||||
*
|
||||
* 러너는 `fn (string $stagingDir): void` 형식이며, 주어진 스테이징 디렉토리에
|
||||
* vendor/ 구조를 직접 생성해야 합니다.
|
||||
*
|
||||
* @param \Closure|null $runner 대체 러너 (null 이면 기본 composer install 사용)
|
||||
*/
|
||||
public function setComposerInstallRunner(?\Closure $runner): void
|
||||
{
|
||||
@@ -78,6 +80,9 @@ class VendorBundler
|
||||
* 코어(루트) vendor/를 번들링합니다.
|
||||
*
|
||||
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
|
||||
*
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
*/
|
||||
public function buildForCore(bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -98,7 +103,10 @@ class VendorBundler
|
||||
* 이유: _bundled 는 Git 추적 소스 디렉토리로 vendor/ 를 두지 않는 것이 원칙.
|
||||
* 활성 디렉토리는 설치 시 composer install 이 실행되어 실제 패키지가 설치된 상태.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @param string $type 확장 타입 ('module' 또는 'plugin')
|
||||
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
*/
|
||||
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -156,6 +164,7 @@ class VendorBundler
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
|
||||
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
*/
|
||||
public function isStale(string $sourcePath, ?string $outputPath = null): bool
|
||||
{
|
||||
@@ -391,7 +400,7 @@ class VendorBundler
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir);
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new VendorInstallException(
|
||||
|
||||
@@ -242,7 +242,7 @@ class LanguagePackController extends AdminBaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 언어팩을 일괄 활성화합니다 (PO #7 reactivate 모달 → "활성화" 버튼).
|
||||
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
|
||||
*
|
||||
* @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
|
||||
* @return JsonResponse 결과 (succeeded/failed 분리)
|
||||
|
||||
@@ -252,7 +252,7 @@ class ModuleController extends AdminBaseController
|
||||
if ($result['success']) {
|
||||
$moduleInfo = $result['module_info'] ?? null;
|
||||
|
||||
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (PO #8: 빈 배열이면 모달 표시 안 함)
|
||||
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (요구사항 #8: 빈 배열이면 모달 표시 안 함)
|
||||
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
|
||||
->getPendingForReactivation('module', $moduleName);
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ class PluginController extends AdminBaseController
|
||||
if ($result['success']) {
|
||||
$pluginInfo = $result['plugin_info'] ?? null;
|
||||
|
||||
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
|
||||
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
|
||||
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
|
||||
->getPendingForReactivation('plugin', $pluginName);
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ class TemplateController extends AdminBaseController
|
||||
if ($result['success']) {
|
||||
$templateInfo = $result['template_info'] ?? null;
|
||||
|
||||
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
|
||||
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
|
||||
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
|
||||
->getPendingForReactivation('template', $templateName);
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ class IdentityVerificationController extends PublicBaseController
|
||||
return $this->error('identity.errors.missing_target', 422);
|
||||
}
|
||||
|
||||
$providerId = $validated['provider_id'] ?? null;
|
||||
if (is_string($providerId) && $providerId === '') {
|
||||
$providerId = null;
|
||||
}
|
||||
|
||||
$challenge = $this->service->start(
|
||||
purpose: (string) $validated['purpose'],
|
||||
target: $target,
|
||||
@@ -66,6 +71,7 @@ class IdentityVerificationController extends PublicBaseController
|
||||
'origin_type' => \App\Enums\IdentityOriginType::Api->value,
|
||||
'origin_identifier' => '/api/identity/challenges',
|
||||
],
|
||||
providerId: $providerId,
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\LanguagePack;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 언어팩 일괄 활성화 요청 (PO #7 reactivate 모달 → "활성화" 버튼).
|
||||
* 언어팩 일괄 활성화 요청 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
|
||||
*
|
||||
* 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 ID 배열을 받아
|
||||
* Service 의 bulkActivate() 로 위임합니다.
|
||||
@@ -31,9 +32,12 @@ class BulkActivateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['required', 'integer', Rule::exists(LanguagePack::class, 'id')],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.bulk_activate_validation_rules', $rules, $this);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ class PolicyCollection extends BaseApiCollection
|
||||
protected function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_create' => 'core.admin.identity.policies.manage',
|
||||
'can_update' => 'core.admin.identity.policies.manage',
|
||||
'can_delete' => 'core.admin.identity.policies.manage',
|
||||
'can_create' => 'core.admin.identity.policies.update',
|
||||
'can_update' => 'core.admin.identity.policies.update',
|
||||
'can_delete' => 'core.admin.identity.policies.update',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ class PolicyResource extends BaseApiResource
|
||||
public function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_update' => 'core.admin.identity.policies.manage',
|
||||
'can_delete' => 'core.admin.identity.policies.manage',
|
||||
'can_update' => 'core.admin.identity.policies.update',
|
||||
'can_delete' => 'core.admin.identity.policies.update',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ class PolicyResource extends BaseApiResource
|
||||
'applies_to' => $this->applies_to,
|
||||
'fail_mode' => $this->fail_mode,
|
||||
'user_overrides' => $this->user_overrides ?? [],
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
...$this->formatTimestamps(),
|
||||
...$this->resourceMeta($request),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class ProviderResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* Resource 수준 abilities 매핑.
|
||||
*
|
||||
* 프로바이더는 관리자 UI 의 S1c 서브섹션에서 설정값을 편집할 수 있으며,
|
||||
* 편집 권한은 `core.admin.identity.providers.update` 로 분리되어 있다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_update' => 'core.admin.identity.providers.update',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스를 배열로 변환합니다.
|
||||
*
|
||||
|
||||
@@ -76,6 +76,7 @@ class InitiateIdentityChallengeAfterRegister implements HookListenerInterface
|
||||
'origin_identifier' => 'core.auth.after_register',
|
||||
'origin_policy_key' => $policy->key,
|
||||
],
|
||||
providerId: $policy->provider_id,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[IDV] signup_after_create challenge 발행 실패', [
|
||||
|
||||
@@ -32,6 +32,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string|null $license
|
||||
* @property array<string, string>|null $description
|
||||
* @property string $status
|
||||
* @property string|null $deactivated_reason
|
||||
* @property \Illuminate\Support\Carbon|null $deactivated_at
|
||||
* @property string|null $incompatible_required_version
|
||||
* @property bool $is_protected
|
||||
* @property array<string, mixed> $manifest
|
||||
* @property string|null $source_type
|
||||
@@ -86,6 +89,9 @@ class LanguagePack extends Model
|
||||
'license',
|
||||
'description',
|
||||
'status',
|
||||
'deactivated_reason',
|
||||
'deactivated_at',
|
||||
'incompatible_required_version',
|
||||
'is_protected',
|
||||
'manifest',
|
||||
'source_type',
|
||||
@@ -109,6 +115,7 @@ class LanguagePack extends Model
|
||||
'target_version_mismatch' => 'boolean',
|
||||
'installed_at' => 'datetime',
|
||||
'activated_at' => 'datetime',
|
||||
'deactivated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class LanguagePackServiceProvider extends ServiceProvider
|
||||
$registrar->cleanupForExtension('template', (string) $identifier);
|
||||
});
|
||||
|
||||
// PO #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
|
||||
// 요구사항 #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
|
||||
HookManager::addAction('core.modules.after_deactivate', function ($identifier) use ($registrar) {
|
||||
$registrar->deactivateForExtension('module', (string) $identifier);
|
||||
});
|
||||
|
||||
@@ -120,6 +120,17 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
|
||||
return $model ? (bool) $model->delete() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정책 모델을 영속화합니다 (Eloquent save 위임).
|
||||
*
|
||||
* @param IdentityPolicy $policy 영속화할 모델
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function save(IdentityPolicy $policy): bool
|
||||
{
|
||||
return (bool) $policy->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 소스의 정책 개수를 반환합니다.
|
||||
*
|
||||
|
||||
@@ -283,7 +283,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
|
||||
$pack->license = $manifest['license'] ?? null;
|
||||
$pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null;
|
||||
$pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value;
|
||||
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (PO #3)
|
||||
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3)
|
||||
$pack->manifest = $manifest;
|
||||
$pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value;
|
||||
$pack->source_url = $bundledIdentifier;
|
||||
@@ -298,7 +298,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
|
||||
* 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
|
||||
*
|
||||
* `built_in` 가상 행은 DB 행이 없는 상태로 항상 active+protected 표시되며, 사용자가
|
||||
* install/uninstall/activate/deactivate 할 수 없습니다 (PO #1, #2).
|
||||
* install/uninstall/activate/deactivate 할 수 없습니다 (요구사항 #1, #2).
|
||||
*
|
||||
* @param string $scope 스코프 (core/module/plugin/template)
|
||||
* @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\PermissionType;
|
||||
use App\Exceptions\CoreUpdateOperationException;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\Helpers\ChangelogParser;
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
@@ -15,6 +16,7 @@ use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Extension\Vendor\EnvironmentDetector;
|
||||
use App\Extension\Vendor\VendorInstallContext;
|
||||
use App\Extension\Vendor\VendorInstallResult;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
@@ -976,7 +978,11 @@ class CoreUpdateService
|
||||
$onProgress?->__invoke('apply', $name.' (auto)');
|
||||
|
||||
if (File::isDirectory($absPath)) {
|
||||
FilePermissionHelper::copyDirectory($absPath, $dest, $onProgress, $excludes, removeOrphans: true);
|
||||
// 자동 발견 폴백은 destination orphan 정리 책임이 없다.
|
||||
// 부모 프로세스의 stale targets 누락을 메우는 안전망이므로,
|
||||
// base 의 활성 서브디렉토리(예: templates/sirsoft-basic) 가 source 에 없다고
|
||||
// 해서 삭제하면 사용자 데이터 영구 손실 위험. orphan 처리는 정상 targets 분기만 수행.
|
||||
FilePermissionHelper::copyDirectory($absPath, $dest, $onProgress, $excludes, removeOrphans: false);
|
||||
} else {
|
||||
File::ensureDirectoryExists(dirname($dest));
|
||||
FilePermissionHelper::copyFile($absPath, $dest);
|
||||
@@ -993,7 +999,12 @@ class CoreUpdateService
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화된 path 가 이미 처리된 상위 path 의 하위 항목인지 검사합니다.
|
||||
* 정규화된 path 가 이미 처리된 상위/하위 path 와 관계가 있는지 검사합니다.
|
||||
*
|
||||
* 양방향 검사:
|
||||
* - 방향 1 — path 가 applied 의 자식: applied=['templates'], path='templates/sub' (자식 중복 복사 방지)
|
||||
* - 방향 2 — path 가 applied 의 부모: applied=['templates/_bundled'], path='templates'
|
||||
* (부모 통째로 복사 시 활성 서브디렉토리 orphan 판정 회피)
|
||||
*
|
||||
* @param array<string, bool> $applied
|
||||
*/
|
||||
@@ -1008,6 +1019,13 @@ class CoreUpdateService
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = $path.'/';
|
||||
foreach ($applied as $appliedPath => $_) {
|
||||
if (str_starts_with((string) $appliedPath, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1207,7 +1225,7 @@ class CoreUpdateService
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, $workingDir);
|
||||
$process = proc_open($command, $descriptors, $pipes, $workingDir, EnvironmentDetector::buildComposerEnv());
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new CoreUpdateOperationException('settings.core_update.composer_failed');
|
||||
@@ -1536,6 +1554,39 @@ class CoreUpdateService
|
||||
*/
|
||||
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
|
||||
{
|
||||
// 부모 in-process fallback 진입 시 stale 메모리 가드.
|
||||
//
|
||||
// 현재 메모리의 `config('app.version')` 이 toVersion 보다 낮으면 부모는 stale 코드를
|
||||
// 보유한 채 step 을 실행 중. upgrade step 안에서 신규 메서드 호출 시 fatal 위험
|
||||
// (이슈 #28 의 발현 메커니즘). `spawn_failure_mode` 와 연동하여 abort/fallback 분기.
|
||||
//
|
||||
// spawn 자식 (ExecuteUpgradeStepsCommand) 의 경우 spawn env 의 APP_VERSION=toVersion
|
||||
// 이 적용된 채 새 프로세스에서 부팅되므로 memoryVersion === toVersion → 가드 미발동.
|
||||
$memoryVersion = (string) config('app.version', $fromVersion);
|
||||
if (version_compare($memoryVersion, $toVersion, '<')) {
|
||||
$mode = config('app.update.spawn_failure_mode', 'fallback');
|
||||
$message = sprintf(
|
||||
'[core-update] runUpgradeSteps 부모 메모리 stale 감지 — memory=%s, target=%s, mode=%s',
|
||||
$memoryVersion,
|
||||
$toVersion,
|
||||
$mode,
|
||||
);
|
||||
|
||||
if ($mode === 'abort') {
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: $fromVersion,
|
||||
reason: $message.' — fail-fast 모드. 수동 재개로 진행하세요.',
|
||||
resumeCommand: sprintf(
|
||||
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Log::warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
|
||||
}
|
||||
|
||||
$upgradesPath = base_path('upgrades');
|
||||
|
||||
if (! File::isDirectory($upgradesPath)) {
|
||||
@@ -1575,6 +1626,21 @@ class CoreUpdateService
|
||||
if (class_exists($className)) {
|
||||
$instance = new $className;
|
||||
if ($instance instanceof UpgradeStepInterface) {
|
||||
// 7.0.0-beta.5 부터 신규 업그레이드 스텝은 AbstractUpgradeStep 상속 의무.
|
||||
// "각 스텝별 동작 100% 동일 보장" invariant 를 위해 카탈로그/변환/핫픽스를
|
||||
// upgrades/data/{version}/ 의 버전 namespace 아래로 격리하도록 강제한다.
|
||||
//
|
||||
// 미상속 시점에 즉시 throw → core:update 전체 중단 → 상위 백업 복원.
|
||||
// 상세: docs/extension/upgrade-step-guide.md §12
|
||||
if (version_compare($version, '7.0.0-beta.5', '>=')
|
||||
&& ! $instance instanceof \App\Extension\AbstractUpgradeStep) {
|
||||
throw new CoreUpdateOperationException(sprintf(
|
||||
'Upgrade step %s must extend App\\Extension\\AbstractUpgradeStep '
|
||||
.'(introduced in 7.0.0-beta.5). See docs/extension/upgrade-step-guide.md §12.',
|
||||
$version,
|
||||
));
|
||||
}
|
||||
|
||||
$steps[$version] = $instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class IdentityPolicyService
|
||||
|
||||
// HasUserOverrides trait 의 auto-record 우회 — 이 저장은 운영자 수정이 아니라
|
||||
// 기본값 복원이므로 trackable 필드가 변경되어도 user_overrides 에 재추가되면 안 됨.
|
||||
return $this->withUserOverridesBypass(fn () => $policy->save());
|
||||
return $this->withUserOverridesBypass(fn () => $this->policyRepository->save($policy));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,6 +480,10 @@ class IdentityPolicyService
|
||||
* 정책의 provider 가 제공하는 렌더 힌트를 반환합니다.
|
||||
* provider_id 가 명시되어 있으면 우선 사용, 미명시 시 purpose 기반 fallback.
|
||||
*
|
||||
* fallback 분기에서도 정책의 provider_id 를 Manager 의 0번 우선순위로 전달한다.
|
||||
* 정책에 지정된 provider 가 비활성/언인스톨된 순간 Manager 가 같은 우선순위 체인으로
|
||||
* 일관 처리하도록 정합화 — IdentityVerificationService::start 와 동일 패턴.
|
||||
*
|
||||
* @param IdentityPolicy $policy 대상 정책
|
||||
* @return string|null Provider 의 렌더 힌트 (UI 분기용) 또는 null
|
||||
*/
|
||||
@@ -491,7 +495,7 @@ class IdentityPolicyService
|
||||
return $this->manager->get($providerId)->getRenderHint();
|
||||
}
|
||||
|
||||
return $this->manager->resolveForPurpose($policy->purpose)->getRenderHint();
|
||||
return $this->manager->resolveForPurpose($policy->purpose, $providerId)->getRenderHint();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,14 +38,22 @@ class IdentityVerificationService
|
||||
/**
|
||||
* Challenge 를 시작합니다.
|
||||
*
|
||||
* $providerId 가 주어지면 Manager 의 0번째 우선순위로 사용된다 (IdentityVerificationManager::resolveForPurpose).
|
||||
* 미등록/미지원이면 silent fallback (기존 우선순위 체인 진행).
|
||||
*
|
||||
* @param string $purpose signup|password_reset|self_update|sensitive_action|플러그인 등록값
|
||||
* @param User|array<string, mixed> $target 로그인 사용자(User) 또는 ['email' => '...'] 배열
|
||||
* @param array<string, mixed> $context origin_type / origin_identifier / origin_policy_key / ip_address / user_agent
|
||||
* @param string|null $providerId Mode A controller 요청 또는 Mode B 정책의 명시 provider id
|
||||
* @return VerificationChallenge 발행된 challenge DTO
|
||||
*/
|
||||
public function start(string $purpose, User|array $target, array $context = []): VerificationChallenge
|
||||
{
|
||||
$provider = $this->manager->resolveForPurpose($purpose);
|
||||
public function start(
|
||||
string $purpose,
|
||||
User|array $target,
|
||||
array $context = [],
|
||||
?string $providerId = null,
|
||||
): VerificationChallenge {
|
||||
$provider = $this->manager->resolveForPurpose($purpose, $providerId);
|
||||
|
||||
$context['purpose'] = $purpose;
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ class LanguagePackBundledRegistrar
|
||||
}
|
||||
|
||||
/**
|
||||
* 호스트 확장 비활성화 시 해당 확장에 종속된 언어팩을 cascade 비활성화합니다 (PO #6).
|
||||
* 호스트 확장 비활성화 시 해당 확장에 종속된 언어팩을 cascade 비활성화합니다 (요구사항 #6).
|
||||
*
|
||||
* 비활성화된 active 팩 ID 목록을 cache 에 stash 하여, 재활성화 시 §6 모달이
|
||||
* 표시할 후보로 사용됩니다.
|
||||
@@ -191,10 +191,10 @@ class LanguagePackBundledRegistrar
|
||||
}
|
||||
|
||||
/**
|
||||
* 호스트 확장 재활성화 시 모달에 표시할 pending 언어팩 목록을 반환합니다 (PO #7).
|
||||
* 호스트 확장 재활성화 시 모달에 표시할 pending 언어팩 목록을 반환합니다 (요구사항 #7).
|
||||
*
|
||||
* stash 된 ID 중 현재도 DB 에 존재하고 inactive 상태인 팩만 필터링합니다.
|
||||
* 빈 배열을 반환하면 프론트엔드는 모달을 띄우지 않습니다 (PO #8).
|
||||
* 빈 배열을 반환하면 프론트엔드는 모달을 띄우지 않습니다 (요구사항 #8).
|
||||
*
|
||||
* @param string $scope 스코프
|
||||
* @param string $targetIdentifier 대상 확장 식별자
|
||||
|
||||
@@ -165,7 +165,7 @@ class LanguagePackService
|
||||
|
||||
/**
|
||||
* 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack
|
||||
* 인스턴스 컬렉션을 반환합니다 (PO #1, #2).
|
||||
* 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2).
|
||||
*
|
||||
* 스캔 대상:
|
||||
* - 코어: lang/{ko,en}/
|
||||
@@ -351,7 +351,7 @@ class LanguagePackService
|
||||
return false;
|
||||
}
|
||||
if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) {
|
||||
// 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (PO 정정).
|
||||
// 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (정정 사항).
|
||||
// 코어 보호 팩은 환경설정 > 언어팩 관리(필터 없음) 화면에서만 노출됨.
|
||||
if ($pack->target_identifier !== $filters['target_identifier']) {
|
||||
return false;
|
||||
@@ -848,7 +848,7 @@ class LanguagePackService
|
||||
return $pack;
|
||||
}
|
||||
|
||||
// PO #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만)
|
||||
// 요구사항 #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만)
|
||||
$this->assertDependencies($pack->manifest ?? []);
|
||||
$this->assertTargetExtensionExists($pack->manifest ?? []);
|
||||
|
||||
@@ -884,7 +884,7 @@ class LanguagePackService
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 언어팩을 일괄 활성화합니다 (PO #7 reactivate 모달 → "활성화" 버튼).
|
||||
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
|
||||
*
|
||||
* 각 ID 에 대해 activate() 를 호출하며, 실패한 항목은 reason 과 함께 응답에 포함합니다.
|
||||
* 의존성 검사는 activate() 내부에서 자동 수행됩니다 (§4).
|
||||
@@ -931,7 +931,7 @@ class LanguagePackService
|
||||
return $pack;
|
||||
}
|
||||
|
||||
// PO #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회
|
||||
// 요구사항 #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회
|
||||
if ($pack->isProtected() && ! $cascadeFromHost) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.protected_pack');
|
||||
}
|
||||
@@ -1322,7 +1322,7 @@ class LanguagePackService
|
||||
*/
|
||||
public function checkUpdates(?string $identifier = null): array
|
||||
{
|
||||
// PO #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
|
||||
// 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
|
||||
$packs = $identifier
|
||||
? collect([$this->repository->findByIdentifier($identifier)])->filter()
|
||||
: $this->repository->paginate([], 1000)->getCollection();
|
||||
@@ -1435,7 +1435,7 @@ class LanguagePackService
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 manifest 로부터 최신 버전을 조회합니다 (PO #4 폴백 경로).
|
||||
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
|
||||
*
|
||||
* @param LanguagePack $pack 대상 언어팩
|
||||
* @return string|null 최신 버전 문자열 또는 null
|
||||
@@ -1538,7 +1538,7 @@ class LanguagePackService
|
||||
*/
|
||||
public function performUpdate(LanguagePack $pack, bool $force = false): LanguagePack
|
||||
{
|
||||
// PO #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백
|
||||
// 요구사항 #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백
|
||||
$githubUrl = $this->resolveGithubUrl($pack);
|
||||
if (! $githubUrl && ! $this->hasBundledManifest($pack->identifier)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.update_no_source');
|
||||
|
||||
+25
-2
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.0-beta.4'),
|
||||
'version' => env('APP_VERSION', '7.0.0-beta.5'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -277,7 +277,11 @@ return [
|
||||
// 개발 도구 메타(`.git`, `.claude`, `.serena` 등) 를 보호한다.
|
||||
// 부모 프로세스의 stale `targets` 가 신버전 신규 디렉토리를 인식하지 못해
|
||||
// 자동 발견이 트리거될 때만 참조된다 (정상 경로의 targets 는 항상 처리됨).
|
||||
'protected_paths' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_PROTECTED_PATHS', '.env,.env.local,.env.production,.env.testing,.env.testing.example,storage,vendor,node_modules,.git,.github,.idea,.vscode,.serena,.claude,.mcp.json,.phpunit.result.cache,core_pending')))),
|
||||
// modules,plugins,templates,lang-packs 부모 디렉토리도 보호 대상에 포함.
|
||||
// targets 에 `{modules,plugins,templates,lang-packs}/_bundled` 가 명시되어 정상 흐름에서는
|
||||
// 처리되므로 영향 없음. 자동 발견 폴백이 부모 디렉토리 단위로 base 를 통째로 복사해
|
||||
// 활성 서브디렉토리(예: templates/sirsoft-basic) 를 잘못 삭제하는 회귀(#347) 차단용 방어 깊이.
|
||||
'protected_paths' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_PROTECTED_PATHS', '.env,.env.local,.env.production,.env.testing,.env.testing.example,storage,vendor,node_modules,.git,.github,.idea,.vscode,.serena,.claude,.mcp.json,.phpunit.result.cache,core_pending,modules,plugins,templates,lang-packs')))),
|
||||
'backup_only' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_BACKUP_ONLY', 'vendor')))),
|
||||
'backup_extra' => ['storage/app/settings'],
|
||||
// 업데이트 종료 시 base_path() 소유자/그룹 기준으로 소유권을 재귀 복원할 경로 목록.
|
||||
@@ -331,6 +335,25 @@ return [
|
||||
'G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE',
|
||||
'storage/logs,storage/framework,storage/app/core_pending,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending'
|
||||
)))),
|
||||
|
||||
// spawn 자식 프로세스 실패 시 동작 모드.
|
||||
//
|
||||
// - 'abort' (기본 / 안전 우선): 즉시 abort 후 운영자에게 수동 명령
|
||||
// (`php artisan core:execute-upgrade-steps --from=... --to=...`)
|
||||
// 안내. 부모 메모리 stale 로 인한 upgrade step fatal
|
||||
// 위험을 원천 차단한다.
|
||||
// - 'fallback' (호환 모드) : 기존 동작 — in-process fallback 으로 진행.
|
||||
// proc_open 비활성 공유 호스팅 등에서만 사용. 부모
|
||||
// 메모리 stale 시 신규 메서드 호출 fatal 위험 잔존.
|
||||
//
|
||||
// 4가지 분기 모두 본 모드를 따른다:
|
||||
// (1) proc_open 비활성
|
||||
// (2) proc_open 자원 생성 실패
|
||||
// (3) 자식 비정상 종료 (exit != 0)
|
||||
// (4) 자식 exit=0 이지만 [STEPS_EXECUTED] 신호 미발행/step 0건 실행 (silent skip)
|
||||
//
|
||||
// `CoreUpdateService::runUpgradeSteps` 의 stale 메모리 가드도 본 모드와 연동한다.
|
||||
'spawn_failure_mode' => env('G7_UPDATE_SPAWN_FAILURE_MODE', 'abort'),
|
||||
],
|
||||
|
||||
];
|
||||
+8
-6
@@ -166,12 +166,14 @@ return [
|
||||
'order' => 7.8,
|
||||
'type' => 'admin',
|
||||
'permissions' => [
|
||||
['identifier' => 'core.admin.identity.manage', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정', 'en' => 'Manage IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 관리할 수 있습니다.', 'en' => 'Can manage identity verification providers.'], 'order' => 1],
|
||||
['identifier' => 'core.admin.identity.policies.manage', 'type' => 'admin', 'name' => ['ko' => '정책 관리', 'en' => 'Manage IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.', 'en' => 'Can CRUD identity verification policies by route/hook.'], 'order' => 2],
|
||||
['identifier' => 'core.admin.identity.logs.read', 'type' => 'admin', 'name' => ['ko' => '로그 열람', 'en' => 'View IDV Logs'], 'description' => ['ko' => '본인인증 이력을 조회할 수 있습니다.', 'en' => 'Can view identity verification logs.'], 'order' => 3],
|
||||
['identifier' => 'core.admin.identity.logs.purge', 'type' => 'admin', 'name' => ['ko' => '로그 파기', 'en' => 'Purge IDV Logs'], 'description' => ['ko' => '본인인증 이력을 파기(보관주기 외)할 수 있습니다.', 'en' => 'Can purge identity verification logs (retention-based).'], 'order' => 4],
|
||||
['identifier' => 'core.admin.identity.messages.read', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 조회', 'en' => 'View IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 조회할 수 있습니다.', 'en' => 'Can view identity verification message definitions and templates.'], 'order' => 5],
|
||||
['identifier' => 'core.admin.identity.messages.update', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 수정', 'en' => 'Update IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 수정할 수 있습니다.', 'en' => 'Can update identity verification message definitions and templates.'], 'order' => 6],
|
||||
['identifier' => 'core.admin.identity.providers.read', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정 조회', 'en' => 'View IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 조회할 수 있습니다.', 'en' => 'Can view identity verification providers.'], 'order' => 1],
|
||||
['identifier' => 'core.admin.identity.providers.update', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정 수정', 'en' => 'Update IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 수정할 수 있습니다.', 'en' => 'Can update identity verification providers.'], 'order' => 2],
|
||||
['identifier' => 'core.admin.identity.policies.read', 'type' => 'admin', 'name' => ['ko' => '정책 조회', 'en' => 'View IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 조회할 수 있습니다.', 'en' => 'Can view identity verification policies by route/hook.'], 'order' => 3],
|
||||
['identifier' => 'core.admin.identity.policies.update', 'type' => 'admin', 'name' => ['ko' => '정책 수정', 'en' => 'Update IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.', 'en' => 'Can CRUD identity verification policies by route/hook.'], 'order' => 4],
|
||||
['identifier' => 'core.admin.identity.logs.read', 'type' => 'admin', 'name' => ['ko' => '로그 열람', 'en' => 'View IDV Logs'], 'description' => ['ko' => '본인인증 이력을 조회할 수 있습니다.', 'en' => 'Can view identity verification logs.'], 'order' => 5],
|
||||
['identifier' => 'core.admin.identity.logs.purge', 'type' => 'admin', 'name' => ['ko' => '로그 파기', 'en' => 'Purge IDV Logs'], 'description' => ['ko' => '본인인증 이력을 파기(보관주기 외)할 수 있습니다.', 'en' => 'Can purge identity verification logs (retention-based).'], 'order' => 6],
|
||||
['identifier' => 'core.admin.identity.messages.read', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 조회', 'en' => 'View IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 조회할 수 있습니다.', 'en' => 'Can view identity verification message definitions and templates.'], 'order' => 7],
|
||||
['identifier' => 'core.admin.identity.messages.update', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 수정', 'en' => 'Update IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 수정할 수 있습니다.', 'en' => 'Can update identity verification message definitions and templates.'], 'order' => 8],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 언어팩 테이블에 비활성화 사유 메타 컬럼 추가.
|
||||
*
|
||||
* 사용자 수동 비활성화(manual)와 코어 버전 비호환에 의한 자동 비활성화(incompatible_core)를
|
||||
* 구분하여 (a) UI 라벨링, (b) 알림 영속화, (c) 재호환 시 원클릭 복구 판정에 사용합니다.
|
||||
*
|
||||
* 다른 확장 테이블(plugins/modules/templates) 의 deactivated_reason 컬럼 스키마와 일관되도록
|
||||
* 동일한 컬럼 정의·인덱스 패턴을 적용합니다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('language_packs', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('language_packs', 'deactivated_reason')) {
|
||||
$table->string('deactivated_reason', 32)
|
||||
->nullable()
|
||||
->after('status')
|
||||
->comment('비활성화 사유: manual(사용자 수동) | incompatible_core(코어 버전 호환성) | null(active)');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('language_packs', 'deactivated_at')) {
|
||||
$table->timestamp('deactivated_at')
|
||||
->nullable()
|
||||
->after('deactivated_reason')
|
||||
->comment('비활성화 시점');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('language_packs', 'incompatible_required_version')) {
|
||||
$table->string('incompatible_required_version', 64)
|
||||
->nullable()
|
||||
->after('deactivated_at')
|
||||
->comment('incompatible_core 시 요구된 g7_version 제약 (재호환 판정용)');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('language_packs', function (Blueprint $table) {
|
||||
$table->index('deactivated_reason', 'language_packs_deactivated_reason_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('language_packs', function (Blueprint $table) {
|
||||
$table->dropIndex('language_packs_deactivated_reason_index');
|
||||
});
|
||||
|
||||
Schema::table('language_packs', function (Blueprint $table) {
|
||||
foreach (['incompatible_required_version', 'deactivated_at', 'deactivated_reason'] as $column) {
|
||||
if (Schema::hasColumn('language_packs', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -179,7 +179,7 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
|
||||
|
||||
브로드캐스팅은 부가 기능이므로 실패해도 메인 작업(사용자 업데이트, 주문 처리, 알림 발송 등)을 중단시키지 않습니다. 개별 리스너에서 try-catch를 추가할 필요가 없습니다.
|
||||
|
||||
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 PO가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
|
||||
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 운영자가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
|
||||
|
||||
### 채널 타입
|
||||
|
||||
|
||||
@@ -126,6 +126,84 @@ return new class extends Migration
|
||||
};
|
||||
```
|
||||
|
||||
### 마이그레이션 멱등성
|
||||
|
||||
```
|
||||
필수: Schema::table 안의 컬럼 추가 (`$table->{type}('{column}')`) 는 Schema::hasColumn 가드와 함께 작성
|
||||
배경: 코어 업그레이드 도중 마이그레이션이 부분 적용된 채 fatal 한 경우, `core:update --force` 재실행 시 이미 적용된 컬럼이 재시도되어 "Column already exists" SQL error 가 발생
|
||||
면제: `// audit:allow migration-idempotency-guard reason: ...` 인라인 주석 (의도적 비-멱등 사유 기재)
|
||||
```
|
||||
|
||||
#### 멱등 작성 예시
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴 — Schema::hasColumn 가드
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('users', 'locked_until')) {
|
||||
$table->timestamp('locked_until')->nullable()->after('email');
|
||||
}
|
||||
if (! Schema::hasColumn('users', 'last_failed_login_at')) {
|
||||
$table->timestamp('last_failed_login_at')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 — 가드 부재
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->timestamp('locked_until')->nullable(); // partial migrate 후 재실행 시 fatal
|
||||
$table->timestamp('last_failed_login_at')->nullable();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 인덱스 추가의 멱등성
|
||||
|
||||
인덱스 추가는 Laravel 의 Schema 빌더가 멱등 가드를 직접 제공하지 않으므로 다음 패턴 중 하나 적용:
|
||||
|
||||
```php
|
||||
// 방식 1 — Schema::hasIndex (Laravel 11+ Schema::getIndexes 기반)
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (! collect(Schema::getIndexes('users'))->pluck('name')->contains('users_email_idx')) {
|
||||
$table->index('email', 'users_email_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 방식 2 — try/catch (인덱스명이 명시되지 않은 경우)
|
||||
public function up(): void
|
||||
{
|
||||
try {
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->index('email');
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
// 이미 존재 — 멱등 skip
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `->change()` 패턴의 멱등성
|
||||
|
||||
`->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. audit rule `migration-idempotency-guard` 는 `->change()` 라인을 자동 제외하므로 false positive 미발생.
|
||||
|
||||
#### 검증 절차
|
||||
|
||||
단일 마이그레이션의 dry-run 출력을 두 번 적용해도 동일 출력인지 확인:
|
||||
|
||||
```bash
|
||||
php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_login_attempt_columns_to_users_table.php
|
||||
```
|
||||
|
||||
신규 마이그레이션 작성 후에는 같은 마이그레이션을 두 번 실행하는 시나리오를 PHPUnit Feature 테스트로 작성해 SQL error 미발생을 검증한다 (`tests/Feature/Upgrades/MultiVersionUpgradePathTest.php` 의 `hasColumn_가드된_마이그레이션은_두_번_적용해도_SQL_error_없다` 사례 참조).
|
||||
|
||||
---
|
||||
|
||||
### 모듈/플러그인 마이그레이션 down() 메서드
|
||||
|
||||
```
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 |
|
||||
| `getDependencies()` | `[]` | 의존성 |
|
||||
| `getMetadata()` | `[]` | 메타데이터 |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -489,8 +489,14 @@ modules/_bundled/sirsoft-ecommerce/
|
||||
├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만, gitignore 대상)
|
||||
│ ├── js/module.iife.js
|
||||
│ └── css/module.css
|
||||
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
|
||||
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
|
||||
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 모듈 의무)
|
||||
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
|
||||
│ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결
|
||||
│ └── 1.1.0/
|
||||
│ ├── manifest.json # kind → delta JSON 매핑
|
||||
│ ├── *.delta.json # added/removed/renamed 시드
|
||||
│ ├── appliers/ # SnapshotApplier 구현 (버전 namespace)
|
||||
│ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace)
|
||||
├── config/
|
||||
│ └── ecommerce.php # 모듈 설정
|
||||
├── database/
|
||||
|
||||
@@ -240,7 +240,7 @@ class Plugin implements PluginInterface
|
||||
| `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
|
||||
| `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 |
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw |
|
||||
|
||||
> **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조.
|
||||
|
||||
@@ -296,8 +296,14 @@ plugins/_bundled/sirsoft-payment/
|
||||
├── LICENSE # 라이선스 전문 (MIT)
|
||||
├── composer.json # PSR-4 오토로딩 + 외부 패키지 의존성
|
||||
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
|
||||
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
|
||||
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
|
||||
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 플러그인 의무)
|
||||
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
|
||||
│ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결
|
||||
│ └── 1.1.0/
|
||||
│ ├── manifest.json # kind → delta JSON 매핑
|
||||
│ ├── *.delta.json # added/removed/renamed 시드
|
||||
│ ├── appliers/ # SnapshotApplier 구현 (버전 namespace)
|
||||
│ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace)
|
||||
├── config/ # 플러그인 설정
|
||||
│ ├── payment.php
|
||||
│ └── settings/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
4. 경로 A (모듈/플러그인) · 경로 C 규율: 기존 클래스의 신규 메서드 호출 금지, 로컬 private 헬퍼 우선
|
||||
5. 모든 분기에 upgrade.log 출력 — 로그 없음 = 디버깅 단서 없음
|
||||
6. beta.3+ 타깃 step 이 중간에 새 프로세스 재진입이 필요하면 `UpgradeHandoffException` throw (섹션 10.5)
|
||||
7. 7.0.0-beta.5+ 신규 step 은 `AbstractUpgradeStep` 상속 의무 + 카탈로그/변환/핫픽스를 `upgrades/data/{version}/` 으로 격리 (섹션 13)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -29,6 +30,8 @@
|
||||
10. [경로 C 내부 inline spawn 패턴](#10-경로-c-내부-inline-spawn-패턴)
|
||||
10.5. [업그레이드 핸드오프 (beta.3+ 인프라)](#105-업그레이드-핸드오프-beta3-인프라)
|
||||
11. [업그레이드 후 데이터 정합성 (완전 동기화)](#11-업그레이드-후-데이터-정합성-완전-동기화)
|
||||
12. [Declarative artifacts 일회성 보정 패턴](#12-declarative-artifacts-일회성-보정-패턴)
|
||||
13. [버전별 데이터 스냅샷 (7.0.0-beta.5+)](#13-버전별-데이터-스냅샷-700-beta5)
|
||||
|
||||
---
|
||||
|
||||
@@ -399,6 +402,39 @@ upgrade step 작성 전 다음을 확인:
|
||||
|
||||
경로 B 라고 판단했더라도, proc_open 차단 환경에서는 in-process fallback 이 작동하므로 **가능하면 경로 A/C 규율도 충족** 하도록 작성하는 것이 안전하다.
|
||||
|
||||
### V-1 안전 작성 패턴 (경로 B 의 사각지대)
|
||||
|
||||
경로 B 의 "spawn 자식이 fresh 디스크 코드를 로드" 가정은 `proc_open` 정상 동작에 의존한다. 다음 4가지 상황에서 in-process fallback 으로 전환되어 V-1 (이전 버전 메모리에 부재한 신규 메서드 호출) fatal 위험이 부활:
|
||||
|
||||
1. `proc_open` 함수 비활성 (보안 설정 / 일부 공유 호스팅)
|
||||
2. `proc_open` 자원 생성 실패 (메모리 부족 / pipe 한도 초과)
|
||||
3. 자식 비정상 종료 (uncaught exception / fatal / OOM)
|
||||
4. 자식 exit=0 이지만 `[STEPS_EXECUTED]` 신호 미발행 또는 step 0건 실행 (silent skip)
|
||||
|
||||
beta.5+ 의 `spawn_failure_mode` (기본 `abort`) 가 위 4분기 모두를 fail-fast 차단하지만, 운영자가 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 으로 호환 모드를 선택하면 V-1 위험이 잔존한다.
|
||||
|
||||
따라서 신규 step 작성 시 다음 안전 패턴을 적용:
|
||||
|
||||
- 신규 도입 (현재 작성 중인 버전에서 처음 추가된) 클래스/메서드/Repository 를 upgrade step 안에서 호출 금지
|
||||
- 부득이 호출이 필요하면 `@upgrade-path C` 어노테이션으로 명시 + 로컬 private 메서드로 인라인 작성
|
||||
- 허용 호출: `FilePermissionHelper`, `File` / `DB` / `Schema` / `Cache` / `Log` 파사드 등 *이전 버전 디스크 코드에도 존재하는* 코어 헬퍼만
|
||||
- 검증: PR review 단계에서 "이 step 이 호출하는 모든 메서드/클래스가 *이전 버전* 디스크 코드에도 존재하는가?" 자문
|
||||
|
||||
#### In-process fallback 진입 시 위험 메커니즘
|
||||
|
||||
부모 프로세스 메모리의 stale 클래스 인스턴스가 Laravel DI 컨테이너에서 반환되어, 디스크의 신버전 코드를 무시한 채 신규 메서드 호출 → `Call to undefined method` fatal. 이슈 #28 의 실 보고 사례:
|
||||
|
||||
```text
|
||||
Call to undefined method App\Services\CoreUpdateService::ensureWritableDirectories()
|
||||
at upgrades/Upgrade_7_0_0_beta_4.php:173 — ensureLangPacksPermissions()
|
||||
```
|
||||
|
||||
beta.4 의 `Upgrade_7_0_0_beta_4` step 이 `app(CoreUpdateService::class)->ensureWritableDirectories(...)` 를 호출했으나, 부모 메모리의 stale beta.3 `CoreUpdateService` 인스턴스에는 `ensureWritableDirectories` 가 없어 fatal. 디스크는 이미 beta.4 였음에도 PHP autoloader 가 beta.3 인스턴스를 재사용한 결과.
|
||||
|
||||
#### 자동 검출 — `upgrade-step-vone-safety` audit rule (manual-only)
|
||||
|
||||
`upgrades/Upgrade_*.php` 안의 `app(\w+Service::class)` / `app(\w+Manager::class)` / `app(\w+Repository::class)` 패턴은 PR review reviewer 에게 manual-only 경고를 발행한다. 자동 차단은 아니지만, 매치된 위치를 보고 "이 메서드가 이전 버전 디스크에 존재했는가" 를 reviewer 가 수동 판정한다. 면제: `// audit:allow upgrade-step-vone-safety reason: ...` 인라인 주석.
|
||||
|
||||
---
|
||||
|
||||
## 10. 경로 C 내부 inline spawn 패턴
|
||||
@@ -595,6 +631,183 @@ $pluginResult = app(PluginManager::class)->resyncAllActiveDeclarativeArtifacts()
|
||||
|
||||
---
|
||||
|
||||
## 13. 버전별 데이터 스냅샷 (7.0.0-beta.5+)
|
||||
|
||||
### 배경
|
||||
|
||||
spawn 자식 (경로 B) 은 디스크의 *최신* 코드/시더/카탈로그를 fresh-load 한다. 멀티 버전 점프 (예: beta.1 → beta.5) 시 beta.2/3/4 의 upgrade step 이 순차 실행되더라도, 각 step 이 호출하는 시더·Manager·헬퍼는 모두 **beta.5 메모리** 위에서 동작한다. 결과: 사용자가 하나씩 단계 업그레이드한 것과 동등하지 않은 데이터 상태.
|
||||
|
||||
본 섹션은 이 비대칭을 해소하는 규약을 정의한다 — **카탈로그 / 변환 / 핫픽스 모두를 그 버전 디렉토리 안에 동결**하여 "각 스텝별 동작 100% 동일 보장" invariant 를 성립시킨다.
|
||||
|
||||
### 적용 시점
|
||||
|
||||
- **코어**: 7.0.0-beta.5 부터 신규 step 의무 (beta.2~4 는 legacy 호환 유지)
|
||||
- **번들 모듈/플러그인**: `module.json` / `plugin.json` 의 `g7_version` 제약 최소 버전이 `7.0.0-beta.5` 이상이면 *그 확장의 현재 version 부터* 신규 step 의무. 그 미만이면 legacy (가드 미발동)
|
||||
- **외부 확장 (`modules/{not _bundled}` / `plugins/{not _bundled}`)**: 런타임 가드는 동일하게 발화 (manifest g7_version 판정), audit 만 적용 제외 (사용자 수정 코드 PR 차단 부적합)
|
||||
- **번들 템플릿/언어팩**: upgrade step 시스템 자체가 부재 — 미래 도입 시 동일 규약 자동 상속
|
||||
|
||||
### 확장 작성자의 적용 트리거
|
||||
|
||||
확장 작성자가 `g7_version` 을 `>=7.0.0-beta.5` 이상으로 상향하는 시점이 본 규약의 *자동 적용 첫 버전* 이다 (`ExtensionUpgradeGuardHelper::resolveSinceVersion`). 그 이후 `upgrades()` 에서 반환하는 신규 step 은 모두 `AbstractUpgradeStep` 상속 의무 — 미상속 시 `ModuleManager::runUpgradeSteps` / `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw.
|
||||
|
||||
| `g7_version` | 확장 working version | 의무 시작 버전 | 효과 |
|
||||
| --- | --- | --- | --- |
|
||||
| `>=7.0.0-beta.5` | `1.2.0` | `1.2.0` | `1.2.0` 이상 step 은 모두 AbstractUpgradeStep 의무 |
|
||||
| `>=7.0.0-beta.4` | `1.2.0` | (legacy) | 가드 미발동 — 신규 step 도 자유 작성 가능 |
|
||||
| (미선언 / null) | `1.2.0` | (legacy) | 가드 미발동 |
|
||||
|
||||
### `dataDir()` 의 코어/확장 자동 분기
|
||||
|
||||
`AbstractUpgradeStep::dataDir()` 는 `ReflectionClass($this)->getFileName()` 으로 *상속받은 구체 클래스의 파일 위치* 를 기준으로 data 디렉토리를 계산:
|
||||
|
||||
| 상속 위치 | `dataDir()` 결과 |
|
||||
| --- | --- |
|
||||
| `upgrades/Upgrade_7_0_0_beta_5.php` (코어) | `upgrades/data/7.0.0-beta.5/` |
|
||||
| `modules/_bundled/vendor-foo/upgrades/Upgrade_1_2_0.php` | `modules/_bundled/vendor-foo/upgrades/data/1.2.0/` |
|
||||
| `plugins/_bundled/vendor-bar/upgrades/Upgrade_2_0_0.php` | `plugins/_bundled/vendor-bar/upgrades/data/2.0.0/` |
|
||||
|
||||
확장은 코어 인프라(`AbstractUpgradeStep`, `DataSnapshot`, `SnapshotApplier` / `DataMigration` 인터페이스, manifest 스키마) 를 그대로 재사용한다 — 별도 사본 없음.
|
||||
|
||||
### 격리 원칙
|
||||
|
||||
각 step 은 다음을 보유:
|
||||
|
||||
- 스텝 파일 `upgrades/Upgrade_X_Y_Z.php` — `AbstractUpgradeStep` 상속만, 비즈니스 로직 없음
|
||||
- `upgrades/data/{version}/manifest.json` — kind → delta JSON 파일 매핑
|
||||
- `upgrades/data/{version}/*.delta.json` — 카탈로그 시드 delta (added / removed / renamed)
|
||||
- `upgrades/data/{version}/appliers/{Kind}Applier.php` — delta JSON 적용기 (버전 namespace)
|
||||
- `upgrades/data/{version}/migrations/*.php` — 변환 / 단발성 핫픽스 (버전 namespace)
|
||||
|
||||
namespace 규약 (코어/확장 자동 분기):
|
||||
|
||||
| 위치 | namespace |
|
||||
| --- | --- |
|
||||
| 코어 (`upgrades/data/{ver}/`) | `App\Upgrades\Data\V{token}\(Appliers\|Migrations)` |
|
||||
| 번들 모듈 (`modules/_bundled/{id}/upgrades/data/{ver}/`) | `App\Upgrades\Data\Ext\Modules\{StudlyId}\V{token}\(Appliers\|Migrations)` |
|
||||
| 번들 플러그인 (`plugins/_bundled/{id}/upgrades/data/{ver}/`) | `App\Upgrades\Data\Ext\Plugins\{StudlyId}\V{token}\(Appliers\|Migrations)` |
|
||||
| 외부 모듈 (`modules/{id}/upgrades/data/{ver}/`) | 동일 패턴 (Ext\Modules) — 사용자 수정 경로도 격리 |
|
||||
| 외부 플러그인 (`plugins/{id}/upgrades/data/{ver}/`) | 동일 패턴 (Ext\Plugins) |
|
||||
|
||||
`{token}` = 점·하이픈을 underscore 로 치환 (예: `7.0.0-beta.5` → `V7_0_0_beta_5`).
|
||||
`{StudlyId}` = 확장 식별자 hyphen/underscore 를 StudlyCase 로 변환 (예: `sirsoft-ecommerce` → `SirsoftEcommerce`).
|
||||
|
||||
`DataSnapshot::versionedNamespace($context, $sourceLocation)` 가 `$sourceLocation` 경로의 `modules|plugins` 마커 substring 을 기준으로 분기 — 코어/확장의 같은 step 버전이라도 *서로 다른 namespace* 가 부여되어 PHP compile-time fatal ("Cannot declare class ...") 회귀가 차단된다.
|
||||
|
||||
### `AbstractUpgradeStep` 위임 흐름
|
||||
|
||||
```php
|
||||
final public function run(UpgradeContext $context): void
|
||||
{
|
||||
$this->dataSnapshot($context)->apply($context); // Applier 순차 실행
|
||||
foreach ($this->dataMigrations($context) as $m) { // Migration 순차 실행 (파일명 정렬 순)
|
||||
$m->run($context);
|
||||
}
|
||||
$this->postRun($context); // 거의 사용 안 함
|
||||
}
|
||||
```
|
||||
|
||||
`dataSnapshot()` / `dataMigrations()` 모두 default impl 이 `data/{version}/` 을 스캔 + `require_once` + 버전 namespace 클래스 인스턴스화. 일반 케이스는 override 불필요.
|
||||
|
||||
### 실행 순서 제어
|
||||
|
||||
`dataMigrations()` 는 `data/{version}/migrations/*.php` 를 파일명 alphabetical 정렬 순으로 실행. 명시적 순서가 필요하면 파일명에 두 자리 숫자 prefix 사용:
|
||||
|
||||
```text
|
||||
01_RecoverActiveExtensionDirs.php
|
||||
02_RecoverPendingStubFiles.php
|
||||
03_VerifyBundledLangPacksFallback.php
|
||||
04_IdentityPermissionPivotMerge.php
|
||||
05_RecoverPublicStorageSymlink.php
|
||||
```
|
||||
|
||||
클래스명 자체는 prefix 없이 (PHP 식별자 제약). `AbstractUpgradeStep` 이 매핑 시 정규식 `/^\d{2,}_/` 으로 제거.
|
||||
|
||||
### Delta JSON 스키마
|
||||
|
||||
`permissions.delta.json` 예:
|
||||
|
||||
```json
|
||||
{
|
||||
"added": [
|
||||
{
|
||||
"identifier": "core.foo.read",
|
||||
"type": "admin",
|
||||
"category": "core.foo",
|
||||
"name": { "ko": "Foo 조회", "en": "Read Foo" }
|
||||
}
|
||||
],
|
||||
"removed": ["core.legacy.x"],
|
||||
"renamed": [
|
||||
{ "from": "core.old.key", "to": "core.new.key" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`role_permissions.delta.json` 예:
|
||||
|
||||
```json
|
||||
{
|
||||
"grants": [{ "role": "user", "permission": "core.notifications.read" }],
|
||||
"revokes": [{ "role": "user", "permission": "core.legacy.read" }]
|
||||
}
|
||||
```
|
||||
|
||||
각 Applier 가 동일 패턴으로 added/removed/renamed (또는 grants/revokes) 를 idempotent SQL 로 적용.
|
||||
|
||||
### Applier / Migration 작성 의무
|
||||
|
||||
- **raw JSON 만 read** (Applier) — 시더 클래스 `Database\Seeders\*` 참조 금지 (fresh-load invariant)
|
||||
- **idempotent** — `Schema::hasColumn` / `where->exists()` 가드 동반
|
||||
- **V-1 안전 강화** — `app(*Service|*Manager|*Repository::class)` 호출 금지 (audit `upgrade-step-data-snapshot` 가 error 로 차단)
|
||||
- **버전 격리** — 다른 버전 namespace `App\Upgrades\Data\V{other}\*` 참조 금지
|
||||
- **로컬 헬퍼 + Illuminate 파사드만 사용** — 신규 도입 클래스 / 미래 변경 가능 클래스 의존 회피
|
||||
- **공용 헬퍼 사용 시 신중** — `FilePermissionHelper::copyDirectory` 처럼 V-1 안전 검증된 헬퍼만 허용 (audit 룰이 미허용 호출은 차단)
|
||||
|
||||
### 강제 메커니즘
|
||||
|
||||
| 시점 | 영역 | 메커니즘 | 동작 |
|
||||
| --- | --- | --- | --- |
|
||||
| 런타임 | 코어 | `CoreUpdateService::runUpgradeSteps()` 의 instance 검증 | 버전 ≥ beta.5 인데 `AbstractUpgradeStep` 미상속 → `CoreUpdateOperationException` throw → update 전체 중단 → 백업 복원 |
|
||||
| 런타임 | 모듈 | `ModuleManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | manifest g7_version 기반 since-version 판정 → 미상속 시 `RuntimeException` throw |
|
||||
| 런타임 | 플러그인 | `PluginManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | 동일 패턴 (식별자만 다름) |
|
||||
| PR 시점 | 코어 + 번들 모듈/플러그인 | audit rule `upgrade-step-data-snapshot` (severity: error) | namespace 불일치 / Seeders use / app() 호출 / 다른 버전 참조 / 공용 디렉토리 사용 자동 차단. 확장 경로는 manifest g7_version 기반 since-version 으로 검사 범위 결정 |
|
||||
|
||||
면제: `// audit:allow upgrade-step-data-snapshot reason: ...` 인라인 주석. legacy 미들 케이스 또는 임시 우회 시.
|
||||
|
||||
### 확장간 namespace 격리
|
||||
|
||||
`DataSnapshot::versionedNamespace($context, $sourceLocation)` 가 step 파일/data 디렉토리 경로의 `modules|plugins` 마커로 코어/확장을 분기하여 namespace 를 결정. 결과:
|
||||
|
||||
- 두 다른 모듈 (`vendor-foo` / `vendor-bar`) 이 동일 step 버전(예: `1.0.0`) 을 가져도 namespace 가 각자 격리:
|
||||
- `App\Upgrades\Data\Ext\Modules\VendorFoo\V1_0_0\Migrations\Shared`
|
||||
- `App\Upgrades\Data\Ext\Modules\VendorBar\V1_0_0\Migrations\Shared`
|
||||
- `require_once` 가 두 파일을 로드해도 별개 FQCN 이라 PHP compile-time fatal ("Cannot declare class ...") 없음.
|
||||
- 모듈 vs 플러그인 vs 코어 namespace 도 서로 격리.
|
||||
|
||||
회귀 안전망:
|
||||
|
||||
- `tests/Unit/Extension/Upgrade/DataSnapshotTest::test_versionedNamespace_different_extensions_same_step_version_produce_different_namespaces` — 정적 검증
|
||||
- `tests/Feature/Upgrades/ExtensionAbstractUpgradeStepFullFlowTest::test_two_extensions_with_same_step_version_isolated_by_namespace` — 실제 실행 회귀 검증 (두 확장 fixture 의 같은 step 버전 + 같은 클래스명 → 양쪽 모두 자기 코드 실행 확인)
|
||||
|
||||
### dogfood — 7.0.0-beta.5
|
||||
|
||||
본 규약의 첫 사례:
|
||||
|
||||
- `upgrades/Upgrade_7_0_0_beta_5.php` — `extends AbstractUpgradeStep` 만 선언, 본문 비어있음
|
||||
- `upgrades/data/7.0.0-beta.5/manifest.json` — `permissions` kind 1건
|
||||
- `upgrades/data/7.0.0-beta.5/permissions.delta.json` — IDV 권한 식별자 rename 2건
|
||||
- `upgrades/data/7.0.0-beta.5/appliers/PermissionsApplier.php` — added/removed/renamed (부재 경로) 적용기
|
||||
- `upgrades/data/7.0.0-beta.5/migrations/` — 5종:
|
||||
1. `01_RecoverActiveExtensionDirs.php` — #347 회귀 후속: 4개 도메인 활성 디렉토리 복구
|
||||
2. `02_RecoverPendingStubFiles.php` — #347 회귀 후속: _pending stub 재생성
|
||||
3. `03_VerifyBundledLangPacksFallback.php` — #347 회귀 후속: lang-packs/_bundled fallback
|
||||
4. `04_IdentityPermissionPivotMerge.php` — IDV 권한 rename 충돌 경로 피벗 병합
|
||||
5. `05_RecoverPublicStorageSymlink.php` — public/storage symlink 복구
|
||||
|
||||
beta.4 까지 출시본의 박제된 핫픽스 모든 동작이 본 격리 구조로 100% 보존되며, 미래 버전이 이 디렉토리를 *수정하지 않는 한* (수정은 audit error) beta.1 → beta.7 같은 멀티 점프에서도 동일한 결과를 보장한다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [코어 업데이트 시스템](../backend/core-update-system.md)
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 본인인증 활동 로그 액션 라벨 일본어 번역 추가 (`verify`, `verify_failed`)
|
||||
|
||||
### Changed
|
||||
|
||||
- 본인인증 관리자 권한 시드를 코어의 조회/수정 분리 정책에 맞춰 갱신
|
||||
- `core.admin.identity.manage` → `core.admin.identity.providers.update`
|
||||
- `core.admin.identity.policies.manage` → `core.admin.identity.policies.update`
|
||||
- `core.admin.identity.providers.read`, `core.admin.identity.policies.read` 신설
|
||||
- 코어 최소 요구 버전을 `>=7.0.0-beta.5` 로 상향 (신 권한 키가 코어 beta.5 시드에 의존)
|
||||
|
||||
## [1.0.0-beta.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -29,6 +29,8 @@ return [
|
||||
'account_locked' => 'アカウントロック',
|
||||
'request' => 'リクエスト',
|
||||
'expired' => '期限切れ',
|
||||
'verify' => '本人認証',
|
||||
'verify_failed' => '本人認証失敗',
|
||||
'export' => 'エクスポート',
|
||||
'import' => 'インポート',
|
||||
'index' => 'リスト表示',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
@@ -20,7 +20,7 @@
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"g7_version": ">=7.0.0-beta.5",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": false
|
||||
|
||||
@@ -195,12 +195,20 @@
|
||||
"name": "本人確認管理 (管理者)",
|
||||
"description": "管理者用IDVプロバイダ/ポリシー/ログ管理権限"
|
||||
},
|
||||
"core.admin.identity.manage": {
|
||||
"name": "プロバイダ設定",
|
||||
"description": "本人確認プロバイダ設定を管理できます。"
|
||||
"core.admin.identity.providers.read": {
|
||||
"name": "プロバイダ設定閲覧",
|
||||
"description": "本人確認プロバイダ設定を閲覧できます。"
|
||||
},
|
||||
"core.admin.identity.policies.manage": {
|
||||
"name": "ポリシー管理",
|
||||
"core.admin.identity.providers.update": {
|
||||
"name": "プロバイダ設定編集",
|
||||
"description": "本人確認プロバイダ設定を編集できます。"
|
||||
},
|
||||
"core.admin.identity.policies.read": {
|
||||
"name": "ポリシー閲覧",
|
||||
"description": "本人確認ポリシー(ルート/フック別)を閲覧できます。"
|
||||
},
|
||||
"core.admin.identity.policies.update": {
|
||||
"name": "ポリシー編集",
|
||||
"description": "本人確認ポリシー(ルート/フック別)をCRUD できます。"
|
||||
},
|
||||
"core.admin.identity.logs.read": {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 위시리스트 제거 활동 로그 액션 라벨 일본어 번역 추가 (`remove`)
|
||||
|
||||
## [1.0.0-beta.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,6 +27,7 @@ return [
|
||||
'partial_cancel' => '部分キャンセル',
|
||||
'payment_complete' => '決済完了',
|
||||
'payment_failed' => '決済失敗',
|
||||
'remove' => '削除',
|
||||
'reorder' => '順序変更',
|
||||
'restore' => '復元',
|
||||
'send_email' => 'メール送信',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 페이지 발행/발행 취소 활동 로그 액션 라벨 일본어 번역 추가 (`publish`, `unpublish`)
|
||||
|
||||
## [1.0.0-beta.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -5,7 +5,9 @@ return [
|
||||
'action' => [
|
||||
'create' => '作成',
|
||||
'delete' => '削除',
|
||||
'publish' => '公開',
|
||||
'restore' => '復元',
|
||||
'unpublish' => '公開キャンセル',
|
||||
'update' => '編集',
|
||||
'upload' => 'アップロード',
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-page",
|
||||
|
||||
@@ -36,6 +36,8 @@ return [
|
||||
'account_locked' => 'Account locked',
|
||||
'request' => 'Request',
|
||||
'expired' => 'Expired',
|
||||
'verify' => 'Identity verified',
|
||||
'verify_failed' => 'Identity verification failed',
|
||||
'export' => 'Export',
|
||||
'import' => 'Import',
|
||||
'index' => 'List Viewed',
|
||||
|
||||
@@ -36,6 +36,8 @@ return [
|
||||
'account_locked' => '계정 잠금',
|
||||
'request' => '요청',
|
||||
'expired' => '만료',
|
||||
'verify' => '본인인증',
|
||||
'verify_failed' => '본인인증 실패',
|
||||
'export' => '내보내기',
|
||||
'import' => '가져오기',
|
||||
'index' => '목록 조회',
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 게시판 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동 비활성화
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
"en": "Module for board management"
|
||||
},
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"g7_version": ">=7.0.0-beta.5",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+12
-7
@@ -51,7 +51,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
|
||||
"disabled": "{{boardIdentityPolicies?.data?.abilities?.can_create !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -545,7 +546,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -633,7 +635,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -681,7 +684,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
@@ -859,7 +862,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -930,7 +934,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -978,7 +983,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 이커머스 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동 비활성화
|
||||
|
||||
### Fixed
|
||||
|
||||
- 위시리스트 활동 로그의 "액션" 열에 `wishlist.remove` 가 번역되지 않은 키 그대로 노출되던 문제 수정
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.0-beta.3",
|
||||
"version": "1.0.0-beta.4",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.0-beta.3",
|
||||
"version": "1.0.0-beta.4",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
|
||||
},
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"g7_version": ">=7.0.0-beta.5",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.0-beta.3",
|
||||
"version": "1.0.0-beta.4",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -173,7 +173,7 @@
|
||||
"if": false,
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"comment": "D-070 (PO 지시: 숨김 처리)",
|
||||
"comment": "D-070 (요청 사항: 숨김 처리)",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-outline btn-sm"
|
||||
|
||||
+12
-7
@@ -51,7 +51,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
|
||||
"disabled": "{{ecommerceIdentityPolicies?.data?.abilities?.can_create !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -545,7 +546,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -633,7 +635,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -681,7 +684,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
@@ -859,7 +862,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -930,7 +934,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -978,7 +983,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
|
||||
@@ -28,6 +28,7 @@ return [
|
||||
'partial_cancel' => 'Partially Cancelled',
|
||||
'payment_complete' => 'Payment Completed',
|
||||
'payment_failed' => 'Payment Failed',
|
||||
'remove' => 'Removed',
|
||||
'reorder' => 'Reordered',
|
||||
'restore' => 'Restored',
|
||||
'send_email' => 'Email Sent',
|
||||
|
||||
@@ -29,6 +29,7 @@ return [
|
||||
'partial_cancel' => '부분 취소',
|
||||
'payment_complete' => '결제 완료',
|
||||
'payment_failed' => '결제 실패',
|
||||
'remove' => '제거',
|
||||
'reorder' => '순서 변경',
|
||||
'restore' => '복원',
|
||||
'send_email' => '이메일 발송',
|
||||
|
||||
+14
@@ -32,6 +32,20 @@ class EcommerceLanguagePackSeederTriggerTest extends ModuleTestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 본 테스트는 활성 hook 체인이 module:seed 를 호출하는 경로를 검증한다.
|
||||
// SeedModuleCommand 는 base_path('modules/{id}/database/seeders') 활성 디렉토리를
|
||||
// is_dir() 로 확인하므로, 활성 디렉토리가 부재한 환경에서는 시더가 silent skip 된다.
|
||||
// 활성 디렉토리가 없으면 본 테스트의 production-like 시나리오를 재현할 수 없으므로 skip.
|
||||
$activeModuleSeederDir = base_path('modules/sirsoft-ecommerce/database/seeders');
|
||||
if (! is_dir($activeModuleSeederDir)) {
|
||||
$this->markTestSkipped(
|
||||
'sirsoft-ecommerce 모듈이 활성 디렉토리에 설치되지 않은 환경 — '
|
||||
.'본 테스트는 module:seed → entity 시더 → ja 자동 동기 production 경로를 검증하므로 '
|
||||
.'php artisan module:install sirsoft-ecommerce 후 재실행 (또는 CI 환경에서 자동 통과)'
|
||||
);
|
||||
}
|
||||
|
||||
// module:seed 가 outer transaction 외부에서 commit 한 데이터 정리
|
||||
DB::table('language_packs')
|
||||
->where('target_identifier', 'sirsoft-ecommerce')
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
### Fixed
|
||||
|
||||
- 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정
|
||||
- 페이지 발행/발행 취소 활동 로그의 "액션" 열에 `page.publish` / `page.unpublish` 가 번역되지 않은 키 그대로 노출되던 문제 수정
|
||||
|
||||
## [1.0.0-beta.2] - 2026-04-20
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ return [
|
||||
'action' => [
|
||||
'create' => 'Created',
|
||||
'delete' => 'Deleted',
|
||||
'publish' => 'Published',
|
||||
'restore' => 'Restored',
|
||||
'unpublish' => 'Unpublished',
|
||||
'update' => 'Updated',
|
||||
'upload' => 'Uploaded',
|
||||
],
|
||||
|
||||
@@ -6,7 +6,9 @@ return [
|
||||
'action' => [
|
||||
'create' => '생성',
|
||||
'delete' => '삭제',
|
||||
'publish' => '발행',
|
||||
'restore' => '복원',
|
||||
'unpublish' => '발행 취소',
|
||||
'update' => '수정',
|
||||
'upload' => '업로드',
|
||||
],
|
||||
|
||||
@@ -865,6 +865,9 @@ class ValidationApi
|
||||
|
||||
$output = [];
|
||||
$returnCode = -1;
|
||||
// root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한
|
||||
// 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응)
|
||||
applyInstallerComposerEnvVars();
|
||||
exec($command, $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
|
||||
@@ -988,7 +988,7 @@
|
||||
|
||||
if (result.success) {
|
||||
// Write DB 기존 테이블 감지: 인라인 상세 카드로 표시 (이슈 #244 대응)
|
||||
// PO 요구: 모달 대신 페이지 본문에 즉시 노출, 백업 동의 체크박스로
|
||||
// 요구사항: 모달 대신 페이지 본문에 즉시 노출, 백업 동의 체크박스로
|
||||
// 다음 단계 진행 여부 제어. 실제 삭제는 Step 5의 db_cleanup task에서 수행.
|
||||
let existingCardHtml = '';
|
||||
if (type === 'write' && result.existing_tables && result.existing_tables.has_tables) {
|
||||
@@ -3960,7 +3960,7 @@
|
||||
});
|
||||
} else {
|
||||
// 처음 시작하는 경우 — 필수 파일 체크 후 "설치 시작" 버튼 표시 (자동 시작 금지)
|
||||
// PO가 모드(SSE/폴링)를 선택한 뒤 명시적으로 시작할 수 있도록 대기
|
||||
// 운영자가 모드(SSE/폴링)를 선택한 뒤 명시적으로 시작할 수 있도록 대기
|
||||
const filesReady = await checkFilesBeforeInstall();
|
||||
if (filesReady) {
|
||||
showInstallationStartSection();
|
||||
|
||||
@@ -157,6 +157,39 @@ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = fa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 PHP 프로세스에 composer root/non-interactive 환경변수를 적용합니다.
|
||||
*
|
||||
* exec() 자식 프로세스는 부모의 환경변수를 상속하므로 putenv() 시점에 설정해두면
|
||||
* 후속 composer 자식 프로세스가 root 환경에서도 정상 동작합니다.
|
||||
*
|
||||
* Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응.
|
||||
*/
|
||||
if (!function_exists('applyInstallerComposerEnvVars')) {
|
||||
function applyInstallerComposerEnvVars(): void
|
||||
{
|
||||
putenv('COMPOSER_ALLOW_SUPERUSER=1');
|
||||
putenv('COMPOSER_NO_INTERACTION=1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_open() 5번째 인자에 병합할 composer 환경변수 배열을 반환합니다.
|
||||
*
|
||||
* 호출자는 기존 $env 와 array_merge 하여 사용합니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
if (!function_exists('buildInstallerComposerEnv')) {
|
||||
function buildInstallerComposerEnv(): array
|
||||
{
|
||||
return [
|
||||
'COMPOSER_ALLOW_SUPERUSER' => '1',
|
||||
'COMPOSER_NO_INTERACTION' => '1',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인스톨러 에러 로깅
|
||||
*
|
||||
@@ -1201,7 +1234,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = '
|
||||
];
|
||||
}
|
||||
|
||||
// G7 핵심 시그니처 테이블 — PO 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
|
||||
// G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
|
||||
// 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등)
|
||||
$coreSignatures = ['users', 'migrations', 'roles', 'permissions'];
|
||||
$g7Signatures = array_map(fn ($t) => $tablePrefix . $t, $coreSignatures);
|
||||
|
||||
@@ -268,6 +268,7 @@ if (!function_exists('checkComposerSSE')) {
|
||||
}
|
||||
putenv('COMPOSER_HOME=' . $composerHome);
|
||||
putenv('HOME=' . $composerHome);
|
||||
applyInstallerComposerEnvVars();
|
||||
|
||||
$output = [];
|
||||
$returnCode = 0;
|
||||
@@ -469,6 +470,9 @@ if (!function_exists('installComposerDependenciesSSE')) {
|
||||
$env['COMPOSER_HOME'] = $composerHome;
|
||||
$env['HOME'] = $composerHome;
|
||||
|
||||
// root/super user 환경 + 비대화형 컨텍스트 대응 (Synology DSM 등 PHP-FPM root 실행)
|
||||
$env = array_merge($env, buildInstallerComposerEnv());
|
||||
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||
if (!isset($env['TEMP']) || !is_dir($env['TEMP']) || !is_writable($env['TEMP'])) {
|
||||
$tempDir = BASE_PATH . '/storage/temp';
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
- IdentityGuardInterceptor — HTTP 428 `identity_verification_required` 응답을 감지해 모달 launcher 호출 후 return_request 자동 재실행. launcher 는 S8 공통 모달 레이아웃이 제공. ActionDispatcher.handleApiCall 응답 후처리 한 곳에 정적 메서드로 직접 위임 — 별도 디스패처 인프라 없이 모든 apiCall 의 choke point 효과 (IdentityGuardInterceptor)
|
||||
|
||||
<!-- ⚠️ 버전 재번호 부여 검토 필요 (PO 결정 대기): 아래 v1.43.1 은 시간상 v1.45.0/v1.44.0 보다 늦지만(2026-04-25), develop 브랜치에서 별도 발행되어 버전 번호 모순 발생. v1.45.1 또는 v1.46.0 으로 재번호 검토 필요. -->
|
||||
<!-- ⚠️ 버전 재번호 부여 검토 필요 (정책 결정 대기): 아래 v1.43.1 은 시간상 v1.45.0/v1.44.0 보다 늦지만(2026-04-25), develop 브랜치에서 별도 발행되어 버전 번호 모순 발생. v1.45.1 또는 v1.46.0 으로 재번호 검토 필요. -->
|
||||
|
||||
## [engine-v1.43.1] - 2026-04-25
|
||||
|
||||
|
||||
@@ -1313,7 +1313,7 @@ describe('트러블슈팅 회귀 테스트 - dataKey 자동 바인딩', () => {
|
||||
// 두 저장소의 분리 구조 자체를 결함으로 보고 통합(SSOT)하려던 시도를 2회 진행했으나
|
||||
// 모두 다른 회귀를 유발하여 원복됨 (2026-04-22 이력 문서 참조).
|
||||
//
|
||||
// 본 describe 는 PO 의 새 방향 (2026-04-26) — "두 저장소 분리를 인정한 상태의 동작 명세" —
|
||||
// 본 describe 는 운영자의 새 방향 (2026-04-26) — "두 저장소 분리를 인정한 상태의 동작 명세" —
|
||||
// 에 따라 각 저장소가 단독/혼합 사용 시 보이는 현재 동작을 단언한다.
|
||||
//
|
||||
// 의도된 분리 책임:
|
||||
@@ -4487,7 +4487,7 @@ describe('[사례 12] _localInit + 자식 useEffect setState가 API 데이터를
|
||||
* - A-4: CKEditor5 syncToForm에 selfManaged:true 명시로 자동 승격 제외 (성능 보존)
|
||||
*
|
||||
* 본 describe 블록은 엔진 내부 신규 구조의 contract test.
|
||||
* 이슈 #282 실제 재현(게시판 WYSIWYG 저장 422)은 PO 브라우저 검증으로 증명.
|
||||
* 이슈 #282 실제 재현(게시판 WYSIWYG 저장 422)은 브라우저 검증으로 증명.
|
||||
*/
|
||||
describe('[사례 32] 이슈 #282 이중 저장소 동기화 + 자동 승격 (engine-v1.43.0)', () => {
|
||||
/**
|
||||
|
||||
@@ -186,7 +186,7 @@ export interface InjectedComponent {
|
||||
overlayFile?: string;
|
||||
};
|
||||
|
||||
/** 편집 가능 여부 (PO 결정: 항상 true) */
|
||||
/** 편집 가능 여부 (정책 결정: 항상 true) */
|
||||
isEditable: boolean;
|
||||
|
||||
/** 주입 위치 */
|
||||
|
||||
+8
-3
@@ -297,7 +297,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
// IDV 관리자 라우트 (identity 정책/로그/프로바이더)
|
||||
Route::prefix('identity')->group(function () {
|
||||
Route::get('providers', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityProviderController::class, 'index'])
|
||||
->middleware('permission:admin,core.admin.identity.manage')
|
||||
->middleware('permission:admin,core.admin.identity.providers.read')
|
||||
->name('api.admin.identity.providers.index');
|
||||
|
||||
Route::get('logs', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityLogController::class, 'index'])
|
||||
@@ -308,16 +308,21 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
->middleware('permission:admin,core.admin.identity.logs.purge')
|
||||
->name('api.admin.identity.logs.purge');
|
||||
|
||||
Route::prefix('policies')->middleware('permission:admin,core.admin.identity.policies.manage')->group(function () {
|
||||
Route::prefix('policies')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'index'])
|
||||
->middleware('permission:admin,core.admin.identity.policies.read')
|
||||
->name('api.admin.identity.policies.index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'store'])
|
||||
->middleware('permission:admin,core.admin.identity.policies.update')
|
||||
->name('api.admin.identity.policies.store');
|
||||
Route::put('{id}', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'update'])
|
||||
->middleware('permission:admin,core.admin.identity.policies.update')
|
||||
->name('api.admin.identity.policies.update');
|
||||
Route::delete('{id}', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'destroy'])
|
||||
->middleware('permission:admin,core.admin.identity.policies.update')
|
||||
->name('api.admin.identity.policies.destroy');
|
||||
Route::post('{id}/reset-field', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'resetField'])
|
||||
->middleware('permission:admin,core.admin.identity.policies.update')
|
||||
->name('api.admin.identity.policies.reset-field');
|
||||
});
|
||||
|
||||
@@ -434,7 +439,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
Route::post('install-from-bundled', [AdminLanguagePackController::class, 'installFromBundled'])
|
||||
->middleware('permission:admin,core.language_packs.install')
|
||||
->name('api.admin.language-packs.install-from-bundled');
|
||||
// PO #7: 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 일괄 활성화
|
||||
// 요구사항 #7: 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 일괄 활성화
|
||||
Route::post('bulk-activate', [AdminLanguagePackController::class, 'bulkActivate'])
|
||||
->middleware('permission:admin,core.language_packs.manage')
|
||||
->name('api.admin.language-packs.bulk-activate');
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 운영자의 수정 권한 보유 여부에 따라 자동 비활성화
|
||||
- 환경설정 본인인증 메시지 탭 "정의 추가" 버튼에도 권한 기반 비활성화 적용
|
||||
- 본인인증 권한이 조회/수정으로 분리됨에 따라, 조회 권한만 있는 운영자도 환경설정 본인인증 화면에 정상 진입 가능 (코어 권한 변경 연동)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 센터의 미확인 알림 카운트 뱃지·알림 행의 unread 점·"읽지 않은 알림만" 체크박스가 다크 모드에서 색상이 부정합하게 노출되던 문제 수정
|
||||
- 언어팩 설치 / 모듈·플러그인·템플릿 재활성화 확인 모달의 액션 버튼이 다크 모드에서 호버 색상이 라이트 모드와 동일하게 보이던 문제 수정
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1 +1 @@
|
||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--Line truncated
|
||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--Line truncated
|
||||
@@ -101,7 +101,7 @@ var SirsoftAdminBasic=(function(Q,c,b,ar){"use strict";function eg(e){const t=Ob
|
||||
`,children:[c.jsxs(_,{ref:d,className:"relative",children:[c.jsxs(re,{onClick:()=>i(!s),className:"relative p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors","aria-label":En("common.notifications"),children:[c.jsx(ne,{name:de.Bell,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),f>0&&c.jsx(Z,{className:"absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full",children:f>9?"9+":f})]}),s&&c.jsxs(_,{className:"absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50",children:[c.jsx(_,{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-700",children:c.jsx(Z,{className:"font-semibold text-gray-900 dark:text-white",children:En("common.notifications")})}),c.jsx(_,{className:"max-h-96 overflow-y-auto",children:t.length===0?c.jsx(_,{className:"px-4 py-8 text-center text-gray-500 dark:text-gray-400",children:c.jsx(Z,{children:En("common.no_notifications")})}):t.map(h=>c.jsx(re,{onClick:()=>{h.onClick?.(),n?.(h.id),i(!1)},className:`
|
||||
w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 transition-colors
|
||||
${h.read?"":"bg-blue-50 dark:bg-blue-900/20"}
|
||||
`,children:c.jsxs(_,{className:"flex items-start gap-3",children:[h.iconName&&c.jsx(ne,{name:h.iconName,className:"w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5"}),c.jsxs(_,{className:"flex-1",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white text-sm",children:h.title}),c.jsx(_,{className:"text-gray-600 dark:text-gray-400 text-sm mt-1",children:h.message}),c.jsx(_,{className:"text-gray-400 dark:text-gray-500 text-xs mt-1",children:h.time})]}),!h.read&&c.jsx(Z,{className:"w-2 h-2 bg-blue-500 rounded-full mt-1.5"})]})},h.id))})]})]}),c.jsxs(_,{ref:u,className:"relative",children:[c.jsxs(re,{onClick:()=>g(!l),className:"flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors",children:[e.avatar?c.jsx(or,{src:e.avatar,alt:e.name,className:"w-8 h-8 rounded-full object-cover"}):c.jsx(_,{className:"w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center",children:c.jsx(ne,{name:de.User,className:"w-5 h-5 text-gray-600 dark:text-gray-400"})}),c.jsxs(_,{className:"hidden md:block text-left",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white text-sm",children:e.name}),e.role&&c.jsx(_,{className:"text-gray-500 dark:text-gray-400 text-xs",children:e.role})]}),c.jsx(ne,{name:de.ChevronDown,className:"w-4 h-4 text-gray-600 dark:text-gray-400"})]}),l&&c.jsxs(_,{className:"absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50",children:[c.jsxs(_,{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-700",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white",children:e.name}),c.jsx(_,{className:"text-gray-500 dark:text-gray-400 text-sm",children:e.email})]}),c.jsxs(_,{className:"py-2",children:[c.jsxs(re,{onClick:()=>{a?.(),g(!1)},className:"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors text-gray-900 dark:text-white",children:[c.jsx(ne,{name:de.User,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),c.jsx(Z,{children:En("common.profile_settings")})]}),c.jsxs(re,{onClick:()=>{r?.(),g(!1)},className:"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-red-600 dark:text-red-400 transition-colors",children:[c.jsx(ne,{name:de.ArrowRight,className:"w-5 h-5"}),c.jsx(Z,{children:En("common.logout")})]})]})]})]})]})},p0=({notifications:e=[],unreadCount:t=0,hasMore:n=!1,loading:a=!1,titleText:r="Notifications",emptyText:o="No notifications",markAllReadText:s="Mark all as read",deleteAllText:i="Delete all",unreadOnlyText:l="Unread only",unreadOnly:g=!1,onNotificationClick:d,onClose:u,onLoadMore:f,onMarkAllRead:h,onDeleteAll:v,onDelete:p,onUnreadOnlyToggle:x,dropdownAlign:m="right",className:T=""})=>{const[C,S]=b.useState(!1),N=b.useRef(null),E=b.useRef(null),k=b.useRef(null),y=b.useRef(null),w=b.useRef(new Set),A=b.useRef(f),D=b.useRef(n),F=b.useRef(a),M=b.useRef(e.length),L=b.useRef(-1);b.useEffect(()=>{A.current=f,D.current=n,F.current=a,M.current=e.length}),b.useEffect(()=>{if(!y.current||!k.current||!C)return;L.current=-1;const H=new IntersectionObserver(([$])=>{$.isIntersecting&&(F.current||!D.current||L.current!==M.current&&(L.current=M.current,A.current?.()))},{root:k.current,threshold:.1});return H.observe(y.current),()=>H.disconnect()},[C]),b.useEffect(()=>{if(!k.current||!C)return;const H=new IntersectionObserver(z=>{z.forEach(G=>{const j=G.target.getAttribute("data-notification-id"),W=G.target.getAttribute("data-unread")==="true";j&&W&&G.isIntersecting&&w.current.add(j)})},{root:k.current,threshold:.5});return k.current.querySelectorAll("[data-notification-id]").forEach(z=>H.observe(z)),()=>H.disconnect()},[C,e]);const U=t>0?t:e.filter(H=>!H.read).length,O=b.useCallback(()=>{if(C){if(U>0){const H=Array.from(w.current).filter($=>{const z=e.find(G=>String(G.id)===String($));return z&&!z.read});H.length>0&&u?.(H)}w.current.clear(),S(!1)}},[C,u,U,e]);b.useEffect(()=>{if(!C)return;const H=$=>{N.current&&!N.current.contains($.target)&&O()};return document.addEventListener("mousedown",H),()=>document.removeEventListener("mousedown",H)},[C,O]);const I=b.useCallback(()=>{C?O():S(!0)},[C,O]);return b.useLayoutEffect(()=>{const H=E.current;if(!C||!H)return;H.style.transform="";const $=H.getBoundingClientRect(),z=typeof window<"u"?window.innerWidth:0;if($.width===0&&$.height===0)return;const G=8;let j=0;$.right>z-G?j=-($.right-(z-G)):$.left<G&&(j=G-$.left),j!==0&&(H.style.transform=`translateX(${j}px)`)},[C,m,e.length]),c.jsxs(_,{ref:N,className:`relative ${T}`,children:[c.jsxs(re,{onClick:I,className:"relative p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors cursor-pointer","aria-label":r,children:[c.jsx(ne,{name:de.Bell,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),U>0&&c.jsx(Z,{className:"absolute top-0 right-0 flex items-center justify-center w-5 h
Line truncated
|
||||
`,children:c.jsxs(_,{className:"flex items-start gap-3",children:[h.iconName&&c.jsx(ne,{name:h.iconName,className:"w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5"}),c.jsxs(_,{className:"flex-1",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white text-sm",children:h.title}),c.jsx(_,{className:"text-gray-600 dark:text-gray-400 text-sm mt-1",children:h.message}),c.jsx(_,{className:"text-gray-400 dark:text-gray-500 text-xs mt-1",children:h.time})]}),!h.read&&c.jsx(Z,{className:"w-2 h-2 bg-blue-500 rounded-full mt-1.5"})]})},h.id))})]})]}),c.jsxs(_,{ref:u,className:"relative",children:[c.jsxs(re,{onClick:()=>g(!l),className:"flex items-center gap-3 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors",children:[e.avatar?c.jsx(or,{src:e.avatar,alt:e.name,className:"w-8 h-8 rounded-full object-cover"}):c.jsx(_,{className:"w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center",children:c.jsx(ne,{name:de.User,className:"w-5 h-5 text-gray-600 dark:text-gray-400"})}),c.jsxs(_,{className:"hidden md:block text-left",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white text-sm",children:e.name}),e.role&&c.jsx(_,{className:"text-gray-500 dark:text-gray-400 text-xs",children:e.role})]}),c.jsx(ne,{name:de.ChevronDown,className:"w-4 h-4 text-gray-600 dark:text-gray-400"})]}),l&&c.jsxs(_,{className:"absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50",children:[c.jsxs(_,{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-700",children:[c.jsx(_,{className:"font-semibold text-gray-900 dark:text-white",children:e.name}),c.jsx(_,{className:"text-gray-500 dark:text-gray-400 text-sm",children:e.email})]}),c.jsxs(_,{className:"py-2",children:[c.jsxs(re,{onClick:()=>{a?.(),g(!1)},className:"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors text-gray-900 dark:text-white",children:[c.jsx(ne,{name:de.User,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),c.jsx(Z,{children:En("common.profile_settings")})]}),c.jsxs(re,{onClick:()=>{r?.(),g(!1)},className:"w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-red-600 dark:text-red-400 transition-colors",children:[c.jsx(ne,{name:de.ArrowRight,className:"w-5 h-5"}),c.jsx(Z,{children:En("common.logout")})]})]})]})]})]})},p0=({notifications:e=[],unreadCount:t=0,hasMore:n=!1,loading:a=!1,titleText:r="Notifications",emptyText:o="No notifications",markAllReadText:s="Mark all as read",deleteAllText:i="Delete all",unreadOnlyText:l="Unread only",unreadOnly:g=!1,onNotificationClick:d,onClose:u,onLoadMore:f,onMarkAllRead:h,onDeleteAll:v,onDelete:p,onUnreadOnlyToggle:x,dropdownAlign:m="right",className:T=""})=>{const[C,S]=b.useState(!1),N=b.useRef(null),E=b.useRef(null),k=b.useRef(null),y=b.useRef(null),w=b.useRef(new Set),A=b.useRef(f),D=b.useRef(n),F=b.useRef(a),M=b.useRef(e.length),L=b.useRef(-1);b.useEffect(()=>{A.current=f,D.current=n,F.current=a,M.current=e.length}),b.useEffect(()=>{if(!y.current||!k.current||!C)return;L.current=-1;const H=new IntersectionObserver(([$])=>{$.isIntersecting&&(F.current||!D.current||L.current!==M.current&&(L.current=M.current,A.current?.()))},{root:k.current,threshold:.1});return H.observe(y.current),()=>H.disconnect()},[C]),b.useEffect(()=>{if(!k.current||!C)return;const H=new IntersectionObserver(z=>{z.forEach(G=>{const j=G.target.getAttribute("data-notification-id"),W=G.target.getAttribute("data-unread")==="true";j&&W&&G.isIntersecting&&w.current.add(j)})},{root:k.current,threshold:.5});return k.current.querySelectorAll("[data-notification-id]").forEach(z=>H.observe(z)),()=>H.disconnect()},[C,e]);const U=t>0?t:e.filter(H=>!H.read).length,O=b.useCallback(()=>{if(C){if(U>0){const H=Array.from(w.current).filter($=>{const z=e.find(G=>String(G.id)===String($));return z&&!z.read});H.length>0&&u?.(H)}w.current.clear(),S(!1)}},[C,u,U,e]);b.useEffect(()=>{if(!C)return;const H=$=>{N.current&&!N.current.contains($.target)&&O()};return document.addEventListener("mousedown",H),()=>document.removeEventListener("mousedown",H)},[C,O]);const I=b.useCallback(()=>{C?O():S(!0)},[C,O]);return b.useLayoutEffect(()=>{const H=E.current;if(!C||!H)return;H.style.transform="";const $=H.getBoundingClientRect(),z=typeof window<"u"?window.innerWidth:0;if($.width===0&&$.height===0)return;const G=8;let j=0;$.right>z-G?j=-($.right-(z-G)):$.left<G&&(j=G-$.left),j!==0&&(H.style.transform=`translateX(${j}px)`)},[C,m,e.length]),c.jsxs(_,{ref:N,className:`relative ${T}`,children:[c.jsxs(re,{onClick:I,className:"relative p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors cursor-pointer","aria-label":r,children:[c.jsx(ne,{name:de.Bell,className:"w-5 h-5 text-gray-600 dark:text-gray-400"}),U>0&&c.jsx(Z,{className:"absolute top-0 right-0 flex items-center justify-center w-5 h
Line truncated
|
||||
px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||
${d===x?"bg-blue-600 text-white":"bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"}
|
||||
${l?"opacity-50 cursor-not-allowed":""}
|
||||
@@ -303,5 +303,5 @@ self.addEventListener('message', async (e) => {
|
||||
transition-colors
|
||||
${E?"bg-blue-50 dark:bg-blue-900/20":""}
|
||||
${S.disabled?"opacity-50 cursor-not-allowed":"cursor-pointer"}
|
||||
`,role:"option","aria-selected":E,"data-item-value":S.value,"data-item-index":N,"data-item-selected":E,children:g?.item?zf(g.item,{item:S,index:N,isSelected:E},`item-${N}`):i||C(S,E)},S.value)})})})]})};function vk(e){const t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null}const yk=({value:e,checked:t=!1,icon:n,label:a,variant:r="blue",customColor:o,className:s="",style:i,onChange:l})=>{const[g,d]=b.useState(!1),u="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border cursor-pointer transition-all";if(o){const p=vk(o),x=document.documentElement.classList.contains("dark");let m;return t?m={backgroundColor:o,color:"#ffffff",borderColor:o,opacity:g?.85:1}:m=p?{backgroundColor:`rgba(${p.r}, ${p.g}, ${p.b}, ${g?x?.25:.12:x?.15:.06})`,color:o,borderColor:`rgba(${p.r}, ${p.g}, ${p.b}, ${x?.3:.2})`}:{},c.jsxs(Ft,{className:`${u} ${s}`,style:{...m,...i},onMouseEnter:()=>d(!0),onMouseLeave:()=>d(!1),children:[c.jsx(ft,{type:"checkbox",value:e,checked:t,onChange:T=>{l?.(T)},className:"hidden"}),n&&c.jsx(ne,{name:n,className:"w-4 h-4"}),c.jsx(_,{children:a})]})}const v=t?{green:"bg-green-600 dark:bg-green-600 text-white dark:text-white border-green-700 dark:border-green-500 hover:bg-green-700 dark:hover:bg-green-500",orange:"bg-orange-600 dark:bg-orange-600 text-white dark:text-white border-orange-700 dark:border-orange-500 hover:bg-orange-700 dark:hover:bg-orange-500",yellow:"bg-yellow-600 dark:bg-yellow-600 text-white dark:text-white border-yellow-700 dark:border-yellow-500 hover:bg-yellow-700 dark:hover:bg-yellow-500",gray:"bg-gray-600 dark:bg-gray-500 text-white dark:text-white border-gray-700 dark:border-gray-400 hover:bg-gray-700 dark:hover:bg-gray-400",blue:"bg-blue-600 dark:bg-blue-600 text-white dark:text-white border-blue-700 dark:border-blue-500 hover:bg-blue-700 dark:hover:bg-blue-500"}[r]:{green:"bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30",orange:"bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-100 dark:hover:bg-orange-900/30",yellow:"bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 hover:bg-yellow-100 dark:hover:bg-yellow-900/30",gray:"bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600",blue:"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30"}[r];return c.jsxs(Ft,{className:`${u} ${v} ${s}`,style:i,children:[c.jsx(ft,{type:"checkbox",value:e,checked:t,onChange:p=>{l?.(p)},className:"hidden"}),n&&c.jsx(ne,{name:n,className:"w-4 h-4"}),c.jsx(_,{children:a})]})},xk=({label:e,icon:t,iconPosition:n="left",position:a="right",className:r="",style:o,children:s})=>{const[i,l]=b.useState(!1),[g,d]=b.useState({top:0,left:0}),u=b.useRef(null),f=b.useRef(null),h=b.useCallback(()=>{if(!u.current)return;const p=u.current.getBoundingClientRect(),x=224;let m=a==="right"?p.right-x:p.left;const T=p.bottom+8;m<8&&(m=8),m+x>window.innerWidth-8&&(m=window.innerWidth-x-8),d({top:T,left:m})},[a]);b.useEffect(()=>{i&&h()},[i,h]),b.useEffect(()=>{if(!i)return;const p=()=>{l(!1)};return window.addEventListener("scroll",p,!0),window.addEventListener("resize",p),()=>{window.removeEventListener("scroll",p,!0),window.removeEventListener("resize",p)}},[i]),b.useEffect(()=>{const p=x=>{const m=x.target,T=u.current?.contains(m),C=f.current?.contains(m);!T&&!C&&l(!1)};return i&&document.addEventListener("mousedown",p),()=>{document.removeEventListener("mousedown",p)}},[i]);const v=i?ar.createPortal(c.jsx("div",{ref:f,className:"fixed z-[9999] w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden",style:{top:g.top,left:g.left},onClick:()=>l(!1),children:s}),document.body):null;return c.jsxs(_,{className:`relative inline-block ${r}`,style:o,children:[c.jsxs(re,{ref:u,onClick:()=>l(!i),className:"flex items-center gap-2",children:[n==="left"&&t&&c.jsx(ne,{name:t,className:"w-4 h-4"}),e&&c.jsx(Z,{className:"text-sm font-medium",children:e}),n==="right"&&t&&c.jsx(ne,{name:t,className:"w-4 h-4"})]}),v]})},wk=({label:e,icon:t,variant:n="default",disabled:a=!1,divider:r=!1,className:o="",style:s,onClick:i})=>{if(r)return c.jsx(_,{className:"h-px bg-gray-200 dark:bg-gray-700 my-1",style:s});const l=n==="danger"?"text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20":"text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",g=a?"opacity-50 cursor-not-allowed":"cursor-pointer",d=()=>{!a&&i&&i()};return c.jsxs(_,{onClick:d,className:`flex items-center gap-3 px-4 py-3 transition-colors ${l} ${g} ${o}`,styl
Line truncated
|
||||
`,role:"option","aria-selected":E,"data-item-value":S.value,"data-item-index":N,"data-item-selected":E,children:g?.item?zf(g.item,{item:S,index:N,isSelected:E},`item-${N}`):i||C(S,E)},S.value)})})})]})};function vk(e){const t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null}const yk=({value:e,checked:t=!1,icon:n,label:a,variant:r="blue",customColor:o,className:s="",style:i,onChange:l})=>{const[g,d]=b.useState(!1),u="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border cursor-pointer transition-all";if(o){const p=vk(o),x=document.documentElement.classList.contains("dark");let m;return t?m={backgroundColor:o,color:"#ffffff",borderColor:o,opacity:g?.85:1}:m=p?{backgroundColor:`rgba(${p.r}, ${p.g}, ${p.b}, ${g?x?.25:.12:x?.15:.06})`,color:o,borderColor:`rgba(${p.r}, ${p.g}, ${p.b}, ${x?.3:.2})`}:{},c.jsxs(Ft,{className:`${u} ${s}`,style:{...m,...i},onMouseEnter:()=>d(!0),onMouseLeave:()=>d(!1),children:[c.jsx(ft,{type:"checkbox",value:e,checked:t,onChange:T=>{l?.(T)},className:"hidden"}),n&&c.jsx(ne,{name:n,className:"w-4 h-4"}),c.jsx(_,{children:a})]})}const v=t?{green:"bg-green-600 dark:bg-green-600 text-white dark:text-white border-green-700 dark:border-green-500 hover:bg-green-700 dark:hover:bg-green-500",orange:"bg-orange-600 dark:bg-orange-600 text-white dark:text-white border-orange-700 dark:border-orange-500 hover:bg-orange-700 dark:hover:bg-orange-500",yellow:"bg-yellow-600 dark:bg-yellow-600 text-white dark:text-white border-yellow-700 dark:border-yellow-500 hover:bg-yellow-700 dark:hover:bg-yellow-500",gray:"bg-gray-600 dark:bg-gray-500 text-white dark:text-white border-gray-700 dark:border-gray-400 hover:bg-gray-700 dark:hover:bg-gray-400",blue:"bg-blue-600 dark:bg-blue-600 text-white dark:text-white border-blue-700 dark:border-blue-500 hover:bg-blue-700 dark:hover:bg-blue-500"}[r]:{green:"bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30",orange:"bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-100 dark:hover:bg-orange-900/30",yellow:"bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 hover:bg-yellow-100 dark:hover:bg-yellow-900/30",gray:"bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600",blue:"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30"}[r];return c.jsxs(Ft,{className:`${u} ${v} ${s}`,style:i,children:[c.jsx(ft,{type:"checkbox",value:e,checked:t,onChange:p=>{l?.(p)},className:"hidden"}),n&&c.jsx(ne,{name:n,className:"w-4 h-4"}),c.jsx(_,{children:a})]})},xk=({label:e,icon:t,iconPosition:n="left",position:a="right",className:r="",style:o,children:s})=>{const[i,l]=b.useState(!1),[g,d]=b.useState({top:0,left:0}),u=b.useRef(null),f=b.useRef(null),h=b.useCallback(()=>{if(!u.current)return;const p=u.current.getBoundingClientRect(),x=224;let m=a==="right"?p.right-x:p.left;const T=p.bottom+8;m<8&&(m=8),m+x>window.innerWidth-8&&(m=window.innerWidth-x-8),d({top:T,left:m})},[a]);b.useEffect(()=>{i&&h()},[i,h]),b.useEffect(()=>{if(!i)return;const p=()=>{l(!1)};return window.addEventListener("scroll",p,!0),window.addEventListener("resize",p),()=>{window.removeEventListener("scroll",p,!0),window.removeEventListener("resize",p)}},[i]),b.useEffect(()=>{const p=x=>{const m=x.target,T=u.current?.contains(m),C=f.current?.contains(m);!T&&!C&&l(!1)};return i&&document.addEventListener("mousedown",p),()=>{document.removeEventListener("mousedown",p)}},[i]);const v=i?ar.createPortal(c.jsx("div",{ref:f,className:"fixed z-[9999] w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden",style:{top:g.top,left:g.left},onClick:()=>l(!1),children:s}),document.body):null;return c.jsxs(_,{className:`relative inline-block ${r}`,style:o,children:[c.jsxs(re,{ref:u,onClick:()=>l(!i),className:"flex items-center gap-2",children:[n==="left"&&t&&c.jsx(ne,{name:t,className:"w-4 h-4"}),e&&c.jsx(Z,{className:"text-sm font-medium",children:e}),n==="right"&&t&&c.jsx(ne,{name:t,className:"w-4 h-4"})]}),v]})},wk=({label:e,icon:t,variant:n="default",disabled:a=!1,divider:r=!1,className:o="",style:s,onClick:i})=>{if(r)return c.jsx(_,{className:"h-px bg-gray-200 dark:bg-gray-700 my-1",style:s});const l=n==="danger"?"text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20":"text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",g=a?"opacity-50 cursor-not-allowed":"cursor-pointer",d=()=>{!a&&i&&i()};return c.jsxs(_,{onClick:d,className:`flex items-center gap-3 px-4 py-3 transition-colors ${l} ${g} ${o}`,styl
Line truncated
|
||||
//# sourceMappingURL=components.iife.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"components.iife.js","sources":["../../src/components/basic/Button.tsx","../../src/components/basic/FileInput.tsx","../../src/components/basic/Input.tsx","../../src/components/basic/Textarea.tsx","../../src/components/basic/Label.tsx","../../src/components/basic/Div.tsx","../../src/components/basic/Span.tsx","../../src/components/basic/P.tsx","../../src/components/basic/Img.tsx","../../src/components/basic/H1.tsx","../../src/components/basic/H2.tsx","../../src/components/basic/H3.tsx","../../src/components/basic/H4.tsx","../../src/components/basic/Ul.tsx","../../src/components/basic/Ol.tsx","../../src/components/basic/Li.tsx","../../src/components/basic/A.tsx","../../src/components/basic/Form.tsx","../../src/components/basic/Svg.tsx","../../src/components/basic/Select.tsx","../../src/components/basic/Option.tsx","../../src/components/basic/Optgroup.tsx","../../src/components/basic/Checkbox.tsx","../../src/components/basic/Table.tsx","../../src/components/basic/Thead.tsx","../../src/components/basic/Tbody.tsx","../../src/components/basic/Tfoot.tsx","../../src/components/basic/Tr.tsx","../../src/components/basic/Th.tsx","../../src/components/basic/Td.tsx","../../src/components/basic/Nav.tsx","../../src/components/basic/Section.tsx","../../src/components/basic/Icon.tsx","../../src/components/basic/Code.tsx","../../src/components/basic/Pre.tsx","../../src/components/basic/Fragment.tsx","../../src/components/basic/Hr.tsx","../../src/components/basic/IconTypes.ts","../../src/components/composite/Card.tsx","../../src/components/composite/ActionMenu.tsx","../../src/components/composite/Pagination.tsx","../../src/components/composite/MultilingualInput.tsx","../../src/components/composite/Toggle.tsx","../../src/components/composite/StatusBadge.tsx","../../src/components/composite/Badge.tsx","../../node_modules/dompurify/dist/purify.es.mjs","../../src/components/composite/HtmlContent.tsx","../../src/components/composite/DataGrid.tsx","../../src/components/composite/CardGrid.tsx","../../src/components/composite/Modal.tsx","../../src/components/composite/Dropdown.tsx","../../src/components/composite/ProductCard.tsx","../../src/components/composite/StatCard.tsx","../../src/components/composite/SearchBar.tsx","../../src/components/composite/FilterGroup.tsx","../../src/components/composite/TabNavigation.tsx","../../src/handlers/scrollToSectionHandler.ts","../../src/components/composite/TabNavigationScroll.tsx","../../src/components/composite/IconButton.tsx","../../src/components/composite/Dialog.tsx","../../src/components/composite/ConfirmDialog.tsx","../../src/components/composite/AlertDialog.tsx","../../src/components/composite/Toast.tsx","../../src/components/composite/Breadcrumb.tsx","../../src/components/composite/EmptyState.tsx","../../src/components/composite/LoadingSpinner.tsx","../../src/components/basic/I.tsx","../../src/components/composite/AdminSidebar.tsx","../../src/components/composite/AdminHeader.tsx","../../src/components/composite/NotificationCenter.tsx","../../src/components/composite/LanguageSelector.tsx","../../src/components/composite/UserProfile.tsx","../../src/components/composite/AdminFooter.tsx","../../src/components/composite/PageHeader.tsx","../../src/components/composite/TemplateCard.tsx","../../src/components/composite/Alert.tsx","../../src/components/composite/LayoutEditorHeader.tsx","../../src/components/composite/LayoutFileList.tsx","../../node_modules/@monaco-editor/loader/lib/es/_virtual/_rollupPluginBabelHelpers.js","../../node_modules/state-local/lib/es/state-local.js","../../node_modules/@monaco-editor/loader/lib/es/config/index.js","../../node_modules/@monaco-editor/loader/lib/es/utils/curry.js","../../node_modules/@monaco-editor/loader/lib/es/utils/isObject.js","../../node_modules/@monaco-editor/loader/lib/es/validators/index.js","../../node_modules/@monaco-editor/loader/lib/es/utils/compose.js","../../node_modules/@monaco-editor/loader/lib/es/utils/deepMerge.js","../../node_modules/@monaco-editor/loader/lib/es/utils/makeCancelable.js","../../node_modules/@monaco-editor/loader/lib/es/loader/index.js","../../node_modules/@monaco-editor/react/dist/index.mjs","../../src/components/composite/CodeEditor.tsx","../../src/components/composite/VersionList.tsx","../../src/components/composite/LayoutHistoryPanel.tsx","../../src/components/composite/LoginForm.tsx","../../src/components/composite/ThemeToggle.tsx","../../src/components/composite/ColumnSelector.tsx","../../src/components/composite/PageTransitionIndicator.tsx","../../src/components/composite/PageLoading.tsx","../../node_modules/@babel/runtime/helpers/esm/typeof.js","../../node_modules/@babel/runtime/helpers/esm/toPrimitive.js","../../node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","../../node_modules/@babel/runtime/helpers/esm/defineProperty.js","../../node_modules/@babel/runtime/helpers/esm/objectSpread2.js","../../node_modules/@babel/runtime/helpers/esm/arrayWithHoles.js","../../node_modules/@babel/rLine truncated
|
||||
{"version":3,"file":"components.iife.js","sources":["../../src/components/basic/Button.tsx","../../src/components/basic/FileInput.tsx","../../src/components/basic/Input.tsx","../../src/components/basic/Textarea.tsx","../../src/components/basic/Label.tsx","../../src/components/basic/Div.tsx","../../src/components/basic/Span.tsx","../../src/components/basic/P.tsx","../../src/components/basic/Img.tsx","../../src/components/basic/H1.tsx","../../src/components/basic/H2.tsx","../../src/components/basic/H3.tsx","../../src/components/basic/H4.tsx","../../src/components/basic/Ul.tsx","../../src/components/basic/Ol.tsx","../../src/components/basic/Li.tsx","../../src/components/basic/A.tsx","../../src/components/basic/Form.tsx","../../src/components/basic/Svg.tsx","../../src/components/basic/Select.tsx","../../src/components/basic/Option.tsx","../../src/components/basic/Optgroup.tsx","../../src/components/basic/Checkbox.tsx","../../src/components/basic/Table.tsx","../../src/components/basic/Thead.tsx","../../src/components/basic/Tbody.tsx","../../src/components/basic/Tfoot.tsx","../../src/components/basic/Tr.tsx","../../src/components/basic/Th.tsx","../../src/components/basic/Td.tsx","../../src/components/basic/Nav.tsx","../../src/components/basic/Section.tsx","../../src/components/basic/Icon.tsx","../../src/components/basic/Code.tsx","../../src/components/basic/Pre.tsx","../../src/components/basic/Fragment.tsx","../../src/components/basic/Hr.tsx","../../src/components/basic/IconTypes.ts","../../src/components/composite/Card.tsx","../../src/components/composite/ActionMenu.tsx","../../src/components/composite/Pagination.tsx","../../src/components/composite/MultilingualInput.tsx","../../src/components/composite/Toggle.tsx","../../src/components/composite/StatusBadge.tsx","../../src/components/composite/Badge.tsx","../../node_modules/dompurify/dist/purify.es.mjs","../../src/components/composite/HtmlContent.tsx","../../src/components/composite/DataGrid.tsx","../../src/components/composite/CardGrid.tsx","../../src/components/composite/Modal.tsx","../../src/components/composite/Dropdown.tsx","../../src/components/composite/ProductCard.tsx","../../src/components/composite/StatCard.tsx","../../src/components/composite/SearchBar.tsx","../../src/components/composite/FilterGroup.tsx","../../src/components/composite/TabNavigation.tsx","../../src/handlers/scrollToSectionHandler.ts","../../src/components/composite/TabNavigationScroll.tsx","../../src/components/composite/IconButton.tsx","../../src/components/composite/Dialog.tsx","../../src/components/composite/ConfirmDialog.tsx","../../src/components/composite/AlertDialog.tsx","../../src/components/composite/Toast.tsx","../../src/components/composite/Breadcrumb.tsx","../../src/components/composite/EmptyState.tsx","../../src/components/composite/LoadingSpinner.tsx","../../src/components/basic/I.tsx","../../src/components/composite/AdminSidebar.tsx","../../src/components/composite/AdminHeader.tsx","../../src/components/composite/NotificationCenter.tsx","../../src/components/composite/LanguageSelector.tsx","../../src/components/composite/UserProfile.tsx","../../src/components/composite/AdminFooter.tsx","../../src/components/composite/PageHeader.tsx","../../src/components/composite/TemplateCard.tsx","../../src/components/composite/Alert.tsx","../../src/components/composite/LayoutEditorHeader.tsx","../../src/components/composite/LayoutFileList.tsx","../../node_modules/@monaco-editor/loader/lib/es/_virtual/_rollupPluginBabelHelpers.js","../../node_modules/state-local/lib/es/state-local.js","../../node_modules/@monaco-editor/loader/lib/es/config/index.js","../../node_modules/@monaco-editor/loader/lib/es/utils/curry.js","../../node_modules/@monaco-editor/loader/lib/es/utils/isObject.js","../../node_modules/@monaco-editor/loader/lib/es/validators/index.js","../../node_modules/@monaco-editor/loader/lib/es/utils/compose.js","../../node_modules/@monaco-editor/loader/lib/es/utils/deepMerge.js","../../node_modules/@monaco-editor/loader/lib/es/utils/makeCancelable.js","../../node_modules/@monaco-editor/loader/lib/es/loader/index.js","../../node_modules/@monaco-editor/react/dist/index.mjs","../../src/components/composite/CodeEditor.tsx","../../src/components/composite/VersionList.tsx","../../src/components/composite/LayoutHistoryPanel.tsx","../../src/components/composite/LoginForm.tsx","../../src/components/composite/ThemeToggle.tsx","../../src/components/composite/ColumnSelector.tsx","../../src/components/composite/PageTransitionIndicator.tsx","../../src/components/composite/PageLoading.tsx","../../node_modules/@babel/runtime/helpers/esm/typeof.js","../../node_modules/@babel/runtime/helpers/esm/toPrimitive.js","../../node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","../../node_modules/@babel/runtime/helpers/esm/defineProperty.js","../../node_modules/@babel/runtime/helpers/esm/objectSpread2.js","../../node_modules/@babel/runtime/helpers/esm/arrayWithHoles.js","../../node_modules/@babel/rLine truncated
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "번들 언어팩 설치 확인 모달 — 모듈/플러그인의 install 모달과 동일한 패턴. 행의 '설치' 버튼이 즉시 apiCall 하지 않고 본 모달을 열어 PO 가 확인 후 진행."
|
||||
"description": "번들 언어팩 설치 확인 모달 — 모듈/플러그인의 install 모달과 동일한 패턴. 행의 '설치' 버튼이 즉시 apiCall 하지 않고 본 모달을 열어 운영자가 확인 후 진행."
|
||||
},
|
||||
"id": "language_pack_install_bundled_modal",
|
||||
"type": "composite",
|
||||
@@ -133,7 +133,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isLanguagePackInstalling || _global.selectedBundledLanguagePack?.install_blocked_reason || _global.selectedBundledLanguagePack?.abilities?.can_install !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "모듈 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
|
||||
"description": "모듈 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
|
||||
},
|
||||
"id": "module_reactivate_language_packs_modal",
|
||||
"type": "composite",
|
||||
@@ -118,7 +118,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "플러그인 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
|
||||
"description": "플러그인 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
|
||||
},
|
||||
"id": "plugin_reactivate_language_packs_modal",
|
||||
"type": "composite",
|
||||
@@ -118,7 +118,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+3
-2
@@ -45,7 +45,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "{{(_local.activeIdentityMessageChannel ?? 'mail') === 'mail' ? 'border-b-2 border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2.5 text-sm font-medium whitespace-nowrap flex items-center gap-2"
|
||||
"className": "{{(_local.activeIdentityMessageChannel ?? 'mail') === 'mail' ? 'border-b-2 border-indigo-500 dark:border-indigo-400 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2.5 text-sm font-medium whitespace-nowrap flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -140,7 +140,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "ml-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-md transition-colors"
|
||||
"className": "ml-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{identityMessages?.data?.abilities?.can_create !== true}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
|
||||
+12
-7
@@ -51,7 +51,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
|
||||
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
|
||||
"disabled": "{{policies?.data?.abilities?.can_create !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -546,7 +547,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -634,7 +636,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -682,7 +685,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
@@ -860,7 +863,8 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"checked": "{{Boolean(policy.enabled)}}",
|
||||
"size": "sm"
|
||||
"size": "sm",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -931,7 +935,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
|
||||
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
|
||||
"disabled": "{{policy.abilities?.can_update !== true}}"
|
||||
},
|
||||
"text": "$t:common.edit",
|
||||
"actions": [
|
||||
@@ -978,7 +983,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{policy.source_type === 'admin'}}",
|
||||
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "템플릿 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
|
||||
"description": "템플릿 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
|
||||
},
|
||||
"id": "template_reactivate_language_packs_modal",
|
||||
"type": "composite",
|
||||
@@ -118,7 +118,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-admin_basic",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"description": "Gnuboard7 Basic Admin Template Components",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
+4
-4
@@ -285,7 +285,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
|
||||
>
|
||||
<Icon name={IconName.Bell} className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
{displayCount > 0 && (
|
||||
<Span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
<Span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 dark:bg-red-600 rounded-full">
|
||||
{displayCount > 99 ? '99+' : displayCount}
|
||||
</Span>
|
||||
)}
|
||||
@@ -310,7 +310,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
|
||||
onUnreadOnlyToggle(e.target.checked);
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer"
|
||||
/>
|
||||
<Span className="text-xs text-gray-600 dark:text-gray-400">{unreadOnlyText}</Span>
|
||||
</Div>
|
||||
@@ -332,7 +332,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 드롭다운은 닫지 않음 — 확인 모달이 닫히면 다시 자연스럽게 노출되어야 하고,
|
||||
// 사용자가 모달 취소 시에도 컨텍스트 유지 (PO 요구)
|
||||
// 사용자가 모달 취소 시에도 컨텍스트 유지 (요구사항)
|
||||
onDeleteAll();
|
||||
}}
|
||||
className="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:underline cursor-pointer"
|
||||
@@ -398,7 +398,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
|
||||
</Div>
|
||||
<Div className="flex flex-col items-center gap-2 flex-shrink-0">
|
||||
{isUnread && (
|
||||
<Span className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />
|
||||
<Span className="w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full mt-1.5" />
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Admin Basic",
|
||||
"en": "Admin Basic"
|
||||
},
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0-beta.5",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 관리자 템플릿",
|
||||
@@ -19,7 +19,7 @@
|
||||
"url": "https://sirsoft.com"
|
||||
},
|
||||
"release_date": "2025-01-01",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"g7_version": ">=7.0.0-beta.5",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-05-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 센터의 미확인 알림 카운트 뱃지·알림 행의 unread 점·"읽지 않은 알림만" 체크박스가 다크 모드에서 색상이 부정합하게 노출되던 문제 수정
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1 +1 @@
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(Line truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(Line truncated
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user