diff --git a/README.md b/README.md index 62b45c4..127a2e8 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,11 @@ * **고속 워크플로우 (Instant Edit)**: 메모 카드 위에 마우스를 올리고 `e`를 누르면 즉시 수정 모드 진입. 모달 클릭의 피로감을 제로로 만듭니다. * **드래그 앤 드롭 링크**: 메모 카드를 작성기(Composer)로 드래그하여 즉시 참조 링크를 삽입하세요. +### 🛠️ 패치 노트 (2026-04-20) +* **세션 타임아웃 카운트다운**: 로그아웃 버튼에 실시간 세션 남은 시간을 표시하는 타이머를 추가하여 예기치 않은 로그아웃으로 인한 데이터 유실을 방지합니다. +* **버튼 UI 최적화**: 타이머 표시 공간을 확보하기 위해 로그아웃 버튼 텍스트를 "종료" / "EXIT"로 축약하였습니다. +* **지능형 세션 연장**: 클릭이나 키보드 입력 등 사용자 활동이 감지되면 타이머가 자동으로 초기화되어 세션이 유지됩니다. + ### 🛠️ 패치 노트 (2026-04-19) * **파일 첨부 접근성 개선**: 지식 작성기(Composer)에 명시적인 파일 첨부(📎) 버튼을 추가했습니다. (드래그 앤 드롭과 병행 가능) * **모바일 UX 최적화**: 모바일 기기에서도 조작이 편리하도록 "파일추가" 텍스트 레이블을 추가했습니다. @@ -163,6 +168,11 @@ We provide a security model where user data is practically undecipherable. Built - **Instant Edit (e-key)**: Hover over a memo and press `e` to jump straight into editing mode. Zero-click productivity. - **Drag & Drop Workflow**: Drag memo cards into the composer to instantly insert a semantic reference. +### 🛠️ Patch Notes (2026-04-20) +- **Session Timeout Countdown**: Added a real-time countdown timer to the logout button to prevent unexpected data loss from session expiration. +- **UI Optimization**: Shortened the logout label to "EXIT" / "종료" to minimize button size and accommodate the countdown timer. +- **Active Session Reset**: The timer automatically resets upon user activity (clicks, key presses), keeping your session active while you work. + ### 🛠️ Patch Notes (2026-04-19) - **Improved Attachment Accessibility**: Added a dedicated Attach File (📎) button to the Composer. - **Mobile UI Optimization**: Added an "Add File" text label next to the icon on mobile devices for better touch usability. diff --git a/app/__init__.py b/app/__init__.py index b05e52e..6b715c6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -88,11 +88,17 @@ def create_app(): init_db() # Session and Security configurations + # config.json에서 타임아웃 로드 (기본 60분, 최소 10분 강제) + session_timeout = cfg.get('session_timeout', 60) + if not isinstance(session_timeout, int) or session_timeout < 10: + session_timeout = 10 + + from datetime import timedelta app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax', SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE', 'False').lower() == 'true', - PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session + PERMANENT_SESSION_LIFETIME=timedelta(minutes=session_timeout) ) @app.after_request diff --git a/app/routes/auth.py b/app/routes/auth.py index ad65e6c..3c16128 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -23,3 +23,10 @@ def login(): def logout(): session.pop('logged_in', None) return redirect(url_for('main.login_page')) + +@auth_bp.route('/api/auth/status') +def auth_status(): + """프론트엔드 세션 체크(Heartbeat)용 엔드포인트""" + if session.get('logged_in'): + return jsonify({'status': 'ok', 'logged_in': True}) + return jsonify({'error': 'Unauthorized', 'logged_in': False}), 401 diff --git a/app/routes/settings.py b/app/routes/settings.py index 56140d6..d6655b0 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -16,9 +16,10 @@ DEFAULT_SETTINGS = { "ai_accent": "#8b5cf6", "enable_ai": True, "lang": "ko", - "enable_categories": False, # 카테고리 기능 활성화 여부 (고급 옵션) - "categories": [], # 무제한 전체 목록 - "pinned_categories": [] # 최대 3개 (Alt+2~4 할당용) + "enable_categories": False, + "categories": [], + "pinned_categories": [], + "session_timeout": 60 # 기본 60분 } @settings_bp.route('/api/settings', methods=['GET']) @@ -32,6 +33,9 @@ def get_settings(): data = json.load(f) # 기본값과 병합하여 신규 필드 등 누락 방지 full_data = {**DEFAULT_SETTINGS, **data} + # 최소 10분 강제 적용 + if full_data.get('session_timeout', 0) < 10: + full_data['session_timeout'] = 10 return jsonify(full_data) except Exception as e: return jsonify(DEFAULT_SETTINGS) @@ -50,12 +54,28 @@ def save_settings(): with open(CONFIG_PATH, 'r', encoding='utf-8') as f: current_data = json.load(f) + # 세션 타임아웃 검증 및 보정 + session_timeout = data.get('session_timeout') + if session_timeout is not None: + try: + session_timeout = int(session_timeout) + if session_timeout < 10: + session_timeout = 10 + data['session_timeout'] = session_timeout + except (ValueError, TypeError): + data.pop('session_timeout', None) + updated_data = {**current_data, **data} with open(CONFIG_PATH, 'w', encoding='utf-8') as f: json.dump(updated_data, f, indent=4, ensure_ascii=False) - current_app.logger.info(f"System Settings Updated: {list(data.keys())}") - return jsonify({'message': 'Settings saved successfully'}) + # Flask 설정 즉시 반영 + if 'session_timeout' in updated_data: + from datetime import timedelta + current_app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=updated_data['session_timeout']) + + current_app.logger.info(f"System Settings Updated: {list(data.keys())} (Session Timeout: {updated_data.get('session_timeout')} min)") + return jsonify({'message': 'Settings saved successfully', 'session_timeout': updated_data.get('session_timeout')}) except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/docs/Bug/20260420_session_timeout_fix.md b/docs/Bug/20260420_session_timeout_fix.md new file mode 100644 index 0000000..9803adc --- /dev/null +++ b/docs/Bug/20260420_session_timeout_fix.md @@ -0,0 +1,21 @@ +# Bug Report: 세션 타임아웃으로 인한 데이터 손실 방지 처리 + +## 1. 버그 내용 +- **현상**: 세션 타임아웃이 발생한 상태에서 사용자가 메모를 저장하려고 하면 API 호출이 실패(401 Unauthorized)하고 로그인 페이지로 리다이렉트됨. 이 과정에서 사용자가 입력한 메모 내용이 보존되지 않고 소실되는 문제 발생. +- **원인**: 세션 타임아웃이 하드코딩(1시간)되어 있어 사용자가 인지하기 어렵고, 프론트엔드에서 세션 만료를 사전에 감지하는 로직이 부재함. + +## 2. 조치 사항 +- **백엔드 (Flask)**: + - `config.json`을 통해 세션 타임아웃 시간을 분 단위로 설정 가능하도록 변경 (`session_timeout` 필드 추가). + - 보안 및 안정성을 위해 최소 타임아웃 시간을 **10분**으로 제한 (백엔드 강제 적용 로직 포함). + - `/api/auth/status` 엔드포인트를 추가하여 세션 유효 여부를 즉시 확인할 수 있게 함. + - 설정 저장 시 `PERMANENT_SESSION_LIFETIME` 설정을 즉시 업데이트하여 서버 재시작 없이 반영되도록 처리. +- **프론트엔드 (JavaScript/i18n)**: + - 환경설정 모달에 세션 타임아웃 입력 필드 추가. + - 10분 미만 입력 시 경고 알림 및 저장 방지 로직 추가. + - **Heartbeat 기능 구현**: `AppService.startSessionHeartbeat()`를 통해 2분 간격으로 세션 상태를 체크하고, 만료 시 즉시 로그인 페이지로 튕기게(Bounce) 처리하여 저장 시점에 당황하는 상황을 방지함. + - 설정 관련 모든 API 호출을 `API.request`로 통합하여 공통 인증 처리를 수행함. + +## 3. 향후 주의사항 +- 세션 타임아웃 설정은 브라우저 쿠키의 생명주기와 관련이 있으므로, 설정을 변경한 직후에는 세션이 즉시 갱신되지 않을 수 있음(기존 쿠키 만료까지 대기). 설정 변경 후에는 가급적 로그아웃 후 재로그인을 권장함. +- Heartbeat 주기를 너무 짧게 설정할 경우 서버 트래픽이 증가할 수 있으므로, 현재 최소 타임아웃(10분)의 1/5 수준인 2분으로 유지함. diff --git a/docs/Bug/FEAT_SESSION_COUNTDOWN.md b/docs/Bug/FEAT_SESSION_COUNTDOWN.md new file mode 100644 index 0000000..6956fbc --- /dev/null +++ b/docs/Bug/FEAT_SESSION_COUNTDOWN.md @@ -0,0 +1,18 @@ +# [FEAT] 세션 타임아웃 카운트다운 타이머 구현 + +## 버그/기능 내용 +- 세션이 소리 소문 없이 만료되어 작업 중인 내용이 저장되지 않는 문제를 방지하기 위해, 로그아웃 버튼에 실시간 남은 세션 시간을 표시하는 기능을 추가함. +- 버튼 크기 비대화를 방지하기 위해 "로그아웃" 텍스트를 "종료"(KO) / "EXIT"(EN)로 축약함. + +## 조치 사항 +1. **로케일 수정**: `static/locales/ko.json`, `en.json`에서 `nav_logout` 값을 축약형으로 변경. +2. **신규 컴포넌트**: `static/js/components/SessionManager.js` 생성. + - 서버 설정(`/api/settings`)의 `session_timeout` 값을 초 단위로 변환하여 관리. + - 1초마다 카운트다운을 수행하고 로그아웃 버튼의 텍스트(`span.text`)를 업데이트. +3. **사용자 활동 감지**: `mousedown`, `keydown`, `touchstart` 이벤트 발생 시 타이머를 원래 설정값으로 리셋. +4. **자동 로그아웃**: 카운트가 `00:00`에 도달하면 알림 표시 후 `/logout`으로 리다이렉트. +5. **통합**: `static/app.js`에서 초기화 코드 추가. + +## 향후 주의 사항 +- `I18nManager.applyTranslations()`가 실행될 때 `data-i18n` 속성에 의해 타이머 텍스트가 덮어씌워질 수 있으나, `SessionManager`가 1초 단위로 다시 그리므로 시각적 문제는 미미함. +- 브라우저 탭이 비활성 상태(Background)일 때 `setInterval`의 작동 주기가 느려질 수 있으나, 다시 포커스를 얻으면 실제 시간 흐름을 보정하거나 활동 시 리셋되므로 사용성에는 큰 문제가 없음. diff --git a/docs/modularization_plan.md b/docs/modularization_plan.md new file mode 100644 index 0000000..2e45180 --- /dev/null +++ b/docs/modularization_plan.md @@ -0,0 +1,23 @@ +# 프로젝트 추가 모듈화 및 리팩토링 계획 + +`memo.py` 모듈화 이후, 코드 품질 향상 및 유지보수성 확보를 위해 추가적으로 개선이 필요한 영역을 분석한 결과입니다. + +## 📋 추가 모듈화 검토 대상 리스트 + +| 대상 영역 | 파일 위치 | 현재 상태 (Lines) | 문제점 및 개선 방향 | 우선순위 | +| :--- | :--- | :--- | :--- | :--- | +| **백엔드 코어** | `app/__init__.py` | 121 lines | `create_app` 함수에 보안, 로깅, CSP 설정이 집중됨. `app/core/`로 분리 필요. | **높음** | +| **프론트엔드 UI** | `static/js/ui.js` | 331 lines | 모든 UI 이벤트 및 렌더링이 집중된 God Object. 레이아웃과 렌더링 로직 분리 필요. | **높음** | +| **DB 스키마** | `app/database.py` | 88 lines | 연결 로직과 테이블 정의가 혼재됨. SQL 파일이나 전용 클래스로 스키마 관리 분리. | 보통 | +| **명령어 엔진** | `static/js/components/SlashCommand.js` | 352 lines | 명령어 데이터와 UI 제어 로직이 섞여 있음. 데이터 중심 구조로 개편 필요. | 보통 | +| **파일 서비스** | `app/routes/file.py` | 162 lines | `memo.py`처럼 Route에 로직이 포함됨. `FileService` 계층 신설 고려. | 보통 | +| **에디터 관리** | `static/js/editor.js` | 215 lines | Toast UI 설정과 커스텀 기능이 얽혀 있음. 플러그인 인터페이스 도입 필요. | 낮음 | + +## 🛠️ 향후 작업 권고 단계 + +1. **1단계 (Core)**: `app/__init__.py`에서 로깅 및 보안 설정을 분리하여 서버 초기화 가독성 확보. +2. **2단계 (UI)**: `ui.js`를 `layout.js`와 `memos.js` 등으로 분리하여 프론트엔드 관리 효율화. +3. **3단계 (Data)**: `database.py` 내의 하드코딩된 스키마를 모델 정의 파일로 이동. + +--- +*작성일: 2026-04-20* diff --git a/static/app.js b/static/app.js index 12e37d0..2febf33 100644 --- a/static/app.js +++ b/static/app.js @@ -16,6 +16,8 @@ import { CategoryManager } from './js/components/CategoryManager.js'; import { ModalManager } from './js/components/ModalManager.js'; import { I18nManager } from './js/utils/I18nManager.js'; import { Constants } from './js/utils/Constants.js'; +import { SessionManager } from './js/components/SessionManager.js'; + document.addEventListener('DOMContentLoaded', async () => { UI.initSidebarToggle(); @@ -244,6 +246,8 @@ document.addEventListener('DOMContentLoaded', async () => { // --- 🔹 App Start --- AppService.refreshData(updateSidebarCallback); VisualLinker.init(); // 💡 연결 도구 초기화 + SessionManager.init(); // ⏱️ 세션 타이머 초기화 (종료/EXIT) + // 💡 전역 취소 리스너 (시각적 연결용) window.addEventListener('keydown', (e) => { diff --git a/static/js/AppService.js b/static/js/AppService.js index cfe7fe0..52138be 100644 --- a/static/js/AppService.js +++ b/static/js/AppService.js @@ -146,5 +146,26 @@ export const AppService = { if (changed) { await this.refreshData(onUpdateSidebar); } + }, + + /** + * 세션 유지 확인을 위한 Heartbeat 시작 (1~2분 간격) + */ + startSessionHeartbeat() { + if (this.heartbeatInterval) return; // 이미 실행 중이면 무시 + + console.log('[AppService] Session heartbeat started.'); + + // 초기 실행 후 인터벌 설정 + API.checkAuthStatus().catch(() => {}); + + this.heartbeatInterval = setInterval(async () => { + try { + await API.checkAuthStatus(); + } catch (err) { + console.warn('[AppService] Session expired or server error during heartbeat.'); + // API.request에서 401 발생 시 이미 리다이렉트 처리함 + } + }, 120000); // 120,000ms = 2분 } }; diff --git a/static/js/api.js b/static/js/api.js index 478bfbc..bd5b99c 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -80,16 +80,23 @@ export const API = { return await this.request(`/api/attachments/${filename}`, { method: 'DELETE' }); }, // 설정 관련 - fetchSettings: async () => { - const res = await fetch('/api/settings'); - return await res.json(); + fetchSettings: async function() { + return await this.request('/api/settings'); }, - saveSettings: async (data) => { - const res = await fetch('/api/settings', { + saveSettings: async function(data) { + return await this.request('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); - return await res.json(); + }, + // 인증 상태 확인 (Heartbeat용) + checkAuthStatus: async function() { + try { + return await this.request('/api/auth/status'); + } catch (err) { + // API.request 내부에서 401 발생 시 이미 리다이렉트 처리함 + throw err; + } } }; diff --git a/static/js/components/SessionManager.js b/static/js/components/SessionManager.js new file mode 100644 index 0000000..00c55de --- /dev/null +++ b/static/js/components/SessionManager.js @@ -0,0 +1,127 @@ +/** + * SessionManager.js + * - 세션 라이프타임 관리 및 로그아웃 버튼 카운트다운 인터페이스 + * - 사용자 활동(클릭, 키 입력) 시 타이머 초기화 + */ +import { API } from '../api.js'; +import { I18nManager } from '../utils/I18nManager.js'; + +export const SessionManager = { + state: { + timeoutMinutes: 60, // 기본값 + remainingSeconds: 3600, + timerId: null, + logoutBtn: null, + baseText: '' + }, + + /** + * 초기화: 설정 로드 및 이벤트 바인딩 + */ + async init() { + try { + const settings = await API.fetchSettings(); + this.state.timeoutMinutes = settings.session_timeout || 60; + this.resetTimer(); + + this.state.logoutBtn = document.getElementById('logoutBtn'); + if (this.state.logoutBtn) { + const textSpan = this.state.logoutBtn.querySelector('.text'); + if (textSpan) { + // i18n 로딩이 늦을 경우를 위해 초기 텍스트 보관 + this.state.baseText = textSpan.textContent.trim().split(' ')[0] || I18nManager.t('nav_logout'); + console.log(`⏱️ SessionManager: Timer target found. Base text: ${this.state.baseText}`); + this.startCountdown(); + } else { + console.error('❌ SessionManager: .text span not found in logoutBtn'); + } + } else { + console.error('❌ SessionManager: logoutBtn not found'); + } + + // 사용자 활동 감지 이벤트 바인딩 + this.bindActivityListeners(); + + console.log(`⏱️ SessionManager: Initialized with ${this.state.timeoutMinutes} min timeout.`); + } catch (err) { + console.error('❌ SessionManager Init Error:', err); + } + }, + + /** + * 타이머 시작 (1초마다 업데이트) + */ + startCountdown() { + if (this.state.timerId) clearInterval(this.state.timerId); + + this.state.timerId = setInterval(() => { + this.state.remainingSeconds--; + + if (this.state.remainingSeconds <= 0) { + this.handleTimeout(); + } else { + this.updateUI(); + } + }, 1000); + }, + + /** + * 타이머를 원복함 (활동 감지 시 호출) + */ + resetTimer() { + this.state.remainingSeconds = this.state.timeoutMinutes * 60; + this.updateUI(); + }, + + /** + * UI 업데이트 (로그아웃 버튼 텍스트 변경) + */ + updateUI() { + if (!this.state.logoutBtn) return; + + const minutes = Math.floor(this.state.remainingSeconds / 60); + const seconds = this.state.remainingSeconds % 60; + const timeStr = `(${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')})`; + + const textSpan = this.state.logoutBtn.querySelector('.text'); + if (textSpan) { + // i18n이 중간에 바뀔 수 있으므로 매번 새로 가져오는 것이 안전할 수 있음 + const currentBase = I18nManager.t('nav_logout'); + textSpan.textContent = `${currentBase} ${timeStr}`; + } + }, + + /** + * 활동 감지 리스너 등록 + */ + bindActivityListeners() { + const resetAction = () => this.resetTimer(); + + // 클릭 및 키보드 입력 감지 + window.addEventListener('mousedown', resetAction); + window.addEventListener('keydown', resetAction); + window.addEventListener('touchstart', resetAction); + + // 추가적으로 API 요청이 발생할 때도 리셋되면 좋음 (api.js에서 처리 권장) + }, + + /** + * 타임아웃 설정을 갱신함 (설정 변경 시 호출 가능) + */ + updateTimeout(minutes) { + if (!minutes || isNaN(minutes)) return; + this.state.timeoutMinutes = parseInt(minutes); + this.resetTimer(); + console.log(`⏱️ SessionManager: Timeout updated to ${this.state.timeoutMinutes} min.`); + }, + + /** + * 타임아웃 발생 시 처리 + */ + handleTimeout() { + clearInterval(this.state.timerId); + console.warn('⌛ Session expired. Logging out...'); + alert(I18nManager.t('msg_session_expired') || '세션이 만료되었습니다. 다시 로그인해주세요.'); + window.location.href = '/logout'; + } +}; diff --git a/static/js/components/ThemeManager.js b/static/js/components/ThemeManager.js index edd5b09..f56dc86 100644 --- a/static/js/components/ThemeManager.js +++ b/static/js/components/ThemeManager.js @@ -65,10 +65,22 @@ export const ThemeManager = { data['enable_ai'] = document.getElementById('set-enable-ai').checked; data['enable_categories'] = document.getElementById('set-enable-categories').checked; - // 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련) + // 언어 설정 처리 const langSelect = document.getElementById('set-lang'); const newLang = langSelect ? langSelect.value : (this.initialLang || 'ko'); if (langSelect) data['lang'] = newLang; + + // 세션 타임아웃 검증 + const timeoutInput = document.getElementById('set-session-timeout'); + let sessionTimeout = timeoutInput ? parseInt(timeoutInput.value) : 60; + + if (sessionTimeout < 10) { + alert(I18nManager.t('msg_session_timeout_min') || '세션 타임아웃은 최소 10분 이상이어야 합니다.'); + sessionTimeout = 10; + timeoutInput.value = 10; + return; + } + data['session_timeout'] = sessionTimeout; try { await API.saveSettings(data); @@ -97,7 +109,8 @@ export const ThemeManager = { encrypted_border: "#00f3ff", ai_accent: "#8b5cf6", lang: "ko", - enable_categories: false + enable_categories: false, + session_timeout: 60 }; this.applyTheme(defaults); } @@ -152,6 +165,23 @@ export const ThemeManager = { await I18nManager.init(lang); const langSelect = document.getElementById('set-lang'); if (langSelect) langSelect.value = lang; + + // 5. 세션 타임아웃 UI 반영 및 Heartbeat 시작 + const sessionTimeout = settings.session_timeout || 60; + const timeoutInput = document.getElementById('set-session-timeout'); + if (timeoutInput) timeoutInput.value = sessionTimeout; + + // 세션 타이머(종료/EXIT) 즉시 반영 + import('./SessionManager.js').then(({ SessionManager }) => { + if (SessionManager && typeof SessionManager.updateTimeout === 'function') { + SessionManager.updateTimeout(sessionTimeout); + } + }); + + // 세션 체크 시작 (Heartbeat) - AppService에 위임하거나 여기서 직접 실행 + if (window.AppService && typeof window.AppService.startSessionHeartbeat === 'function') { + window.AppService.startSessionHeartbeat(); + } }, rgbaToHex(rgba) { diff --git a/static/locales/en.json b/static/locales/en.json index e315e3c..5929144 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -8,7 +8,7 @@ "nav_explorer": "Knowledge Explorer", "nav_calendar": "Calendar", "nav_nebula": "Knowledge Nebula", - "nav_logout": "Logout", + "nav_logout": "EXIT", "nav_settings": "Settings", "nav_toggle": "Toggle Sidebar", "nav_categories": "Categories", @@ -35,6 +35,7 @@ "settings_ai_enable": "Enable AI Features", "settings_category_enable": "Enable Category Feature (Advanced)", "settings_lang": "Language", + "settings_session_timeout": "Session Timeout (min, min 10m)", "settings_save": "Save Settings", "settings_reset": "Reset", "settings_close": "Close", @@ -43,12 +44,14 @@ "msg_delete_confirm": "Are you sure you want to delete this memo? This cannot be undone.", "msg_save_success": "Saved successfully!", "msg_settings_saved": "🎨 Settings have been saved to the server!", + "msg_session_timeout_min": "Session timeout must be at least 10 minutes.", "msg_ai_loading": "Gemini AI is analyzing the memo...", "msg_encrypted_locked": "🚫 Encryption detected. Decrypt first to modify.", "msg_auth_failed": "Invalid credentials. Please try again.", "msg_network_error": "Network instability or server error occurred.", "msg_confirm_discard": "Discard all current content and delete uploaded files from the server?", "msg_alert_password_required": "A password is required to encrypt this knowledge.", + "msg_session_expired": "⌛ Session expired. Please log in again for security.", "msg_draft_restore_confirm": "📝 There is an auto-saved draft.\nTitle: \"{title}\"\nWould you like to restore it?", "title_pin": "Pin to Top", diff --git a/static/locales/ko.json b/static/locales/ko.json index 34a77bf..5ab2c88 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -8,7 +8,7 @@ "nav_explorer": "지식 탐색기", "nav_calendar": "달력 탐색", "nav_nebula": "지식 맵 보기", - "nav_logout": "로그아웃", + "nav_logout": "종료", "nav_settings": "환경 설정", "nav_toggle": "사이드바 토글", "nav_categories": "카테고리", @@ -37,6 +37,7 @@ "settings_ai_enable": "AI 기능 활성화", "settings_category_enable": "카테고리 기능 활성화 (고급)", "settings_lang": "언어 설정", + "settings_session_timeout": "세션 유지 시간 (분, 최소 10분)", "settings_save": "저장", "settings_reset": "초기화", "settings_close": "닫기", @@ -45,12 +46,14 @@ "msg_delete_confirm": "이 메모를 정말 삭제할까요? 되돌릴 수 없습니다.", "msg_save_success": "저장되었습니다!", "msg_settings_saved": "🎨 테마 설정이 서버에 저장되었습니다!", + "msg_session_timeout_min": "세션 타임아웃은 최소 10분 이상이어야 합니다.", "msg_ai_loading": "Gemini AI가 메모를 분석 중입니다...", "msg_encrypted_locked": "🚫 암호화된 메모입니다. 먼저 해독하세요.", "msg_auth_failed": "올바른 자격 증명이 아닙니다. 다시 시도해 주세요.", "msg_network_error": "네트워크 불안정 또는 서버 오류가 발생했습니다.", "msg_confirm_discard": "작성 중인 내용을 지우고 창을 닫을까요?", "msg_alert_password_required": "암호화하려면 비밀번호를 입력해야 합니다.", + "msg_session_expired": "⌛ 세션이 만료되었습니다. 보안을 위해 다시 로그인해 주세요.", "msg_draft_restore_confirm": "📝 임시 저장된 메모가 있습니다.\n제목: \"{title}\"\n복원하시겠습니까?", "title_pin": "중요 (상단 고정)", diff --git a/templates/modals/settings.html b/templates/modals/settings.html index ad0112d..8e6b00c 100644 --- a/templates/modals/settings.html +++ b/templates/modals/settings.html @@ -26,12 +26,15 @@ -
+
- + + +