/**
* /login — 共有トークンの入力画面(P1 の簡易トークン認証)。
*
* ★このパスは認証をかけない★(`lib/proxy/authenticate.ts` の `isPublicPath()`)。
* ただし ★IP制限はかかる★ ので、許可されないIPからは 403 になる。
*
* ★入力値をログに出さないこと★(`console.log` も禁止。`no-console` で lint が落ちる)
* ★リダイレクトに絶対URLを組まないこと★ — LB が HTTPS を終端するので、
* アプリ側で組むと `http://` になる。相対パスで済ませる。
*
* `AppShell`(サイドバー付きの器)は使わない。未認証の画面にメニューを出さない。
*/
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import { loginErrorMessage } from "@/lib/auth/login-error-message";
import { safeNextPath } from "@/lib/auth/next-path";
function LoginForm() {
const { t } = useTheme();
const router = useRouter();
const searchParams = useSearchParams();
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (response.ok) {
// 入力欄をすぐ空にする(画面に残さない)
setToken("");
router.replace(safeNextPath(searchParams.get("next")));
router.refresh();
return;
}
// ★失敗の理由は状態コードで分ける。トークンの中身に関わることは言わない★(指示書BC 5-6)
setError(loginErrorMessage(response.status));
} catch {
setError("通信に失敗しました。時間をおいて再度お試しください");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} style={styles.card}>
<h1 style={styles.title}>D-Watch</h1>
<p style={styles.lead}>{t("DFAロボット監視システム")}</p>
<label htmlFor="token" style={styles.label}>
{t("アクセストークン")}
</label>
<input
id="token"
name="token"
type="password"
autoComplete="current-password"
value={token}
onChange={(e) => setToken(e.target.value)}
style={styles.input}
disabled={submitting}
required
/>
{error ? (
<p role="alert" style={styles.error}>
{t(error)}
</p>
) : null}
<button type="submit" style={styles.button} disabled={submitting}>
{submitting ? t("確認中…") : t("ログイン")}
</button>
<p style={styles.note}>{t("社内利用のみ。接続元IPも制限されています。")}</p>
</form>
);
}
export default function LoginPage() {
return (
<main style={styles.page}>
{/* useSearchParams はプリレンダリング時に Suspense 境界が要る */}
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</main>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background: "var(--bg)",
},
card: {
width: "100%",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 8,
padding: 32,
borderRadius: 12,
border: "1px solid var(--bd)",
background: "var(--sf)",
},
title: { fontSize: 24, fontWeight: 700, color: "var(--tx)" },
lead: { fontSize: 13, color: "var(--tx-d)", marginBottom: 16 },
label: { fontSize: 12, color: "var(--tx-d)" },
input: {
padding: "10px 12px",
borderRadius: 8,
border: "1px solid var(--bd)",
background: "var(--bg)",
color: "var(--tx)",
fontSize: 14,
},
error: { fontSize: 13, color: "var(--ng-b)", marginTop: 4 },
button: {
marginTop: 12,
padding: "10px 12px",
borderRadius: 8,
border: "none",
background: "var(--pr)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
},
note: { fontSize: 11, color: "var(--tx-m)", marginTop: 12 },
};