Newer
Older
client_dwatch / app / login / page.tsx
/**
 * /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 },
};