feat: 관리자 알림 설정 페이지 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
This commit is contained in:
@@ -79,6 +79,11 @@ def init_db():
|
||||
webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, webpage_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notify_config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
# 컬럼 추가 (기존 DB 마이그레이션)
|
||||
for col, definition in [
|
||||
@@ -420,6 +425,68 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# ─── Admin: 알림 설정 ────────────────────────────────────
|
||||
class NotifyConfig(BaseModel):
|
||||
discord_webhook_url: Optional[str] = ""
|
||||
gmail_user: Optional[str] = ""
|
||||
gmail_app_password: Optional[str] = ""
|
||||
alert_email_to: Optional[str] = ""
|
||||
|
||||
def get_notify_config_from_db(conn) -> dict:
|
||||
"""DB에서 알림 설정 조회, 없으면 환경변수 fallback"""
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT key, value FROM notify_config")
|
||||
rows = cur.fetchall()
|
||||
db_config = {r["key"]: r["value"] for r in rows}
|
||||
return {
|
||||
"discord_webhook_url": db_config.get("discord_webhook_url", os.getenv("DISCORD_WEBHOOK_URL", "")),
|
||||
"gmail_user": db_config.get("gmail_user", os.getenv("GMAIL_USER", "")),
|
||||
"gmail_app_password": db_config.get("gmail_app_password", os.getenv("GMAIL_APP_PASSWORD", "")),
|
||||
"alert_email_to": db_config.get("alert_email_to", os.getenv("ALERT_EMAIL_TO", "")),
|
||||
}
|
||||
|
||||
@app.get("/api/admin/notify-config")
|
||||
def get_notify_config(token=Depends(require_admin), conn=Depends(get_db)):
|
||||
cfg = get_notify_config_from_db(conn)
|
||||
# 비밀번호는 설정 여부만 반환 (보안)
|
||||
return {
|
||||
"discord_webhook_url": cfg["discord_webhook_url"],
|
||||
"gmail_user": cfg["gmail_user"],
|
||||
"gmail_app_password": "••••••••" if cfg["gmail_app_password"] else "",
|
||||
"alert_email_to": cfg["alert_email_to"],
|
||||
}
|
||||
|
||||
@app.put("/api/admin/notify-config")
|
||||
def save_notify_config(data: NotifyConfig, token=Depends(require_admin), conn=Depends(get_db)):
|
||||
cur = conn.cursor()
|
||||
fields = {
|
||||
"discord_webhook_url": data.discord_webhook_url,
|
||||
"gmail_user": data.gmail_user,
|
||||
"alert_email_to": data.alert_email_to,
|
||||
}
|
||||
# 비밀번호가 마스킹값이면 업데이트 안 함
|
||||
if data.gmail_app_password and data.gmail_app_password != "••••••••":
|
||||
fields["gmail_app_password"] = data.gmail_app_password
|
||||
|
||||
for key, value in fields.items():
|
||||
if value is not None:
|
||||
cur.execute("""
|
||||
INSERT INTO notify_config (key, value, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()
|
||||
""", (key, value))
|
||||
conn.commit()
|
||||
|
||||
# notifier 모듈 환경변수 동적 업데이트
|
||||
import notifier
|
||||
cfg = get_notify_config_from_db(conn)
|
||||
notifier.DISCORD_WEBHOOK_URL = cfg["discord_webhook_url"]
|
||||
notifier.GMAIL_USER = cfg["gmail_user"]
|
||||
notifier.GMAIL_APP_PASSWORD = cfg["gmail_app_password"]
|
||||
notifier.ALERT_EMAIL_TO = cfg["alert_email_to"]
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/admin/notify-test")
|
||||
async def notify_test(token=Depends(require_admin)):
|
||||
# Discord + Gmail (Pod 이상/복구 테스트)
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
<button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button>
|
||||
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
|
||||
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
|
||||
<button class="nav-btn admin-only" onclick="showPage('admin-notify')" style="display:none">🔔 알림 설정</button>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
@@ -359,6 +360,56 @@
|
||||
<tbody id="users-table-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: 알림 설정 -->
|
||||
<div id="page-admin-notify" class="page">
|
||||
<div class="page-header">
|
||||
<div><h2>🔔 알림 설정</h2><p>Discord Webhook 및 이메일 알림 수신 설정을 관리합니다.</p></div>
|
||||
<button class="btn btn-sm btn-purple" onclick="testNotify()">📨 테스트 발송</button>
|
||||
</div>
|
||||
<div class="table-wrapper" style="padding:28px;max-width:640px;">
|
||||
<div style="margin-bottom:24px;">
|
||||
<h3 style="font-size:1rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
||||
<span style="background:#5865f2;color:#fff;border-radius:8px;padding:4px 10px;font-size:.85rem;">Discord</span>
|
||||
Webhook 설정
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input type="text" id="nc-discord-url" placeholder="https://discord.com/api/webhooks/..."/>
|
||||
<p style="font-size:.78rem;color:var(--muted);margin-top:4px">채널 설정 → 연동 → 웹후크에서 URL을 복사하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border);padding-top:24px;margin-bottom:24px;">
|
||||
<h3 style="font-size:1rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
||||
<span style="background:#ea4335;color:#fff;border-radius:8px;padding:4px 10px;font-size:.85rem;">Gmail</span>
|
||||
이메일 설정
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label>발송 Gmail 계정</label>
|
||||
<input type="email" id="nc-gmail-user" placeholder="sender@gmail.com"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gmail 앱 비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
<input type="password" id="nc-gmail-pw" placeholder="변경 시에만 입력 (xxxx xxxx xxxx xxxx)"/>
|
||||
<button class="pw-toggle" onclick="togglePw('nc-gmail-pw',this)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p style="font-size:.78rem;color:var(--muted);margin-top:4px">myaccount.google.com/apppasswords 에서 발급하세요. 변경하지 않을 경우 비워두세요.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>수신 이메일 주소</label>
|
||||
<input type="email" id="nc-alert-to" placeholder="receiver@gmail.com"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button class="btn btn-sm btn-gray" onclick="loadNotifyConfig()">초기화</button>
|
||||
<button class="btn btn-sm" onclick="saveNotifyConfig()">💾 저장</button>
|
||||
</div>
|
||||
<div id="notify-save-msg" style="margin-top:12px;font-size:.85rem;text-align:right;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -450,6 +501,7 @@ function showPage(name){
|
||||
if(name==='board')loadBoard();
|
||||
if(name==='admin-pages')loadAdminPages();
|
||||
if(name==='admin-users')loadAdminUsers();
|
||||
if(name==='admin-notify')loadNotifyConfig();
|
||||
}
|
||||
|
||||
function closeModal(id){document.getElementById(id).classList.remove('open');}
|
||||
@@ -908,6 +960,43 @@ async function saveAccess(){
|
||||
closeModal('access-modal');alert('권한이 저장되었습니다.');
|
||||
}
|
||||
|
||||
// ── 알림 설정 ──────────────────────────────────────────────
|
||||
async function loadNotifyConfig(){
|
||||
try{
|
||||
const data = await apiFetch('/admin/notify-config');
|
||||
document.getElementById('nc-discord-url').value = data.discord_webhook_url || '';
|
||||
document.getElementById('nc-gmail-user').value = data.gmail_user || '';
|
||||
document.getElementById('nc-gmail-pw').value = data.gmail_app_password || '';
|
||||
document.getElementById('nc-alert-to').value = data.alert_email_to || '';
|
||||
document.getElementById('notify-save-msg').textContent = '';
|
||||
}catch(e){alert('알림 설정 불러오기 실패: '+e.message);}
|
||||
}
|
||||
|
||||
async function saveNotifyConfig(){
|
||||
const msgEl = document.getElementById('notify-save-msg');
|
||||
const payload = {
|
||||
discord_webhook_url: document.getElementById('nc-discord-url').value.trim(),
|
||||
gmail_user: document.getElementById('nc-gmail-user').value.trim(),
|
||||
gmail_app_password: document.getElementById('nc-gmail-pw').value,
|
||||
alert_email_to: document.getElementById('nc-alert-to').value.trim(),
|
||||
};
|
||||
try{
|
||||
await apiFetch('/admin/notify-config',{method:'PUT',body:JSON.stringify(payload)});
|
||||
msgEl.style.color = 'var(--success)';
|
||||
msgEl.textContent = '✅ 설정이 저장되었습니다.';
|
||||
}catch(e){
|
||||
msgEl.style.color = 'var(--danger)';
|
||||
msgEl.textContent = '❌ 저장 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testNotify(){
|
||||
try{
|
||||
await apiFetch('/admin/notify-test');
|
||||
alert('✅ 테스트 알림이 발송되었습니다.\nDiscord와 이메일을 확인해주세요.');
|
||||
}catch(e){alert('테스트 실패: '+e.message);}
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────
|
||||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
function escJs(s){return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
|
||||
|
||||
Reference in New Issue
Block a user