![[管理画面フォーマット開発編 #11] パスワード再発行依頼とメールテンプレート統合](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-password-request%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #11パスワード再発行依頼とメールテンプレート統合
管理画面で受け付けたパスワード再発行依頼を、Server Action・Shadcn/uiのデータテーブル・メール送信を組み合わせて運用可能なワークフローに統合
初回公開日
最終更新日
0. はじめに
本記事では、「パスワードを忘れた」ユーザーからの再発行依頼を管理画面で受け付け、処理・通知まで完結させる仕組み を構築します。
【管理画面フォーマット制作編 #8】 ログイン後404ページ + ログイン前のパスワード忘れ導線UI で作成したUIのDB連携でもあります。
同時に、これまで個別ファイルに分散していたメール本文・件名の定義を
src/lib/email/templates.ts
に一元化 し、運用時の変更・再利用を容易にします。txt
1[Before]
2フォーム送信 → 管理者宛メール(個別コード)
3→ 管理者が手動で再発行・返信
4
5[After]
6フォーム送信 → PasswordRequest に記録
7→ 管理者が一覧画面から「再発行/拒否」
8→ 自動でパスワード再生成+本人へ通知
9→ 管理者メール・本人メールも templates.ts に統合
上の図は、従来のメール連携を「DBとServer Actionを軸とした運用フロー」へ置き換えるイメージです。
フォーム送信時はユーザーの存在を秘匿したまま依頼を受付け、管理画面では部署単位のADMIN権限が安全に処理できる構成とします。
フォーム送信時はユーザーの存在を秘匿したまま依頼を受付け、管理画面では部署単位のADMIN権限が安全に処理できる構成とします。
本記事で到達するゴール
- PasswordRequest テーブルを新設し、依頼の受付・処理履歴をDBで一元管理
- フロント側から部署コード+メール+備考を送信し、部署ADMINへ自動通知
- 管理画面
/users/password-request
にて一覧・フィルタ・CSV出力を実装 - 「再発行」「拒否」操作をServer Actionで実装し、argon2による安全なパスワード再生成
- すべてのメール送信処理を
email/templates.ts
に集約し、件名・本文を共通フォーマット化
読み進める前に
本記事は「管理画面フォーマット開発編」の第11回にあたります。
これまでの #8〜#10 でDB連携・部署別ロール・メニュー制御が完了しており、本章ではその基盤上で ユーザー運用に不可欠な「パスワード再発行ワークフロー」 を追加していきます。
これまでの #8〜#10 でDB連携・部署別ロール・メニュー制御が完了しており、本章ではその基盤上で ユーザー運用に不可欠な「パスワード再発行ワークフロー」 を追加していきます。
主な変更ファイルと目的
区分 | ファイル / モジュール | 目的 |
---|---|---|
DB定義 | schema.prisma | PasswordRequest モデル新設・Enum追加 |
公開フォーム | auth/password-forgot.ts / client.tsx | 依頼受付と管理者メール通知 |
管理UI | /users/password-request/* | 一覧・フィルタ・再発行/拒否ボタン |
Server Actions | users/password-requests.ts | 再発行・拒否処理 + 通知メール |
メールテンプレート | email/templates.ts | 件名+本文を統一形式で管理 |
既存アクション改修 | 各Server Action (email-change.ts 等) | templates統一APIに差し替え |
この後の章では、DB定義からServer Action、一覧UI、テンプレート統合までを順に解説していきます。
まずは PasswordRequest テーブルの追加 から進めます。
まずは PasswordRequest テーブルの追加 から進めます。
⸻
1. PasswordRequestテーブルの追加 ─ DB設計とモデル定義
今回の最初のステップは、パスワード再発行依頼を保存・管理するための専用テーブル を新設することです。
これまで「依頼フォーム → メール通知」のみで完結していた流れを、Prisma上の正式なエンティティとして永続化 できるようにします。
これまで「依頼フォーム → メール通知」のみで完結していた流れを、Prisma上の正式なエンティティとして永続化 できるようにします。
txt
1[Before]
2フォーム送信 → メール送信(即時処理)
3→ 履歴は残らず、依頼の重複や処理状況を管理できない
4
5[After]
6フォーム送信 → PasswordRequest に記録
7→ 管理者が一覧画面で「再発行」「拒否」操作
8→ 履歴・処理者・IP/UAまでDBで一元管理
モデル設計のポイント
今回の追加は
schema.prisma
における 3箇所の改修+1モデルの新設 で構成されます。変更箇所 | 対応内容 | 目的 |
---|---|---|
Enum追加 | PasswordRequestStatus を定義 | PENDING / ISSUED / REJECTED の状態管理 |
Department | passwordRequests リレーションを追加 | 部署ごとの依頼を一覧化可能に |
User | passwordRequests リレーションを追加 | 特定ユーザの依頼履歴参照に対応 |
新モデル | PasswordRequest モデルを新設 | 再発行依頼・処理・監査情報を保存 |
これにより、部署単位で依頼を集約し、ユーザ単位で履歴を追跡できる構成となります。
部署コードやメールアドレスは文字列そのまま保存し、認証可否に関係なくリクエストを記録できる点もポイントです(存在秘匿性を維持)。
部署コードやメールアドレスは文字列そのまま保存し、認証可否に関係なくリクエストを記録できる点もポイントです(存在秘匿性を維持)。
モデル全体の構造
prisma/schema.prisma
に下記のように追記してします。prisma
1// ==============================
2// Enums
3// ==============================
4// 追加
5enum PasswordRequestStatus {
6 PENDING // 受付済(未処理)
7 ISSUED // 管理者が再発行を実行
8 REJECTED // 管理者が依頼を却下
9}
prisma
1model Department {
2 // ...既存フィールド...
3
4 /// 逆リレーション: 部署に紐づく許可ドメイン一覧
5 allowedDomains AllowedEmailDomain[]
6 /// 逆リレーション: 部署配下ユーザーのメール変更申請
7 emailChangeRequests EmailChangeRequest[]
8 /// 逆リレーション: 部署ごとのメニュー上書き
9 departmentMenus DepartmentMenu[]
10 // 追加: パスワード再発行依頼(この部署宛)
11 passwordRequests PasswordRequest[]
12
13 // ...既存index...
14}
prisma
1model User {
2 // ...既存フィールド...
3
4 /// 逆リレーション: このユーザーが出したメール変更申請
5 emailChangeRequests EmailChangeRequest[]
6 // 追加: このユーザーに紐づくパスワード再発行依頼(同定できた場合)
7 passwordRequests PasswordRequest[]
8
9 // ...既存unique/index...
10}
prisma
1// ==============================
2// PasswordRequest(新規)
3// - 公開フォームからの“再発行依頼”を保持
4// - 入力生値(部署コード)を保持しつつ、解決できれば departmentId / userId を持つ
5// ==============================
6model PasswordRequest {
7 id String @id @default(uuid())
8
9 // 依頼入力
10 departmentCodeInput String // フォーム入力の部署コード(生値)
11 emailPuny String // 依頼メール(punycode ASCII)
12 note String?
13 ip String? // 任意: 監査
14 userAgent String? // 任意: 監査
15
16 // 解決結果(NULL許容:存在秘匿のため、特定できないケースを許容)
17 departmentId String?
18 userId String?
19
20 // 状態管理
21 status PasswordRequestStatus @default(PENDING)
22 processedAt DateTime? @db.Timestamptz
23 processedBy String? // 承認者/却下者の表示名
24
25 // 監査
26 createdAt DateTime @default(now()) @db.Timestamptz
27 updatedAt DateTime @updatedAt @db.Timestamptz
28
29 // リレーション
30 department Department? @relation(fields: [departmentId], references: [id], onDelete: Restrict)
31 user User? @relation(fields: [userId], references: [id], onDelete: Restrict)
32
33 // 索引
34 @@index([departmentId, emailPuny])
35 @@index([departmentCodeInput])
36 @@index([status])
37 @@index([createdAt])
38}
上記のように、
「存在するかどうか」を利用者に明示しないため、
PasswordRequest
モデルは入力値・解決結果・処理結果の3レイヤ構造を意識しています。「存在するかどうか」を利用者に明示しないため、
departmentId
と userId
は NULL許容 とし、該当部署・ユーザが見つからなかった場合でもDB記録は残す設計です。Prismaマイグレーション
DB定義を追加後、以下のコマンドでスキーマ変更を反映します。
zsh
1npx prisma migrate dev --name add-password-request
2npx prisma generate
これにより、
次章では、このテーブルを利用して 公開フォームから依頼を受け付ける処理 を実装していきます。
PasswordRequest
テーブルがDBに作成され、同時に Prisma Client も新しいモデル定義を反映します。次章では、このテーブルを利用して 公開フォームから依頼を受け付ける処理 を実装していきます。
2. 公開フォームからの再発行依頼を処理する
公開フォーム(未ログイン)からの「パスワード再発行依頼」を DBへ記録 し、部署が特定できる場合のみ管理者へ メール通知 するフローに差し替えます。
存在秘匿のため、入力が正しくDBが生きている限り 常に成功レスポンスを返す 挙動に統一します(入力不正や障害時のみエラー)。
存在秘匿のため、入力が正しくDBが生きている限り 常に成功レスポンスを返す 挙動に統一します(入力不正や障害時のみエラー)。
txt
1[Flow]
2Client(Form) ──▶ passwordForgotAction(values)
3 ├─ validate & normalize (Zod)
4 ├─ resolve department by code (nullable)
5 ├─ resolve user by email+dept (nullable)
6 ├─ create PasswordRequest(PENDING) ← 履歴を保存
7 └─ notify admins in department (if resolved)
8 ↑ 件名/本文は templates に集約
サーバアクション(新規作成)
ts
1// src/app/_actions/auth/password-forgot.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { headers } from "next/headers";
6import { sendMail } from "@/lib/mailer";
7import { z } from "zod";
8import { accountIdSchema, emailSchema } from "@/lib/users/schema";
9import { adminPasswordForgotNotify } from "@/lib/email/templates";
10
11/**
12 * 入力(サーバ側):trim など最終正規化を足す
13 * - 形式検証は users/schema の共通スキーマを利用
14 */
15const serverSchema = z
16 .object({
17 accountId: accountIdSchema,
18 email: emailSchema,
19 note: z.string().optional(),
20 })
21 .transform((v) => ({
22 ...v,
23 accountId: v.accountId.trim(),
24 email: v.email.trim(), // emailSchemaでpuny化済み
25 note: v.note?.trim() || "",
26 }));
27
28export type PasswordForgotResult = { ok: true } | { ok: false };
29
30const ADMIN_PRIORITY_THRESHOLD = 100;
31
32/**
33 * パスワード再発行依頼
34 * - 常に存在秘匿:部署/ユーザの有無に関係なく {ok:true} を返す
35 * - 入力不正 or DB障害のみ {ok:false}
36 * - 部署が解決できた場合は、部署内の“ADMIN相当(priority>=100)”全員へメール通知
37 */
38export async function passwordForgotAction(
39 values: unknown,
40): Promise<PasswordForgotResult> {
41 const parsed = serverSchema.safeParse(values);
42 if (!parsed.success) return { ok: false };
43
44 const { accountId, email, note } = parsed.data;
45
46 const h = await headers();
47 const ip = h.get("x-real-ip") || h.get("x-forwarded-for") || undefined;
48 const ua = h.get("user-agent") || undefined;
49
50 try {
51 // 1) 部署コード→部署解決
52 const dept = await prisma.department.findUnique({
53 where: { code: accountId },
54 select: { id: true },
55 });
56
57 // 2) ユーザー解決(部署が特定できたら)
58 const user =
59 dept &&
60 (await prisma.user.findFirst({
61 where: {
62 departmentId: dept.id,
63 email, // punycode ASCIIで保存されている設計
64 deletedAt: null,
65 },
66 select: { id: true },
67 }));
68
69 // 3) 依頼の記録(存在の有無に関わらず)
70 await prisma.passwordRequest.create({
71 data: {
72 departmentCodeInput: accountId,
73 emailPuny: email,
74 note: note || null,
75 ip: ip || null,
76 userAgent: ua || null,
77 departmentId: dept?.id ?? null,
78 userId: user?.id ?? null,
79 status: "PENDING",
80 },
81 });
82
83 // 4) 通知(部署が解決できた場合のみ)
84 if (dept?.id) {
85 // 部署内のADMIN相当(priority >= 100)を抽出
86 const admins = await prisma.user.findMany({
87 where: {
88 departmentId: dept.id,
89 deletedAt: null,
90 isActive: true,
91 OR: [
92 // Role直付けで priority>=100
93 { role: { is: { priority: { gte: ADMIN_PRIORITY_THRESHOLD } } } },
94 // DepartmentRole: override(参照Roleのpriority>=100 & DR有効)
95 {
96 departmentRole: {
97 is: {
98 isEnabled: true,
99 role: { is: { priority: { gte: ADMIN_PRIORITY_THRESHOLD } } },
100 },
101 },
102 },
103 // DepartmentRole: custom(DR.priority>=100 & DR有効)
104 {
105 departmentRole: {
106 is: {
107 isEnabled: true,
108 roleId: null,
109 priority: { gte: ADMIN_PRIORITY_THRESHOLD },
110 },
111 },
112 },
113 ],
114 },
115 select: { email: true },
116 });
117
118 // メール件名/本文(簡潔に。内部通知なので詳細OK)
119 const mail = adminPasswordForgotNotify({
120 accountId,
121 email,
122 note,
123 ip,
124 ua,
125 });
126
127 for (const a of admins) {
128 try {
129 await sendMail({
130 to: a.email,
131 subject: mail.subject,
132 text: mail.text,
133 });
134 } catch (e) {
135 // 通知失敗は致命でない(ログのみ)
136 console.error("[mailer] password-forgot notify failed:", e);
137 }
138 }
139 }
140
141 // 存在秘匿:常に成功メッセージ
142 return { ok: true };
143 } catch (e) {
144 console.error("[passwordForgotAction] failed:", e);
145 // 入力は正しいがDB障害など
146 return { ok: false };
147 }
148}
上記のとおり、Zodで入力を最終正規化 した後、部署・ユーザを「可能なら」解決し、
部署が解決できた場合のみ、ADMIN相当(priority>=100) の全員へ通知メールを送ります。件名・本文は
PasswordRequest
に必ず記録します。部署が解決できた場合のみ、ADMIN相当(priority>=100) の全員へ通知メールを送ります。件名・本文は
templates
に集約済みです。メールの
templates
は下記の通りです。ts
1// src/lib/email/templates.ts(抜粋)
2
3/** 管理者向け:パスワード再発行依頼の着信通知 */
4export function adminPasswordForgotNotify(params: {
5 accountId: string;
6 email: string;
7 note?: string;
8 ip?: string;
9 ua?: string;
10}) {
11 const subject = "【DELOGs】パスワード再発行依頼が届きました";
12 const lines = [
13 "公開フォームからパスワード再発行依頼を受け付けました。",
14 "",
15 `■ 部署コード入力:${params.accountId}`,
16 `■ 申請メール :${params.email}`,
17 params.note ? `■ 備考 :${params.note}` : null,
18 params.ip ? `■ IP :${params.ip}` : null,
19 params.ua ? `■ UA :${params.ua}` : null,
20 "",
21 "管理画面の「ユーザ管理 > パスワード再発行依頼」から処理してください。",
22 ].filter(Boolean);
23 return { subject, text: lines.join("\n") };
24}
公開フォーム(クライアント)の差し替え
tsx
1// src/app/(public)/password-forgot/client.tsx
2"use client";
3
4import { useState } from "react";
5import { toast } from "sonner";
6import PasswordForgotForm, {
7 type ForgotRequestValues,
8} from "@/components/login/password-forgot-form";
9import { passwordForgotAction } from "@/app/_actions/auth/password-forgot";
10
11export default function PasswordForgotClient() {
12 const [loading, setLoading] = useState(false);
13 const [submitted, setSubmitted] = useState(false);
14
15 const handleSubmit = async (values: ForgotRequestValues) => {
16 if (submitted) return; // 二重送信ガード(念のため)
17 setLoading(true);
18 try {
19 const res = await passwordForgotAction(values);
20 if (res.ok) {
21 setSubmitted(true);
22 toast.success("依頼を受け付けました。登録メールをご確認ください。");
23 } else {
24 // 入力不正 or 障害時:存在を明かさない曖昧メッセージ
25 toast.error("送信内容を確認してください。");
26 }
27 } catch {
28 // 通信例外:曖昧メッセージ(存在秘匿維持)
29 toast.error("エラーが発生しました。時間をおいて再度お試しください。");
30 } finally {
31 setLoading(false);
32 }
33 };
34
35 return (
36 <PasswordForgotForm
37 onSubmit={handleSubmit}
38 loading={loading}
39 submitted={submitted}
40 onCancel={() => history.back()}
41 />
42 );
43}
クライアント側は、擬似待機から Server Action 呼び出し に変更しています。
存在秘匿のポリシーに合わせ、成功系のトースト文言は一定・エラー時も曖昧メッセージで統一しました。
次章では、受け付けた依頼を 管理画面から再発行/拒否 できるように、一覧・操作の実装へ進みます。
存在秘匿のポリシーに合わせ、成功系のトースト文言は一定・エラー時も曖昧メッセージで統一しました。
次章では、受け付けた依頼を 管理画面から再発行/拒否 できるように、一覧・操作の実装へ進みます。
3. 管理画面での依頼一覧と再発行処理
ここからは、公開フォームで登録された
処理結果は即時にDBへ反映され、依頼者にはメール通知が送信されます。
PasswordRequest
データを 管理者が一覧・確認・再発行・拒否できる管理画面 に統合していきます。処理結果は即時にDBへ反映され、依頼者にはメール通知が送信されます。
Server Actionの実装 ─ password-requests.ts
管理者が依頼一覧から 「再発行(ISSUED)」 または 「拒否(REJECTED)」 を行えるようにする Server Action を新規作成します。
部署単位での権限検証・競合防止・argon2による安全なパスワード再発行・メール通知までを一貫して行う構成です。
部署単位での権限検証・競合防止・argon2による安全なパスワード再発行・メール通知までを一貫して行う構成です。
ts
1// src/app/_actions/users/password-requests.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { z } from "zod";
7import { getEffectiveRole } from "@/lib/auth/effective-role";
8import { sendMail } from "@/lib/mailer";
9import { passwordIssued } from "@/lib/email/templates";
10import { generatePassword } from "@/lib/security/password";
11import * as argon2 from "argon2";
12
13type ActionResult<T = unknown> =
14 | { ok: true; data?: T }
15 | { ok: false; message: string };
16
17// ADMIN 相当(メール変更申請と同値)
18const ADMIN_PRIORITY_THRESHOLD = 100;
19
20const idSchema = z.object({ id: z.string().uuid() });
21
22/** 呼び出しユーザが ADMIN(実効 priority>=100)かつ同一部署かを確認 */
23async function requireAdminSameDepartment(
24 userId: string,
25 departmentId: string,
26) {
27 const me = await prisma.user.findUnique({
28 where: { id: userId },
29 select: {
30 id: true,
31 name: true,
32 isActive: true,
33 departmentId: true,
34 roleId: true,
35 departmentRoleId: true,
36 },
37 });
38
39 if (!me || !me.isActive)
40 return { ok: false as const, message: "ユーザが無効化されています。" };
41
42 if (me.departmentId !== departmentId)
43 return { ok: false as const, message: "越権操作です。" };
44
45 let eff = null;
46 if (me.departmentRoleId) {
47 eff = await getEffectiveRole({
48 departmentId: me.departmentId,
49 departmentRoleId: me.departmentRoleId,
50 });
51 } else if (me.roleId) {
52 eff = await getEffectiveRole({
53 departmentId: me.departmentId,
54 roleId: me.roleId,
55 });
56 }
57 if (!eff || eff.priority < ADMIN_PRIORITY_THRESHOLD)
58 return { ok: false as const, message: "権限がありません。" };
59
60 return { ok: true as const, meName: me.name ?? "ADMIN" };
61}
62
63/**
64 * 再発行(ISSUED)
65 * - 競合対策: status=PENDING を条件に updateMany
66 * - 同一Txで User.hashedPassword を argon2 で更新
67 * - 成功後に依頼メール宛に新パスワードを通知
68 */
69export async function issuePasswordRequestAction(
70 formData: FormData,
71): Promise<ActionResult> {
72 const ses = await lookupSessionFromCookie();
73 if (!ses.ok) return { ok: false, message: "認証が必要です" };
74
75 const parsed = idSchema.safeParse({ id: formData.get("id") });
76 if (!parsed.success) return { ok: false, message: "IDが不正です" };
77
78 // 対象取得
79 const req = await prisma.passwordRequest.findUnique({
80 where: { id: parsed.data.id },
81 select: {
82 id: true,
83 status: true,
84 departmentId: true,
85 userId: true,
86 emailPuny: true,
87 // 通知メール用に名前・部署情報も拾う
88 user: { select: { name: true } },
89 },
90 });
91 if (!req) return { ok: false, message: "依頼が見つかりません" };
92
93 if (!req.departmentId || !req.userId) {
94 return {
95 ok: false,
96 message:
97 "対象ユーザが特定できないため処理できません(部署/ユーザ未解決)。",
98 };
99 }
100
101 // 権限チェック
102 const g = await requireAdminSameDepartment(ses.userId, req.departmentId);
103 if (!g.ok) return g;
104
105 if (req.status !== "PENDING") {
106 return { ok: false, message: "未処理の依頼のみ再発行できます" };
107 }
108
109 // 新パスワード生成 & ハッシュ
110 const newPassword = generatePassword(20);
111 const hashed = await argon2.hash(newPassword, { type: argon2.argon2id });
112
113 // Tx: 条件付きで依頼をISSUEDにし、ユーザのパスワードを更新
114 const result = await prisma.$transaction(async (tx) => {
115 const updated = await tx.passwordRequest.updateMany({
116 where: { id: req.id, status: "PENDING" },
117 data: {
118 status: "ISSUED",
119 processedAt: new Date(),
120 processedBy: g.meName,
121 },
122 });
123 if (updated.count !== 1)
124 return { ok: false as const, message: "競合が発生しました" };
125
126 await tx.user.update({
127 where: { id: req.userId! },
128 data: { hashedPassword: hashed },
129 });
130
131 // 成功を返しつつ、メール本文に使う情報を渡す
132 return {
133 ok: true as const,
134 data: {
135 to: req.emailPuny,
136 userName: req.user?.name ?? "",
137 newPassword,
138 },
139 };
140 });
141
142 if (!result.ok) return result;
143
144 // メール通知(失敗しても処理は成功)
145 try {
146 const { subject, text } = passwordIssued({
147 name: result.data!.userName,
148 email: result.data!.to,
149 newPassword: result.data!.newPassword,
150 });
151 await sendMail({ to: result.data!.to, subject, text });
152 } catch (e) {
153 console.error("[mail] password reissue notification failed:", e);
154 }
155
156 return { ok: true };
157}
158
159/**
160 * 拒否(REJECTED)
161 * - 競合対策: status=PENDING を条件に updateMany
162 */
163export async function rejectPasswordRequestAction(
164 formData: FormData,
165): Promise<ActionResult> {
166 const ses = await lookupSessionFromCookie();
167 if (!ses.ok) return { ok: false, message: "認証が必要です" };
168
169 const parsed = idSchema.safeParse({ id: formData.get("id") });
170 if (!parsed.success) return { ok: false, message: "IDが不正です" };
171
172 const req = await prisma.passwordRequest.findUnique({
173 where: { id: parsed.data.id },
174 select: { id: true, status: true, departmentId: true },
175 });
176 if (!req) return { ok: false, message: "依頼が見つかりません" };
177 if (!req.departmentId)
178 return { ok: false, message: "部署未解決の依頼は却下できません" };
179
180 const g = await requireAdminSameDepartment(ses.userId, req.departmentId);
181 if (!g.ok) return g;
182
183 if (req.status !== "PENDING") {
184 return { ok: false, message: "未処理の依頼のみ拒否できます" };
185 }
186
187 const updated = await prisma.passwordRequest.updateMany({
188 where: { id: req.id, status: "PENDING" },
189 data: {
190 status: "REJECTED",
191 processedAt: new Date(),
192 processedBy: g.meName,
193 },
194 });
195 if (updated.count !== 1) return { ok: false, message: "競合が発生しました" };
196
197 return { ok: true };
198}
○主な構成と役割
関数/要素 | 目的 |
---|---|
requireAdminSameDepartment | 呼び出しユーザーが同一部署かつ ADMIN 権限 (priority ≥ 100) であることを検証 |
issuePasswordRequestAction | ステータス PENDING の依頼を ISSUED に更新し、ユーザーパスワードを再生成 |
rejectPasswordRequestAction | ステータス PENDING の依頼を REJECTED に更新 |
generatePassword | 強固な 20 文字のランダムパスワードを生成 |
argon2.hash() | パスワードを argon2id でハッシュ化し安全に保存 |
passwordIssued | 再発行通知メールを生成して依頼者へ送信 |
○ 処理の流れ(再発行の場合)
txt
1ADMINユーザ
2 │ クリック(再発行)
3 ▼
4issuePasswordRequestAction()
5 │
6 ├─ 認証チェック(Cookie)
7 ├─ 部署・権限検証(getEffectiveRole)
8 ├─ status = "PENDING" の依頼をロック
9 ├─ 新パスワード生成 → argon2 でハッシュ化
10 ├─ User.hashedPassword を更新
11 ├─ PasswordRequest.status = "ISSUED"
12 └─ 依頼者へメール送信(passwordIssued)
○状態遷移まとめ
対象 | 前 | 後 | 備考 |
---|---|---|---|
PasswordRequest | PENDING | ISSUED | processedAt / processedBy を記録 |
PasswordRequest | PENDING | REJECTED | 管理者操作で明示的拒否 |
User | (旧ハッシュ) | (argon2idハッシュ) | 新パスワード適用後に通知メール |
○実装ポイント
項目 | 内容 |
---|---|
権限チェック | 同一部署 + ADMIN相当(priority≥100) のみ許可 |
競合対策 | updateMany({ where: { id, status: "PENDING" } }) で二重操作防止 |
メール通知 | 送信失敗はログのみ。処理結果は成功扱い |
戻り値 | { ok: true } / { ok: false; message } のシンプル構成 |
UI反映 | DataTable 側で即時状態更新+トースト通知 |
○まとめ
- 1トランザクション完結:依頼とユーザー更新を同時に安全反映。
- 存在秘匿の維持:部署・ユーザー未解決の依頼は処理対象外。
- 失敗許容設計:メール送信例外は致命ではなく運用再送可能。
これにより、管理画面からの「再発行」「拒否」操作をサーバサイドで安全に完結させることができます。
メールテンプレートの追加 ─ templates.ts
パスワード再発行処理の完了時に依頼者へ通知するため、
このテンプレートは、Server Action(
src/lib/email/templates.ts
に passwordIssued() 関数を追加します。このテンプレートは、Server Action(
issuePasswordRequestAction
)から呼び出され、再発行完了メールの 件名・本文 を一括生成します。ts
1// src/lib/email/templates.ts(抜粋)
2
3/** パスワード再発行:本人通知(依頼メール宛) */
4export function passwordIssued(params: {
5 name?: string;
6 email: string; // punycode (ASCII) 可
7 newPassword: string; // 平文(再発行ワンショット)
8}) {
9 const loginUrl = buildLoginUrl();
10 const subject = "【DELOGs】パスワードを再発行しました";
11 const text = [
12 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。",
13 "",
14 params.name ? `${params.name} 様` : "ご担当者様",
15 "",
16 "パスワードを再発行しました。以下の情報でログインしてください。",
17 "",
18 `ログインURL:${loginUrl}`,
19 `メール :${params.email}`,
20 `新パスワード:${params.newPassword}`,
21 "",
22 "※ セキュリティのため、ログイン後にパスワードを変更してください。",
23 "※ このメールに心当たりがない場合は、管理者へお問い合わせください。",
24 ].join("\n");
25 return { subject, text };
26}
○構成と目的
関数名 | 役割 |
---|---|
buildLoginUrl | ログイン画面のURLを生成(既存ユーティリティ) |
passwordIssued | パスワード再発行完了の通知メール本文を生成 |
戻り値 | { subject: string; text: string } 形式で、Server Action からそのまま sendMail() に渡せる |
○実装ポイント
項目 | 内容 |
---|---|
引数 | name , email , newPassword を受け取り、宛名を動的に生成 |
文面生成 | 改行を維持するため、Array.join("\n") で整形 |
ログインURL | buildLoginUrl() により環境変数 APP_ORIGIN を利用して構築 |
国際化対応 | punycode ASCII メールもそのまま扱える設計 |
責務分離 | Server Action では送信のみ担当し、文面定義は templates.ts に統一 |
○メールテンプレート統合の狙い
既存の
emailChangeApproved
や userWelcome
と同様に、件名・本文生成を テンプレート層で完結 させることで、
各 Server Action からの呼び出し構文を統一し、再利用性と保守性を高めます。これにより、後続の通知メール(管理者向けなど)も 同一フォーマットで簡潔に拡張 できるようになります。
状態フィルタコンポーネントの追加 ─ status-multi-select.tsx
パスワード再発行依頼テーブルの「状態(未処理/再発行済み/拒否)」を複数選択で絞り込むため、
StatusMultiSelectPw
を追加します。URL 同期の都合上、選択空配列は「すべて」 を意味する設計で、UI上は全選択として振る舞います。tsx
1// src/app/(protected)/users/password-request/status-multi-select.tsx
2"use client";
3
4import * as React from "react";
5import { Check } from "lucide-react";
6import { Button } from "@/components/ui/button";
7import {
8 Command,
9 CommandGroup,
10 CommandItem,
11 CommandInput,
12 CommandEmpty,
13} from "@/components/ui/command";
14import { Separator } from "@/components/ui/separator";
15import * as PopoverPrimitive from "@radix-ui/react-popover";
16
17export type PwReqStatus = "PENDING" | "ISSUED" | "REJECTED";
18
19export const PW_STATUS_LABEL: Record<PwReqStatus, string> = {
20 PENDING: "未処理",
21 ISSUED: "再発行済み",
22 REJECTED: "拒否",
23};
24
25export function StatusMultiSelectPw({
26 value,
27 onChange,
28 options,
29 footer,
30}: {
31 /** 選択中。空配列は「すべて」を意味する(URL/状態同期の表現) */
32 value: PwReqStatus[];
33 onChange: (next: PwReqStatus[]) => void;
34 /** 一覧に登場した状態だけを渡す(value/label) */
35 options: Array<PwReqStatus | { value: PwReqStatus; label: string }>;
36 footer?: React.ReactNode;
37}) {
38 const [needle, setNeedle] = React.useState("");
39
40 // options を正規化
41 const normalized = React.useMemo(
42 () =>
43 options.map((o) =>
44 typeof o === "string"
45 ? { value: o, label: PW_STATUS_LABEL[o] }
46 : { value: o.value, label: o.label ?? PW_STATUS_LABEL[o.value] },
47 ),
48 [options],
49 );
50
51 const all = React.useMemo(() => normalized.map((o) => o.value), [normalized]);
52
53 // 空=すべて(UI上は全選択に見せる)
54 const effectiveSelected = value.length ? value : all;
55
56 const toggle = (s: PwReqStatus) => {
57 const set = new Set(effectiveSelected);
58 set.has(s) ? set.delete(s) : set.add(s);
59 const next = Array.from(set) as PwReqStatus[];
60 onChange(next.length === all.length ? [] : next); // 全選択→ [] に畳む
61 };
62
63 const allSelected = effectiveSelected.length === all.length;
64
65 const filtered = React.useMemo(() => {
66 const q = needle.trim().toLowerCase();
67 if (!q) return normalized;
68 return normalized.filter(
69 (o) =>
70 o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
71 );
72 }, [needle, normalized]);
73
74 return (
75 <div className="flex max-h-[60vh] w-full flex-col">
76 <div className="p-2">
77 <Command shouldFilter={false}>
78 <CommandInput
79 value={needle}
80 onValueChange={setNeedle}
81 placeholder="状態を検索…"
82 />
83 <CommandEmpty>該当する状態がありません</CommandEmpty>
84 <CommandGroup heading="状態を選択(複数選択可)">
85 {filtered.map((o) => {
86 const checked = effectiveSelected.includes(o.value);
87 return (
88 <CommandItem
89 key={o.value}
90 onSelect={() => toggle(o.value)}
91 className="flex items-center gap-2"
92 >
93 <span
94 className="flex h-5 w-5 items-center justify-center rounded border"
95 aria-checked={checked}
96 role="checkbox"
97 >
98 {checked ? <Check className="h-3 w-3" /> : null}
99 </span>
100 <span className="truncate">{o.label}</span>
101 </CommandItem>
102 );
103 })}
104 </CommandGroup>
105 </Command>
106 </div>
107
108 <Separator />
109
110 <div className="flex items-center justify-between gap-2 p-2">
111 <div className="flex gap-2">
112 <Button
113 type="button"
114 variant="ghost"
115 size="sm"
116 onClick={() => onChange(all)} // UI上“すべて選択”だが URL表現は [] で十分なので下で畳んでもOK
117 disabled={allSelected}
118 className="cursor-pointer"
119 >
120 すべて
121 </Button>
122 <Button
123 type="button"
124 variant="ghost"
125 size="sm"
126 onClick={() => onChange([])}
127 className="cursor-pointer"
128 >
129 クリア
130 </Button>
131 </div>
132 <div className="flex items-center">{footer}</div>
133 </div>
134 </div>
135 );
136}
○役割と配置
項目 | 内容 |
---|---|
目的 | 依頼の状態を複数選択でフィルタ(検索入力つき) |
露出先 | columns.tsx の「状態」ヘッダーPopover内コンポーネント |
型 | PwReqStatus = "PENDING" | "ISSUED" | "REJECTED" |
ラベル | PW_STATUS_LABEL で日本語表示(未処理/再発行済み/拒否) |
○Props(I/F)
Prop | 型 | 役割 |
---|---|---|
value | PwReqStatus[] | 選択値。空配列=全選択 と解釈(URL表現用) |
onChange | (next: PwReqStatus[]) => void | 選択変更通知(空→全選択の畳み込み含む) |
options | Array<PwReqStatus | { value; label }> | 一覧に出現した状態のみを渡す(柔軟にラベル差し替え可) |
footer | React.ReactNode | Popover のフッター(閉じるボタン等) |
○振る舞い(UI/状態同期の要点)
シナリオ | 挙動 |
---|---|
初期表示 | value=[] の場合、UI上は全状態にチェック(実効集合で扱う) |
トグル選択 | 選択集合を加除。全選択になったら [] に畳む(URL短縮) |
検索 | ラベル/値を部分一致でフィルタ(CommandInput ) |
すべて/クリア | 「すべて」=全状態に展開、「クリア」= [] (=全選択表現) |
視覚表示 | チェックボックス風UI(CommandItem + Check ) |
○統合ポイント(どこで使うか)
ファイル | 追加箇所 |
---|---|
columns.tsx | 「状態」列ヘッダーの Popover に StatusMultiSelectPw を配置(table.options.meta の pwStatusOptions /pwStatuses /setPwStatuses を受け取り) |
data-table.tsx | URL同期用のクエリステートに statuses を保持。空配列=すべて の解釈でフィルタを実施 |
このコンポーネントにより、「URL=単一情報源」で状態フィルタを再現でき、一覧の直リンク共有やリロード後の状態復元が自然に機能します。
型定義の拡張 ─ table-meta.d.ts
パスワード再発行一覧で使うメタ情報(フィルタや操作ハンドラ)を
@tanstack/table-core
の TableMeta
に拡張します。既存の「メール変更申請」向けメタと干渉しないように、パスワード再発行専用のキー(pw*
系) を追加しました。ts
1// src/types/table-meta.d.ts
2import "@tanstack/table-core";
3import type { ReqStatus } from "@/app/(protected)/users/email-change-requests/status-multi-select";
4import type { PwReqStatus } from "@/app/(protected)/users/password-request/status-multi-select";
5
6declare module "@tanstack/table-core" {
7 interface TableMeta<TData extends RowData> {
8 onMoveUp?: (id: string, _row?: TData) => void;
9 onMoveDown?: (id: string, _row?: TData) => void;
10 onToggleActive?: (displayId: string, next: boolean, _row?: TData) => void;
11 // ★ 追加:処理中IDセット
12 movingIds?: Set<string>;
13 togglingIds?: Set<string>;
14 // ★ 追加:この行は「最終的にこうなるはず」という可視状態
15 pendingVisible?: Map<string, boolean>;
16
17 /** 依頼を「再発行済み」にする */
18 onIssue?: (id: string, _row?: TData) => void | Promise<void>;
19 /** 依頼を「拒否」にする */
20 onReject?: (id: string, _row?: TData) => void | Promise<void>;
21 /** メール変更申請を「承認」する */
22 onApprove?: (id: string, _row?: TData) => void | Promise<void>;
23 // ▼ ユーザ一覧用(必要なものだけ)
24 roleOptions?: Array<{ value: string; label: string }>;
25 roles?: string[];
26 setRoles?: (next: string[]) => void;
27
28 status?: "ALL" | "ACTIVE" | "INACTIVE";
29 setStatus?: (next: "ALL" | "ACTIVE" | "INACTIVE") => void;
30
31 createdRange?: import("react-day-picker").DateRange | undefined;
32 setCreatedRange?: (
33 r: import("react-day-picker").DateRange | undefined,
34 ) => void;
35
36 updatedRange?: import("react-day-picker").DateRange | undefined;
37 setUpdatedRange?: (
38 r: import("react-day-picker").DateRange | undefined,
39 ) => void;
40
41 // ▼ ロール一覧用(masters/roles)
42 kindOptions?: Array<{ value: string; label: string }>;
43 kinds?: string[];
44 setKinds?: (next: string[]) => void;
45
46 // ▼ “申請系”の状態(メール変更申請・パスワード再発行の両方をカバー)
47 // メール変更申請: PENDING/VERIFIED/APPROVED/REJECTED/EXPIRED
48 // パスワード再発行: PENDING/ISSUED/REJECTED
49 statusOptions?: Array<{ value: ReqStatus; label: string }>;
50 statuses?: ReqStatus[];
51 setStatuses?: (next: ReqStatus[]) => void;
52
53 // ▼ 新規:パスワード再発行一覧(メール変更申請と分離)
54 pwStatusOptions?: Array<{ value: PwReqStatus; label: string }>;
55 pwStatuses?: PwReqStatus[];
56 setPwStatuses?: (next: PwReqStatus[]) => void;
57
58 // 日付レンジ(申請日時・処理日時)
59 requestedRange?: import("react-day-picker").DateRange | undefined;
60 setRequestedRange?: (
61 r: import("react-day-picker").DateRange | undefined,
62 ) => void;
63
64 processedRange?: import("react-day-picker").DateRange | undefined;
65 setProcessedRange?: (
66 r: import("react-day-picker").DateRange | undefined,
67 ) => void;
68 }
69}
70
71export {};
○変更概要(差分要点)
区分 | 変更点 | 目的 |
---|---|---|
import | PwReqStatus を追加インポート | パスワード再発行の状態型を参照するため |
申請系状態(共通) | statusOptions/statuses/setStatuses 維持 | 既存のメール変更申請一覧の互換性を担保 |
新規(PW専用) | pwStatusOptions/pwStatuses/setPwStatuses を追加 | パスワード再発行一覧の状態フィルタを独立管理 |
操作ハンドラ | onIssue / onReject を定義済み | 「再発行」「拒否」操作をテーブルメタ経由で受け取る |
日付レンジ | requestedRange/processedRange を共通で利用 | 申請日時・処理日時の両方に適用可能 |
○型の責務分離
メタキー | 想定利用先 | 説明 |
---|---|---|
statusOptions/statuses/setStatuses | メール変更申請一覧 | VERIFIED/APPROVED/REJECTED/EXPIRED 等を扱う |
pwStatusOptions/pwStatuses/setPwStatuses | パスワード再発行一覧 | PENDING/ISSUED/REJECTED を扱う |
requestedRange/setRequestedRange | 両一覧 | 受付(申請)日時の範囲選択 |
processedRange/setProcessedRange | 両一覧 | 処理(承認/却下/再発行)日時の範囲選択 |
onIssue/onReject | パスワード再発行一覧 | Server Action を呼び出すハンドラ |
○統合ポイント
ファイル | 読み書きするメタ |
---|---|
page.tsx | 初期化データ(状態オプション・レンジ)を計算し DataTable に渡す |
columns.tsx | ヘッダーPopoverから pwStatusOptions/pwStatuses/setPwStatuses を参照 |
data-table.tsx | URL同期ステートと TableMeta を橋渡し(onIssue/onReject もここで注入) |
○設計の意図
- 衝突回避:メール変更申請とパスワード再発行で状態語彙が異なるため、
pw*
を分離して UI ロジックの混線を防止。 - 再利用性:日付レンジや共通ハンドラの命名を保ちつつ、一覧別の状態フィルタだけ独立。
- 見通し:将来、別の「申請系」画面が増えても、
<prefix>Status*
で拡張可能。
この拡張により、テーブル列ヘッダーのフィルタUIと URL 同期ロジックを崩さず、画面ごとの状態語彙 を安全に共存させられます。
ページ構成の更新 ─ page.tsx
モックデータから Prisma + Server Components (RSC) に切り替え、部署スコープかつ実効ロール情報を付与した行データを生成します。ページ先頭で 閲覧ガード を通し、同部署内の依頼のみ取得。Punycode の復号と「実効ロール(Role / DepartmentRole を統合)」の正規化を行い、
DataTable
に必要最小限の列で渡します。tsx
1// src/app/(protected)/users/password-request/page.tsx
2import type { Metadata } from "next";
3import { prisma } from "@/lib/database";
4import { SidebarTrigger } from "@/components/ui/sidebar";
5import {
6 Breadcrumb,
7 BreadcrumbItem,
8 BreadcrumbLink,
9 BreadcrumbList,
10 BreadcrumbPage,
11 BreadcrumbSeparator,
12} from "@/components/ui/breadcrumb";
13import { Separator } from "@/components/ui/separator";
14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
15import * as punycode from "punycode/";
16import { getEffectiveRole } from "@/lib/auth/effective-role";
17
18import DataTable from "./data-table";
19import { columns, type PasswordRequestRow } from "./columns";
20import { PW_STATUS_LABEL, type PwReqStatus } from "./status-multi-select";
21
22export const metadata: Metadata = {
23 title: "パスワード再発行依頼 | 管理画面レイアウト【DELOGs】",
24 description:
25 "Data table(shadcn/ui + @tanstack/react-table)でパスワード再発行依頼を一覧表示",
26};
27
28export default async function Page() {
29 // 1) ページ閲覧ガード
30 const viewer = await guardHrefOrRedirect("/users/password-request", "/");
31
32 // 2) 自分の部署
33 const me = await prisma.user.findUnique({
34 where: { id: viewer.userId },
35 select: { departmentId: true },
36 });
37 if (!me) return null;
38
39 // 3) 部署内の再発行依頼を取得(必要列のみ)
40 const raw = await prisma.passwordRequest.findMany({
41 where: { departmentId: me.departmentId },
42 orderBy: { createdAt: "desc" },
43 select: {
44 id: true,
45 createdAt: true, // 依頼日時
46 processedAt: true,
47 processedBy: true,
48 note: true,
49 status: true,
50 emailPuny: true,
51 user: {
52 select: {
53 displayId: true,
54 name: true,
55 roleId: true,
56 departmentRoleId: true,
57 },
58 },
59 },
60 });
61
62 // 4) 実効ロールへ正規化 & 表示用に整形
63 const rows: PasswordRequestRow[] = await Promise.all(
64 raw.map(async (r) => {
65 const eff = r.user
66 ? r.user.departmentRoleId
67 ? await getEffectiveRole({
68 departmentId: me.departmentId,
69 departmentRoleId: r.user.departmentRoleId,
70 })
71 : r.user.roleId
72 ? await getEffectiveRole({
73 departmentId: me.departmentId,
74 roleId: r.user.roleId,
75 })
76 : null
77 : null;
78
79 return {
80 id: r.id,
81 requestedAt: r.createdAt,
82 processedAt: r.processedAt ?? null,
83 processedBy: r.processedBy ?? null,
84 userId: r.user?.displayId ?? "-",
85 userName: r.user?.name ?? "-",
86 email: punycode.toUnicode(r.emailPuny),
87 status: r.status as PwReqStatus,
88 roleCode: eff?.code ?? "",
89 roleName: eff?.name ?? "(不明)",
90 roleBadgeColor: eff?.badgeColor ?? null,
91 note: r.note ?? "",
92 };
93 }),
94 );
95
96 // 5) フィルタ選択肢(一覧に登場するものだけ)
97 const roleOptions = Array.from(
98 new Map(
99 rows
100 .filter((r) => r.roleCode) // 空コードは除外
101 .map((r) => [r.roleCode, { value: r.roleCode, label: r.roleName }]),
102 ).values(),
103 );
104
105 const statusOptions = Array.from(new Set(rows.map((r) => r.status))).map(
106 (s) => ({
107 value: s as PwReqStatus,
108 label: PW_STATUS_LABEL[s as PwReqStatus],
109 }),
110 );
111
112 return (
113 <>
114 <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">
115 <div className="flex items-center gap-2 px-4">
116 <SidebarTrigger className="-ml-1" />
117 <Separator
118 orientation="vertical"
119 className="mr-2 data-[orientation=vertical]:h-4"
120 />
121 <Breadcrumb>
122 <BreadcrumbList>
123 <BreadcrumbItem className="hidden md:block">
124 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
125 </BreadcrumbItem>
126 <BreadcrumbSeparator className="hidden md:block" />
127 <BreadcrumbItem>
128 <BreadcrumbPage>パスワード再発行依頼</BreadcrumbPage>
129 </BreadcrumbItem>
130 </BreadcrumbList>
131 </Breadcrumb>
132 </div>
133 </header>
134
135 <div className="container p-4 pt-0">
136 <DataTable
137 columns={columns}
138 data={rows}
139 roleOptions={roleOptions}
140 statusOptions={statusOptions}
141 canDownloadData={viewer.canDownloadData}
142 />
143 </div>
144 </>
145 );
146}
○変更点の要約
観点 | 変更前 | 変更後 |
---|---|---|
データ取得 | モック関数 listPasswordRequests | prisma.passwordRequest.findMany (部署絞り + 必要列のみ) |
ガード | guardHrefOrRedirect (導入済) | 継続使用し viewer 情報から部署を解決 |
Eメール表示 | そのまま表示 | punycode.toUnicode で人可読に復号 |
ロール表示 | モック値 | getEffectiveRole で Role/DepartmentRole を正規化(code/name/color) |
フィルタ選択肢 | モック | 取得行から実在コードを動的生成(役割・状態) |
ダウンロード可否 | 固定 | viewer.canDownloadData を DataTable に伝播 |
付加列 | なし | note (備考)を行に含める |
○データ整形フロー
ステップ | 処理 | 出力 |
---|---|---|
1 | viewer = guardHrefOrRedirect(...) | ユーザID/権限(RSC) |
2 | me = prisma.user.findUnique(...) | 所属 departmentId |
3 | passwordRequest.findMany(...) | 依頼の生レコード(必要列のみ) |
4 | punycode.toUnicode(emailPuny) | 表示用メール |
5 | getEffectiveRole(...) | roleCode/name/badgeColor |
6 | 行オブジェクトへ整形 | PasswordRequestRow[] |
7 | roleOptions/statusOptions 生成 | フィルタUIに供給 |
8 | DataTable へ props 渡し | RSC→Client の境界最小化 |
○実装上のポイント
項目 | 意図 / 効果 |
---|---|
select の徹底 | ネットワークとシリアライズを抑制、RSC → Client の境界を軽量化 |
実効ロールの非同期解決 | 各行ごとに Role/DepartmentRole を統合し、UI 表示を一貫化 |
Punycode 復号 | DB保存(ASCII)と UI 表示(Unicode)を分離して可読性担保 |
フィルタ候補動的化 | 一覧に存在する値のみを選択肢化し、ノイズを削減 |
canDownloadData の伝播 | 役割に応じた CSV ダウンロード権限を UI に反映 |
カラム定義の更新 ─ columns.tsx
依頼一覧テーブルのカラム構成を DB連携版 に最適化し、フィルタ/ソートの操作性と検索範囲を強化しました。特に、備考
note
の表示と検索対象への追加、実効ロールの表示(色バッジ対応)、日付レンジフィルタ(依頼日時・処理日時)、状態の複数選択フィルタを実装しています。tsx
1// src/app/(protected)/users/password-request/columns.tsx
2"use client";
3
4import type { ColumnDef, HeaderContext } 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 { SlidersVertical } from "lucide-react";
10import {
11 Popover,
12 PopoverContent,
13 PopoverTrigger,
14} from "@/components/ui/popover";
15import * as PopoverPrimitive from "@radix-ui/react-popover";
16
17import { DateRangePicker } from "@/components/filters/date-range-picker";
18import { RolesChecklist } from "@/components/filters/roles-checklist";
19import { SortButton } from "@/components/datagrid/sort-button";
20import { StatusMultiSelectPw } from "./status-multi-select";
21
22export type PasswordRequestRow = {
23 id: string;
24 requestedAt: Date;
25 processedAt: Date | null;
26 processedBy: string | null;
27 userId: string; // ユーザ displayId
28 userName: string;
29 email: string;
30 roleCode: string;
31 roleName: string;
32 roleBadgeColor: string | null;
33 status: "PENDING" | "ISSUED" | "REJECTED";
34 note: string;
35};
36
37function fmt(d?: Date | null) {
38 if (!d) return "-";
39 return format(d, "yyyy/MM/dd HH:mm", { locale: ja });
40}
41
42function HeaderWithFilter({
43 title,
44 active,
45 children,
46 contentClassName,
47 trailing,
48}: {
49 title: string;
50 active: boolean;
51 children: React.ReactNode;
52 contentClassName?: string;
53 trailing?: React.ReactNode;
54}) {
55 return (
56 <div className="flex items-center gap-1">
57 <span className="whitespace-nowrap">{title}</span>
58 <Popover>
59 <PopoverTrigger asChild>
60 <Button
61 type="button"
62 size="icon"
63 variant={active ? "default" : "outline"}
64 className="h-7 w-7 cursor-pointer"
65 aria-label={`${title}のフィルタ`}
66 title={`${title}のフィルタ`}
67 >
68 <SlidersVertical className="h-3.5 w-3.5" />
69 </Button>
70 </PopoverTrigger>
71 <PopoverContent
72 align="end"
73 className={["p-0", contentClassName ?? "w-80"].join(" ")}
74 >
75 {children}
76 </PopoverContent>
77 </Popover>
78 {trailing ? <div className="ml-0.5">{trailing}</div> : null}
79 </div>
80 );
81}
82
83function HeaderWithSort<TData, TValue>({
84 title,
85 ctx,
86}: {
87 title: string;
88 ctx: HeaderContext<TData, TValue>;
89}) {
90 return (
91 <div className="flex items-center gap-1">
92 <span className="whitespace-nowrap">{title}</span>
93 <SortButton
94 column={ctx.column}
95 aria-label={`${title}でソート`}
96 title={`${title}でソート`}
97 />
98 </div>
99 );
100}
101
102export const columns: ColumnDef<PasswordRequestRow>[] = [
103 // 依頼日時(フィルタ+ソート)
104 {
105 accessorKey: "requestedAt",
106 header: (ctx) => {
107 const table = ctx.table;
108 const r = table.options.meta?.requestedRange;
109 const setR: (
110 r: import("react-day-picker").DateRange | undefined,
111 ) => void = table.options.meta?.setRequestedRange ?? (() => {});
112 const active = !!(r?.from || r?.to);
113 return (
114 <HeaderWithFilter
115 title="依頼日時"
116 active={active}
117 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]"
118 trailing={
119 <SortButton
120 column={ctx.column}
121 aria-label="依頼日時でソート"
122 title="依頼日時でソート"
123 />
124 }
125 >
126 <DateRangePicker label="依頼日時" value={r} onChange={setR} />
127 </HeaderWithFilter>
128 );
129 },
130 cell: ({ row }) => fmt(row.original.requestedAt),
131 },
132
133 // ユーザID(displayId)
134 {
135 accessorKey: "userId",
136 header: (ctx) => <HeaderWithSort title="ユーザID" ctx={ctx} />,
137 cell: ({ row }) => <span className="font-mono">{row.original.userId}</span>,
138 },
139
140 // ユーザ名
141 {
142 accessorKey: "userName",
143 header: (ctx) => <HeaderWithSort title="ユーザ名" ctx={ctx} />,
144 },
145
146 // メール
147 {
148 accessorKey: "email",
149 header: (ctx) => <HeaderWithSort title="メール" ctx={ctx} />,
150 },
151 // ★ 備考(新規:ソートのみ)
152 {
153 accessorKey: "note",
154 header: (ctx) => <HeaderWithSort title="備考" ctx={ctx} />,
155 size: 180,
156 cell: ({ row }) => (
157 <div className="max-w-[220px] truncate">{row.original.note}</div>
158 ),
159 },
160 // ロール(フィルタ+ソート)
161 {
162 accessorKey: "roleCode",
163 header: (ctx) => {
164 const table = ctx.table;
165 const roleOptions = table.options.meta?.roleOptions ?? [];
166 const roles =
167 table.options.meta?.roles ?? roleOptions.map((o) => o.value);
168 const setRoles = table.options.meta?.setRoles ?? (() => {});
169 // roles は「見做し:空=すべて」ではなく DataTable 側で“有効集合”が渡る想定なので、
170 // active 判定は roleOptions 長と比較でOK
171 const active = roles.length !== roleOptions.length;
172
173 return (
174 <HeaderWithFilter
175 title="ロール"
176 active={active}
177 contentClassName="w-[340px]"
178 trailing={
179 <SortButton
180 column={ctx.column}
181 aria-label="ロールでソート"
182 title="ロールでソート"
183 />
184 }
185 >
186 <RolesChecklist
187 value={roles}
188 onChange={setRoles}
189 options={roleOptions}
190 footer={
191 <PopoverPrimitive.Close asChild>
192 <Button
193 variant="ghost"
194 size="sm"
195 type="button"
196 className="cursor-pointer"
197 >
198 閉じる
199 </Button>
200 </PopoverPrimitive.Close>
201 }
202 />
203 </HeaderWithFilter>
204 );
205 },
206 size: 60,
207 cell: ({ row }) => {
208 const { roleCode, roleName, roleBadgeColor } = row.original;
209 const style = roleBadgeColor
210 ? { backgroundColor: roleBadgeColor, color: "#fff", border: "none" }
211 : undefined;
212 return (
213 <Badge
214 variant={roleBadgeColor ? "secondary" : "default"}
215 style={style}
216 title={roleCode}
217 >
218 {roleName}
219 </Badge>
220 );
221 },
222 },
223
224 // 状態(フィルタ + ソート)
225 {
226 accessorKey: "status",
227 header: (ctx) => {
228 const table = ctx.table;
229 const opts = table.options.meta?.pwStatusOptions ?? [];
230 const selectedRaw = table.options.meta?.pwStatuses ?? []; // 空=すべて(URL表現)
231 // ← “初期オフ表示”にするため、空配列のときは全選択と同義に解釈して active=false
232 const effectiveSelected =
233 selectedRaw.length > 0 ? selectedRaw : opts.map((o) => o.value);
234 const active = effectiveSelected.length !== opts.length;
235 const setSelected = table.options.meta?.setPwStatuses ?? (() => {});
236
237 return (
238 <HeaderWithFilter
239 title="状態"
240 active={active}
241 trailing={
242 <SortButton
243 column={ctx.column}
244 aria-label="状態でソート"
245 title="状態でソート"
246 />
247 }
248 >
249 <StatusMultiSelectPw
250 value={selectedRaw}
251 onChange={setSelected}
252 options={opts}
253 footer={
254 <PopoverPrimitive.Close asChild>
255 <Button
256 variant="ghost"
257 size="sm"
258 type="button"
259 className="cursor-pointer"
260 >
261 閉じる
262 </Button>
263 </PopoverPrimitive.Close>
264 }
265 />
266 </HeaderWithFilter>
267 );
268 },
269 cell: ({ row }) => {
270 const s = row.original.status;
271 if (s === "ISSUED") return <Badge>再発行済み</Badge>;
272 if (s === "REJECTED") return <Badge variant="destructive">拒否</Badge>;
273 return <Badge variant="outline">未処理</Badge>;
274 },
275 },
276
277 // 処理日時(フィルタ+ソート)
278 {
279 accessorKey: "processedAt",
280 header: (ctx) => {
281 const table = ctx.table;
282 const r = table.options.meta?.processedRange;
283 const setR: (
284 r: import("react-day-picker").DateRange | undefined,
285 ) => void = table.options.meta?.setProcessedRange ?? (() => {});
286 const active = !!(r?.from || r?.to);
287 return (
288 <HeaderWithFilter
289 title="処理日時"
290 active={active}
291 contentClassName="w-[268px] md:w-[520px] max-w-[90vw]"
292 trailing={
293 <SortButton
294 column={ctx.column}
295 aria-label="処理日時でソート"
296 title="処理日時でソート"
297 />
298 }
299 >
300 <DateRangePicker label="処理日時" value={r} onChange={setR} />
301 </HeaderWithFilter>
302 );
303 },
304 cell: ({ row }) => fmt(row.original.processedAt),
305 },
306
307 // 処理者
308 {
309 accessorKey: "processedBy",
310 header: (ctx) => <HeaderWithSort title="処理者" ctx={ctx} />,
311 },
312
313 // 操作(UIのみ・ServerAction未接続)
314 {
315 id: "actions",
316 header: "操作",
317 enableSorting: false,
318 enableResizing: false,
319 cell: ({ row, table }) => {
320 const r = row.original;
321 const disabled = r.status !== "PENDING";
322 return (
323 <div className="flex gap-2">
324 <Button
325 size="sm"
326 disabled={disabled}
327 onClick={() => table.options.meta?.onIssue?.(r.id, r)}
328 data-testid={`issue-btn-${r.id}`}
329 className="cursor-pointer"
330 >
331 再発行
332 </Button>
333 <Button
334 size="sm"
335 variant="outline"
336 disabled={disabled}
337 onClick={() => table.options.meta?.onReject?.(r.id, r)}
338 data-testid={`reject-btn-${r.id}`}
339 className="cursor-pointer"
340 >
341 拒否
342 </Button>
343 </div>
344 );
345 },
346 },
347
348 // hidden 検索列(ユーザID/メール/ユーザ名/処理者)
349 {
350 id: "q",
351 accessorFn: (r) =>
352 `${r.userId} ${r.email} ${r.userName} ${r.processedBy ?? ""} ${r.note ?? ""}`.toLowerCase(),
353 enableHiding: true,
354 enableSorting: false,
355 enableResizing: false,
356 size: 0,
357 header: () => null,
358 cell: () => null,
359 },
360];
○変更点の要約(差分の主眼)
観点 | 変更前 | 変更後 |
---|---|---|
ロール表示 | モック roleInfoMap | 実効ロール(code/name/badgeColor)を行ごとに受け取りバッジ表示 |
フィルタ | なし | 日付レンジ(依頼/処理)、ロール複数選択、状態複数選択を Popover UI で提供 |
備考列 | なし | 追加(ソート対象・省スペース表示) |
検索対象 | accountId/email/userName/note | userId/email/userName/processedBy/note (hidden列q で集約) |
操作列 | PENDING 以外も押下可 | status !== "PENDING" は操作ボタンを無効化 |
並び替え UI | ヘッダ文字のみ | SortButton コンポーネントで昇降順を明示 |
状態フィルタ | なし | StatusMultiSelectPw (空配列=すべての意味でURL表現と整合) |
○カラム構成(表示/機能)
カラムID | 表示 | ソート | フィルタ | 備考 |
---|---|---|---|---|
requestedAt | ✓ | ✓ | 日付レンジ | 依頼日時 |
userId | ✓ | ✓ | ― | displayId を等幅表示 |
userName | ✓ | ✓ | ― | ― |
✓ | ✓ | ― | puny→Unicode 復号済みを表示 | |
note | ✓ | ✓ | ― | 新規追加、最大幅でトリミング表示 |
roleCode(表示は name) | ✓ | ✓ | 複数選択 | badgeColor による色バッジ |
status | ✓ | ✓ | 複数選択 | PENDING/ISSUED/REJECTED |
processedAt | ✓ | ✓ | 日付レンジ | 処理日時 |
processedBy | ✓ | ✓ | ― | 処理者表示 |
actions | ✓ | ― | ― | 再発行/拒否(PENDINGのみ有効) |
q(hidden) | ― | ― | ― | 全文検索用(userId/email/userName/processedBy/note) |
○実装のポイント
- ヘッダUIの共通化:
HeaderWithFilter
とHeaderWithSort
を用意し、アイコン・Popover・並び替えボタンの統一感を確保。 - 状態フィルタの仕様:
StatusMultiSelectPw
は「空配列=すべて」を採用し、URL表現とUIの初期表示(全選択相当)を両立。 - 検索列の拡充:hidden 列
q
にnote
を加え、備考もキーワード検索にヒットさせる。 - アクセシビリティ/可読性:ロールは badgeColor がある場合のみセカンダリ表示&白文字にして可読性を担保。IDは等幅で視認性を向上。
○注意点
note
は行の横幅を圧迫しやすいため、トリミング表示(truncate
)で折り返しを抑制。- フィルタは DataTable の meta から読み書きする設計。
roleOptions / roles / setRoles
、pwStatusOptions / pwStatuses / setPwStatuses
、requestedRange / processedRange
を table meta で受け取ること。
DataTableの更新 ─ data-table.tsx
DB連携版に合わせて URL同期型フィルタ ・ 列表示の永続化 ・ サーバアクション連携 ・ CSV出力 を実装しました。UIは即時反映(楽観更新)しつつ、失敗時はトーストで通知します。
tsx
1// src/app/(protected)/users/password-request/data-table.tsx
2"use client";
3
4import * as React from "react";
5import type {
6 ColumnDef,
7 SortingState,
8 VisibilityState,
9} from "@tanstack/react-table";
10import {
11 flexRender,
12 getCoreRowModel,
13 getPaginationRowModel,
14 getSortedRowModel,
15 useReactTable,
16} from "@tanstack/react-table";
17import { Table } from "@/components/datagrid/table-container";
18import {
19 TableBody,
20 TableCell,
21 TableHead,
22 TableHeader,
23 TableRow,
24} from "@/components/ui/table";
25import type { DateRange } from "react-day-picker";
26import { format } from "date-fns";
27import { ja } from "date-fns/locale";
28import { toast } from "sonner";
29
30import type { PasswordRequestRow } from "./columns";
31import type { RoleOption } from "@/components/filters/roles-checklist";
32import { type PwReqStatus, PW_STATUS_LABEL } from "./status-multi-select";
33import {
34 issuePasswordRequestAction,
35 rejectPasswordRequestAction,
36} from "@/app/_actions/users/password-requests";
37
38// 共通フック&ユーティリティ
39import { useDatagridQueryState } from "@/lib/datagrid/use-datagrid-query-state";
40import { usePersistentDatagridState } from "@/lib/datagrid/use-persistent-datagrid-state";
41import { fromDateRange, toDateRange } from "@/lib/datagrid/date-io";
42import { buildCsv, downloadCsv, fmtDateTime } from "@/lib/datagrid/csv";
43
44// UI 部品
45import { DatagridToolbar } from "@/components/datagrid/datagrid-toolbar";
46import { DatagridSummary } from "@/components/datagrid/datagrid-summary";
47import { DatagridPagination } from "@/components/datagrid/datagrid-pagination";
48
49type Props = {
50 columns: ColumnDef<PasswordRequestRow, unknown>[];
51 data: PasswordRequestRow[];
52 roleOptions: RoleOption[];
53 statusOptions: { value: PwReqStatus; label: string }[];
54 canDownloadData?: boolean;
55};
56
57export default function DataTable({
58 columns,
59 data,
60 roleOptions,
61 statusOptions,
62 canDownloadData = false,
63}: Props) {
64 const [mounted, setMounted] = React.useState(false);
65 React.useEffect(() => setMounted(true), []);
66
67 // 列ID(actions / q 除外)
68 const allColumnIds = React.useMemo(
69 () =>
70 [
71 "requestedAt",
72 "userId",
73 "userName",
74 "email",
75 "note",
76 "roleCode",
77 "status",
78 "processedAt",
79 "processedBy",
80 ] as const,
81 [],
82 );
83 type ColId = (typeof allColumnIds)[number];
84
85 // URL同期(statuses は空配列=すべて)
86 const [queryState, setQueryState] = useDatagridQueryState(
87 "password-reqs",
88 {
89 q: "",
90 roles: [] as string[],
91 statuses: [] as PwReqStatus[],
92 requestedRange: undefined as { from?: string; to?: string } | undefined,
93 processedRange: undefined as { from?: string; to?: string } | undefined,
94 cols: Array.from(allColumnIds) as ColId[],
95 },
96 { persistKey: "password-reqs" },
97 );
98
99 // ページサイズだけLS(デフォルト20件)
100 const [persisted, setPersisted] = usePersistentDatagridState("pw-reqs", {
101 pageSize: 20,
102 });
103
104 // 並び順(ローカル)
105 const [sorting, setSorting] = React.useState<SortingState>([
106 { id: "requestedAt", desc: true },
107 ]);
108
109 // 行(UI即時反映用)
110 const [localRows, setLocalRows] = React.useState<PasswordRequestRow[]>(
111 () => data,
112 );
113 React.useEffect(() => setLocalRows(data), [data]);
114
115 // ロール(空配列=すべて)
116 const allRoleCodes = React.useMemo(
117 () => roleOptions.map((o) => o.value),
118 [roleOptions],
119 );
120 const rolesForFilter = queryState.roles.length
121 ? queryState.roles
122 : allRoleCodes;
123
124 // 状態
125 const pwStatusOptions = React.useMemo(
126 () =>
127 Array.from(new Set(localRows.map((r) => r.status))).map((s) => ({
128 value: s as PwReqStatus,
129 label: PW_STATUS_LABEL[s as PwReqStatus],
130 })),
131 [localRows],
132 );
133 // ↓これを pwStatusOptions から作る(空配列=すべて)
134 const allStatusCodes = React.useMemo(
135 () => pwStatusOptions.map((o) => o.value),
136 [pwStatusOptions],
137 );
138 const statusesForFilter = queryState.statuses.length
139 ? queryState.statuses
140 : allStatusCodes;
141 // 日付レンジ
142 const requestedRange = React.useMemo(
143 () => toDateRange(queryState.requestedRange),
144 [queryState.requestedRange],
145 );
146 const processedRange = React.useMemo(
147 () => toDateRange(queryState.processedRange),
148 [queryState.processedRange],
149 );
150
151 // setter 群
152 const setQ = (v: string) => setQueryState((s) => ({ ...s, q: v }));
153 const setRoles = (next: string[]) =>
154 setQueryState((s) => ({ ...s, roles: next }));
155 const setPwStatuses = (next: PwReqStatus[]) =>
156 setQueryState((s) => ({ ...s, statuses: next }));
157 const setRequestedRange = (r?: DateRange) =>
158 setQueryState((s) => ({ ...s, requestedRange: fromDateRange(r) }));
159 const setProcessedRange = (r?: DateRange) =>
160 setQueryState((s) => ({ ...s, processedRange: fromDateRange(r) }));
161 const setVisibleColumnIds = (ids: ColId[]) =>
162 setQueryState((s) => ({ ...s, cols: ids }));
163
164 // 操作(UIのみ/サーバアクション差し替え前提)
165 const onIssue = React.useCallback(
166 async (id: string) => {
167 const fd = new FormData();
168 fd.append("id", id);
169
170 try {
171 const res = await issuePasswordRequestAction(fd);
172 if (!res.ok) {
173 toast.error(res.message || "再発行に失敗しました。");
174 return;
175 }
176
177 // ローカル行更新(即時反映)
178 setLocalRows((prev) =>
179 prev.map((r) =>
180 r.id === id
181 ? {
182 ...r,
183 status: "ISSUED",
184 processedAt: new Date(),
185 processedBy: "(you)",
186 }
187 : r,
188 ),
189 );
190 toast.success("パスワードを再発行し、通知メールを送信しました。");
191 } catch (e) {
192 console.error("[password-req] issue failed:", e);
193 toast.error("サーバーエラーが発生しました。");
194 }
195 },
196 [setLocalRows],
197 );
198
199 const onReject = React.useCallback(
200 async (id: string) => {
201 const fd = new FormData();
202 fd.append("id", id);
203
204 try {
205 const res = await rejectPasswordRequestAction(fd);
206 if (!res.ok) {
207 toast.error(res.message || "拒否に失敗しました。");
208 return;
209 }
210
211 setLocalRows((prev) =>
212 prev.map((r) =>
213 r.id === id
214 ? {
215 ...r,
216 status: "REJECTED",
217 processedAt: new Date(),
218 processedBy: "(you)",
219 }
220 : r,
221 ),
222 );
223 toast.message("依頼を拒否しました。");
224 } catch (e) {
225 console.error("[password-req] reject failed:", e);
226 toast.error("サーバーエラーが発生しました。");
227 }
228 },
229 [setLocalRows],
230 );
231
232 // 可視列(SSR一致のため mount 後にURLの列を採用)
233 const effectiveVisibleColumnIds: ColId[] = mounted
234 ? (queryState.cols as ColId[])
235 : (Array.from(allColumnIds) as ColId[]);
236 const columnVisibility = React.useMemo<VisibilityState>(() => {
237 const set = new Set(effectiveVisibleColumnIds);
238 return Object.fromEntries(
239 (["actions", "q", ...allColumnIds] as const).map((id) => [
240 id,
241 id === "actions" ? true : set.has(id as ColId),
242 ]),
243 ) as VisibilityState;
244 }, [effectiveVisibleColumnIds, allColumnIds]);
245
246 // フィルタ
247 const filteredData = React.useMemo(() => {
248 const needle = queryState.q.trim().toLowerCase();
249 const roleSet = new Set(rolesForFilter);
250 const statusSet = new Set(statusesForFilter);
251 const inRange = (d?: Date | null, r?: DateRange) => {
252 if (!d) return false;
253 if (!r?.from && !r?.to) return true;
254 const ts = d.getTime();
255 if (r?.from && ts < new Date(r.from).setHours(0, 0, 0, 0)) return false;
256 if (r?.to && ts > new Date(r.to).setHours(23, 59, 59, 999)) return false;
257 return true;
258 };
259
260 return localRows.filter((r) => {
261 const passQ =
262 !needle ||
263 `${r.userId} ${r.email} ${r.userName} ${r.processedBy ?? ""} ${r.note ?? ""}`
264 .toLowerCase()
265 .includes(needle);
266 const passRole = r.roleCode
267 ? roleSet.has(r.roleCode)
268 : roleSet.size === rolesForFilter.length;
269 const passStatus = statusSet.has(r.status);
270 const passRequested = inRange(r.requestedAt, requestedRange);
271 const passProcessed = r.processedAt
272 ? inRange(r.processedAt, processedRange)
273 : !processedRange?.from && !processedRange?.to;
274 return passQ && passRole && passStatus && passRequested && passProcessed;
275 });
276 }, [
277 localRows,
278 queryState.q,
279 rolesForFilter,
280 statusesForFilter,
281 requestedRange,
282 processedRange,
283 ]);
284
285 // テーブル
286 const table = useReactTable({
287 data: filteredData,
288 columns,
289 state: { sorting, columnVisibility },
290 onSortingChange: setSorting,
291 getCoreRowModel: getCoreRowModel(),
292 getSortedRowModel: getSortedRowModel(),
293 getPaginationRowModel: getPaginationRowModel(),
294 initialState: { pagination: { pageIndex: 0, pageSize: 20 } },
295 meta: {
296 onIssue,
297 onReject,
298 // フィルタUIが読むメタ
299 roleOptions,
300 roles: rolesForFilter,
301 setRoles,
302 pwStatusOptions, // ← 修正
303 pwStatuses: queryState.statuses,
304 setPwStatuses,
305 requestedRange,
306 setRequestedRange,
307 processedRange,
308 setProcessedRange,
309 },
310 });
311
312 // mount後にLSのpageSizeを反映
313 React.useEffect(() => {
314 if (mounted) table.setPageSize(persisted.pageSize);
315 }, [mounted, persisted.pageSize, table]);
316
317 // CSV(可視列のみ)
318 const columnLabels = {
319 requestedAt: "依頼日時",
320 userId: "ユーザID",
321 userName: "ユーザ名",
322 email: "メール",
323 note: "備考",
324 roleCode: "ロール",
325 status: "状態",
326 processedAt: "処理日時",
327 processedBy: "処理者",
328 } as const;
329
330 const onDownloadCsv = React.useCallback(() => {
331 const visibleLeaf = table
332 .getVisibleLeafColumns()
333 .map((c) => c.id)
334 .filter((id) => id !== "actions" && id !== "q") as ColId[];
335
336 const headers = visibleLeaf.map((id) => columnLabels[id]);
337
338 const rows = filteredData.map((r) =>
339 visibleLeaf.map((id) => {
340 switch (id) {
341 case "requestedAt":
342 return fmtDateTime(r.requestedAt);
343 case "userId":
344 return r.userId;
345 case "userName":
346 return r.userName;
347 case "email":
348 return r.email;
349 case "note":
350 return r.note ?? "";
351 case "roleCode":
352 return r.roleName ?? r.roleCode;
353 case "status":
354 return r.status === "PENDING"
355 ? "未処理"
356 : r.status === "ISSUED"
357 ? "再発行済"
358 : "拒否";
359 case "processedAt":
360 return r.processedAt ? fmtDateTime(r.processedAt) : "";
361 case "processedBy":
362 return r.processedBy ?? "";
363 default:
364 return "";
365 }
366 }),
367 );
368
369 const csv = buildCsv(headers, rows);
370 const ts = format(new Date(), "yyyyMMdd_HHmmss", { locale: ja });
371 downloadCsv(`password_requests_${ts}.csv`, csv);
372 }, [filteredData, table]);
373
374 const filteredCount = filteredData.length;
375 const visibleColsText = (mounted ? effectiveVisibleColumnIds : allColumnIds)
376 .map((id) => columnLabels[id])
377 .join(", ");
378
379 const roleText =
380 queryState.roles.length === 0
381 ? "ロール: すべて"
382 : `ロール: ${queryState.roles
383 .map(
384 (v) =>
385 new Map(roleOptions.map((o) => [o.value, o.label])).get(v) ?? v,
386 )
387 .join(", ")}`;
388
389 const statusText =
390 queryState.statuses.length === 0
391 ? "状態: すべて"
392 : `状態: ${queryState.statuses
393 .map((s) => pwStatusOptions.find((o) => o.value === s)?.label ?? s)
394 .join(", ")}`;
395
396 return (
397 <div className="space-y-3">
398 <DatagridToolbar<ColId>
399 qTitle={`${columnLabels.userId}/${columnLabels.userName}/${columnLabels.email}/${columnLabels.processedBy}/${columnLabels.note}`}
400 q={queryState.q}
401 onChangeQ={setQ}
402 columnOptions={allColumnIds.map((id) => ({
403 value: id,
404 label: columnLabels[id],
405 }))}
406 visibleColumnIds={
407 mounted
408 ? (queryState.cols as ColId[])
409 : (Array.from(allColumnIds) as ColId[])
410 }
411 onChangeVisibleColumns={setVisibleColumnIds}
412 canDownloadData={canDownloadData}
413 onDownloadCsv={onDownloadCsv}
414 />
415
416 {/* 件数・サマリ・全解除 */}
417 <div className="flex items-center justify-between gap-3">
418 <div className="text-sm" data-testid="count">
419 表示件数: {filteredCount} 件
420 </div>
421 <div className="flex max-w-[60%] items-center justify-end gap-2">
422 <DatagridSummary
423 mounted={mounted}
424 roleText={roleText}
425 statusText={statusText}
426 createdTitle="依頼"
427 updatedTitle="処理"
428 createdRangeISO={queryState.requestedRange}
429 updatedRangeISO={queryState.processedRange}
430 createdRange={requestedRange}
431 updatedRange={processedRange}
432 visibleColsText={visibleColsText}
433 />
434 <button
435 type="button"
436 className="text-muted-foreground shrink-0 cursor-pointer text-xs underline"
437 title="全フィルタ解除"
438 onClick={() => {
439 setQueryState((s) => ({
440 ...s,
441 q: "",
442 roles: [],
443 statuses: [],
444 requestedRange: undefined,
445 processedRange: undefined,
446 cols: Array.from(allColumnIds) as ColId[],
447 }));
448 setPersisted((p) => ({ ...p, pageSize: 20 }));
449 table.setPageSize(20);
450 }}
451 >
452 全フィルタ解除
453 </button>
454 </div>
455 </div>
456
457 {/* テーブル */}
458 <div className="overflow-x-auto rounded-md border pb-1">
459 <Table className="w-full" data-testid="pw-requests-table">
460 <TableHeader className="bg-muted/60 sticky top-0 z-20 text-xs backdrop-blur">
461 {table.getHeaderGroups().map((hg) => (
462 <TableRow key={hg.id}>
463 {hg.headers.map((header) => (
464 <TableHead
465 key={header.id}
466 style={{ width: header.column.getSize() }}
467 >
468 {header.isPlaceholder
469 ? null
470 : flexRender(
471 header.column.columnDef.header,
472 header.getContext(),
473 )}
474 </TableHead>
475 ))}
476 </TableRow>
477 ))}
478 </TableHeader>
479 <TableBody>
480 {table.getRowModel().rows.length ? (
481 table.getRowModel().rows.map((row) => (
482 <TableRow key={row.id} data-testid={`row-${row.original.id}`}>
483 {row.getVisibleCells().map((cell) => (
484 <TableCell
485 key={cell.id}
486 style={{ width: cell.column.getSize() }}
487 >
488 {flexRender(
489 cell.column.columnDef.cell,
490 cell.getContext(),
491 )}
492 </TableCell>
493 ))}
494 </TableRow>
495 ))
496 ) : (
497 <TableRow>
498 <TableCell
499 colSpan={table.getAllColumns().length}
500 className="text-muted-foreground py-10 text-center text-sm"
501 >
502 条件に一致する依頼が見つかりませんでした。
503 </TableCell>
504 </TableRow>
505 )}
506 </TableBody>
507 </Table>
508 </div>
509
510 <DatagridPagination<PasswordRequestRow>
511 table={table}
512 pageSize={table.getState().pagination.pageSize}
513 onChangePageSize={(n) => {
514 table.setPageSize(n);
515 setPersisted((p) => ({ ...p, pageSize: n }));
516 }}
517 />
518 </div>
519 );
520}
○変更点の要約(差分の主眼)
観点 | 変更前 | 変更後 |
---|---|---|
データソース | モック配列をそのまま表示 | SSRで取得した行をローカル状態に取り込み(localRows ) |
フィルタ同期 | ローカルのみ | useDatagridQueryState でURLと同期(復帰や共有に強い) |
ページサイズ | 固定10件 | usePersistentDatagridState でLS永続化(初期20件) |
列表示制御 | なし | 可視列IDをURLに保持、VisibilityState で反映 |
状態フィルタ | 単一選択 | PwReqStatus[] (空=すべて)複数選択に対応 |
ロールフィルタ | 単一選択 | 複数選択(空=すべて)に変更 |
日付フィルタ | なし | 依頼日時/処理日時のレンジフィルタを追加 |
操作(再発行/拒否) | モック更新 | Server Action呼び出し+楽観更新+トースト通知 |
CSV出力 | なし | 可視列のみを対象にUTF-8 CSVを生成・DL |
検索対象 | 手入力フィルタ | hidden列q 相当をDataTable側で再現(note含む) |
○UIフロー(概略)
ユーザ操作(検索/フィルタ/列表示/ページサイズ/再発行/拒否)
→ URL/LSへ同期 → テーブル描画更新
→ (操作系のみ)Server Action実行 → 成否トースト/ローカル行更新
○フィルタ/ソート仕様(実装ルール)
種別 | 入力 | 同期先 | 振る舞い |
---|---|---|---|
キーワード | q | URL | userId/email/userName/processedBy/note に部分一致 |
ロール | roles: string[] | URL | 空=すべて。roleCode 空行は「すべて扱い」に合流 |
状態 | statuses: PwReqStatus[] | URL | 空=すべて。UIは全選択と同義で表示 |
依頼日時 | requestedRange | URL | 日単位(from/toを00:00〜23:59で判定) |
処理日時 | processedRange | URL | 値なしは未フィルタ。processedAt がnullの場合は除外 |
並び順 | sorting | ローカル | 既定:依頼日時降順 |
○操作(Server Action連携)
操作 | 呼び出し | 成功時のローカル反映 | 失敗時の挙動 |
---|---|---|---|
再発行 | issuePasswordRequestAction(FormData{id}) | status=ISSUED ・processedAt=now ・processedBy="(you)" | エラートースト表示(行は不変) |
拒否 | rejectPasswordRequestAction(FormData{id}) | status=REJECTED ・processedAt=now ・processedBy="(you)" | エラートースト表示(行は不変) |
○CSV出力の仕様
- 可視列のみを対象(
actions/q
は除外)。 - ラベルは日本語(例:
依頼日時/ユーザID/備考/…
)。 - 日付は
YYYY/MM/DD HH:mm
、状態は「未処理/再発行済/拒否」に変換。 - ファイル名:
password_requests_YYYYMMDD_HHMMSS.csv
○注意点(運用・実装)
- SSR時の列表示ギャップを避けるため、mount前は全列表示→mount後にURLの可視列へ揃えます。
pwStatusOptions
は 実データから動的生成(一覧に存在しない状態は候補に出さない)。- 楽観更新はUI即時性優先。サーバ失敗時はトーストで通知し、必要に応じて再読み込みで整合を取る想定です。
この章では、以上7ファイルの改修を通じて、「依頼 → 管理画面受付 → 再発行通知」までの一連のサイクルを完成させました。
次章は、ついでの作業です。本記事の2章でメールテンプレートを整理しましたので、同様にメール通知をしている箇所を修正します。
次章は、ついでの作業です。本記事の2章でメールテンプレートを整理しましたので、同様にメール通知をしている箇所を修正します。
4. メールテンプレートを一元管理する
本文で扱うのは 認証メール/承認通知/ようこそメール のテンプレート統合です。第2章の
adminPasswordForgotNotify
と第3章の passwordIssued
は既に説明済みのため、本章では emailChangeVerify / emailChangeApproved / userWelcome / adminEmailChangeVerifiedNotify に絞って整理します。すべてのテンプレートは 件名(subject)と本文(text)を返す純関数 とし、各 Server Action から同一の呼び出し規約で利用できるように統一しました。ts
1// src/lib/email/templates.ts
2const { APP_ORIGIN } = process.env;
3
4export function buildVerifyUrl(token: string) {
5 const origin = APP_ORIGIN ?? "http://localhost:3000";
6 const url = new URL("/profile/email/verify", origin);
7 url.searchParams.set("token", token);
8 return url.toString();
9}
10
11export function buildLoginUrl() {
12 const origin = APP_ORIGIN ?? "http://localhost:3000";
13 return new URL("/", origin).toString();
14}
15
16/** メールアドレス変更:認証メール(新メール宛) */
17export function emailChangeVerify(params: {
18 newEmail: string;
19 token: string;
20 expiresAt: Date;
21}) {
22 const url = buildVerifyUrl(params.token);
23 const until = params.expiresAt.toLocaleString("ja-JP", {
24 timeZone: "Asia/Tokyo",
25 });
26 const subject = "【DELOGs】メールアドレス変更の確認";
27 const text = [
28 "DELOGsシステムよりの自動返信です。このメールに返信いただいても応答できませんのでご了承ください。",
29 "",
30 "メールアドレス変更の確認のため、以下のURLをクリックしてください。ログイン画面が表示される場合は、変更前のメールアドレスでログインしてください。",
31 "",
32 url,
33 "",
34 `上記URLの有効期限:${until} まで`,
35 "",
36 `変更予定のメールアドレス:${params.newEmail}`,
37 "",
38 "上記URLへアクセス後に管理者の承認が完了すると、メールアドレスの変更が完了します。",
39 "",
40 "",
41 "※氏名や変更前アドレスはセキュリティを考慮して記載していません。",
42 "※このメールに心当たりがない場合は破棄してください。",
43 ].join("\n");
44 return { subject, text };
45}
46
47/** メールアドレス変更:承認完了(新メール宛) */
48export function emailChangeApproved(params: {
49 newEmail: string; // punycode (ASCII)可
50}) {
51 const subject = "【DELOGs】メールアドレス変更が承認されました";
52 const text = [
53 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。",
54 "",
55 "メールアドレス変更の申請が管理者により承認されました。",
56 "",
57 `■ 変更後メール:${params.newEmail}`,
58 "",
59 "以後のログイン・通知は上記の新しいメールアドレスが対象となります。",
60 "",
61 "※このメールに心当たりがない場合は、管理者へお問い合わせください。",
62 ].join("\n");
63 return { subject, text };
64}
65
66/** 新規ユーザ:ようこそメール(本人宛) */
67export function userWelcome(params: {
68 name: string;
69 email: string;
70 departmentCode: string;
71 initialPassword: string;
72}) {
73 const loginUrl = buildLoginUrl();
74 const subject = "【DELOGs】アカウント発行のお知らせ";
75 const text = [
76 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。",
77 "",
78 `${params.name} 様`,
79 "",
80 "アカウントが作成されました。以下の情報でログインしてください。",
81 "",
82 `ログインURL:${loginUrl}`,
83 `部署コード :${params.departmentCode}`,
84 `メール :${params.email}`,
85 `初期パスワード:${params.initialPassword}`,
86 "",
87 "※ 初回ログイン後にパスワードを変更してください。",
88 "※ このメールに心当たりがない場合は、管理者へお問い合わせください。",
89 ].join("\n");
90 return { subject, text };
91}
92
93/** パスワード再発行:本人通知(依頼メール宛) */
94export function passwordIssued(params: {
95 name?: string;
96 email: string; // punycode (ASCII) 可
97 newPassword: string; // 平文(再発行ワンショット)
98}) {
99 const loginUrl = buildLoginUrl();
100 const subject = "【DELOGs】パスワードを再発行しました";
101 const text = [
102 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。",
103 "",
104 params.name ? `${params.name} 様` : "ご担当者様",
105 "",
106 "パスワードを再発行しました。以下の情報でログインしてください。",
107 "",
108 `ログインURL:${loginUrl}`,
109 `メール :${params.email}`,
110 `新パスワード:${params.newPassword}`,
111 "",
112 "※ セキュリティのため、ログイン後にパスワードを変更してください。",
113 "※ このメールに心当たりがない場合は、管理者へお問い合わせください。",
114 ].join("\n");
115 return { subject, text };
116}
117
118/** 管理者向け:パスワード再発行依頼の着信通知 */
119export function adminPasswordForgotNotify(params: {
120 accountId: string;
121 email: string;
122 note?: string;
123 ip?: string;
124 ua?: string;
125}) {
126 const subject = "【DELOGs】パスワード再発行依頼が届きました";
127 const lines = [
128 "公開フォームからパスワード再発行依頼を受け付けました。",
129 "",
130 `■ 部署コード入力:${params.accountId}`,
131 `■ 申請メール :${params.email}`,
132 params.note ? `■ 備考 :${params.note}` : null,
133 params.ip ? `■ IP :${params.ip}` : null,
134 params.ua ? `■ UA :${params.ua}` : null,
135 "",
136 "管理画面の「ユーザ管理 > パスワード再発行依頼」から処理してください。",
137 ].filter(Boolean);
138 return { subject, text: lines.join("\n") };
139}
140
141/** 管理者向け:メール変更の本人認証(VERIFIED)が完了した通知 */
142export function adminEmailChangeVerifiedNotify(params: {
143 userName: string;
144 userEmail: string;
145}) {
146 const subject = "【DELOGs】メール変更リクエストが確認されました";
147 const text = [
148 `ユーザ ${params.userName} (${params.userEmail}) が新しいメールアドレスを認証しました。`,
149 "",
150 "管理者画面にて承認または却下を行ってください。",
151 ].join("\n");
152 return { subject, text };
153}
方針と命名規約
テンプレートは「 何を送るか 」に基づく動詞+目的語の形式で命名し、返り値は
{ subject, text }
に統一します。アプリの URL は APP_ORIGIN
由来の buildLoginUrl / buildVerifyUrl を介して生成し、 環境依存の差異をテンプレート内に閉じ込める 方針です。種別 | 関数名 | 送信対象 | 目的 |
---|---|---|---|
メール変更の本人確認 | emailChangeVerify | 本人(新メール) | 認証リンクと有効期限を通知 |
メール変更の承認完了 | emailChangeApproved | 本人(新メール) | 管理者承認の完了を通知 |
新規ユーザの案内 | userWelcome | 本人 | 初期パスワードとログイン情報を通知 |
メール変更の本人認証完了(管理者向け) | adminEmailChangeVerifiedNotify | 管理者 | VERIFIED 到達を全管理者へ周知 |
既存Actionの差し替えポイント
各 Server Action は import の置き換え と 送信部のワンライナー化のみで移行できます。以下、変更済みの4箇所について、利用位置だけ示します。
ファイル | 利用するテンプレート | 差し替え概要 |
---|---|---|
src/app/_actions/profile/email-change.ts | emailChangeVerify | 旧 emailChangeText を置換。const { subject, text } = emailChangeVerify(...) → sendMail({ to, subject, text }) |
src/app/_actions/users/email-change-requests.ts | emailChangeApproved | 承認完了の本人通知を統一APIで送信 |
src/app/_actions/users/create-user.ts | userWelcome | ウェルカムメールの件名・本文生成をテンプレ化 |
src/app/_actions/profile/email-verify.ts | adminEmailChangeVerifiedNotify | VERIFIED 到達時、実効 priority≥100 の管理者全員へ通知(並列送信) |
A. src/app/_actions/profile/email-change.ts
ts
1// 先頭の import を変更
2// import { emailChangeText } from "@/lib/email/templates";
3import { emailChangeVerify } from "@/lib/email/templates";
4
5// 送信部を差し替え
6const { subject, text } = emailChangeVerify({
7 newEmail: newEmailAscii,
8 token,
9 expiresAt,
10});
11await sendMail({ to: newEmailAscii, subject, text });
B. src/app/_actions/users/email-change-requests.ts
ts
1// import { emailChangeApprovedText } from "@/lib/email/templates";
2import { emailChangeApproved } from "@/lib/email/templates";
3
4// 承認通知メール送信
5const { subject, text } = emailChangeApproved({ newEmail: req.newEmailPuny });
6await sendMail({ to: req.newEmailPuny, subject, text });
C. src/app/_actions/users/create-user.ts
ts
1// import { userWelcomeText } from "@/lib/email/templates";
2import { userWelcome } from "@/lib/email/templates";
3
4// 送信部
5const { subject, text } = userWelcome({
6 name: created.name,
7 email: normalizedEmail,
8 departmentCode: created.department.code,
9 initialPassword: password,
10});
11await sendMail({ to: normalizedEmail, subject, text });
D. src/app/_actions/profile/email-verify.ts
ts
1import { adminEmailChangeVerifiedNotify } from "@/lib/email/templates";
2
3// ...
4await Promise.allSettled(
5 admins.map((a) => {
6 const mail = adminEmailChangeVerifiedNotify({
7 userName: req.user.name,
8 userEmail: req.user.email,
9 });
10 return sendMail({ to: a.email, subject: mail.subject, text: mail.text });
11 }),
12);
変更の狙い(実運用視点)
課題 | 統一後の解決 |
---|---|
ファイルごとに件名/本文を直書きし、表記揺れが発生 | テンプレ関数に集約し、表記・署名・語尾を統一 |
送信ロジックにURL生成や時刻整形が散在 | buildLoginUrl / buildVerifyUrl とテンプレ内部で完結 |
各所で例外処理やログメッセージがばらつく | Server Action側は sendMail({ to, subject, text }) のみ。例外ログは一箇所で書式統一 |
マルチリンガル/ブランド名変更への耐性が低い | 件名・本文の一元管理で差し替え容易(将来の i18n 入口) |
profile/email-verify.ts
の整理(通知対象の厳密化)
これは、今回の記事からは脱線しますが、第2章基準に合わせ、**管理者判定を「実効 priority≥100」**へ統一しました。Role直付け/DepartmentRole(override/custom)のいずれも同一しきい値で抽出し、
adminEmailChangeVerifiedNotify
を用いて並列送信します。これにより 通知漏れ と 過剰通知 の双方を抑制します。ts
1// src/app/_actions/profile/email-verify.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { sendMail } from "@/lib/mailer";
7import { adminEmailChangeVerifiedNotify } from "@/lib/email/templates";
8
9export type VerifyResult = { ok: true } | { ok: false; message: string };
10
11// PasswordForgot と同基準で統一
12const ADMIN_PRIORITY_THRESHOLD = 100;
13
14export async function verifyEmailChangeAction(
15 token: string,
16): Promise<VerifyResult> {
17 // 1) 認証
18 const session = await lookupSessionFromCookie();
19 if (!session.ok) {
20 return { ok: false, message: "認証にはログインが必要です" };
21 }
22
23 // 2) リクエスト取得
24 const req = await prisma.emailChangeRequest.findUnique({
25 where: { token },
26 include: { user: true },
27 });
28 if (!req) {
29 return {
30 ok: false,
31 message: "無効なURLです。認証URLを確認して再度アクセスしてください",
32 };
33 }
34 if (req.userId !== session.userId) {
35 return { ok: false, message: "ログインユーザーが一致しません" };
36 }
37
38 // 3) 期限・状態チェック
39 const now = new Date();
40 if (req.expiresAt < now) {
41 await prisma.emailChangeRequest.update({
42 where: { id: req.id },
43 data: { status: "EXPIRED" },
44 });
45 return { ok: false, message: "このURLは有効期限が切れています" };
46 }
47
48 if (["APPROVED", "REJECTED"].includes(req.status)) {
49 return { ok: false, message: "すでに処理済みの申請です" };
50 }
51
52 // 4) VERIFIED に更新
53 await prisma.emailChangeRequest.update({
54 where: { id: req.id },
55 data: { status: "VERIFIED" },
56 });
57
58 // 5) ADMIN 全員へ通知(実効priority>=100 を網羅)
59 // - Role 直付け
60 // - DepartmentRole override(参照Roleのpriority)
61 // - DepartmentRole custom(自前priority)
62 const admins = await prisma.user.findMany({
63 where: {
64 deletedAt: null,
65 isActive: true,
66 departmentId: req.departmentId,
67 OR: [
68 // Role 直付け
69 {
70 role: {
71 is: {
72 isActive: true,
73 priority: { gte: ADMIN_PRIORITY_THRESHOLD },
74 },
75 },
76 },
77 // DepartmentRole: override(参照Roleのpriorityで判定、DRは有効)
78 {
79 departmentRole: {
80 is: {
81 isEnabled: true,
82 role: {
83 is: { priority: { gte: ADMIN_PRIORITY_THRESHOLD } },
84 },
85 },
86 },
87 },
88 // DepartmentRole: custom(DR.priorityで判定、DRは有効)
89 {
90 departmentRole: {
91 is: {
92 isEnabled: true,
93 roleId: null,
94 priority: { gte: ADMIN_PRIORITY_THRESHOLD },
95 },
96 },
97 },
98 ],
99 },
100 select: { email: true },
101 });
102
103 // 失敗しても致命にはしない(並列で送る)
104 await Promise.allSettled(
105 admins.map((a) => {
106 const mail = adminEmailChangeVerifiedNotify({
107 userName: req.user.name,
108 userEmail: req.user.email,
109 });
110 return sendMail({ to: a.email, subject: mail.subject, text: mail.text });
111 }),
112 );
113
114 return { ok: true };
115}
○参照の目安(テンプレAPIの設計意図)
項目 | 仕様 |
---|---|
返り値 | 常に { subject, text } |
引数 | 文面生成に必要な最小限(メール・氏名・トークン・期限など) |
改行/全角記号 | 文中のレイアウトはテンプレ側で管理(本文に \n を含めて返す) |
URL/時刻 | テンプレ側で build*Url() と toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }) を適用 |
互換性 | 旧API(*Text )は本記事時点で未使用化。以降は新APIのみを使用 |
この章の目的は「どのメールも同じ作法で送れる」状態を作ることです。以後はテンプレート側の修正だけで、件名ルールや文面統一・リンク方針の変更を一括反映できます。
5. まとめと次回予告
今回の記事では、 ログイン前のパスワード再発行依頼から、管理画面での処理・通知まで を一連のフローとして実装しました。
これまでモックデータで動かしていた依頼処理を完全に DB連携+Server Action構成 に移行し、セキュリティと運用効率の両立を実現しています。
これまでモックデータで動かしていた依頼処理を完全に DB連携+Server Action構成 に移行し、セキュリティと運用効率の両立を実現しています。
txt
1[Before] [After]
2フォーム送信 → console.log 公開フォーム → PasswordRequest保存
3 ↓
4 管理者メール通知(adminPasswordForgotNotify)
5 ↓
6 管理画面で再発行/拒否(Server Action)
7 ↓
8 本人へパスワード通知(passwordIssued)
これにより、
- フォーム入力時の部署コード+メールの突合
- 登録ユーザが存在しない場合の「存在秘匿」処理
- 管理者への依頼通知(メール)
- Server Action 経由での再発行/拒否処理
- 本人へのワンタイムパスワード通知
までを統一的なルールで構築できました。
今回の実装ポイント(総括)
項目 | 役割 | 補足 |
---|---|---|
PasswordRequest モデル | 再発行依頼をDBで管理 | 部署・ユーザを解決できなくても保存 |
passwordForgotAction | 公開フォーム受付 | 部署コード+メールで突合、存在秘匿対応 |
password-requests.ts | 管理者の再発行・拒否アクション | 権限チェック+argon2+トランザクション処理 |
data-table.tsx | 再発行一覧UI | URL同期フィルタ+即時反映+CSV出力 |
templates.ts | メール文面の一元化 | すべて { subject, text } 形式で統一 |
全体を通して、「内部と外部の接点」となる再発行フローを安全に整備できた点が最大の成果です。
データベース構造・通知テンプレート・UI の三層が揃ったことで、他の依頼系フロー(例:アカウント登録依頼や退職者申請など)への横展開も容易になりました。
データベース構造・通知テンプレート・UI の三層が揃ったことで、他の依頼系フロー(例:アカウント登録依頼や退職者申請など)への横展開も容易になりました。
次回予告
次回は、これまで構築してきた 管理画面フォーマットのデモ環境 を紹介し、 GitHub上で公開 していきます。
しばらく間が空くかもしれませんが、管理画面フォーマット開発の集大成として、 誰でも動かせるサンプル環境を仕上げていきます。
参考文献
区分 | 資料名/URL | 内容概要 |
---|---|---|
Prisma公式 | https://www.prisma.io/docs | モデル定義・enum追加・リレーション管理に関するリファレンス |
Next.js公式 | https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions | Server Actionによる安全なサーバサイド処理の実装指針 |
shadcn/ui | https://ui.shadcn.com/ | UI構築で使用しているコンポーネントライブラリ |
React Hook Form | https://react-hook-form.com/ | ログイン・再発行フォームなどのバリデーション設計に利用 |
Zod | https://zod.dev/ | 入力検証やFormDataの安全なパース処理に活用 |
argon2公式実装 | https://github.com/ranisalt/node-argon2 | パスワードハッシュ処理(Argon2id)の採用元 |
date-fns | https://date-fns.org/ | DataTable上での日時フォーマット処理に使用 |
punycode | https://github.com/bestiejs/punycode.js | 国際化ドメインを含むメールアドレスの安全な変換処理 |
Sonner | https://sonner.emilkowal.ski/ | 操作完了・エラー通知を行うトースト通知ライブラリ |
DELOGs既存記事 | https://delogs.jp/next-js/backend/format-profile | メールアドレス変更機能(認証・承認)との連携基盤となった記事 |
DELOGs既存記事 | https://delogs.jp/next-js/shadcn-ui/format-404-password-forgot | パスワード忘れ導線UI(公開フォーム部分)の前提実装 |
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #10] メニュー管理UIをDB連携する
グローバルで一貫したMenuテーブルを保ちながら、部署ごとにメニュー表示をカスタマイズ
2025/10/12公開
![[管理画面フォーマット開発編 #10] メニュー管理UIをDB連携するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-menu%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修
DepartmentRole導入に伴い、プロフィール管理で「実効ロール」を参照するように修正と一部ついでの変更
2025/10/8公開
![[管理画面フォーマット開発編 #9 後編] 部署別ロール対応 ─ プロフィール管理の改修のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
2025/10/5公開
![[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-users%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開
![[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計
グローバルで一貫したRoleテーブルを保ちながら、部署ごとにロールをカスタマイズするために「DepartmentRole」テーブルを新設
2025/9/29公開
![[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-db%2Fhero-thumbnail.jpg&w=1200&q=75)