guardia-itsm/static/login.html
DESKTOP-TKLFCPRython 1f8b926066 feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright
[PMS 완성]
- core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX)
- routers/si_report.py: daily|weekly|monthly + 메신저 발송
- routers/deliverables.py: 산출물 CRUD + 제출/검토
- si_issues.py: 이슈→SR 자동 연결
- scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서
- models.py: Deliverable 모델

[준수성 자동 점검]
- core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙
- routers/compliance.py: 스캔 + HTML/Excel 보고서

[JMeter 성능 테스트]
- routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서

[공공기관 필수 기능]
- routers/public_checklist.py: 행안부 기준 19개 항목

[UI/브랜드]
- 로고(ziologo.png) + Copyright 2026 All Rights Reserved
- Nifty 계층형 사이드바 (PMS 서브메뉴)
- X-Powered-By + X-Copyright 응답 헤더
- manual/15_UI_Nifty_가이드.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:50:29 +09:00

257 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA ITSM — 로그인</title>
<link rel="stylesheet" href="/static/login.css">
<style>
/* ── 소셜 로그인 추가 스타일 ── */
.social-divider {
display: flex; align-items: center; gap: 10px;
margin: 18px 0 14px;
}
.social-divider hr { flex: 1; border: none; border-top: 1px solid rgba(255,255,255,.12); }
.social-divider span { font-size: 11px; color: var(--text-muted, #94a3b8); white-space: nowrap; }
.social-btns { display: flex; flex-direction: column; gap: 9px; }
.social-btns.hidden { display: none; }
.btn-social {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 8px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.06);
color: #e2e8f0; font-size: 13px; font-weight: 500;
cursor: pointer; text-decoration: none;
transition: background .18s, border-color .18s;
}
.btn-social:hover { background: rgba(255,255,255,.13); border-color: rgba(255,255,255,.28); }
.social-icon {
width: 20px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 4px; font-size: 13px;
}
.icon-google { background: #fff; }
.icon-github { background: #24292e; color:#fff; }
.icon-kakao { background: #fee500; }
.icon-naver { background: #03c75a; color:#fff; }
.icon-sso { background: #4b6cf8; color:#fff; font-size:10px; }
/* ── 탭 (ID/PW ↔ 소셜) ── */
.login-tabs { display: flex; gap: 0; margin-bottom: 18px; border-bottom: 1px solid rgba(255,255,255,.12); }
.login-tab {
flex: 1; padding: 9px 0; text-align: center;
font-size: 12px; font-weight: 600; cursor: pointer;
color: var(--text-muted, #94a3b8);
border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s;
}
.login-tab.active { color: #818cf8; border-bottom-color: #818cf8; }
.login-panel { display: none; }
.login-panel.active { display: block; }
/* OAuth 오류 배너 */
#oauth-error {
background: rgba(239,68,68,.18); border: 1px solid rgba(239,68,68,.4);
border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #fca5a5;
margin-bottom: 14px; display: none;
}
</style>
</head>
<body>
<div id="login-bg">
<div id="login-wrap">
<div id="login-card">
<!-- 로고 -->
<div id="login-logo">
<div class="logo-shield"><span>G</span></div>
<div class="logo-text">
<div class="logo-title">GUARDiA ITSM</div>
<div class="logo-sub">인프라 자동화 플랫폼</div>
</div>
</div>
<!-- OAuth 오류 배너 -->
<div id="oauth-error"></div>
<!---->
<div class="login-tabs" id="login-tabs">
<div class="login-tab active" data-tab="password" onclick="switchTab('password')">계정 로그인</div>
<div class="login-tab" data-tab="social" onclick="switchTab('social')" id="social-tab-btn">소셜 로그인</div>
</div>
<!-- ── 패널 1: 계정 ID/PW ── -->
<div class="login-panel active" id="panel-password">
<form id="login-form" autocomplete="off">
<div class="field-group">
<label for="username">아이디</label>
<input type="text" id="username" name="username"
placeholder="아이디를 입력하세요" autocomplete="username" required>
</div>
<div class="field-group">
<label for="password">비밀번호</label>
<div class="pw-wrap">
<input type="password" id="password" name="password"
placeholder="비밀번호를 입력하세요" autocomplete="current-password" required>
<button type="button" class="pw-toggle" id="pw-toggle" tabindex="-1">👁</button>
</div>
</div>
<div id="login-error" class="error-msg" style="display:none"></div>
<button type="submit" id="login-btn" class="btn-login">
<span id="btn-text">로그인</span>
<span id="btn-spinner" class="spinner" style="display:none"></span>
</button>
</form>
<!-- 테스트 계정 -->
<div class="account-table">
<div class="account-title">테스트 계정</div>
<div class="account-row">
<span class="acc-role admin">관리자</span>
<code class="acc-info clickable" onclick="fillLogin('admin','1111')">admin / 1111</code>
</div>
<div class="account-row">
<span class="acc-role engineer">엔지니어</span>
<code class="acc-info clickable" onclick="fillLogin('engineer1','1111')">engineer1 / 1111</code>
</div>
<div class="account-row">
<span class="acc-role pm">PM</span>
<code class="acc-info clickable" onclick="fillLogin('pm1','1111')">pm1 / 1111</code>
</div>
<div class="account-row">
<span class="acc-role customer">고객</span>
<code class="acc-info clickable" onclick="fillLogin('customer1','1111')">customer1 / 1111</code>
</div>
<div class="account-note">↑ 클릭하면 자동 입력됩니다</div>
</div>
</div>
<!-- ── 패널 2: 소셜 로그인 ── -->
<div class="login-panel" id="panel-social">
<div id="social-loading" style="text-align:center;padding:20px;color:#94a3b8;font-size:13px">
로그인 방법을 불러오는 중...
</div>
<div class="social-btns hidden" id="social-btns"></div>
<div id="social-none" style="display:none;text-align:center;padding:20px;font-size:12px;color:#94a3b8">
소셜 로그인이 설정되지 않았습니다.<br>
<small>환경변수 GOOGLE_CLIENT_ID 등을 설정하세요.</small>
</div>
<div class="social-divider" style="margin-top:18px">
<hr><span>또는</span><hr>
</div>
<button class="btn-social" onclick="switchTab('password')" style="justify-content:center">
🔑 계정 ID/PW로 로그인
</button>
</div>
<!-- 힌트 -->
<div class="login-hint">
초기 비밀번호 <code>1111</code> — 최초 로그인 시 변경 필요
</div>
</div><!-- /login-card -->
<div id="login-footer">
<img src="/static/icons/logo.png" alt="" style="height:20px;vertical-align:middle;margin-right:6px;opacity:.7" onerror="this.style.display='none'">
Copyright &copy; 2026 GUARDiA All Rights Reserved. — 온프레미스 인프라 자동화 플랫폼
</div>
</div>
</div>
<script src="/static/login.js"></script>
<script>
// ── 탭 전환 ──────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.login-tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.login-panel').forEach(p =>
p.classList.toggle('active', p.id === `panel-${tab}`));
}
// ── OAuth 오류 처리 ──────────────────────────────────────
(function handleOAuthCallback() {
const params = new URLSearchParams(location.search);
const oauthToken = params.get('oauth_token');
const oauthError = params.get('error');
const reason = params.get('reason');
if (oauthToken) {
// OAuth 성공 → 토큰 저장 후 메인으로
const username = params.get('username') || '';
const role = params.get('role') || 'CUSTOMER';
localStorage.setItem('access_token', oauthToken);
localStorage.setItem('username', username);
localStorage.setItem('role', role);
location.replace('/');
return;
}
if (oauthError || reason) {
const msgs = {
oauth_failed: 'OAuth 인증에 실패했습니다.',
state_mismatch: 'CSRF 검증 실패 — 다시 시도하세요.',
token_exchange: '토큰 교환 중 오류가 발생했습니다.',
no_email: '이메일 정보를 가져올 수 없습니다.',
access_denied: '로그인이 취소되었습니다.',
unknown_provider: '지원하지 않는 로그인 방법입니다.',
};
const msg = msgs[reason] || msgs[oauthError] || `인증 오류: ${reason || oauthError}`;
const el = document.getElementById('oauth-error');
el.textContent = '⚠️ ' + msg;
el.style.display = 'block';
history.replaceState({}, '', '/login');
}
})();
// ── 소셜 제공자 버튼 동적 로드 ──────────────────────────
const PROVIDER_META = {
google: { label: 'Google', bg: '#fff', color: '#333', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>' },
github: { label: 'GitHub', bg: '#24292e', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>' },
kakao: { label: '카카오', bg: '#fee500', color: '#3c1e1e', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#3c1e1e" d="M12 3C6.48 3 2 6.48 2 10.8c0 2.75 1.64 5.17 4.12 6.59l-.84 3.07c-.07.26.22.46.44.31l3.71-2.46c.82.15 1.67.23 2.57.23 5.52 0 10-3.48 10-7.74S17.52 3 12 3z"/></svg>' },
naver: { label: '네이버', bg: '#03c75a', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="white" d="M16.273 12.845L7.376 0H0v24h7.727V11.155L16.624 24H24V0h-7.727z"/></svg>' },
keycloak: { label: 'SSO (Keycloak)', bg: '#4b6cf8', color: '#fff', svg: '🔐' },
};
async function loadSocialProviders() {
try {
const res = await fetch('/api/auth/oauth/providers');
const { providers } = await res.json();
document.getElementById('social-loading').style.display = 'none';
if (!providers || providers.length === 0) {
document.getElementById('social-none').style.display = 'block';
document.getElementById('social-tab-btn').style.display = 'none';
return;
}
const btns = document.getElementById('social-btns');
btns.classList.remove('hidden');
providers.forEach(p => {
const meta = PROVIDER_META[p.icon] || { label: p.name, bg: '#4b6cf8', color: '#fff', svg: '🔗' };
const a = document.createElement('a');
a.href = `/api/auth/oauth/${p.id}/start`;
a.className = 'btn-social';
a.innerHTML = `
<span class="social-icon" style="background:${meta.bg};color:${meta.color}">${meta.svg}</span>
<span>${meta.label}로 로그인</span>`;
btns.appendChild(a);
});
} catch {
document.getElementById('social-loading').textContent = '소셜 로그인 정보를 불러올 수 없습니다.';
}
}
loadSocialProviders();
</script>
</body>
</html>