diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts
index 91b20788..b50b1435 100644
--- a/frontend/e2e/auth.spec.ts
+++ b/frontend/e2e/auth.spec.ts
@@ -1,11 +1,28 @@
import { test, expect } from "@playwright/test";
+import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth";
+
/**
* 認証フロー E2E テスト
+ *
+ * 動線方針: 未ログインでも職務経歴書を入力でき(お試し入力)、保存時にログインを促す。
+ * `/career` はログイン強制せず、`/login` は明示ログイン・OAuth エラー着地用に残す。
*/
-test.describe("未認証ユーザー", () => {
- test("ルートへのアクセスは /login へリダイレクトされる", async ({ page }) => {
+/** localhost:8000 への全リクエストを 404 で返すキャッチオール(master-data 等を解決させる)。 */
+async function routeBackendNotFound(page: import("@playwright/test").Page) {
+ await page.route("http://localhost:8000/**", (route) =>
+ route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }),
+ }),
+ );
+}
+
+test.describe("未認証ユーザー(お試し入力)", () => {
+ test("ルート / は /login ではなく職務経歴書のお試し入力画面を表示する", async ({ page }) => {
+ await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
@@ -15,13 +32,23 @@ test.describe("未認証ユーザー", () => {
);
await page.goto("/");
- await page.waitForURL("**/login", { timeout: 5_000 });
- // ログインボタンが表示されることを確認する
- await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible();
+ // /login へリダイレクトされず、職務経歴書フォームが表示される
+ await expect(page.getByRole("heading", { name: "職務経歴書" })).toBeVisible();
+ await expect(page).toHaveURL(/\/career/);
+ // 氏名を入力できる(お試し入力)
+ await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎");
+ await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎");
+
+ // サイドバー: ユーザーメニュー位置にログイントリガーが表示される
+ await expect(page.getByRole("button", { name: "ログイン" })).toBeVisible();
+ // GitHub連携 / ブログ連携 は表示され、押下でログイン導線になる(非活性ではない)
+ await expect(page.getByRole("button", { name: "GitHub連携" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "ブログ連携" })).toBeEnabled();
});
- test("/career へ直接アクセスすると /login にリダイレクトされる", async ({ page }) => {
+ test("未ログインで連携メニューを押すとログイン促進モーダルが出る", async ({ page }) => {
+ await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
@@ -31,14 +58,33 @@ test.describe("未認証ユーザー", () => {
);
await page.goto("/career");
- await page.waitForURL("**/login", { timeout: 5_000 });
+ await page.getByRole("button", { name: "GitHub連携" }).click();
+ await expect(
+ page.getByRole("heading", { name: "ログインして保存しましょう" }),
+ ).toBeVisible();
+ });
+
+ test("未ログインでプレビューを押すとログイン促進モーダルが出る", async ({ page }) => {
+ await routeBackendNotFound(page);
+ await page.route("**/auth/me", (route) =>
+ route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Unauthorized" }),
+ }),
+ );
- await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible();
+ await page.goto("/career");
+ await page.getByRole("button", { name: "プレビュー" }).click();
+ await expect(
+ page.getByRole("heading", { name: "ログインして保存しましょう" }),
+ ).toBeVisible();
});
- test("GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({ page }) => {
+ test("未ログインで保存しようとするとログイン促進モーダルが出る", async ({ page }) => {
let loginUrlRequest: string | null = null;
+ await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
@@ -46,7 +92,6 @@ test.describe("未認証ユーザー", () => {
body: JSON.stringify({ detail: "Unauthorized" }),
}),
);
-
await page.route("**/auth/github/login-url*", (route) => {
loginUrlRequest = route.request().url();
return route.fulfill({
@@ -58,19 +103,146 @@ test.describe("未認証ユーザー", () => {
}),
});
});
-
- // mock OAuth URL への遷移を止める
await page.route("**/mock-github-oauth*", (route) =>
+ route.fulfill({ status: 200, contentType: "text/html", body: "" }),
+ );
+
+ await page.goto("/career");
+ await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎");
+ await page.getByRole("button", { name: "保存する" }).click();
+
+ // ログイン促進モーダルが表示される
+ await expect(
+ page.getByRole("heading", { name: "ログインして保存しましょう" }),
+ ).toBeVisible();
+
+ // 入力ドラフトが sessionStorage に退避される(ログイン往復で失わないため)
+ expect(
+ await page.evaluate(() => sessionStorage.getItem("career_draft")),
+ ).toContain("山田太郎");
+
+ // モーダルの GitHub ログインで OAuth 開始 URL が呼ばれる
+ await page.getByRole("button", { name: "GitHubでログイン" }).click();
+ await expect.poll(() => loginUrlRequest).not.toBeNull();
+ expect(loginUrlRequest).toContain("/auth/github/login-url");
+ expect(loginUrlRequest).toContain("return_to=");
+ });
+
+ test("退避ドラフトはログイン後に自動保存され失われない(新規ユーザー)", async ({ page }) => {
+ // 認証済み(サイドバー付き)状態をセットアップ。
+ await setupAuth(page);
+ // API はホスト名非依存(Vite プロキシ経由のため)でモックする。
+ // 未モックの /api/* が実バックエンドに到達して 401 を返すと _onUnauthorized で
+ // 匿名へ戻ってしまうため、必要なエンドポイントをすべて塞ぐ。
+ await page.route("**/api/master-data/qualification", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
+ );
+ await page.route("**/api/master-data/technology-stack", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
+ );
+ // 新規ユーザー: 既存経歴書なし(404)。
+ await page.route("**/api/resumes/latest", (route) =>
route.fulfill({
- status: 200,
- contentType: "text/html",
- body: "
mock oauth",
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }),
+ }),
+ );
+ // 自動保存の POST を捕捉する。
+ let createdName: string | null = null;
+ await page.route("**/api/resumes", (route) => {
+ if (route.request().method() === "POST") {
+ createdName = route.request().postDataJSON()?.full_name ?? null;
+ return route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify({
+ id: "new-resume-id",
+ full_name: createdName ?? "",
+ career_summary: "",
+ self_pr: "",
+ experiences: [],
+ qualifications: [],
+ }),
+ });
+ }
+ return route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ });
+
+ // ログイン往復から復帰した状態を再現するため、アプリ起動前にドラフトを sessionStorage へ仕込む。
+ await page.addInitScript(() => {
+ sessionStorage.setItem(
+ "career_draft",
+ JSON.stringify({
+ full_name: "山田太郎",
+ career_summary: "テスト要約",
+ self_pr: "テスト自己PR",
+ experiences: [
+ {
+ company: "株式会社テスト",
+ business_description: "受託開発",
+ start_date: "2020-04",
+ end_date: null,
+ is_current: true,
+ is_it_company: true,
+ description: "",
+ employee_count: "100",
+ capital: "1",
+ capital_unit: "万円",
+ clients: [],
+ },
+ ],
+ qualifications: [],
+ }),
+ );
+ });
+
+ await page.goto("/career");
+ await waitForAuthenticatedLayout(page);
+ // サイドバーのナビゲーション(認証済みレイアウト専用)が出ていることを確認する。
+ await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible();
+
+ // 退避ドラフトが自動保存(POST)され、入力した氏名が送られる。
+ await expect.poll(() => createdName).toBe("山田太郎");
+ // フォームにも氏名が反映され、入力は失われていない。
+ await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎");
+ // 退避領域は消費後に破棄される。
+ await expect
+ .poll(() => page.evaluate(() => sessionStorage.getItem("career_draft")))
+ .toBeNull();
+ });
+});
+
+test.describe("ログイン画面", () => {
+ test("/login の GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({
+ page,
+ }) => {
+ let loginUrlRequest: string | null = null;
+
+ await routeBackendNotFound(page);
+ await page.route("**/auth/me", (route) =>
+ route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Unauthorized" }),
}),
);
+ await page.route("**/auth/github/login-url*", (route) => {
+ loginUrlRequest = route.request().url();
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ authorization_url: "http://localhost:5173/mock-github-oauth",
+ state: "mock-state",
+ }),
+ });
+ });
+ await page.route("**/mock-github-oauth*", (route) =>
+ route.fulfill({ status: 200, contentType: "text/html", body: "" }),
+ );
await page.goto("/login");
- await page.waitForURL("**/login", { timeout: 5_000 });
-
await page.getByRole("button", { name: "GitHubでログイン" }).click();
await expect.poll(() => loginUrlRequest).not.toBeNull();
expect(loginUrlRequest).toContain("/auth/github/login-url");
diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css
index 05eb2765..81134c40 100644
--- a/frontend/src/App.module.css
+++ b/frontend/src/App.module.css
@@ -128,6 +128,7 @@
padding-left: calc(1.2rem - 3px);
}
+
/* ── GitHub連携: 展開グループ(フォーク含むオプション)──────────── */
.sidebarItemGroup {
@@ -195,6 +196,7 @@
border-top: 1px solid var(--sidebar-border);
}
+
.mainContent {
flex: 1;
margin-left: var(--sidebar-width);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9b870471..a7f00c76 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,5 @@
import ErrorBoundary from "./components/ErrorBoundary";
+import { LoginPromptProvider } from "./components/auth/LoginPromptProvider";
import { useAuthSession } from "./hooks/useAuthSession";
import { useTheme } from "./hooks/useTheme";
import { AppRoutes } from "./router";
@@ -14,17 +15,19 @@ export default function App() {
return (
- {
- void handleLogout();
- }}
- onLoginSuccess={handleLoginSuccess}
- />
+
+ {
+ void handleLogout();
+ }}
+ onLoginSuccess={handleLoginSuccess}
+ />
+
);
}
diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx
deleted file mode 100644
index 2bdfa7d5..00000000
--- a/frontend/src/components/AuthenticatedLayout.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useState } from "react";
-import { NavLink, Outlet, useNavigate } from "react-router-dom";
-
-import type { AuthUser } from "../router/guards";
-import type { Theme } from "../hooks/useTheme";
-import { UI_MESSAGES } from "../constants/messages";
-import { NotificationBell } from "./NotificationBell";
-import { UserMenu } from "./UserMenu";
-import { ChevronDownIcon } from "./icons/ChevronDownIcon";
-import shared from "../styles/shared.module.css";
-import styles from "../App.module.css";
-
-/**
- * 認証済みユーザー向けのサイドバー付きレイアウト。
- * PrivateRoute でガードされた後にのみレンダリングされるため、user は非 null。
- */
-export function AuthenticatedLayout({
- user,
- theme,
- onToggleTheme,
- onLogout,
-}: {
- user: AuthUser;
- theme: Theme;
- onToggleTheme: () => void;
- onLogout: () => void;
-}) {
- const navigate = useNavigate();
- // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。
- const [githubOptionsOpen, setGithubOptionsOpen] = useState(false);
- const [includeForks, setIncludeForks] = useState(false);
- // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
-
- /**
- * GitHub 連携を実行する。
- * 連携 API のトリガーはサブパネル内の「連携実行」ボタンのみ。
- * サイドバー項目のクリックは画面遷移に徹し、連携リクエストは飛ばさない
- * (不要なリクエストの温床になるため)。
- * 実行意図を runNonce としてページへ渡し、ダッシュボード側で
- * 連携実行(ポーリング)とエラー表示を担わせる。
- */
- const triggerGitHubLink = () => {
- navigate("/github_link", {
- state: { runNonce: Date.now(), includeForks },
- });
- };
-
- return (
-
-
- {/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */}
- {sidebarCollapsed && (
-
- )}
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx
new file mode 100644
index 00000000..4f971c46
--- /dev/null
+++ b/frontend/src/components/SidebarLayout.tsx
@@ -0,0 +1,201 @@
+import { useState } from "react";
+import type { ReactNode } from "react";
+import { NavLink, Outlet, useNavigate } from "react-router-dom";
+
+import { initiateGitHubLogin } from "../api";
+import type { AuthUser } from "../router/guards";
+import type { Theme } from "../hooks/useTheme";
+import { AUTH_PROMPT_MESSAGES, UI_MESSAGES } from "../constants/messages";
+import { NotificationBell } from "./NotificationBell";
+import { UserMenu } from "./UserMenu";
+import { useLoginPrompt } from "./auth/loginPromptContext";
+import { ChevronDownIcon } from "./icons/ChevronDownIcon";
+import shared from "../styles/shared.module.css";
+import styles from "../App.module.css";
+
+/**
+ * サイドバー付きの共通レイアウト。認証状態に応じて中身を出し分ける。
+ *
+ * - 認証済み (`user` 非 null): 全ナビゲーションを活性化し、フッターに通知ベル + ユーザーメニュー。
+ * レイアウトルートとして使う場合は `children` を渡さず `` を描画する。
+ * - 未認証 (`user` === null): 職務経歴書のお試し入力のみ可能。GitHub連携 / ブログ連携は
+ * 表示するが、押下するとログイン促進モーダルを開く。フッターはユーザーメニュー(ログインモード)で、
+ * トリガーが「ログイン」、メニューにダークモード / ログイン / Issue報告 / 著作権表示を出す
+ * (通知ベルは出さない)。
+ */
+export function SidebarLayout({
+ user,
+ theme,
+ onToggleTheme,
+ onLogout,
+ children,
+}: {
+ user: AuthUser | null;
+ theme: Theme;
+ onToggleTheme: () => void;
+ onLogout: () => void;
+ children?: ReactNode;
+}) {
+ const navigate = useNavigate();
+ const requestLogin = useLoginPrompt();
+ // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。
+ const [githubOptionsOpen, setGithubOptionsOpen] = useState(false);
+ const [includeForks, setIncludeForks] = useState(false);
+ // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+
+ const isAuthenticated = user !== null;
+
+ /**
+ * GitHub 連携を実行する。
+ * 連携 API のトリガーはサブパネル内の「連携実行」ボタンのみ。
+ * サイドバー項目のクリックは画面遷移に徹し、連携リクエストは飛ばさない
+ * (不要なリクエストの温床になるため)。
+ * 実行意図を runNonce としてページへ渡し、ダッシュボード側で
+ * 連携実行(ポーリング)とエラー表示を担わせる。
+ */
+ const triggerGitHubLink = () => {
+ navigate("/github_link", {
+ state: { runNonce: Date.now(), includeForks },
+ });
+ };
+
+ const handleLogin = () => {
+ void initiateGitHubLogin(window.location.origin);
+ };
+
+ return (
+
+
+ {/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */}
+ {sidebarCollapsed && (
+
+ )}
+
+
+
{children ?? }
+
+
+ );
+}
diff --git a/frontend/src/components/UserMenu.module.css b/frontend/src/components/UserMenu.module.css
index 7562eae9..e4f6850d 100644
--- a/frontend/src/components/UserMenu.module.css
+++ b/frontend/src/components/UserMenu.module.css
@@ -39,6 +39,61 @@
opacity: 0.6;
}
+/* 未認証フッター: ログインボタン + ▲ メニュー開閉ボタンを横並びにするコンテナ。 */
+.guestTrigger {
+ display: flex;
+ align-items: stretch;
+ gap: 0.4rem;
+ padding: 0 0.2rem;
+}
+
+/* ユーザー名位置のログインボタン(GitHub ログイン開始)。 */
+.loginButton {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ background: var(--github-bg);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 0.6rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.loginButton:hover {
+ background: var(--github-hover);
+}
+
+.loginIcon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+/* ▲ メニュー(ダークモード / Issue報告 / 著作権)開閉ボタン。 */
+.guestChevron {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ border-radius: 6px;
+ padding: 0 0.5rem;
+ color: var(--sidebar-username);
+ font-size: 0.7rem;
+ opacity: 0.7;
+ cursor: pointer;
+ transition: background 0.15s, opacity 0.15s;
+}
+
+.guestChevron:hover {
+ background: var(--sidebar-hover-bg);
+ opacity: 1;
+}
+
.menu {
position: absolute;
bottom: calc(100% + 0.4rem);
diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx
index 1cfc1175..c9e8ee13 100644
--- a/frontend/src/components/UserMenu.tsx
+++ b/frontend/src/components/UserMenu.tsx
@@ -1,19 +1,30 @@
import { useEffect, useRef, useState } from "react";
-import { EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages";
+import { AUTH_PROMPT_MESSAGES, EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages";
import type { Theme } from "../hooks/useTheme";
+import { GitHubMarkIcon } from "./icons/GitHubMarkIcon";
import styles from "./UserMenu.module.css";
+/**
+ * サイドバーフッターのユーザーメニュー。
+ * 認証済み: ユーザー名トリガー(押下でメニュー開閉)+ ダークモード / ログアウト / Issue報告 / コピーライト。
+ * 未認証: ユーザー名位置に「ログイン」ボタン(押下で OAuth 開始)+ 右に ▲ チェブロン(押下でメニュー開閉)。
+ * メニューはダークモード / Issue報告 / コピーライト(ログアウト/ログイン項目は出さない)。
+ */
export function UserMenu({
+ isAuthenticated,
username,
theme,
onToggleTheme,
onLogout,
+ onLogin,
}: {
+ isAuthenticated: boolean;
username: string | null;
theme: Theme;
onToggleTheme: () => void;
onLogout: () => void;
+ onLogin: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -36,20 +47,25 @@ export function UserMenu({
{open && (
-
-
+ {/* 未認証ではログインはトリガー側のボタンに集約するため、メニューには出さない。 */}
+ {isAuthenticated && (
+ <>
+
+
+ >
+ )}
{UI_MESSAGES.COPYRIGHT}
)}
-
+ {isAuthenticated ? (
+
+ ) : (
+ // 未認証: ユーザー名位置にログインボタン、右に ▲(メニュー開閉)。
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/components/auth/LoginPromptModal.module.css b/frontend/src/components/auth/LoginPromptModal.module.css
new file mode 100644
index 00000000..6bb710e5
--- /dev/null
+++ b/frontend/src/components/auth/LoginPromptModal.module.css
@@ -0,0 +1,91 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--overlay-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.dialog {
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border-radius: 12px;
+ padding: 2rem 1.8rem;
+ width: 90vw;
+ max-width: 420px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
+ text-align: center;
+}
+
+.title {
+ margin: 0 0 0.8rem;
+ font-size: 1.3rem;
+ font-weight: 700;
+}
+
+.description {
+ margin: 0 0 0.5rem;
+ font-size: 0.95rem;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+
+.draftKept {
+ margin: 0 0 1.5rem;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.githubLogin {
+ background: var(--github-bg);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+}
+
+.githubLogin:hover {
+ background: var(--github-hover);
+}
+
+.githubIcon {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.later {
+ background: none;
+}
+
+.loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 1.5rem 0;
+}
+
+.loading p {
+ margin: 0;
+ font-weight: 600;
+}
+
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid var(--spinner-track);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
diff --git a/frontend/src/components/auth/LoginPromptModal.test.tsx b/frontend/src/components/auth/LoginPromptModal.test.tsx
new file mode 100644
index 00000000..9a6725d7
--- /dev/null
+++ b/frontend/src/components/auth/LoginPromptModal.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { AUTH_PROMPT_MESSAGES } from "../../constants/messages";
+import { LoginPromptModal } from "./LoginPromptModal";
+
+// initiateGitHubLogin は実際に window.location.assign する副作用があるためモックする。
+const initiateGitHubLogin = vi.fn<() => Promise>().mockResolvedValue(undefined);
+vi.mock("../../api", () => ({
+ initiateGitHubLogin: (...args: unknown[]) => initiateGitHubLogin(...(args as [])),
+}));
+
+describe("LoginPromptModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("タイトルと説明を表示する", () => {
+ render( {}} />);
+
+ expect(screen.getByText(AUTH_PROMPT_MESSAGES.TITLE)).toBeInTheDocument();
+ expect(screen.getByText(AUTH_PROMPT_MESSAGES.DESCRIPTION)).toBeInTheDocument();
+ });
+
+ it("GitHubログインボタンで initiateGitHubLogin を呼ぶ", async () => {
+ render( {}} />);
+
+ fireEvent.click(
+ screen.getByRole("button", { name: AUTH_PROMPT_MESSAGES.GITHUB_LOGIN }),
+ );
+
+ await waitFor(() => {
+ expect(initiateGitHubLogin).toHaveBeenCalledWith(window.location.origin);
+ });
+ });
+
+ it("「あとで」で onClose を呼ぶ", () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: AUTH_PROMPT_MESSAGES.LATER }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/components/auth/LoginPromptModal.tsx b/frontend/src/components/auth/LoginPromptModal.tsx
new file mode 100644
index 00000000..1104be07
--- /dev/null
+++ b/frontend/src/components/auth/LoginPromptModal.tsx
@@ -0,0 +1,66 @@
+import { useState } from "react";
+
+import { initiateGitHubLogin } from "../../api";
+import { AUTH_PROMPT_MESSAGES } from "../../constants/messages";
+import { GitHubMarkIcon } from "../icons/GitHubMarkIcon";
+import styles from "./LoginPromptModal.module.css";
+
+/**
+ * 未ログインで職務経歴書を保存しようとしたときに表示するログイン促進モーダル。
+ *
+ * 入力済みドラフトは呼び出し側で sessionStorage に退避済み。ここではログインへ誘導するだけで、
+ * GitHub OAuth へリダイレクトする。往復後 `/career` に戻ると useCareerDraftRestore がドラフトを復元する。
+ */
+export function LoginPromptModal({ onClose }: { onClose: () => void }) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleGitHubLogin = async () => {
+ setIsLoading(true);
+ try {
+ await initiateGitHubLogin(window.location.origin);
+ } catch {
+ // 開始に失敗したらボタンを再操作可能に戻す(エラーはリダイレクト前のため握って良い)。
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {isLoading ? (
+
+
+
{AUTH_PROMPT_MESSAGES.REDIRECTING}
+
+ ) : (
+ <>
+
{AUTH_PROMPT_MESSAGES.TITLE}
+
{AUTH_PROMPT_MESSAGES.DESCRIPTION}
+
{AUTH_PROMPT_MESSAGES.DRAFT_KEPT}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/auth/LoginPromptProvider.tsx b/frontend/src/components/auth/LoginPromptProvider.tsx
new file mode 100644
index 00000000..4e335099
--- /dev/null
+++ b/frontend/src/components/auth/LoginPromptProvider.tsx
@@ -0,0 +1,21 @@
+import { useCallback, useState } from "react";
+import type { ReactNode } from "react";
+
+import { LoginPromptModal } from "./LoginPromptModal";
+import { LoginPromptContext } from "./loginPromptContext";
+
+/**
+ * ログイン促進モーダルを 1 つだけ保持し、配下のどこからでも `useLoginPrompt()` で開けるようにする Provider。
+ * サイドバー(SidebarLayout)とお試し入力フォーム(CareerResumeForm)の双方が同じモーダルを共有する。
+ */
+export function LoginPromptProvider({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+ const requestLogin = useCallback(() => setOpen(true), []);
+
+ return (
+
+ {children}
+ {open && setOpen(false)} />}
+
+ );
+}
diff --git a/frontend/src/components/auth/loginPromptContext.ts b/frontend/src/components/auth/loginPromptContext.ts
new file mode 100644
index 00000000..9a4fa8a7
--- /dev/null
+++ b/frontend/src/components/auth/loginPromptContext.ts
@@ -0,0 +1,20 @@
+import { createContext, useContext } from "react";
+
+/**
+ * ログイン促進モーダルを開く関数を配布するコンテキスト。
+ * 未ログインで要ログイン機能(プレビュー / ダウンロード / 連携への遷移など)を使おうとしたときに、
+ * サイドバーとお試し入力フォームの双方から同じ 1 つのモーダルを開くために使う。
+ *
+ * Provider 本体は {@link ../auth/LoginPromptProvider} を参照。
+ */
+export const LoginPromptContext = createContext<(() => void) | null>(null);
+
+/** ログイン促進モーダルを開く関数を取得する。Provider 配下でのみ利用できる。 */
+export function useLoginPrompt(): () => void {
+ const ctx = useContext(LoginPromptContext);
+ if (!ctx) {
+ // 配線ミスを握りつぶさず即座に気付けるようにする(開発者向け内部エラー)。
+ throw new Error("useLoginPrompt must be used within a LoginPromptProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/components/forms/CareerResumeForm.module.css b/frontend/src/components/forms/CareerResumeForm.module.css
index 87042523..469ce8a7 100644
--- a/frontend/src/components/forms/CareerResumeForm.module.css
+++ b/frontend/src/components/forms/CareerResumeForm.module.css
@@ -1,3 +1,27 @@
+/* ヘッダーのアイコンボタン(PDF出力 / Markdown出力)。アイコンを中央寄せする。 */
+.iconButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.headerIcon {
+ width: 18px;
+ height: 18px;
+ display: block;
+}
+
+/* PDF 出力中のローディングスピナー(アイコンと同サイズ)。spin は styles.css のグローバル定義。 */
+.buttonSpinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--spinner-track);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
.inputWithUnit {
display: flex;
align-items: center;
diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx
index 23dc70f2..8d109f31 100644
--- a/frontend/src/components/forms/CareerResumeForm.tsx
+++ b/frontend/src/components/forms/CareerResumeForm.tsx
@@ -1,4 +1,4 @@
-import { CSSProperties, FormEvent, useCallback, useMemo, useRef, useState } from "react";
+import { CSSProperties, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import {
@@ -10,13 +10,15 @@ import {
getLatestCareerResume,
updateCareerResume,
} from "../../api";
-import { SUCCESS_MESSAGES, UI_MESSAGES } from "../../constants/messages";
+import { AUTH_PROMPT_MESSAGES, SUCCESS_MESSAGES, UI_MESSAGES } from "../../constants/messages";
import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers";
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
+import { useCareerDraftRestore } from "../../hooks/career/useCareerDraftRestore";
import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
+import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders";
import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders";
import { buildCareerChanges } from "../../utils/careerDiff";
@@ -26,9 +28,15 @@ import { usePdfActions } from "../../hooks/usePdfActions";
import { useMessageToast } from "../ui/toast";
import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
+import { useLoginPrompt } from "../auth/loginPromptContext";
import { CareerDiffModal } from "./CareerDiffModal";
import { MarkdownFieldModal } from "./MarkdownFieldModal";
import { Skeleton } from "../ui/Skeleton";
+import { SaveIcon } from "../icons/SaveIcon";
+import { EyeIcon } from "../icons/EyeIcon";
+import { TrashIcon } from "../icons/TrashIcon";
+import { PdfDownloadIcon } from "../icons/PdfDownloadIcon";
+import { MarkdownDownloadIcon } from "../icons/MarkdownDownloadIcon";
import { PdfPreviewModal } from "./PdfPreviewModal";
import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel";
import layout from "./CareerResumeForm.module.css";
@@ -37,7 +45,9 @@ import { CareerExperienceSection } from "./sections/CareerExperienceSection";
import { CareerQualificationsSection } from "./sections/CareerQualificationsSection";
import { CareerSelfPrSection } from "./sections/CareerSelfPrSection";
-export function CareerResumeForm() {
+export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean }) {
+ // 未ログインで要ログイン機能を使おうとしたときに開く共通モーダル。
+ const requestLogin = useLoginPrompt();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// 保存時の変更点確認ダイアログの表示状態。
const [showSaveConfirm, setShowSaveConfirm] = useState(false);
@@ -48,6 +58,17 @@ export function CareerResumeForm() {
const assist = useResumeImportAssist();
const splitRef = useRef(null);
const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef);
+
+ /**
+ * 初期フォーム。未ログインのお試し入力では、退避ドラフトがあればそれを初期値にする
+ * (リロードやログイン往復後も入力を失わない。ドラフトは下の effect で常に最新化する)。
+ * 認証済みでは空フォーム(サーバ最新は useDocumentForm の loadLatest が反映する)。
+ */
+ const createInitialForm = useCallback(
+ () => (isAuthenticated ? createInitialCareerForm() : loadCareerDraft() ?? createInitialCareerForm()),
+ [isAuthenticated],
+ );
+
const {
form,
setForm,
@@ -62,7 +83,7 @@ export function CareerResumeForm() {
deleteDoc,
saveButtonText,
} = useDocumentForm({
- createInitialForm: createInitialCareerForm,
+ createInitialForm,
loadLatest: getLatestCareerResume,
createDocument: createCareerResume,
updateDocument: updateCareerResume,
@@ -71,8 +92,38 @@ export function CareerResumeForm() {
mapResponseToForm: mapCareerResumeToForm,
successMessage: SUCCESS_MESSAGES.CAREER_SAVED,
cacheKey: "career",
+ // 未ログインのお試し入力では loadLatest を行わず空フォームで起動する(401 を無駄打ちしない)。
+ skipLoad: !isAuthenticated,
+ });
+
+ // ログイン後(往復から復帰)に退避ドラフトを復元する情報トースト用メッセージ。
+ const [restoreMessage, setRestoreMessage] = useState(null);
+
+ // 未ログイン入力 → ログイン往復してきたユーザーの退避ドラフトをフォームへ橋渡しする。
+ useCareerDraftRestore({
+ isAuthenticated,
+ loading,
+ documentId: resumeId,
+ setForm,
+ save,
+ notifyRestored: () => setRestoreMessage(AUTH_PROMPT_MESSAGES.DRAFT_RESTORED),
});
+ /**
+ * 未ログイン時は入力内容を常に sessionStorage に退避し続ける。
+ * これによりプレビュー/ダウンロード/連携メニューなど任意のログイン導線を踏んでも
+ * 入力が失われず、ログイン往復後に復元できる。氏名が空のうちは意味のあるドラフトでないため退避しない
+ * (空ドラフトでログイン後に自動保存が空振りするのを防ぐ)。
+ */
+ useEffect(() => {
+ if (isAuthenticated) return;
+ if (form.full_name.trim()) {
+ saveCareerDraft(form);
+ } else {
+ clearCareerDraft();
+ }
+ }, [isAuthenticated, form]);
+
/**
* 保存前バリデーション(項目バリデーション)のメッセージ。
* 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、
@@ -118,14 +169,12 @@ export function CareerResumeForm() {
getPdfBlobUrl: getCareerResumePdfBlobUrl,
});
- // PDF アクションとフォーム保存/削除の成否を、チャンネルごとに独立してトーストで通知する。
- // pdf と form を `??` で統合すると、片方の値が残っている間にもう片方が更新されても
- // 統合値が変化せずトーストが出ないため、それぞれ個別に橋渡しする。
- // 成功は自動消去、失敗は手動クローズ(ブリッジ内で variant 別に制御)。
useMessageToast(formSuccess, "success");
useMessageToast(formError, "error");
useMessageToast(pdfSuccess, "success");
useMessageToast(pdfError, "error");
+ // ログイン後のドラフト復元(既存経歴書あり)を通知する情報トースト。
+ useMessageToast(restoreMessage, "success");
/** Skeleton 表示・入力ロックの統合フラグ */
const formLocked = loading;
@@ -160,22 +209,44 @@ export function CareerResumeForm() {
setForm((prev) => ({ ...prev, [key]: value }));
};
+ /** バリデーション失敗を画面へ反映する(メッセージ・フォーカス・モーダル自動展開)。 */
+ const applyValidationError = (validation: NonNullable>) => {
+ setValidationError(validation.message);
+ focusNonceRef.current += 1;
+ setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
+ // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く
+ // (隠れた textarea には直接フォーカスできないため)。
+ if (
+ validation.locator.kind === "career_summary" ||
+ validation.locator.kind === "self_pr"
+ ) {
+ setEditingField(validation.locator.kind);
+ }
+ };
+
const onSubmit = (event: FormEvent) => {
event.preventDefault();
+
+ // 未ログインのお試し入力: 全項目の入力完了は求めず(カジュアルな体験を優先)、
+ // 氏名だけ確認して(空の経歴書でログインさせない)ドラフトを退避し、ログインを促す。
+ // 残りの項目検証はログイン後の実保存時にサーバ側で行う。
+ if (!isAuthenticated) {
+ if (!form.full_name.trim()) {
+ const validation = validateCareerForm(form);
+ if (validation) applyValidationError(validation);
+ return;
+ }
+ setValidationError(null);
+ setFocusTarget(null);
+ // 入力内容は effect で sessionStorage に退避済み。ログインを促す。
+ requestLogin();
+ return;
+ }
+
// 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。
const validation = validateCareerForm(form);
if (validation) {
- setValidationError(validation.message);
- focusNonceRef.current += 1;
- setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
- // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く
- // (隠れた textarea には直接フォーカスできないため)。
- if (
- validation.locator.kind === "career_summary" ||
- validation.locator.kind === "self_pr"
- ) {
- setEditingField(validation.locator.kind);
- }
+ applyValidationError(validation);
return;
}
setValidationError(null);
@@ -202,6 +273,27 @@ export function CareerResumeForm() {
setShowDeleteConfirm(false);
};
+ /**
+ * 要ログイン機能(プレビュー / PDF / Markdown 出力)のハンドラ。
+ * 未ログインならログイン促進モーダルを開き、ログイン済みなら本来の処理を行う。
+ * 入力中ドラフトは effect で sessionStorage に退避済みのため、ログイン往復後も失われない。
+ */
+ const handlePreview = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onPreviewPdf(resumeId);
+ };
+ const handleDownloadPdf = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED);
+ };
+ const handleDownloadMarkdown = () => {
+ if (!isAuthenticated) return requestLogin();
+ if (resumeId) onDownloadMarkdown(resumeId);
+ };
+ // 未ログインでは要ログイン機能ボタンを活性にしてログイン導線にする。
+ // ログイン済みでは保存済み(resumeId あり)まで非活性。
+ const exportDisabled = formLocked || (isAuthenticated && !resumeId);
+
return (
<>
{showDeleteConfirm && (
@@ -250,48 +342,67 @@ export function CareerResumeForm() {
職務経歴書
{/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */}
-
- {/* 左=入力フォーム / 右=PDF 原本ビュー。スプリッターで左右リサイズする。
- splitWrap はコンテナクエリの基準(= split に割り当てられる実幅)。
- 横幅が足りない時は split を縦積みに切り替える。PDF カラム幅は CSS 変数で渡し、
- 縦積み時は CSS 側で全幅に上書きする(inline style だと上書きできないため変数経由)。 */}
diff --git a/frontend/src/components/icons/GitHubMarkIcon.tsx b/frontend/src/components/icons/GitHubMarkIcon.tsx
new file mode 100644
index 00000000..0b109ade
--- /dev/null
+++ b/frontend/src/components/icons/GitHubMarkIcon.tsx
@@ -0,0 +1,20 @@
+/**
+ * GitHub マークアイコン。ログインボタン等で使う(色はボタン側で指定)。
+ */
+export function GitHubMarkIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/icons/MarkdownDownloadIcon.tsx b/frontend/src/components/icons/MarkdownDownloadIcon.tsx
new file mode 100644
index 00000000..1b0c4378
--- /dev/null
+++ b/frontend/src/components/icons/MarkdownDownloadIcon.tsx
@@ -0,0 +1,30 @@
+/**
+ * Markdown ダウンロード(出力)マーク。Markdown 公式マーク(枠 + 「M」+ 下向き矢印)。
+ * マーク自体が下向き矢印を含み「書き出し」を想起させる。色は currentColor を継承する。
+ */
+export function MarkdownDownloadIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/icons/PdfDownloadIcon.tsx b/frontend/src/components/icons/PdfDownloadIcon.tsx
new file mode 100644
index 00000000..1e360913
--- /dev/null
+++ b/frontend/src/components/icons/PdfDownloadIcon.tsx
@@ -0,0 +1,51 @@
+/**
+ * PDF ダウンロード(出力)マーク。書類 + 「PDF」+ 下向き矢印で「PDFを書き出す」を表す。
+ * 色はボタン側の currentColor を継承する。
+ */
+export function PdfDownloadIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/icons/SaveIcon.tsx b/frontend/src/components/icons/SaveIcon.tsx
new file mode 100644
index 00000000..ce58a7bb
--- /dev/null
+++ b/frontend/src/components/icons/SaveIcon.tsx
@@ -0,0 +1,19 @@
+/**
+ * 保存マーク(フロッピーディスク)。職務経歴書の保存 / 更新ボタンで使う。
+ * 色はボタン側の currentColor を継承する。
+ */
+export function SaveIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 15e41317..14ff1b87 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -78,6 +78,31 @@ export const SUCCESS_MESSAGES = {
CAREER_PDF_DOWNLOADED: "職務経歴書PDFをダウンロードしました。",
} as const;
+/**
+ * 未ログインお試し入力からログインへ誘導する動線の文言。
+ * 職務経歴書を未ログインで入力 → 保存時にログインを促すモーダル / 匿名ヘッダーで使う。
+ */
+export const AUTH_PROMPT_MESSAGES = {
+ /** ログイン促進モーダルの見出し。 */
+ TITLE: "ログインして保存しましょう",
+ /** ログインで得られる価値の説明。 */
+ DESCRIPTION: "ログインすると職務経歴書を保存でき、PDF出力やいつでも編集ができます。",
+ /** 入力内容が失われない旨の安心メッセージ。 */
+ DRAFT_KEPT: "入力した内容は保持されます。",
+ /** GitHub ログインボタン(モーダル)。 */
+ GITHUB_LOGIN: "GitHubでログイン",
+ /** モーダルを閉じて入力に戻るボタン。 */
+ LATER: "あとで",
+ /** GitHub へリダイレクト中の表示。 */
+ REDIRECTING: "GitHub認証へリダイレクト中...",
+ /** 未認証サイドバーのフッターに置くログイン CTA(モーダルとの重複表記を避けて短縮形)。 */
+ SIDEBAR_LOGIN: "ログイン",
+ /** 未認証時に非活性な連携メニューへ付けるツールチップ。 */
+ LOGIN_REQUIRED_HINT: "ログインすると利用できます",
+ /** ログイン後、既存の職務経歴書がある場合にドラフトを復元したことを伝える。 */
+ DRAFT_RESTORED: "入力内容を復元しました。保存すると既存の職務経歴書を更新します。",
+} as const;
+
/** JSX に直書きされていた UI 文言(ErrorBoundary など) */
export const UI_MESSAGES = {
ERROR_BOUNDARY_TITLE: "予期しないエラーが発生しました",
@@ -87,6 +112,21 @@ export const UI_MESSAGES = {
"まだ連携データがありません。連携してアクティビティを可視化しましょう。",
SIDEBAR_COLLAPSE: "サイドバーを閉じる",
SIDEBAR_EXPAND: "サイドバーを開く",
+ // 未認証フッターの ▲ チェブロン(ダークモード / Issue報告 / 著作権メニュー)の aria-label
+ FOOTER_MENU: "メニュー",
+ // サイドバーのナビゲーション項目
+ NAV_CAREER: "職務経歴書",
+ NAV_GITHUB_LINK: "GitHub連携",
+ NAV_BLOG_LINK: "ブログ連携",
+ // GitHub連携サブパネル
+ GITHUB_LINK_OPTIONS: "GitHub連携オプション",
+ GITHUB_LINK_RUN: "連携実行",
+ GITHUB_INCLUDE_FORKS: "フォークしたリポジトリを含む",
+ // ユーザーメニュー(フッター)
+ DARK_MODE: "ダークモード",
+ LOGOUT: "ログアウト",
+ // 認証済みだがユーザー名が取得できない場合のフォールバック表示
+ MENU_FALLBACK: "Menu",
SOURCE_PANEL_COLLAPSE: "原本パネルを閉じる",
SOURCE_PANEL_EXPAND: "原本パネルを開く",
// 入力モーダルの × 閉じるボタン(aria-label)
@@ -105,6 +145,11 @@ export const UI_MESSAGES = {
REPORT_ISSUE: "GitHub Issueに報告",
OPENS_IN_NEW_TAB: "(新しいタブで開きます)",
COPYRIGHT: "© 2026 DevForge",
+ // 職務経歴書ヘッダーのアイコンボタン(aria-label / title 用)
+ RESUME_PREVIEW: "プレビュー",
+ RESUME_EXPORT_PDF: "PDF出力",
+ RESUME_EXPORT_MARKDOWN: "Markdown出力",
+ RESUME_DELETE_ALL: "データを削除",
// 職務経歴書フォームの項目削除アイコン(aria-label / title 用)
RESUME_DELETE_EXPERIENCE: "職務経歴を削除",
RESUME_DELETE_CLIENT: "取引先を削除",
diff --git a/frontend/src/hooks/career/useCareerDraftRestore.test.ts b/frontend/src/hooks/career/useCareerDraftRestore.test.ts
new file mode 100644
index 00000000..2f9bfbd5
--- /dev/null
+++ b/frontend/src/hooks/career/useCareerDraftRestore.test.ts
@@ -0,0 +1,109 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { createInitialCareerForm } from "../../formMappers";
+import type { CareerFormState } from "../../payloadBuilders";
+import { loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
+import { useCareerDraftRestore } from "./useCareerDraftRestore";
+
+/** テスト用ドラフト(氏名だけ埋めた最小フォーム) */
+function draftForm(): CareerFormState {
+ return { ...createInitialCareerForm(), full_name: "山田太郎" };
+}
+
+type Options = Parameters
[0];
+
+function setup(overrides: Partial) {
+ const setForm = vi.fn();
+ const save = vi.fn<(form?: CareerFormState) => Promise>().mockResolvedValue(true);
+ const notifyRestored = vi.fn();
+ const hook = renderHook((props: Options) => useCareerDraftRestore(props), {
+ initialProps: {
+ isAuthenticated: true,
+ loading: false,
+ documentId: null,
+ setForm,
+ save,
+ notifyRestored,
+ ...overrides,
+ },
+ });
+ return { setForm, save, notifyRestored, hook };
+}
+
+describe("useCareerDraftRestore", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ it("ドラフトが無ければ何もしない", () => {
+ const { setForm, save, notifyRestored } = setup({});
+
+ expect(save).not.toHaveBeenCalled();
+ expect(setForm).not.toHaveBeenCalled();
+ expect(notifyRestored).not.toHaveBeenCalled();
+ });
+
+ it("新規ユーザー(documentId=null)はドラフトを自動保存し、成功時に退避を破棄する", async () => {
+ const draft = draftForm();
+ saveCareerDraft(draft);
+
+ const { save, setForm, notifyRestored } = setup({ documentId: null });
+
+ await waitFor(() => {
+ expect(save).toHaveBeenCalledWith(draft);
+ });
+ await waitFor(() => {
+ expect(loadCareerDraft()).toBeNull();
+ });
+ // 新規保存パスでは復元通知も setForm も不要
+ expect(notifyRestored).not.toHaveBeenCalled();
+ expect(setForm).not.toHaveBeenCalled();
+ });
+
+ it("既存ユーザー(documentId あり)はフォームへ復元し通知する(自動保存しない)", async () => {
+ const draft = draftForm();
+ saveCareerDraft(draft);
+
+ const { save, setForm, notifyRestored } = setup({ documentId: "existing-id" });
+
+ await waitFor(() => {
+ expect(setForm).toHaveBeenCalledWith(draft);
+ });
+ expect(notifyRestored).toHaveBeenCalledTimes(1);
+ expect(save).not.toHaveBeenCalled();
+ expect(loadCareerDraft()).toBeNull();
+ });
+
+ it("未ログインの間は何もしない", () => {
+ saveCareerDraft(draftForm());
+ const { save, setForm } = setup({ isAuthenticated: false });
+
+ expect(save).not.toHaveBeenCalled();
+ expect(setForm).not.toHaveBeenCalled();
+ // ドラフトは温存される(ログイン後に処理する)
+ expect(loadCareerDraft()).not.toBeNull();
+ });
+
+ it("ロード未確定(loading=true)の間は待機し、確定後に処理する", async () => {
+ const draft = draftForm();
+ saveCareerDraft(draft);
+
+ const { save, hook } = setup({ loading: true, documentId: null });
+ expect(save).not.toHaveBeenCalled();
+
+ hook.rerender({
+ isAuthenticated: true,
+ loading: false,
+ documentId: null,
+ setForm: vi.fn(),
+ save,
+ notifyRestored: vi.fn(),
+ });
+
+ await waitFor(() => {
+ expect(save).toHaveBeenCalledWith(draft);
+ });
+ });
+});
diff --git a/frontend/src/hooks/career/useCareerDraftRestore.ts b/frontend/src/hooks/career/useCareerDraftRestore.ts
new file mode 100644
index 00000000..3a591129
--- /dev/null
+++ b/frontend/src/hooks/career/useCareerDraftRestore.ts
@@ -0,0 +1,73 @@
+import { useEffect, useRef } from "react";
+import type { Dispatch, SetStateAction } from "react";
+
+import type { CareerFormState } from "../../payloadBuilders";
+import { clearCareerDraft, loadCareerDraft } from "../../utils/careerDraft";
+
+type UseCareerDraftRestoreOptions = {
+ /** 認証済みか。未ログイン(匿名入力中)は何もしない。 */
+ isAuthenticated: boolean;
+ /** useDocumentForm の loading。ロード確定(新規/既存判定)まで待つ。 */
+ loading: boolean;
+ /** ロード確定後の documentId。null=サーバに既存経歴書なし(新規ユーザー)。 */
+ documentId: string | null;
+ /** 退避ドラフトをフォームへ復元する(useDocumentForm.setForm)。 */
+ setForm: Dispatch>;
+ /** ドラフトを保存する(useDocumentForm.save)。成功で true。 */
+ save: (overrideForm?: CareerFormState) => Promise;
+ /** 既存経歴書ありでドラフトを復元したときの通知(情報トースト)。 */
+ notifyRestored: () => void;
+};
+
+/**
+ * 未ログインで入力 → ログイン往復してきたユーザーの退避ドラフトをフォームへ橋渡しするフック。
+ *
+ * `/career` で認証済みになった初回(ロード確定後)に一度だけ発火する:
+ * 1. 退避ドラフトが無ければ何もしない。
+ * 2. サーバに既存経歴書なし(documentId === null)→ ドラフトをそのまま新規保存し、成功時にトースト。
+ * 3. 既存経歴書あり → ドラフトをフォームへ復元(dirty 表示)し情報トースト。上書き保存はユーザー操作に委ねる。
+ *
+ * いずれもフォームへ反映 / 保存を試みた時点でドラフトは破棄する。再マウント時に古いドラフトを
+ * 復元し直して既存編集を踏み潰す事故を防ぐため、消費後の sessionStorage は残さない。
+ */
+export function useCareerDraftRestore({
+ isAuthenticated,
+ loading,
+ documentId,
+ setForm,
+ save,
+ notifyRestored,
+}: UseCareerDraftRestoreOptions): void {
+ // 一度処理したら再実行しない(同一マウント内での多重発火を防ぐ)。
+ const handledRef = useRef(false);
+
+ useEffect(() => {
+ if (handledRef.current) return;
+ // 未ログイン、またはロード未確定(新規/既存が決まっていない)うちは待機する。
+ if (!isAuthenticated || loading) return;
+
+ const draft = loadCareerDraft();
+ handledRef.current = true;
+ if (!draft) return;
+
+ if (documentId === null) {
+ // 新規ユーザー: 退避ドラフトをそのまま保存する。
+ void (async () => {
+ const ok = await save(draft);
+ if (ok) {
+ clearCareerDraft();
+ } else {
+ // 保存失敗(例: バリデーション 422)。データを失わないようフォームへ展開し、
+ // ユーザーが修正して再保存できるようにする。エラーはトーストで別途通知済み。
+ setForm(draft);
+ clearCareerDraft();
+ }
+ })();
+ } else {
+ // 既存経歴書あり: ドラフトをフォームへ復元し、更新はユーザー判断に委ねる。
+ setForm(draft);
+ clearCareerDraft();
+ notifyRestored();
+ }
+ }, [isAuthenticated, loading, documentId, setForm, save, notifyRestored]);
+}
diff --git a/frontend/src/hooks/useDocumentForm.test.ts b/frontend/src/hooks/useDocumentForm.test.ts
index 408a9023..026cfdc4 100644
--- a/frontend/src/hooks/useDocumentForm.test.ts
+++ b/frontend/src/hooks/useDocumentForm.test.ts
@@ -161,6 +161,62 @@ describe("useDocumentForm", () => {
expect(result.current.documentId).toBe("doc-cached");
});
+ /**
+ * skipLoad(匿名モード)では loadLatest を呼ばず、最初から loading=false で
+ * 空フォームを起動すること。未ログインのお試し入力で 401 を無駄打ちしない契約を守る。
+ */
+ it("skipLoad=true のとき loadLatest を呼ばず loading=false で起動する", () => {
+ const { result } = setup({ skipLoad: true });
+
+ expect(result.current.loading).toBe(false);
+ expect(mockLoadLatest).not.toHaveBeenCalled();
+ expect(result.current.form).toEqual({ title: "" });
+ expect(result.current.documentId).toBeNull();
+ });
+
+ /**
+ * save(overrideForm) は現在の form state ではなく渡したフォームを保存すること。
+ * ログイン後に退避ドラフトを state 反映を待たずに即保存するために使う。
+ */
+ it("save(overrideForm) は渡したフォームで保存し成功時 true を返す", async () => {
+ mockLoadLatest.mockRejectedValue(new Error("Not found"));
+ mockCreateDocument.mockResolvedValueOnce({ id: "new-id", title: "ドラフト" });
+
+ const { result } = setup();
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ let returned: boolean | undefined;
+ await act(async () => {
+ returned = await result.current.save({ title: "ドラフト" });
+ });
+
+ expect(returned).toBe(true);
+ expect(mockBuildPayload).toHaveBeenLastCalledWith({ title: "ドラフト" });
+ expect(mockCreateDocument).toHaveBeenCalledWith({ title: "ドラフト" });
+ expect(result.current.success).toBe("保存しました");
+ });
+
+ /** save() 失敗時は false を返すこと(呼び出し側がドラフト破棄を判断するため)。 */
+ it("save() 失敗時は false を返す", async () => {
+ mockLoadLatest.mockRejectedValue(new Error("Not found"));
+ mockCreateDocument.mockRejectedValueOnce(new Error("失敗"));
+
+ const { result } = setup();
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ let returned: boolean | undefined;
+ await act(async () => {
+ returned = await result.current.save();
+ });
+
+ expect(returned).toBe(false);
+ expect(result.current.error).toBe("失敗");
+ });
+
/** beforeSave でエラーがスローされた場合、API が呼ばれずエラーが表示されること */
it("beforeSave でエラーがスローされた場合 API が呼ばれずエラーがセットされる", async () => {
mockLoadLatest.mockRejectedValue(new Error("Not found"));
diff --git a/frontend/src/hooks/useDocumentForm.ts b/frontend/src/hooks/useDocumentForm.ts
index 80e45ab6..5e68f9e6 100644
--- a/frontend/src/hooks/useDocumentForm.ts
+++ b/frontend/src/hooks/useDocumentForm.ts
@@ -22,6 +22,11 @@ export type UseDocumentFormOptions Promise;
/** 指定するとページ遷移してもフォーム状態が Redux ストアに保持される */
cacheKey?: FormCacheKey;
+ /**
+ * true の間はマウント時の loadLatest を行わず、空フォームで起動する。
+ * 未ログインのお試し入力(匿名モード)で 401 を無駄打ちしないために使う。
+ */
+ skipLoad?: boolean;
};
export function useDocumentForm({
@@ -35,6 +40,7 @@ export function useDocumentForm) {
const dispatch = useAppDispatch();
const cached = useAppSelector((s) =>
@@ -56,7 +62,7 @@ export function useDocumentForm(
cached?.documentId ?? null,
);
- const [loading, setLoading] = useState(!cached);
+ const [loading, setLoading] = useState(!cached && !skipLoad);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(null);
@@ -129,8 +135,9 @@ export function useDocumentForm {
- // キャッシュが既にある場合は API ロードをスキップ
- if (cached) return;
+ // キャッシュが既にある場合・匿名モード(skipLoad)の場合は API ロードをスキップ。
+ // 匿名モードは空フォームで起動し、ログイン後の再マウントで初めてロードする。
+ if (cached || skipLoad) return;
let active = true;
setLoading(true);
@@ -162,21 +169,26 @@ export function useDocumentForm {
if (saving) return "保存中...";
return documentId ? "更新する" : "保存する";
}, [documentId, saving]);
- const save = async () => {
+ /**
+ * フォームを保存する。`overrideForm` を渡すと現在の form state ではなくそれを保存する
+ * (ログイン後に退避ドラフトを state 反映を待たず即保存するケースで使う)。
+ * 成功で true、失敗で false を返す(呼び出し側が成否に応じてドラフト破棄を判断できる)。
+ */
+ const save = async (overrideForm?: FormState): Promise => {
setSaving(true);
setError(null);
setSuccess(null);
try {
if (beforeSave) await beforeSave();
- const payload = buildPayload(form);
+ const payload = buildPayload(overrideForm ?? form);
const saved = documentId
? await updateDocument(documentId, payload)
: await createDocument(payload);
@@ -186,12 +198,14 @@ export function useDocumentForm;
+/**
+ * 職務経歴書ページ。
+ * `isAuthenticated=false`(未ログインのお試し入力)では保存時にログインを促す。
+ */
+export default function CareerPage({ isAuthenticated }: { isAuthenticated: boolean }) {
+ return ;
}
diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx
index 730eb797..6d30c773 100644
--- a/frontend/src/router/routes.tsx
+++ b/frontend/src/router/routes.tsx
@@ -1,7 +1,8 @@
import { Route, Routes, Navigate } from "react-router-dom";
import type { Theme } from "../hooks/useTheme";
-import { AuthenticatedLayout } from "../components/AuthenticatedLayout";
+import { SidebarLayout } from "../components/SidebarLayout";
+import { LoadingOverlay } from "../components/LoadingOverlay";
import { PrivateRoute, PublicRoute, type AuthUser } from "./guards";
import CareerPage from "../pages/CareerPage";
import GitHubLinkPage from "../pages/GitHubLinkPage";
@@ -20,6 +21,38 @@ type AppRoutesProps = {
onLoginSuccess: (user: { username: string; is_github_user: boolean }) => void;
};
+/**
+ * `/career` を認証有無の両対応にするディスパッチャ。
+ * - 認証判定中: LoadingOverlay(認証状態確定前のちらつきを防ぐ)
+ * - それ以外: 共通の SidebarLayout を描画。未認証時は連携メニューを非活性にし、
+ * フッターにログインボタンを出す(SidebarLayout が user の有無で出し分ける)。
+ */
+function CareerRoute({
+ user,
+ authLoading,
+ theme,
+ onToggleTheme,
+ onLogout,
+}: {
+ user: AuthUser | null;
+ authLoading: boolean;
+ theme: Theme;
+ onToggleTheme: () => void;
+ onLogout: () => void;
+}) {
+ if (authLoading) return ;
+ return (
+
+
+
+ );
+}
+
/**
* アプリケーション全体のルート定義。
* パスとページコンポーネントの対応を管理する。
@@ -40,11 +73,25 @@ export default function AppRoutes({
} />
- {/* 認証済みルート */}
+ {/* 職務経歴書: 認証有無の両対応(未ログインはお試し入力、保存時にログインを促す) */}
+
+ }
+ />
+
+ {/* 認証済み専用ルート(GitHub連携・ブログ連携) */}
}>
}
>
- } />
} />
} />
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index e11b172e..7cb3efdf 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -2,7 +2,7 @@
color-scheme: light;
font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
- /* Layout(サイドバー幅・モバイル上部バー高。AuthenticatedLayout とモーダルのオフセットで共用) */
+ /* Layout(サイドバー幅・モバイル上部バー高。SidebarLayout とモーダルのオフセットで共用) */
--sidebar-width: 220px;
--topbar-height: 48px;
diff --git a/frontend/src/utils/careerDraft.test.ts b/frontend/src/utils/careerDraft.test.ts
new file mode 100644
index 00000000..0697f89b
--- /dev/null
+++ b/frontend/src/utils/careerDraft.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { createInitialCareerForm } from "../formMappers";
+import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "./careerDraft";
+
+describe("careerDraft", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it("save → load で同じ内容を復元できる", () => {
+ const form = { ...createInitialCareerForm(), full_name: "山田太郎" };
+ saveCareerDraft(form);
+
+ expect(loadCareerDraft()).toEqual(form);
+ });
+
+ it("ドラフト未保存なら load は null を返す", () => {
+ expect(loadCareerDraft()).toBeNull();
+ });
+
+ it("clear すると load は null になる", () => {
+ saveCareerDraft({ ...createInitialCareerForm(), full_name: "削除対象" });
+ clearCareerDraft();
+
+ expect(loadCareerDraft()).toBeNull();
+ });
+
+ it("壊れた JSON は null を返し、退避領域を掃除する", () => {
+ sessionStorage.setItem("career_draft", "{壊れた");
+ // 警告ログは抑止する(テスト出力を汚さない)
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(loadCareerDraft()).toBeNull();
+ expect(sessionStorage.getItem("career_draft")).toBeNull();
+ });
+
+ it("形が不正なドラフト(旧フォーマット等)は null を返し破棄する", () => {
+ // パースは成功するが CareerFormState の必須形(experiences 配列等)を満たさない。
+ sessionStorage.setItem("career_draft", JSON.stringify({ full_name: "山田太郎" }));
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(loadCareerDraft()).toBeNull();
+ expect(sessionStorage.getItem("career_draft")).toBeNull();
+ });
+});
diff --git a/frontend/src/utils/careerDraft.ts b/frontend/src/utils/careerDraft.ts
new file mode 100644
index 00000000..a82c5982
--- /dev/null
+++ b/frontend/src/utils/careerDraft.ts
@@ -0,0 +1,68 @@
+import type { CareerFormState } from "../payloadBuilders";
+
+/**
+ * 未ログインで入力した職務経歴書ドラフトを OAuth 往復の間だけ退避するユーティリティ。
+ *
+ * ## なぜ sessionStorage か
+ * GitHub OAuth は `window.location.assign` で全画面遷移するため、入力中フォームを保持する
+ * Redux `formCache` は redux-persist の blacklist(PII 保護)対象で復元されない。
+ * sessionStorage は OAuth 往復(同一タブ内の全画面ロード)を生き残ることが既存の CSRF state
+ * 管理(GitHubCallbackPage)で実証済みのため、これに合わせてドラフトを橋渡しする。
+ *
+ * ## ライフサイクル
+ * - 保存ボタン押下時(未ログイン)に `saveCareerDraft` で退避し、ログイン促進モーダルを開く。
+ * - ログイン完了後に `loadCareerDraft` で読み出し、フォームへ復元したら `clearCareerDraft` で消す。
+ * - タブを閉じれば自然に消える(PII をディスクへ残さない)。
+ */
+const CAREER_DRAFT_KEY = "career_draft";
+
+/** ドラフトを sessionStorage へ退避する。直列化に失敗しても致命ではないのでログのみ。 */
+export function saveCareerDraft(form: CareerFormState): void {
+ try {
+ sessionStorage.setItem(CAREER_DRAFT_KEY, JSON.stringify(form));
+ } catch (error) {
+ console.warn("職務経歴書ドラフトの退避に失敗しました", error);
+ }
+}
+
+/**
+ * パース済みオブジェクトが CareerFormState の最低限の形を満たすかを検証する。
+ * 旧フォーマットや破損データを復元してフォーム描画時に落ちる(experiences.map 等)のを防ぐ軽量チェック。
+ */
+function isCareerDraft(value: unknown): value is CareerFormState {
+ if (typeof value !== "object" || value === null) return false;
+ const v = value as Record;
+ return (
+ typeof v.full_name === "string" &&
+ typeof v.career_summary === "string" &&
+ typeof v.self_pr === "string" &&
+ Array.isArray(v.experiences) &&
+ Array.isArray(v.qualifications)
+ );
+}
+
+/** 退避済みドラフトを読み出す。存在しない / 壊れている / 形が不正な場合は null(破棄する)。 */
+export function loadCareerDraft(): CareerFormState | null {
+ const raw = sessionStorage.getItem(CAREER_DRAFT_KEY);
+ if (!raw) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (error) {
+ console.warn("職務経歴書ドラフトの読み出しに失敗しました", error);
+ clearCareerDraft();
+ return null;
+ }
+ // パースは成功したが想定の形でない(旧フォーマット等)場合も破棄する。
+ if (!isCareerDraft(parsed)) {
+ console.warn("職務経歴書ドラフトの形式が不正なため破棄します");
+ clearCareerDraft();
+ return null;
+ }
+ return parsed;
+}
+
+/** 退避済みドラフトを破棄する。 */
+export function clearCareerDraft(): void {
+ sessionStorage.removeItem(CAREER_DRAFT_KEY);
+}