This commit is contained in:
admin
2026-05-20 17:45:09 +09:00
parent 63ac6bc99a
commit da3aef25ce
19 changed files with 17286 additions and 407 deletions

672
frontend/egov.html Normal file
View File

@@ -0,0 +1,672 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-Gov 電子送達 NJTS</title>
<script src="js/auth.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
"Yu Gothic UI",
"メイリオ",
sans-serif;
background: #f5f6fa;
padding: 24px;
color: #212529;
}
header {
max-width: 660px;
margin: 0 auto 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 18px;
}
.back-link {
font-size: 13px;
color: #0d6efd;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.card {
max-width: 660px;
margin: 0 auto 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 28px 32px;
}
.card h2 {
font-size: 16px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.card .desc {
font-size: 13px;
color: #6c757d;
margin-bottom: 20px;
}
/* ステータスバー */
.status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 6px;
background: #f8f9fa;
margin-bottom: 20px;
font-size: 13px;
}
.badge {
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.idle {
background: #e9ecef;
color: #6c757d;
}
.starting {
background: #fff3cd;
color: #856404;
}
.waiting_mfa {
background: #cff4fc;
color: #055160;
}
.logged_in {
background: #d1e7dd;
color: #0a3622;
}
.downloading {
background: #cfe2ff;
color: #084298;
}
.error {
background: #f8d7da;
color: #842029;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #dee2e6;
border-top-color: #0d6efd;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* フォーム */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.form-group input {
width: 100%;
padding: 9px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #86b7fe;
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
}
.btn {
padding: 10px 22px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.btn-primary {
background: #6f42c1;
color: #fff;
}
.btn-primary:hover {
background: #5a32a3;
}
.btn-secondary {
background: #6c757d;
color: #fff;
}
.btn-secondary:hover {
background: #5c636a;
}
/* MFA */
.mfa-notice {
background: #e7f3ff;
border: 1px solid #b6d4fe;
border-radius: 6px;
padding: 12px 14px;
font-size: 13px;
margin-bottom: 16px;
line-height: 1.6;
}
.mfa-input {
font-size: 30px;
letter-spacing: 12px;
text-align: center;
width: 100%;
padding: 10px;
border: 2px solid #6f42c1;
border-radius: 5px;
}
.btn-success {
background: #198754;
color: #fff;
}
.btn-success:hover {
background: #146c43;
}
.btn-success:disabled {
background: #a3cfbb;
cursor: not-allowed;
}
/* ダウンロード結果 */
.dl-result {
margin-top: 14px;
padding: 12px 16px;
background: #d1e7dd;
border: 1px solid #a3cfbb;
border-radius: 6px;
font-size: 13px;
display: flex;
align-items: center;
gap: 12px;
}
.dl-result a {
margin-left: auto;
font-weight: 700;
color: #0a3622;
text-decoration: none;
background: #fff;
border: 1px solid #a3cfbb;
padding: 4px 12px;
border-radius: 4px;
}
.dl-result a:hover {
background: #c2e0cc;
}
.screenshot-wrap summary {
font-size: 12px;
color: #888;
cursor: pointer;
margin-bottom: 6px;
}
.screenshot-wrap img {
max-width: 100%;
border: 1px solid #dee2e6;
border-radius: 4px;
}
/* ログイン済みアクション */
.action-note {
font-size: 13px;
color: #555;
margin-bottom: 16px;
line-height: 1.6;
}
/* API 認証 */
.btn-gbiz {
background: #0077cc;
color: #fff;
width: 100%;
text-align: center;
padding: 12px 22px;
font-size: 15px;
}
.btn-gbiz:hover {
background: #0055a5;
}
.method-tabs {
display: flex;
gap: 6px;
margin-bottom: 18px;
}
.method-tab {
flex: 1;
padding: 8px;
border: 2px solid #dee2e6;
border-radius: 5px;
background: #f8f9fa;
cursor: pointer;
font-size: 12px;
font-weight: 600;
text-align: center;
color: #6c757d;
}
.method-tab.active {
border-color: #6f42c1;
background: #f3eeff;
color: #6f42c1;
}
</style>
</head>
<body>
<header>
<h1>🏛 e-Gov 電子送達</h1>
<a class="back-link" href="index.html">← メニューに戻る</a>
</header>
<!-- ログインカード -->
<div class="card">
<h2>🔐 e-Gov ログインGビズID</h2>
<p class="desc">GビズID プレミアムで e-Gov にログインします。</p>
<!-- ステータス -->
<div class="status-bar">
<span class="badge idle" id="badge">未ログイン</span>
<span class="spinner" id="spinner" style="display: none"></span>
<span id="msgText" style="color: #555"></span>
</div>
<!-- 認証方式タブ -->
<div class="method-tabs" id="methodTabs">
<div class="method-tab active" id="tabApi" onclick="switchTab('api')">
🔑 API認証推奨
</div>
<div class="method-tab" id="tabBrowser" onclick="switchTab('browser')">
🌐 ブラウザ自動操作
</div>
</div>
<!-- ─── API 認証セクション ─── -->
<div id="apiAuthSection">
<p
style="
font-size: 13px;
color: #555;
margin-bottom: 16px;
line-height: 1.6;
"
>
公式 e-Gov API を使用してGビズIDで認証します。<br />
ボタンを押すとGビズIDのログインページに移動します。認証後、自動でここに戻ります。
</p>
<button class="btn btn-gbiz" onclick="startApiAuth()">
GビズID でログインAPI方式
</button>
</div>
<!-- ─── Playwright セクション ─── -->
<div id="loginSection" style="display: none">
<div class="form-group">
<label>GビズIDメールアドレス</label>
<input
type="email"
id="userId"
placeholder="info@company.co.jp"
autocomplete="username"
/>
</div>
<div class="form-group">
<label>パスワード</label>
<input
type="password"
id="password"
autocomplete="current-password"
onkeydown="if (event.key === 'Enter') startLogin();"
/>
</div>
<button class="btn btn-primary" onclick="startLogin()">
ログイン開始
</button>
</div>
<!-- ② MFA入力WAITING_MFA 時のみ表示) -->
<div id="mfaSection" style="display: none">
<div class="mfa-notice">
📱 <strong>GビズID アプリ</strong>を開き、表示されている
<strong>6桁の認証コード</strong> を入力してください。<br />
<small
>※ コードは 30
秒ごとに更新されます。新しいコードで入力してください。</small
>
</div>
<div class="form-group">
<label>認証コード6桁</label>
<input
class="mfa-input"
type="text"
id="mfaCode"
maxlength="6"
inputmode="numeric"
pattern="[0-9]*"
placeholder="000000"
oninput="if (this.value.length === 6) submitMfa();"
/>
</div>
<button class="btn btn-primary" onclick="submitMfa()">認証</button>
</div>
<!-- ③ ログイン済み -->
<div id="loggedSection" style="display: none">
<p class="action-note">
✅ e-Gov にログインしました。<br />
電子送達一覧から最初のファイルをダウンロードできます。
</p>
<div
style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center"
>
<button class="btn btn-success" id="dlBtn" onclick="startDownload()">
📥 電子送達をダウンロード
</button>
<button class="btn btn-secondary" onclick="doLogout()">
ログアウト
</button>
</div>
<div id="dlResult" style="display: none" class="dl-result">
<span id="dlMsg"></span>
<a id="dlLink" href="/egov/downloads/latest" target="_blank">保存</a>
</div>
</div>
<!-- スクリーンショット(デバッグ) -->
<div class="screenshot-wrap" id="ssWrap" style="display: none">
<details>
<summary>ブラウザ画面を確認(デバッグ用)</summary>
<img id="ssImg" src="" alt="スクリーンショット" />
</details>
</div>
</div>
<script>
let timer = null;
let currentTab = "api";
// ─── タブ切り替え ──────────────────────────────────────
function switchTab(tab) {
currentTab = tab;
document
.getElementById("tabApi")
.classList.toggle("active", tab === "api");
document
.getElementById("tabBrowser")
.classList.toggle("active", tab === "browser");
if (tab === "api") {
show("apiAuthSection");
hide("loginSection");
} else {
hide("apiAuthSection");
show("loginSection");
}
}
// ─── API 認証 ──────────────────────────────────────────
async function startApiAuth() {
const res = await fetch("/egov/auth-url");
if (!res.ok) {
const e = await res.json();
alert(
e.detail ||
"認証URLの取得に失敗しました。\n/egov/config で資格情報を設定してください。",
);
return;
}
const { auth_url } = await res.json();
// 同じタブで遷移(リダイレクトバック受信のため)
location.href = auth_url;
}
// ─── OAuth2 コールバック処理 ───────────────────────────
async function handleOAuthCallback(code, oauthState) {
setUI("starting", "e-Gov 認証コードを処理中...");
hide("apiAuthSection");
hide("loginSection");
hide("methodTabs");
try {
const res = await fetch("/egov/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state: oauthState }),
});
// URL からパラメータを削除
history.replaceState({}, "", "/egov.html");
if (res.ok) {
setUI("logged_in", "API認証完了");
show("loggedSection");
} else {
const e = await res.json();
setUI("error", "API認証エラー: " + (e.detail || "不明なエラー"));
show("methodTabs");
show("apiAuthSection");
}
} catch (err) {
history.replaceState({}, "", "/egov.html");
setUI("error", "コールバックエラー: " + err.message);
show("methodTabs");
show("apiAuthSection");
}
}
async function startLogin() {
const userId = document.getElementById("userId").value.trim();
const password = document.getElementById("password").value;
if (!userId || !password) {
alert("GビズID とパスワードを入力してください");
return;
}
const res = await fetch("/egov/login-start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, password }),
});
if (!res.ok) {
const e = await res.json();
alert(e.detail || "エラー");
return;
}
startPolling();
}
async function submitMfa() {
const code = document.getElementById("mfaCode").value.trim();
if (code.length !== 6) {
alert("6桁のコードを入力してください");
return;
}
const res = await fetch("/egov/login-mfa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mfa_code: code }),
});
if (!res.ok) {
const e = await res.json();
alert(e.detail || "エラー");
return;
}
hide("mfaSection");
setUI("starting", "認証コードを送信中...");
}
async function startDownload() {
document.getElementById("dlBtn").disabled = true;
document.getElementById("dlBtn").textContent = "⏳ ダウンロード中...";
hide("dlResult");
const res = await fetch("/egov/download-start", { method: "POST" });
if (!res.ok) {
const e = await res.json();
alert(e.detail || "ダウンロード開始エラー");
document.getElementById("dlBtn").disabled = false;
document.getElementById("dlBtn").textContent =
"📥 電子送達をダウンロード";
return;
}
startPolling();
}
async function doLogout() {
await fetch("/egov/logout", { method: "POST" });
stopPolling();
hide("loggedSection");
show("loginSection");
document.getElementById("mfaCode").value = "";
hide("dlResult");
setUI("idle", "");
}
function startPolling() {
if (timer) return;
timer = setInterval(async () => {
try {
const r = await fetch("/egov/status");
apply(await r.json());
} catch (_) {}
}, 1500);
}
function stopPolling() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function apply(d) {
setUI(d.status, d.message);
if (d.screenshot) {
document.getElementById("ssImg").src =
"data:image/jpeg;base64," + d.screenshot;
show("ssWrap");
}
// API トークンが有効 or ログイン済み
if (d.token_valid || d.status === "logged_in") {
hide("methodTabs");
hide("apiAuthSection");
hide("loginSection");
hide("mfaSection");
show("loggedSection");
if (d.last_download) {
const dlMsg = document.getElementById("dlMsg");
dlMsg.textContent = `${d.last_download.filename} (${(d.last_download.size / 1024).toFixed(1)} KB)`;
show("dlResult");
document.getElementById("dlBtn").disabled = false;
document.getElementById("dlBtn").textContent =
"📥 電子送達をダウンロード";
stopPolling();
} else if (
d.status === "logged_in" &&
!["downloading"].includes(d.status)
) {
stopPolling();
}
return;
}
if (d.status === "waiting_mfa") {
hide("loginSection");
hide("apiAuthSection");
show("mfaSection");
setTimeout(() => document.getElementById("mfaCode").focus(), 100);
} else if (d.status === "error") {
stopPolling();
if (currentTab === "api") {
show("apiAuthSection");
hide("loginSection");
} else {
show("loginSection");
hide("apiAuthSection");
}
hide("mfaSection");
hide("loggedSection");
document.getElementById("dlBtn").disabled = false;
document.getElementById("dlBtn").textContent =
"📥 電子送達をダウンロード";
}
}
function setUI(status, msg) {
const labels = {
idle: "未ログイン",
starting: "ログイン中",
waiting_mfa: "認証待ち",
logged_in: "ログイン済み",
downloading: "DL中",
error: "エラー",
};
const badge = document.getElementById("badge");
badge.textContent = labels[status] || status;
badge.className = "badge " + status;
document.getElementById("msgText").textContent = msg || "";
document.getElementById("spinner").style.display = [
"starting",
"waiting_mfa",
"downloading",
].includes(status)
? "inline-block"
: "none";
}
function show(id) {
document.getElementById(id).style.display = "";
}
function hide(id) {
document.getElementById(id).style.display = "none";
}
// 初期状態取得
(async () => {
// OAuth2 コールバック検出
const params = new URLSearchParams(location.search);
const code = params.get("code");
const oauthState = params.get("state");
if (code) {
await handleOAuthCallback(code, oauthState || "");
return;
}
// 通常ロード: ステータス確認
try {
const r = await fetch("/egov/status");
const d = await r.json();
apply(d);
if (!["idle", "logged_in", "error"].includes(d.status))
startPolling();
} catch (_) {}
})();
</script>
</body>
</html>

View File

@@ -118,6 +118,19 @@
<p>給与・賞与の計算や従業員管理を行います。</p>
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
</section>
<section class="card">
<h2>🏛 e-Gov 電子送達</h2>
<p>
年金事務所からの電子送達書類(社会保険通知など)を取得・管理します。<br />
GビズIDでe-Govにログインし、毎月の電子通知CSVを確認できます。
</p>
<p>
<a class="btn" href="egov.html" style="background: #6f42c1"
>e-Gov 連携を開く</a
>
</p>
</section>
</main>
<script>

File diff suppressed because it is too large Load Diff