![[管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UI](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-404-password-forgot%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット制作編 #8ログイン後404ページ + ログイン前のパスワード忘れ導線UI
管理画面に「ログイン後の404ページ」と、ログイン前にユーザが管理者へ依頼できる「パスワード忘れ導線UI」を追加
初回公開日
最終更新日
0. はじめに
これまでの管理画面シリーズでは、ログイン画面やユーザ管理、サイドバーメニュー管理など、利用者が日常的に操作する基本機能を整備してきました。今回の記事では、それらに加えて「もしもの時」に備えるUIを実装します。
本記事で取り扱うテーマ
本記事のテーマは以下の2点です。
-
ログイン後の404ページ
ログイン後にユーザが誤ったURLへアクセスした際に表示される専用の404ページを設けます。未ログイン時のアクセスはログイン画面に誘導される設計とし、ログイン済みの場合のみ404ページを表示する構成とします。 -
ログイン前のパスワード忘れ導線UI
利用者がパスワードを忘れた場合、一般的な「自己リセット」はセキュリティ上のリスクが高いため採用しません。代わりに、利用者がアカウント管理者へ「再発行依頼」を送信できるUIを用意します。管理者は依頼を受けて一時パスワードを発行し、利用者に渡す流れです。
技術スタックと前提環境
前提とする技術スタックとコードスタイルを確認します。既存の書き方を踏襲します。
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSS |
- Next.js 15(App Router)/TypeScript
- UIは shadcn/ui を用い、フォームは
react-hook-form
+zodResolver
この記事の位置づけ
本記事は、 管理画面フォーマット作成編 #7 サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで の続きとなります。
前回(#7)ではサイドバーメニュー管理UIを作成し、管理画面としての「操作対象を選ぶ」仕組みを整えました。今回の実装は一見地味ですが、実サービスを運用する上で欠かせない「例外時の体験」と「パスワードリカバリの安全設計」を担う重要な要素となります。
次の章からは、それぞれのUIをどのように構成するかを具体的に見ていきます。
1. ログイン後404ページの設計
管理画面を利用する中で、ユーザが誤ったURLを入力したり、存在しないページへブックマークからアクセスするケースは必ず発生します。こうした場合に「ただ真っ白な画面」や「ブラウザの標準エラー」を出すのではなく、サービス全体として統一感のある404ページを提供することが重要です。
ここでは「ログイン済みの場合にのみ表示される404ページ」を設計します。未ログインのユーザにはログイン画面が表示される仕組みを維持し、二重の404ページは設けません。
なぜログイン後専用の404が必要か?
-
ユーザ体験の一貫性
サイドバーやダッシュボードがある「管理画面の世界」にいる状態で404が出るとき、デザインも同じレイアウトであるべきです。 -
セキュリティ
未ログイン時に「管理画面用の404」が出てしまうと、攻撃者に「内部に管理画面が存在する」ことを示す余計な情報を与えることになります。そのため、未ログインの場合は従来通りログイン画面に誘導します。 -
メンテナンス性
/not-found
という共通のルートに統一することで、複数のページに404用の処理を書かずに済みます。
キャッチオールルートとリダイレクトの仕組み
Next.js の App Router には「キャッチオールルート」という機能があります。
これを使うと、存在しないURLにアクセスされた際に、まとめて特定のページに誘導できます。
これを使うと、存在しないURLにアクセスされた際に、まとめて特定のページに誘導できます。
txt
1(protected)
2├─ dashboard/
3├─ users/
4├─ not-found.tsx ← 専用の404ページ
5└─ […catchAll]/ ← 存在しないルートをすべて拾う
[...catchAll]
の役割とリダイレクトファイル作成
キャッチオールルート
これにより、存在しないURLにアクセスされたときでも、最終的に
[...catchAll]
を用意し、その中で 404を返すようにします。これにより、存在しないURLにアクセスされたときでも、最終的に
/not-found
ページを表示できます。src/app/(protected)/[...catchAll]/page.tsx
を下記の内容で作成します。tsx
1// src/app/(protected)/[...catchAll]/page.tsx
2import { notFound } from "next/navigation";
3
4/**
5 * (protected) 配下でマッチしない全ルートを捕捉し、HTTP 404 を返す。
6 * レンダリングされるUIは同セグメントの not-found.tsx。
7 */
8export default function ProtectedCatchAllPage() {
9 notFound(); // ← ここで 404 を返す
10}
[...catchAll]/page.tsx
は、(protected) 配下でルートに一致しない URL をすべて受け止め、notFound()
を呼びます。これにより HTTP 404 が返り、同階層の not-found.tsx
が描画されます。2. 404ページの実装(HTTP 404 + 独自UI)
要件は「HTTPは404で返す」かつ「独自デザインのUIを表示」です。
Next.js App Routerでは、
Next.js App Routerでは、
notFound()
を呼ぶと ステータス404 が返り、同じルートセグメント階層で最も近い not-found.tsx
(特別ファイル)がレンダリングされます。したがって、(
存在しないURLは
/not-found/page.tsx
を作るのではなく) (protected)/not-found.tsx
を作成し、そこに独自UIを置くのが正解です。存在しないURLは
(protected)/[...catchAll]/page.tsx
で拾い、notFound()
を呼ぶことで 404 とし、(protected)/not-found.tsx
のUI を表示します。txt
1(app)
2 └─ (protected)
3 ├─ dashboard/
4 ├─ users/
5 ├─ not-found.tsx ← ★ 特別ファイル(このUIが404時に表示される)
6 └─ [...catchAll]/
7 └─ page.tsx ← ★ 前章で作成。存在しないURLを捕捉して notFound() を呼ぶ
404ページの作成
tsx
1// src/app/(protected)/not-found.tsx
2import type { Metadata } from "next";
3import { SidebarTrigger } from "@/components/ui/sidebar";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbList,
8 BreadcrumbPage,
9} from "@/components/ui/breadcrumb";
10import { Separator } from "@/components/ui/separator";
11import { FileWarning } from "lucide-react";
12import NotFoundClient from "./_components/not-found-client";
13
14// ページメタ(任意)。404はインデックス対象外なので簡素でOK。
15export const metadata: Metadata = {
16 title: "ページが見つかりません",
17};
18
19export default function ProtectedNotFound() {
20 return (
21 <>
22 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
23 <div className="flex items-center gap-2 px-4">
24 <SidebarTrigger className="-ml-1" />
25 <Separator
26 orientation="vertical"
27 className="mr-2 data-[orientation=vertical]:h-4"
28 />
29 <Breadcrumb>
30 <BreadcrumbList>
31 <BreadcrumbItem>
32 <BreadcrumbPage>ページが見つかりません</BreadcrumbPage>
33 </BreadcrumbItem>
34 </BreadcrumbList>
35 </Breadcrumb>
36 </div>
37 </header>
38 <div className="flex h-full flex-col items-center justify-center gap-6 p-6 text-center">
39 <FileWarning className="text-muted-foreground mx-auto h-12 w-12" />
40 <div className="space-y-2">
41 <p className="text-2xl font-bold">ページが見つかりません</p>
42 <p className="text-muted-foreground">
43 URLをご確認いただくか、サイドバーから目的のページを選択してください。
44 </p>
45 </div>
46 {/* ルーター操作などクライアント挙動は子コンポーネントへ分離 */}
47 <NotFoundClient />
48 </div>
49 </>
50 );
51}
(protected)/not-found.tsx
は 特別ファイルで、同セグメント内で notFound()
が呼ばれると必ずこのUIが描画されます。ボタンクリックなど クライアント専用の挙動(
useRouter
等)はサーバーコンポーネントから直接使えないため、子のクライアントコンポーネントに分離します。404ページ用のコンポーネント作成
tsx
1// src/app/(protected)/_components/not-found-client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { Button } from "@/components/ui/button";
6
7export default function NotFoundClient() {
8 const router = useRouter();
9
10 return (
11 <div className="flex flex-wrap justify-center gap-3">
12 <Button onClick={() => router.push("/dashboard")}>
13 ダッシュボードへ戻る
14 </Button>
15 <Button variant="outline" onClick={() => router.back()}>
16 前のページに戻る
17 </Button>
18 </div>
19 );
20}
_components/not-found-client.tsx
は クライアントコンポーネントです。- 「ダッシュボードへ戻る」→
/dashboard
へ遷移 - 「前のページに戻る」→
router.back()
でブラウザ履歴に戻る
これで HTTPは404、かつ 独自UI を (protected) セグメント内で統一的に表示できます。

3. パスワード忘れフローの要件整理
ここからは「ログイン前のパスワード忘れ」をどう扱うかを整理します。
一般的なWebサービスでは「パスワードを忘れたらメールで自己リセット」という仕組みが多いですが、今回の管理画面では採用しません。
法人利用を前提としたセキュリティ設計として、「利用者は自己リセット不可」→「管理者が依頼を受けて再発行」 という流れにします。
一般的なWebサービスでは「パスワードを忘れたらメールで自己リセット」という仕組みが多いですが、今回の管理画面では採用しません。
法人利用を前提としたセキュリティ設計として、「利用者は自己リセット不可」→「管理者が依頼を受けて再発行」 という流れにします。
なぜ自己リセットを禁止するのか
-
セキュリティリスクを避けるため
攻撃者がメールアドレスやアカウントIDを推測できると、自己リセット機能は突破口になります。 -
法人向け運用を前提にしているため
アカウントID(accountId
)は企業単位で管理され、利用者は社内の一員。
パスワード再発行は社内管理者の責任で行う方が自然です。 -
監査・内部統制を強化できる
どの利用者が「パスワードを忘れた」と申告し、誰が「再発行したか」を明示できるため、コンプライアンスに強い設計になります。
フローの全体像
パスワード忘れ時の流れを、利用者と管理者に分けて整理すると次の通りです。
-
利用者側
- ログイン画面から「パスワードを忘れた方へ」リンクをクリック
accountId
・メールアドレス・任意のメモを入力して依頼送信- 成否に関わらず「依頼を受け付けました」と表示(存在確認は返さない)
-
管理者側
- 管理画面の「パスワード再発行依頼一覧」に通知が届く
- 対象ユーザを確認し、一時パスワードを発行
- 発行したパスワードを安全に通知(初回ログイン時に変更を強制することも可能)
txt
1利用者 → 依頼フォーム送信
2 ↓
3 管理者 → 一覧で確認
4 ↓
5 管理者 → 一時パス発行
6 ↓
7 利用者 → 新しいパスワードでログイン
必要なUI
-
利用者側
/password/forgot
ページに依頼フォーム- 入力項目は
accountId
・email
・備考(任意)
-
管理者側
/password-requests
ページで依頼一覧を表示- 詳細ページで「対象ユーザ選択」「一時パスワード発行」「次回変更強制」のUIを提供
これにより、利用者は依頼まで・管理者が再発行という責任分担が明確になります。
セキュリティ要件の整理
-
存在確認を返さない
→ 常に「依頼を受け付けました」と表示する。 -
一時パスワードは一度きり表示
→ 発行直後にコピー可能にするが、再表示は不可。 -
監査ログを確保
→ 誰が再発行したかをprocessedBy
として保存する。 -
次回ログイン時に変更を強制
→ 一時パスで入ったら必ず本人に変更させる。
この4点を守ることで、セキュリティとユーザ体験の両立を目指します。
4. ユーザ側の依頼フォーム実装
この節では、下部構造から順に 実装し、バリデーションは既存スキーマを再利用します。
まず「スキーマの再利用のための最小リファクタ」を行い、その後に フォーム → クライアント → ページ の順で作成します。
まず「スキーマの再利用のための最小リファクタ」を行い、その後に フォーム → クライアント → ページ の順で作成します。
共通スキーマの再利用方針(最小リファクタ)
accountId
はsrc/lib/login/schema.ts
からaccountIdSchema
を新規エクスポート。email
はsrc/lib/users/schema.ts
のemailSchema
をエクスポート化。- これにより、依頼フォーム側では バリデーション定義を重複させず に済みます。
ts
1// 修正: src/lib/login/schema.ts(変更箇所のみ抜粋)
2
3/* 途中は既存のまま */
4
5// 変更: 既存の emailSchema を export へ昇格
6export const emailSchema = z.email("メールアドレスの形式が正しくありません");
7
8/* 途中は既存のまま */
9
10// 追加: 共通で使い回すためエクスポート
11export const accountIdSchema = z
12 .string()
13 .min(15, "アカウントIDは15文字以上で入力してください。")
14 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
15 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
16 .regex(/[0-9]/, "数字を1文字以上含めてください。");
17
18export const loginSchema = z.object({
19 accountId: accountIdSchema,
20 email: z.email("有効なメールアドレスを入力してください。"),
21 password: z
22 .string()
23 .min(15, "パスワードは15文字以上で入力してください。")
24 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
25 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
26 .regex(/[0-9]/, "数字を1文字以上含めてください。"),
27});
emailSchema
を export へ昇格し、他所から再利用可能にしました。- 既存の
userCreateSchema
/userUpdateSchema
などはそのまま利用できます。 accountIdSchema
を 単独でエクスポート し、他のフォームからも同じルールを参照できるようにしました。loginSchema
は従来通りの構成で動作します。
フォーム本体(コンポーネント): src/components/login/password-forgot-form.tsx
フォームは共通化の方針 に従い
UIの構成・記述スタイルは
src/components/
配下へ。UIの構成・記述スタイルは
users/user-form.tsx
に できるだけ揃え、Zod は上記でエクスポートしたスキーマを合成して使います。tsx
1// src/components/login/password-forgot-form.tsx
2"use client";
3
4import { useForm } from "react-hook-form";
5import { zodResolver } from "@hookform/resolvers/zod";
6import { z } from "zod";
7
8import {
9 Form,
10 FormField,
11 FormItem,
12 FormLabel,
13 FormControl,
14 FormMessage,
15 FormDescription,
16} from "@/components/ui/form";
17import { Card, CardContent, CardFooter } from "@/components/ui/card";
18import { Input } from "@/components/ui/input";
19import { Button } from "@/components/ui/button";
20
21import { accountIdSchema } from "@/lib/users/schema";
22import { emailSchema } from "@/lib/users/schema";
23
24/* =========================
25 スキーマ(合成)
26 ========================= */
27export const forgotRequestSchema = z.object({
28 accountId: accountIdSchema,
29 email: emailSchema,
30 note: z.string().optional(),
31});
32export type ForgotRequestValues = z.infer<typeof forgotRequestSchema>;
33
34/* =========================
35 公開インターフェース(型)
36 ========================= */
37type Props = {
38 onSubmit: (values: ForgotRequestValues) => void | Promise<void>;
39 onCancel?: () => void;
40 loading?: boolean;
41 /** 送信済み状態(true の間は入力不可・ボタン群の代わりにラベル表示) */
42 submitted?: boolean;
43};
44
45/* =========================
46 エクスポート本体
47 ========================= */
48export default function PasswordForgotForm({
49 onSubmit,
50 onCancel,
51 loading,
52 submitted,
53}: Props) {
54 const form = useForm<ForgotRequestValues>({
55 resolver: zodResolver(forgotRequestSchema),
56 defaultValues: { accountId: "", email: "", note: "" },
57 mode: "onBlur",
58 });
59
60 const isBusy = loading || form.formState.isSubmitting || Boolean(submitted);
61
62 return (
63 <Form {...form}>
64 <form
65 data-testid="password-forgot-form"
66 onSubmit={form.handleSubmit(onSubmit)}
67 >
68 <Card className="w-full rounded-md">
69 <CardContent className="space-y-6 pt-1">
70 <AccountIdField disabled={isBusy} />
71 <EmailField disabled={isBusy} />
72 <NoteField disabled={isBusy} />
73 <FormDescription>
74 送信後、管理者より新規のパスワードが発行されます。メールで再発行通知があるまでしばらくお待ち下さい。
75 </FormDescription>
76 </CardContent>
77
78 <CardFooter className="mt-4 flex items-center gap-2">
79 <Button
80 type="button"
81 variant="outline"
82 onClick={onCancel}
83 data-testid="cancel-btn"
84 className="cursor-pointer"
85 >
86 キャンセル
87 </Button>
88
89 {submitted ? (
90 // ラベル表示(ボタンの代替)
91 <span
92 role="status"
93 aria-live="polite"
94 className="bg-muted text-foreground inline-flex items-center rounded-md px-2 py-1 font-medium"
95 data-testid="submitted-label"
96 >
97 再発行依頼完了
98 </span>
99 ) : (
100 <Button
101 type="submit"
102 data-testid="submit-forgot"
103 className="cursor-pointer"
104 disabled={isBusy}
105 >
106 {isBusy ? "送信中..." : "依頼を送信"}
107 </Button>
108 )}
109 </CardFooter>
110 </Card>
111 </form>
112 </Form>
113 );
114}
115
116/* =========================
117 小さなフィールド群
118 ========================= */
119function AccountIdField({ disabled }: { disabled: boolean }) {
120 return (
121 <FormField
122 name="accountId"
123 render={({ field }) => (
124 <FormItem>
125 <FormLabel className="font-semibold">アカウントID *</FormLabel>
126 <FormControl>
127 <Input
128 {...field}
129 disabled={disabled}
130 inputMode="text"
131 placeholder="例: COMPANY012345678"
132 autoComplete="username"
133 aria-label="アカウントID"
134 data-testid="accountId"
135 />
136 </FormControl>
137 <FormMessage data-testid="accountId-error" />
138 </FormItem>
139 )}
140 />
141 );
142}
143
144function EmailField({ disabled }: { disabled: boolean }) {
145 return (
146 <FormField
147 name="email"
148 render={({ field }) => (
149 <FormItem>
150 <FormLabel className="font-semibold">メールアドレス *</FormLabel>
151 <FormControl>
152 <Input
153 {...field}
154 disabled={disabled}
155 type="email"
156 placeholder="you@example.com"
157 autoComplete="email"
158 aria-label="メールアドレス"
159 data-testid="email"
160 />
161 </FormControl>
162 <FormMessage data-testid="email-error" />
163 </FormItem>
164 )}
165 />
166 );
167}
168
169function NoteField({ disabled }: { disabled: boolean }) {
170 return (
171 <FormField
172 name="note"
173 render={({ field }) => (
174 <FormItem>
175 <FormLabel className="font-semibold">備考(任意)</FormLabel>
176 <FormControl>
177 <Input
178 {...field}
179 disabled={disabled}
180 placeholder="依頼の背景や担当者名など"
181 inputMode="text"
182 autoComplete="off"
183 aria-label="備考"
184 data-testid="note"
185 />
186 </FormControl>
187 <FormMessage data-testid="note-error" />
188 </FormItem>
189 )}
190 />
191 );
192}
- 依頼フォーム側では バリデーション定義を一切複製せず、
accountIdSchema
とemailSchema
を合成しています。 - UI構成・命名・
data-testid
付与などはusers/user-form.tsx
に寄せ、プロジェクト内の統一感を保っています。 onCancel
をオプションにしておくと、呼び出し側(client.tsx
)でhistory.back()
など柔軟に扱えます。submitted
を 親から受け取る制御プロップ とし、入力欄のdisabled
とフッターの表示切替を一括制御します。- ボタンは 依頼済みラベル に置換。
aria-live="polite"
を付与し、支援技術にも分かりやすくしています。 isBusy
でloading
/submitting
/submitted
を合算して無効化を一本化しました。
クライアント(送信ハンドラ): src/app/(public)/password-forgot/client.tsx
UIのみ のため、バリデーション通過後は OK としてトースト表示します。
将来は Server Action に差し替える想定で、送信関数の入口を1か所に集約します。
将来は Server Action に差し替える想定で、送信関数の入口を1か所に集約します。
tsx
1// src/app/(public)/password-forgot/client.tsx
2"use client";
3
4import { useState } from "react";
5import { toast } from "sonner";
6import PasswordForgotForm from "@/components/login/password-forgot-form";
7
8export default function PasswordForgotClient() {
9 const [loading, setLoading] = useState(false);
10 const [submitted, setSubmitted] = useState(false);
11
12 const handleSubmit = async () => {
13 if (submitted) return; // 二重送信ガード(念のため)
14 setLoading(true);
15 try {
16 // 将来: Server Action に置換
17 await new Promise((r) => setTimeout(r, 700));
18 setSubmitted(true);
19 toast.success("依頼を受け付けました。登録メールをご確認ください。");
20 } catch {
21 // エラー時も秘匿(受付メッセージは同一)
22 setSubmitted(true);
23 toast.success("依頼を受け付けました。登録メールをご確認ください。");
24 } finally {
25 setLoading(false);
26 }
27 };
28
29 return (
30 <PasswordForgotForm
31 onSubmit={handleSubmit}
32 loading={loading}
33 submitted={submitted}
34 onCancel={() => history.back()}
35 />
36 );
37}
- 例外時でも 同じメッセージ を返し、アカウント/メールの有無を 推測されない ようにします。
onCancel
は単純にhistory.back()
。
ページ骨格: src/app/(public)/password-forgot/page.tsx
次にページ枠を用意して、中央配置+見出しを整えます。
tsx
1// src/app/(public)/password-forgot/page.tsx
2import type { Metadata } from "next";
3import Client from "./client";
4import Image from "next/image";
5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
7export const metadata: Metadata = {
8 title: "パスワード再発行依頼",
9};
10
11export default function PasswordForgotPage() {
12 return (
13 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800">
14 <Card className="w-full max-w-md">
15 <CardHeader>
16 <CardTitle className="flex flex-col justify-center gap-4">
17 {/* light用ロゴ(=ダークモード時に非表示) */}
18 <Image
19 src="/logo.svg"
20 alt="サイトロゴ"
21 width={160}
22 height={40}
23 priority
24 className="h-[40px] w-[160px] dark:hidden"
25 />
26
27 {/* dark用ロゴ(=ダークモード時に表示) */}
28 <Image
29 src="/logo-d.svg"
30 alt="サイトロゴ(ダーク)"
31 width={160}
32 height={40}
33 priority
34 className="hidden h-[40px] w-[160px] dark:block"
35 />
36
37 <h1 className="text-2xl font-bold">パスワード再発行の依頼</h1>
38 <p className="text-muted-foreground mt-1 text-left">
39 アカウントIDとメールアドレスを入力して、管理者に再発行を依頼します。
40 </p>
41 </CardTitle>
42 </CardHeader>
43 <CardContent>
44 <Client />
45 </CardContent>
46 </Card>
47 </main>
48 );
49}
ページ自体は薄く保ち、ロジックは
この分離により、Server Action 置換点は
client.tsx
、UI/バリデーションは components/
へ寄せました。この分離により、Server Action 置換点は
client.tsx
のみ に集約され、保守性が上がります。src/app/(public)配下の共通レイアウト
次に、
src/app/(public)
配下でも共通でトースト通知を有効にするために、src/app/(public)/layout.tsx
を作成します。tsx
1// src/app/(public)/layout.tsx
2import type { Metadata } from "next";
3import { Toaster } from "@/components/ui/sonner";
4
5export const metadata: Metadata = {
6 title: "管理画面 | DELOGs",
7 description: "ログイン前の共通レイアウト",
8};
9
10export default function PubulicLayout({
11 children,
12}: {
13 children: React.ReactNode;
14}) {
15 return (
16 <>
17 {children}
18 <Toaster richColors closeButton />
19 </>
20 );
21}
本当にトースト通知を有効にするだけの共通レイアウトです。
ログイン画面にパスワード忘れの導線設置
最後に
src/app/page.tsx
に導線を設置します。tsx
1// src/app/page.tsx
2import Image from "next/image";
3import Link from "next/link"; //追加
4import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5import LoginForm from "@/components/login/login-form"; // ログインフォームコンポーネント
6import { HandHelping } from "lucide-react"; //追加
7
8export default function Page() {
9 return (
10 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800">
11 <Card className="w-full max-w-md">
12 <CardHeader>
13 <CardTitle className="flex justify-center">
14 {/* light用ロゴ(=ダークモード時に非表示) */}
15 <Image
16 src="/logo.svg"
17 alt="サイトロゴ"
18 width={160}
19 height={40}
20 priority
21 className="h-[40px] w-[160px] dark:hidden"
22 />
23
24 {/* dark用ロゴ(=ダークモード時に表示) */}
25 <Image
26 src="/logo-d.svg"
27 alt="サイトロゴ(ダーク)"
28 width={160}
29 height={40}
30 priority
31 className="hidden h-[40px] w-[160px] dark:block"
32 />
33 </CardTitle>
34 </CardHeader>
35 <CardContent>
36 <LoginForm />
37 {/* 追加: パスワード忘れの導線 */}
38 <Link
39 href="/password-forgot"
40 className="my-2 ml-auto flex items-center justify-end gap-2 text-sm"
41 >
42 パスワードをお忘れの方
43 <HandHelping />
44 </Link>
45 </CardContent>
46 </Card>
47 </main>
48 );
49}
next/link
とルシードアイコンを追加/password-forgot
への導線を設置
▼ ログイン画面

▼ パスワード再発行依頼画面

以上で、パスワード忘れ導線のUIが完成しました。次章では、管理者側のUIを作成していきます。
5. 管理者側の依頼一覧と再発行UI
この章では、一覧テーブルだけで「再発行」まで完結する管理者UIを実装します。
実装順は下層から上層へ(依存エラーを避けるため)
実装順は下層から上層へ(依存エラーを避けるため)
mock → columns.tsx → data-table.tsx → page.tsx
の順で進めます。検索・フィルタは、既存の「ユーザ一覧(
具体的には、キーワード検索(
/users
)」と同じ体験に揃えます。具体的には、キーワード検索(
accountId / email / userName / note
)と、ロール絞り込み、状態絞り込み(未処理/再発行済み) を提供します。補足:日付の表示整形には 日付処理ライブラリ
date-fns
を使います(後述のコードで format
/ ja
を import)。モック + 型定義(src/lib/users/password-request.mock.ts
)
一覧で使う データ型・モック・ユーティリティ を1ファイルに集約します。
また、UIから呼び出す
mockUsers
を参照して ユーザ名/ロール を解決し、見つからない場合は -
を表示にします。また、UIから呼び出す
listPasswordRequests
/ markIssued
を用意し、将来は Server Action に置き換えます。ts
1// src/lib/users/password-request.mock.ts
2
3import { z } from "zod";
4import { mockUsers, CURRENT_ACCOUNT_CODE } from "@/lib/users/mock";
5import type { RoleCode } from "@/lib/users/schema";
6
7/* =========================
8 型定義
9 ========================= */
10export const PasswordRequestStatus = z.enum(["PENDING", "ISSUED", "REJECTED"]);
11export type PasswordRequestStatus = z.infer<typeof PasswordRequestStatus>;
12
13export type PasswordRequest = {
14 id: string;
15 accountId: string;
16 email: string;
17 note?: string;
18 status: PasswordRequestStatus;
19 requestedAt: Date;
20 processedAt?: Date;
21 processedBy?: string;
22
23 userName: string;
24 userRole: RoleCode | "-";
25};
26
27function resolveUser(accountId: string, email: string) {
28 const u = mockUsers.find(
29 (x) =>
30 x.accountCode === accountId &&
31 x.email.toLowerCase() === email.toLowerCase(),
32 );
33 return u
34 ? { userName: u.name, userRole: u.roleCode as RoleCode }
35 : { userName: "-", userRole: "-" as const };
36}
37
38/* モックデータ */
39export const passwordRequests: PasswordRequest[] = [
40 {
41 id: "PR000001",
42 accountId: CURRENT_ACCOUNT_CODE,
43 email: "admin@example.com",
44 note: "本人からの電話申請",
45 status: "PENDING",
46 requestedAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
47 ...resolveUser(CURRENT_ACCOUNT_CODE, "admin@example.com"),
48 },
49 {
50 id: "PR000002",
51 accountId: CURRENT_ACCOUNT_CODE,
52 email: "editor@example.com",
53 note: "",
54 status: "ISSUED",
55 requestedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2),
56 processedAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
57 processedBy: "Admin User",
58 ...resolveUser(CURRENT_ACCOUNT_CODE, "editor@example.com"),
59 },
60];
61
62export function listPasswordRequests(accountId: string): PasswordRequest[] {
63 return passwordRequests.filter((r) => r.accountId === accountId);
64}
65
66export function markIssued(
67 id: string,
68 processedBy: string,
69): PasswordRequest | undefined {
70 const r = passwordRequests.find((x) => x.id === id);
71 if (!r) return undefined;
72 if (r.status !== "PENDING") return r;
73 r.status = "ISSUED";
74 r.processedAt = new Date();
75 r.processedBy = processedBy;
76 return r;
77}
78
79export function markRejected(
80 id: string,
81 processedBy: string,
82): PasswordRequest | undefined {
83 const r = passwordRequests.find((x) => x.id === id);
84 if (!r) return undefined;
85 if (r.status !== "PENDING") return r;
86 r.status = "REJECTED";
87 r.processedAt = new Date();
88 r.processedBy = processedBy;
89 return r;
90}
PasswordRequest
にuserName
/userRole
を含め、テーブル側での join を避けて描画を簡潔にしました。- 照合できない場合でも表示が崩れないよう
"-"
を返します。 - UI専用API(
listPasswordRequests
/markIssued
/markRejected
)は、将来 Server Action に置き換えるだけで移行できます。
一覧用のデータテーブルの型拡張
前回記事 管理画面フォーマット作成編 #7 サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで でもやったように、今回も
src/types/table-meta.d.ts
で型拡張を行います。今回は依頼への対応実行するハンドラを追加します。ts
1// src/types/table-meta.d.ts
2import "@tanstack/table-core";
3
4declare module "@tanstack/table-core" {
5 interface TableMeta<TData extends RowData> {
6 onMoveUp?: (id: string, _row?: TData) => void;
7 onMoveDown?: (id: string, _row?: TData) => void;
8
9 /** 依頼を「再発行済み」にする */
10 onIssue?: (id: string, _row?: TData) => void | Promise<void>;
11 /** 依頼を「拒否」にする */
12 onReject?: (id: string, _row?: TData) => void | Promise<void>;
13 }
14}
15
16export {};
onIssue
とonReject
を追加しました。日付処理ライブラリ date-fns
を導入
今回まだ
Date Picker
は利用しませんが、列定義の中で日付処理を行いますので、事前にdate-fns
を導入します。zsh
1npm install date-fns
列定義(src/app/(protected)/users/password-request/columns.tsx
)
「ユーザ一覧」と同じトーン&マナーで列定義を行います。
ロールは
重要ポイント:
行アクションのクリックは TanStack Table の
(これにより、Server から Client 関数を直接実行しない ので RSC 境界エラーを回避できます)
ロールは
getRoles()
を使って 表示名/色 を取得し、Badge
で強調します。重要ポイント:
columns
は 「関数ではなく定数」 としてエクスポートし、行アクションのクリックは TanStack Table の
table.options.meta.onIssue
経由で呼び出します。(これにより、Server から Client 関数を直接実行しない ので RSC 境界エラーを回避できます)
tsx
1// src/app/(protected)/users/password-request/columns.tsx
2"use client";
3
4import type { ColumnDef } from "@tanstack/react-table";
5import { format } from "date-fns";
6import { ja } from "date-fns/locale";
7import { Badge } from "@/components/ui/badge";
8import { Button } from "@/components/ui/button";
9import type { PasswordRequest } from "@/lib/users/password-request.mock";
10import { getRoles } from "@/lib/roles/mock";
11
12function fmt(d?: Date) {
13 if (!d) return "-";
14 return format(d, "yyyy/MM/dd HH:mm", { locale: ja });
15}
16
17const roleInfoMap: Record<string, { label: string; color: string }> =
18 Object.fromEntries(
19 getRoles().map((r) => [
20 r.code,
21 { label: r.displayName, color: r.badgeColor },
22 ]),
23 );
24
25export const columns: ColumnDef<PasswordRequest>[] = [
26 {
27 accessorKey: "requestedAt",
28 header: "依頼日時",
29 cell: ({ row }) => fmt(row.original.requestedAt),
30 },
31 { accessorKey: "accountId", header: "アカウントID" },
32 { accessorKey: "email", header: "メール" },
33 { accessorKey: "userName", header: "ユーザ名" },
34 {
35 accessorKey: "userRole",
36 header: "ユーザロール",
37 cell: ({ row }) => {
38 const role = row.original.userRole;
39 if (role === "-") return "-";
40 const info = roleInfoMap[role];
41 const label = info?.label ?? role;
42 const style = info
43 ? { backgroundColor: info.color, color: "#fff", border: "none" }
44 : undefined;
45 return (
46 <Badge variant="secondary" style={style} title={role}>
47 {label}
48 </Badge>
49 );
50 },
51 },
52 {
53 accessorKey: "status",
54 header: "状態",
55 cell: ({ row }) => {
56 const s = row.original.status;
57 if (s === "ISSUED") return <Badge>再発行済み</Badge>;
58 if (s === "REJECTED") return <Badge variant="destructive">拒否</Badge>;
59 return <Badge variant="outline">未処理</Badge>;
60 },
61 },
62 {
63 accessorKey: "processedAt",
64 header: "処理日時",
65 cell: ({ row }) => fmt(row.original.processedAt),
66 },
67 { accessorKey: "processedBy", header: "処理者" },
68
69 {
70 id: "actions",
71 header: "操作",
72 enableSorting: false,
73 enableResizing: false,
74 cell: ({ row, table }) => {
75 const r = row.original;
76 const disabled = r.status !== "PENDING"; // 処理済みは操作不可
77 return (
78 <div className="flex gap-2">
79 <Button
80 size="sm"
81 disabled={disabled}
82 onClick={() => table.options.meta?.onIssue?.(r.id, r)}
83 data-testid={`issue-btn-${r.id}`}
84 className="cursor-pointer"
85 >
86 再発行
87 </Button>
88 <Button
89 size="sm"
90 variant="outline"
91 disabled={disabled}
92 onClick={() => table.options.meta?.onReject?.(r.id, r)}
93 data-testid={`reject-btn-${r.id}`}
94 className="cursor-pointer"
95 >
96 拒否
97 </Button>
98 </div>
99 );
100 },
101 },
102
103 // 検索用 hidden 列
104 {
105 id: "q",
106 accessorFn: (row) =>
107 `${row.accountId} ${row.email} ${row.userName} ${row.note ?? ""}`.toLowerCase(),
108 enableHiding: true,
109 enableSorting: false,
110 header: () => null,
111 cell: () => null,
112 },
113];
- 関数ではなく定数
columns
をエクスポートすることで、Server 側から「実行」されず、RSC 境界の問題を避けられます。 - 行アクションは
table.options.meta.onIssue
に委譲し、クリック時のみ クライアント関数を呼びます。 - 日付整形は
date-fns
を使用(yyyy/MM/dd HH:mm
)。ロケールはja
を指定しています。 再発行
か拒否
を実行できるようにしています。
テーブル本体(src/app/(protected)/users/password-request/data-table.tsx
)
ユーザ一覧の
キーワード検索/ロール絞り込み/状態絞り込み と ページング を提供します。
ここで
また、行の状態を即時反映するため、一覧データは ローカル状態に保持 して更新します。
data-table.tsx
を踏襲し、キーワード検索/ロール絞り込み/状態絞り込み と ページング を提供します。
ここで
meta.onIssue
を定義し、列定義(actionsセル)から呼べるようにします。また、行の状態を即時反映するため、一覧データは ローカル状態に保持 して更新します。
tsx
1// src/app/(protected)/users/password-request/data-table.tsx
2"use client";
3
4import * as React from "react";
5import type { ColumnDef, SortingState } from "@tanstack/react-table";
6import {
7 flexRender,
8 getCoreRowModel,
9 getPaginationRowModel,
10 getSortedRowModel,
11 useReactTable,
12} from "@tanstack/react-table";
13import { Input } from "@/components/ui/input";
14import {
15 Select,
16 SelectContent,
17 SelectItem,
18 SelectTrigger,
19 SelectValue,
20} from "@/components/ui/select";
21import {
22 Table,
23 TableBody,
24 TableCell,
25 TableHead,
26 TableHeader,
27 TableRow,
28} from "@/components/ui/table";
29import { Button } from "@/components/ui/button";
30import { toast } from "sonner";
31
32import type { PasswordRequest } from "@/lib/users/password-request.mock";
33import { markIssued, markRejected } from "@/lib/users/password-request.mock";
34import type { RoleOption } from "@/lib/users/mock";
35
36type ReqStatusFilter = "ALL" | "PENDING" | "ISSUED" | "REJECTED";
37type RoleFilter = "ALL" | string;
38
39type Props<TData> = {
40 columns: ColumnDef<TData, unknown>[];
41 data: TData[];
42 roleOptions: RoleOption[];
43};
44
45export default function DataTable<TData extends PasswordRequest>({
46 columns,
47 data,
48 roleOptions,
49}: Props<TData>) {
50 const [tableData, setTableData] = React.useState<PasswordRequest[]>(
51 () => data as PasswordRequest[],
52 );
53
54 const [q, setQ] = React.useState("");
55 const [role, setRole] = React.useState<RoleFilter>("ALL");
56 const [status, setStatus] = React.useState<ReqStatusFilter>("ALL");
57 const [sorting, setSorting] = React.useState<SortingState>([
58 { id: "requestedAt", desc: true },
59 ]);
60
61 const filteredData = React.useMemo(() => {
62 const needle = q.trim().toLowerCase();
63 return tableData.filter((r) => {
64 const passQ =
65 !needle ||
66 `${r.accountId} ${r.email} ${r.userName} ${r.note ?? ""}`
67 .toLowerCase()
68 .includes(needle);
69 const passRole = role === "ALL" || r.userRole === role;
70 const passStatus = status === "ALL" || r.status === status;
71 return passQ && passRole && passStatus;
72 }) as unknown as TData[];
73 }, [tableData, q, role, status]);
74
75 const onIssue = React.useCallback(
76 async (id: string) => {
77 // 将来: Server Action に置換
78 markIssued(id, "Admin User");
79 setTableData((prev) =>
80 prev.map((r) =>
81 r.id === id
82 ? {
83 ...r,
84 status: "ISSUED",
85 processedAt: new Date(),
86 processedBy: "Admin User",
87 }
88 : r,
89 ),
90 );
91 toast.success("再発行を受け付けました。", { duration: 2400 });
92 },
93 [setTableData],
94 );
95
96 const onReject = React.useCallback(
97 async (id: string) => {
98 // 将来: Server Action に置換(理由入力などは別UIで拡張可能)
99 markRejected(id, "Admin User");
100 setTableData((prev) =>
101 prev.map((r) =>
102 r.id === id
103 ? {
104 ...r,
105 status: "REJECTED",
106 processedAt: new Date(),
107 processedBy: "Admin User",
108 }
109 : r,
110 ),
111 );
112 toast.message("依頼を拒否しました。", { duration: 2400 });
113 },
114 [setTableData],
115 );
116
117 const table = useReactTable({
118 data: filteredData,
119 columns,
120 state: { sorting },
121 onSortingChange: setSorting,
122 getCoreRowModel: getCoreRowModel(),
123 getSortedRowModel: getSortedRowModel(),
124 getPaginationRowModel: getPaginationRowModel(),
125 initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
126 meta: { onIssue, onReject }, // ← 追加
127 });
128
129 const filteredCount = filteredData.length;
130
131 return (
132 <div className="space-y-3">
133 {/* 検索/フィルタ */}
134 <div className="flex flex-wrap items-center gap-3">
135 <Input
136 name="filter-q"
137 data-testid="filter-q"
138 value={q}
139 onChange={(e) => setQ(e.target.value)}
140 placeholder="アカウントID・メール・氏名・備考で検索"
141 className="w-[220px] basis-full text-sm md:basis-auto"
142 aria-label="検索キーワード"
143 />
144
145 <Select
146 value={role}
147 onValueChange={(v) => setRole(v as RoleFilter)}
148 name="filter-role"
149 >
150 <SelectTrigger className="w-auto" data-testid="filter-role">
151 <SelectValue placeholder="ロール" />
152 </SelectTrigger>
153 <SelectContent className="text-xs">
154 <SelectItem value="ALL">
155 <span className="text-muted-foreground">すべてのロール</span>
156 </SelectItem>
157 {roleOptions.map((o) => (
158 <SelectItem key={o.value} value={o.value}>
159 {o.label}
160 </SelectItem>
161 ))}
162 </SelectContent>
163 </Select>
164
165 <Select
166 value={status}
167 onValueChange={(v) => setStatus(v as ReqStatusFilter)}
168 name="filter-status"
169 >
170 <SelectTrigger className="w-auto" data-testid="filter-status">
171 <SelectValue placeholder="状態" />
172 </SelectTrigger>
173 <SelectContent>
174 <SelectItem value="ALL">
175 <span className="text-muted-foreground">すべての状態</span>
176 </SelectItem>
177 <SelectItem value="PENDING">未処理のみ</SelectItem>
178 <SelectItem value="ISSUED">再発行済みのみ</SelectItem>
179 <SelectItem value="REJECTED">拒否のみ</SelectItem>
180 </SelectContent>
181 </Select>
182 </div>
183
184 <div className="flex items-center justify-between">
185 <div className="text-sm" data-testid="count">
186 表示件数: {filteredCount} 件
187 </div>
188 </div>
189
190 {/* テーブル */}
191 <div className="overflow-x-auto rounded-md border pb-1">
192 <Table data-testid="requests-table" className="w-full">
193 <TableHeader className="bg-muted/50 text-xs">
194 {table.getHeaderGroups().map((hg) => (
195 <TableRow key={hg.id}>
196 {hg.headers.map((header) => (
197 <TableHead
198 key={header.id}
199 style={{ width: header.column.getSize() }}
200 >
201 {header.isPlaceholder
202 ? null
203 : flexRender(
204 header.column.columnDef.header,
205 header.getContext(),
206 )}
207 </TableHead>
208 ))}
209 </TableRow>
210 ))}
211 </TableHeader>
212 <TableBody>
213 {table.getRowModel().rows.length ? (
214 table.getRowModel().rows.map((row) => (
215 <TableRow
216 key={row.id}
217 data-testid={`row-${(row.original as PasswordRequest).id}`}
218 >
219 {row.getVisibleCells().map((cell) => (
220 <TableCell
221 key={cell.id}
222 style={{ width: cell.column.getSize() }}
223 >
224 {flexRender(
225 cell.column.columnDef.cell,
226 cell.getContext(),
227 )}
228 </TableCell>
229 ))}
230 </TableRow>
231 ))
232 ) : (
233 <TableRow>
234 <TableCell
235 colSpan={columns.length}
236 className="text-muted-foreground py-10 text-center text-sm"
237 >
238 条件に一致する依頼が見つかりませんでした。
239 </TableCell>
240 </TableRow>
241 )}
242 </TableBody>
243 </Table>
244 </div>
245
246 {/* ページング */}
247 <div className="flex items-center justify-end gap-2">
248 <span className="text-muted-foreground text-sm">
249 Page {table.getState().pagination.pageIndex + 1} /{" "}
250 {table.getPageCount() || 1}
251 </span>
252 <Button
253 variant="outline"
254 size="sm"
255 onClick={() => table.previousPage()}
256 disabled={!table.getCanPreviousPage()}
257 data-testid="page-prev"
258 className="cursor-pointer"
259 >
260 前へ
261 </Button>
262 <Button
263 variant="outline"
264 size="sm"
265 onClick={() => table.nextPage()}
266 disabled={!table.getCanNextPage()}
267 data-testid="page-next"
268 className="cursor-pointer"
269 >
270 次へ
271 </Button>
272 </div>
273 </div>
274 );
275}
meta
にonIssue
とonReject
を渡し、列内ボタンから呼べるようにしました。- ハンドラは 即時にローカル状態を更新し、将来は Server Action へ置換可能です。
- ページングや検索UIは、ユーザ一覧の書き方をそのまま踏襲しています。
ページ骨格(src/app/(protected)/users/password-request/page.tsx
)
page.tsx
は Server Component として、データ取得とレイアウトだけを担当します。「ユーザ一覧」と同様に、
columns
定数と DataTable
クライアントコンポーネントをそのまま渡す形にします。tsx
1// src/app/(protected)/users/password-request/page.tsx
2import type { Metadata } from "next";
3import { SidebarTrigger } from "@/components/ui/sidebar";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbLink,
8 BreadcrumbList,
9 BreadcrumbPage,
10 BreadcrumbSeparator,
11} from "@/components/ui/breadcrumb";
12import { Separator } from "@/components/ui/separator";
13
14import DataTable from "./data-table";
15import { columns } from "./columns";
16
17import { CURRENT_ACCOUNT_CODE, mockRoleOptions } from "@/lib/users/mock";
18import { listPasswordRequests } from "@/lib/users/password-request.mock";
19
20export const metadata: Metadata = {
21 title: "パスワード再発行依頼 | 管理画面レイアウト【DELOGs】",
22 description:
23 "Data table(shadcn/ui + @tanstack/react-table)でパスワード再発行依頼を一覧表示",
24};
25
26export default function Page() {
27 // UIのみ:ログイン中アカウント配下の依頼をモックから取得
28 const accountCode = CURRENT_ACCOUNT_CODE;
29 const rows = listPasswordRequests(accountCode);
30
31 return (
32 <>
33 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
34 <div className="flex items-center gap-2 px-4">
35 <SidebarTrigger className="-ml-1" />
36 <Separator
37 orientation="vertical"
38 className="mr-2 data-[orientation=vertical]:h-4"
39 />
40 <Breadcrumb>
41 <BreadcrumbList>
42 <BreadcrumbItem className="hidden md:block">
43 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
44 </BreadcrumbItem>
45 <BreadcrumbSeparator className="hidden md:block" />
46 <BreadcrumbItem>
47 <BreadcrumbPage>パスワード再発行依頼</BreadcrumbPage>
48 </BreadcrumbItem>
49 </BreadcrumbList>
50 </Breadcrumb>
51 </div>
52 </header>
53
54 <div className="max-w-full p-4 pt-0">
55 <DataTable
56 columns={columns}
57 data={rows}
58 roleOptions={mockRoleOptions}
59 />
60 </div>
61 </>
62 );
63}
- Server → Client 関数の直接呼び出しを行わない 構成です。
columns
は 定数のため、Server 側から渡しても問題ありません。- 行アクションは
data-table.tsx
内のmeta.onIssue
によって クライアント側で完結します。
メニューへ追加
前回記事で作成した
src/lib/sidebar/menu.mock.ts
に今回の「パスワード再発行」メニューを追記します。ts
1// src/lib/sidebar/menu.mock.ts(抜粋)
2 {
3 displayId: "M00000016",
4 parentId: "M00000011",
5 order: 1,
6 title: "新規登録",
7 href: "/users/new",
8 iconName: undefined,
9 match: "exact",
10 pattern: undefined,
11 minPriority: undefined,
12 isSection: false,
13 isActive: true,
14 },
15 // 追加
16 {
17 displayId: "M00000017",
18 parentId: "M00000011",
19 order: 1,
20 title: "パスワード再発行",
21 href: "/users/password-request",
22 iconName: undefined,
23 match: "exact",
24 pattern: undefined,
25 minPriority: undefined,
26 isSection: false,
27 isActive: true,
28 },
29
INITIAL_MENU_RECORDS
配列に上記の追記するだけで、サイドメニューの「ユーザ管理」に「パスワード再発行」が追加されて導線が設置されます。以上で、本記事の目標は達成です。
▼ パスワード再発行画面

6. まとめと次回予告
本記事の振り返り
今回のゴールは「HTTPは正しく404を返しつつ、UIは独自デザイン」「パスワード忘れの自己リセットを避け、管理者フローに統一」の2点でした。App Router の
notFound()
とセグメント専用の not-found.tsx
を組み合わせ、(protected) 内だけで統一の 404 体験を提供。あわせて、ログイン前の「再発行依頼フォーム」と、管理者向けの「依頼一覧+ワンクリック再発行/拒否」UIまでを、既存の設計・記法(page.tsx
/ client.tsx
/ data-table.tsx
/ columns.tsx
)に沿って実装しました。次回予告
「管理画面フォーマット制作編」の総仕上げとして、これまで作ってきた 共通レイアウト/ナビゲーション/フォーム基盤/一覧テーブル構成 を総括し、デモ を公開する予定です。
参考文献
- Next.js Documentation - notFound
- shadcn/ui Documentation - Table
- @tanstack/react-table Documentation
- date-fns Documentation
- TypeScript Handbook - Declaration Merging
Githubリポジトリ
この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法
これまで進めてきたログイン画面、ユーザー管理、ロール管理、サイドバー管理などをまとめ、「UIのみ版」デモを公開
2025/9/4公開
![[管理画面フォーマット制作編 #9] Shadcn/ui で作る管理画面フォーマット ─ デモ公開とカスタマイズ方法のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fdashboard-format-ui-demo%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御まで
サイドバーに表示するメニューをUIから登録・編集・削除できる管理画面を作成
2025/8/29公開
![[管理画面フォーマット制作編 #7] サイドバーメニュー管理UI ─ 3層・並び順・priority可視制御までのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-menu-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)
ロールテーブルを管理画面から操作するためのUIを、Next.js 15 + shadcn/ui + React Hook Form + Zodで実装
2025/8/26公開
![[管理画面フォーマット制作編 #6] マスタ管理-ロール管理(UIのみ)のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-role-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更
管理画面に「プロフィール」ページを追加し、ユーザ自身が情報やパスワードを更新できるUIを作成
2025/8/22公開
![[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-profile-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期
Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト
2025/8/19公開
![[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fsidebar-active-sync%2Fhero-thumbnail.jpg&w=1200&q=75)