릴리즈 v0.2.7: 빌드 오류 수정, supervisor 권한 보완 및 Gitea 설정 기능 고도화
This commit is contained in:
parent
98b52c390e
commit
bc996a3980
9
.gitignore
vendored
9
.gitignore
vendored
@ -22,14 +22,6 @@ dist-ssr
|
||||
build
|
||||
out
|
||||
|
||||
# Environment Variables (CRITICAL)
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@ -50,7 +42,6 @@ Desktop.ini
|
||||
server/uploads/*
|
||||
!server/uploads/.gitkeep
|
||||
server/server.zip
|
||||
server/public_key.pem
|
||||
server/private_key.pem
|
||||
|
||||
# Project Specific - Camera/Stream
|
||||
|
||||
@ -22,19 +22,22 @@ SmartIMS 배포는 **Git Tag 기반 배포** 방식을 권장합니다. 이는
|
||||
|
||||
### 2.2. 설치 단계
|
||||
1. **소스 클론**:
|
||||
`/volume1/web` 위치에서 아래 명령어를 실행하면 `smartims` 폴더가 생성되며 그 안에 소스가 들어갑니다.
|
||||
```bash
|
||||
git clone [저장소_URL] smartims
|
||||
cd /volume1/web
|
||||
# 저장소 URL 뒤에 'smartims'를 붙여 폴더명을 지정합니다.
|
||||
git clone https://gitea.qideun.com/SOKUREE/smart_ims.git smartims
|
||||
cd smartims
|
||||
```
|
||||
2. **안정 버전(Tag) 전환**:
|
||||
```bash
|
||||
git fetch --all --tags
|
||||
# 현재 배포 가능한 최신 태그로 전환 (예: v0.2.5)
|
||||
git checkout v0.2.5
|
||||
# v0.2.6 버전으로 전환
|
||||
git checkout v0.2.6
|
||||
```
|
||||
3. **환경 설정**:
|
||||
* `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력).
|
||||
* `server/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
|
||||
* `server/config/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
|
||||
4. **패키지 설치 및 빌드**:
|
||||
```bash
|
||||
# 전체 의존성 설치 및 프론트엔드 빌드
|
||||
@ -71,17 +74,19 @@ npm run build
|
||||
pm2 reload smartims-api
|
||||
```
|
||||
|
||||
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (검토 중)
|
||||
시스템의 "버전 정보" 화면에서 신규 버전을 감지하고 업데이트하는 기능을 검토 중입니다.
|
||||
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (지원됨)
|
||||
시스템의 **"시스템 관리 > 버전 정보"** 화면에서 신규 버전을 감지하고 원 클릭으로 업데이트할 수 있습니다.
|
||||
|
||||
* 참고: 이 기능을 사용하려면 **[기본 설정]** 메뉴에서 Gitea 원격 저장소 URL과 (필요 시) 계정 정보를 먼저 설정해야 합니다.
|
||||
|
||||
* **동작 원리**:
|
||||
1. 서버가 원격 저장소의 최신 Tag 리스트를 정기적으로 또는 요청 시 확인합니다.
|
||||
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교하여 업데이트 버튼을 활성화합니다.
|
||||
3. 업데이트 실행 시 서버 내부적으로 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동화된 스크립트로 수행합니다.
|
||||
1. 서버가 설정된 원격 저장소의 최신 Tag 리스트를 확인합니다.
|
||||
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교합니다.
|
||||
3. 업데이트 실행 시 서버가 백그라운드에서 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동으로 수행합니다.
|
||||
|
||||
* **기대 효과**:
|
||||
* 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능.
|
||||
* 버전 불일치 문제 예방 및 운영 편의성 증대.
|
||||
* 운영 서버 환경에서도 편리한 버전 관리.
|
||||
|
||||
---
|
||||
|
||||
|
||||
23
server/.env
Normal file
23
server/.env
Normal file
@ -0,0 +1,23 @@
|
||||
# ==============================================
|
||||
# [Common Settings]
|
||||
# ==============================================
|
||||
DB_HOST=sokuree.com
|
||||
DB_USER=choibk
|
||||
DB_PASSWORD=^Ocean1472bk
|
||||
PORT=3005
|
||||
|
||||
# ==============================================
|
||||
# [Development Environment] - Local Windows
|
||||
# ==============================================
|
||||
# 로컬 개발용 DB (분리됨: sokuree_platform_dev)
|
||||
DB_NAME=sokuree_platform_dev
|
||||
DB_PORT=3307
|
||||
# Windows 환경 호환성 (tcp는 권한 오류 발생 가능)
|
||||
CCTV_TRANSPORT_OVERRIDE=auto
|
||||
|
||||
# ==============================================
|
||||
# [Production Environment] - Synology NAS
|
||||
# ==============================================
|
||||
# DB_NAME=sokuree_platform_prod
|
||||
# DB_PORT=3307
|
||||
|
||||
@ -7,6 +7,7 @@ const fs = require('fs');
|
||||
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
||||
const { generateLicense, verifyLicense } = require('../utils/licenseManager');
|
||||
const { checkRemoteKey } = require('../utils/remoteLicense');
|
||||
const cryptoUtil = require('../utils/cryptoUtil');
|
||||
|
||||
// Load Public Key for Verification
|
||||
const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
|
||||
@ -32,7 +33,10 @@ const ALLOWED_SETTING_KEYS = [
|
||||
'asset_categories',
|
||||
'asset_locations',
|
||||
'asset_statuses',
|
||||
'asset_maintenance_types'
|
||||
'asset_maintenance_types',
|
||||
'gitea_url',
|
||||
'gitea_user',
|
||||
'gitea_password'
|
||||
];
|
||||
|
||||
// --- .env File Utilities ---
|
||||
@ -70,7 +74,7 @@ const mysql = require('mysql2/promise');
|
||||
// 0. Server Configuration (Subscriber ID & Session Timeout)
|
||||
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key', 'gitea_url', 'gitea_user', 'gitea_password')");
|
||||
const settings = {};
|
||||
rows.forEach(r => settings[r.setting_key] = r.setting_value);
|
||||
|
||||
@ -81,6 +85,9 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
subscriber_id: settings.subscriber_id || '',
|
||||
session_timeout: parseInt(settings.session_timeout) || 60,
|
||||
encryption_key: settings.encryption_key || '',
|
||||
gitea_url: settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git',
|
||||
gitea_user: settings.gitea_user || '',
|
||||
gitea_password: settings.gitea_password ? cryptoUtil.decryptMasterKey(settings.gitea_password) : '',
|
||||
db_config: {
|
||||
host: env.DB_HOST || '',
|
||||
user: env.DB_USER || '',
|
||||
@ -109,6 +116,16 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
|
||||
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
|
||||
}
|
||||
if (req.body.gitea_url !== undefined) {
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_url', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_url]);
|
||||
}
|
||||
if (req.body.gitea_user !== undefined) {
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_user', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_user]);
|
||||
}
|
||||
if (req.body.gitea_password !== undefined) {
|
||||
const encryptedPass = cryptoUtil.encryptMasterKey(req.body.gitea_password);
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_password', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedPass]);
|
||||
}
|
||||
|
||||
// Handle .env DB settings
|
||||
if (db_config) {
|
||||
@ -129,7 +146,6 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
|
||||
});
|
||||
|
||||
// --- Crypto & Key Rotation ---
|
||||
const cryptoUtil = require('../utils/cryptoUtil');
|
||||
|
||||
// 0.2 Test DB Connection
|
||||
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
@ -234,8 +250,8 @@ router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res
|
||||
try {
|
||||
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
// Special handling for encryption_key to protect it in DB
|
||||
if (key === 'encryption_key') {
|
||||
// Special handling for sensitive keys to protect it in DB
|
||||
if (key === 'encryption_key' || key === 'gitea_password') {
|
||||
stringValue = cryptoUtil.encryptMasterKey(stringValue);
|
||||
}
|
||||
|
||||
@ -427,6 +443,25 @@ router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), asyn
|
||||
|
||||
// --- System Update Logic ---
|
||||
|
||||
const getGiteaAuth = async () => {
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('gitea_url', 'gitea_user', 'gitea_password')");
|
||||
const settings = {};
|
||||
rows.forEach(r => settings[r.setting_key] = r.setting_value);
|
||||
|
||||
const url = settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git';
|
||||
|
||||
if (settings.gitea_user && settings.gitea_password) {
|
||||
const pass = cryptoUtil.decryptMasterKey(settings.gitea_password);
|
||||
return { url, user: settings.gitea_user, pass: pass };
|
||||
}
|
||||
return { url, user: null, pass: null };
|
||||
} catch (e) {
|
||||
console.error('Failed to get Gitea auth:', e);
|
||||
}
|
||||
return { url: 'https://gitea.qideun.com/SOKUREE/smart_ims.git', user: null, pass: null };
|
||||
};
|
||||
|
||||
// 5. Get Version Info (Current & Remote)
|
||||
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
@ -434,15 +469,27 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// Run git fetch to update tags from remote
|
||||
exec('git fetch --tags', (err, stdout, stderr) => {
|
||||
// Prepare git fetch command with auth if available
|
||||
const auth = await getGiteaAuth();
|
||||
let fetchCmd = 'git fetch --tags';
|
||||
|
||||
if (auth.user && auth.pass) {
|
||||
// Inject auth into URL
|
||||
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||
fetchCmd = `git fetch ${authenticatedUrl} --tags`;
|
||||
} else {
|
||||
fetchCmd = `git fetch ${auth.url} --tags`;
|
||||
}
|
||||
|
||||
exec(fetchCmd, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Git fetch failed:', err);
|
||||
console.error('Stderr:', stderr);
|
||||
// Mask password in error message
|
||||
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
|
||||
return res.json({
|
||||
current: currentVersion,
|
||||
latest: null,
|
||||
error: `원격 저장소 동기화 실패: ${stderr || err.message}`
|
||||
error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}`
|
||||
});
|
||||
}
|
||||
|
||||
@ -485,7 +532,18 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
|
||||
// This operation is asynchronous. We start it and return a message.
|
||||
// In a real production, we might want to log this to a terminal-like view.
|
||||
|
||||
const auth = await getGiteaAuth();
|
||||
let authPrefix = '';
|
||||
if (auth.user && auth.pass) {
|
||||
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||
authPrefix = `git remote set-url origin ${authenticatedUrl} && `;
|
||||
} else {
|
||||
authPrefix = `git remote set-url origin ${auth.url} && `;
|
||||
}
|
||||
|
||||
const updateScript = `
|
||||
${authPrefix}
|
||||
git fetch --tags &&
|
||||
git checkout ${targetTag} &&
|
||||
npm install &&
|
||||
npm run build &&
|
||||
|
||||
@ -1,23 +1,27 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { ModuleGuard } from '../shared/auth/ModuleGuard';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '../shared/auth/AuthContext';
|
||||
import { SystemProvider } from '../shared/context/SystemContext';
|
||||
import { AuthGuard } from '../shared/auth/AuthGuard';
|
||||
import { MainLayout } from '../widgets/layout/MainLayout';
|
||||
import { LoginPage } from '../pages/auth/LoginPage';
|
||||
import { DashboardPage } from '../modules/asset/pages/DashboardPage';
|
||||
import { AssetListPage } from '../modules/asset/pages/AssetListPage';
|
||||
import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
|
||||
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
|
||||
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
|
||||
|
||||
// Modules
|
||||
import { assetModule } from '../modules/asset/module';
|
||||
import { cctvModule } from '../modules/cctv/module';
|
||||
import { productionModule } from '../modules/production/module';
|
||||
import ModuleLoader from '../platform/ModuleLoader';
|
||||
|
||||
// Platform / System Pages
|
||||
import { UserManagementPage } from '../platform/pages/UserManagementPage';
|
||||
import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage';
|
||||
import { ProductionPage } from '../production/pages/ProductionPage';
|
||||
import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage';
|
||||
import { AuthProvider } from '../shared/auth/AuthContext';
|
||||
import { AuthGuard } from '../shared/auth/AuthGuard';
|
||||
|
||||
import { SystemProvider } from '../shared/context/SystemContext';
|
||||
import { VersionPage } from '../platform/pages/VersionPage';
|
||||
import { LicensePage } from '../system/pages/LicensePage';
|
||||
|
||||
function App() {
|
||||
import '../platform/styles/global.css';
|
||||
|
||||
const modules = [assetModule, cctvModule, productionModule];
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SystemProvider>
|
||||
@ -29,39 +33,20 @@ function App() {
|
||||
{/* Protected Routes */}
|
||||
<Route element={
|
||||
<AuthGuard>
|
||||
<MainLayout />
|
||||
<MainLayout modulesList={modules} />
|
||||
</AuthGuard>
|
||||
}>
|
||||
{/* Asset Management Routes */}
|
||||
<Route element={<ModuleGuard moduleCode="asset"><Outlet /></ModuleGuard>}>
|
||||
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
|
||||
<Route path="/asset/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/asset/register" element={<AssetRegisterPage />} />
|
||||
<Route path="/asset/list" element={<AssetListPage />} />
|
||||
<Route path="/asset/facilities" element={<AssetListPage />} />
|
||||
<Route path="/asset/tools" element={<AssetListPage />} />
|
||||
<Route path="/asset/general" element={<AssetListPage />} />
|
||||
<Route path="/asset/settings" element={<AssetSettingsPage />} />
|
||||
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
|
||||
<Route path="/asset/consumables" element={<AssetListPage />} />
|
||||
<Route path="/asset/instruments" element={<AssetListPage />} />
|
||||
<Route path="/asset/vehicles" element={<AssetListPage />} />
|
||||
</Route>
|
||||
{/* Dynamic Module Routes */}
|
||||
<Route path="/*" element={<ModuleLoader modules={modules} />} />
|
||||
|
||||
{/* Production Management Routes */}
|
||||
<Route element={<ModuleGuard moduleCode="production"><Outlet /></ModuleGuard>}>
|
||||
<Route path="/production/dashboard" element={<ProductionPage />} />
|
||||
</Route>
|
||||
{/* Navigation Fallback within Layout */}
|
||||
<Route index element={<Navigate to="/asset/dashboard" replace />} />
|
||||
|
||||
{/* Monitoring Routes */}
|
||||
<Route element={<ModuleGuard moduleCode="monitoring"><Outlet /></ModuleGuard>}>
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
{/* Platform Admin Routes */}
|
||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||
<Route path="/admin/license" element={<LicensePage />} />
|
||||
<Route path="/admin/version" element={<VersionPage />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@ -33,9 +33,12 @@ export const ModuleLoader = ({ modules }: ModuleLoaderProps) => {
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
// 2. 沅뚰븳 泥댄겕 (Role 湲곕컲)
|
||||
if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) {
|
||||
return null;
|
||||
// 2. 권한 체크 (Role 기반)
|
||||
if (module.requiredRoles && user) {
|
||||
// supervisor는 모든 권한을 가짐
|
||||
if (user.role !== 'supervisor' && !module.requiredRoles.includes(user.role)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -10,6 +10,9 @@ interface SystemSettings {
|
||||
session_timeout: number;
|
||||
encryption_key: string;
|
||||
subscriber_id: string;
|
||||
gitea_url: string;
|
||||
gitea_user: string;
|
||||
gitea_password: string;
|
||||
db_config: {
|
||||
host: string;
|
||||
user: string;
|
||||
@ -25,6 +28,9 @@ export function BasicSettingsPage() {
|
||||
session_timeout: 60,
|
||||
encryption_key: '',
|
||||
subscriber_id: '',
|
||||
gitea_url: '',
|
||||
gitea_user: '',
|
||||
gitea_password: '',
|
||||
db_config: {
|
||||
host: '',
|
||||
user: '',
|
||||
@ -39,6 +45,7 @@ export function BasicSettingsPage() {
|
||||
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
|
||||
security: null,
|
||||
encryption: null,
|
||||
repository: null,
|
||||
database: null
|
||||
});
|
||||
|
||||
@ -176,7 +183,7 @@ export function BasicSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => {
|
||||
const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => {
|
||||
if (section === 'database' && !isDbVerified) {
|
||||
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
|
||||
return;
|
||||
@ -187,6 +194,11 @@ export function BasicSettingsPage() {
|
||||
|
||||
if (section === 'security') payload = { session_timeout: settings.session_timeout };
|
||||
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
|
||||
else if (section === 'repository') payload = {
|
||||
gitea_url: settings.gitea_url,
|
||||
gitea_user: settings.gitea_user,
|
||||
gitea_password: settings.gitea_password
|
||||
};
|
||||
else if (section === 'database') {
|
||||
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
|
||||
payload = { db_config: settings.db_config };
|
||||
@ -351,6 +363,62 @@ export function BasicSettingsPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Section 2.5: Gitea Repository Update Settings */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<RefreshCcw size={20} className="text-emerald-600" />
|
||||
시스템 업데이트 저장소 설정 (Gitea)
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-xs text-slate-500 mb-4">원격 저장소(Gitea)에서 최신 업데이트 태그를 가져오기 위한 주소 및 인증 정보를 설정합니다.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">저장소 URL (Git 주소)</label>
|
||||
<Input
|
||||
value={settings.gitea_url}
|
||||
onChange={e => setSettings({ ...settings, gitea_url: e.target.value })}
|
||||
placeholder="https://gitea.example.com/org/repo.git"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">Gitea 접속 ID</label>
|
||||
<Input
|
||||
value={settings.gitea_user}
|
||||
onChange={e => setSettings({ ...settings, gitea_user: e.target.value })}
|
||||
placeholder="gitea_update_user"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">Gitea 비밀번호 (또는 Token)</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.gitea_password}
|
||||
onChange={e => setSettings({ ...settings, gitea_password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{saveResults.repository && (
|
||||
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.repository.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveResults.repository.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||
{saveResults.repository.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={fetchSettings}>취소</Button>
|
||||
<Button size="sm" onClick={() => handleSaveSection('repository')} disabled={saving} icon={<Save size={14} />}>계정 저장</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user