DELOGs
[管理画面フォーマット開発編 #11] パスワード再発行依頼とメールテンプレート統合

管理画面フォーマット開発編 #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権限が安全に処理できる構成とします。

本記事で到達するゴール

  • PasswordRequest テーブルを新設し、依頼の受付・処理履歴をDBで一元管理
  • フロント側から部署コード+メール+備考を送信し、部署ADMINへ自動通知
  • 管理画面 /users/password-request にて一覧・フィルタ・CSV出力を実装
  • 「再発行」「拒否」操作をServer Actionで実装し、arg​on2による安全なパスワード再生成
  • すべてのメール送信処理を email/templates.ts に集約し、件名・本文を共通フォーマット化

読み進める前に

本記事は「管理画面フォーマット開発編」の第11回にあたります。
これまでの #8〜#10 でDB連携・部署別ロール・メニュー制御が完了しており、本章ではその基盤上で ユーザー運用に不可欠な「パスワード再発行ワークフロー」 を追加していきます。

主な変更ファイルと目的

区分ファイル / モジュール目的
DB定義schema.prismaPasswordRequest モデル新設・Enum追加
公開フォームauth/password-forgot.ts / client.tsx依頼受付と管理者メール通知
管理UI/users/password-request/*一覧・フィルタ・再発行/拒否ボタン
Server Actionsusers/password-requests.ts再発行・拒否処理 + 通知メール
メールテンプレートemail/templates.ts件名+本文を統一形式で管理
既存アクション改修各Server Action (email-change.ts等)templates統一APIに差し替え
この後の章では、DB定義からServer Action、一覧UI、テンプレート統合までを順に解説していきます。
まずは PasswordRequest テーブルの追加 から進めます。

1. PasswordRequestテーブルの追加 ─ DB設計とモデル定義

今回の最初のステップは、パスワード再発行依頼を保存・管理するための専用テーブル を新設することです。
これまで「依頼フォーム → メール通知」のみで完結していた流れを、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 の状態管理
DepartmentpasswordRequests リレーションを追加部署ごとの依頼を一覧化可能に
UserpasswordRequests リレーションを追加特定ユーザの依頼履歴参照に対応
新モデル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レイヤ構造を意識しています。
「存在するかどうか」を利用者に明示しないため、departmentIduserIdNULL許容 とし、該当部署・ユーザが見つからなかった場合でもDB記録は残す設計です。

Prismaマイグレーション

DB定義を追加後、以下のコマンドでスキーマ変更を反映します。
zsh
1npx prisma migrate dev --name add-password-request 2npx prisma generate
これにより、PasswordRequest テーブルがDBに作成され、同時に Prisma Client も新しいモデル定義を反映します。
次章では、このテーブルを利用して 公開フォームから依頼を受け付ける処理 を実装していきます。

2. 公開フォームからの再発行依頼を処理する

公開フォーム(未ログイン)からの「パスワード再発行依頼」を 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で入力を最終正規化 した後、部署・ユーザを「可能なら」解決し、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. 管理画面での依頼一覧と再発行処理

ここからは、公開フォームで登録された PasswordRequest データを 管理者が一覧・確認・再発行・拒否できる管理画面 に統合していきます。
処理結果は即時にDBへ反映され、依頼者にはメール通知が送信されます。

Server Actionの実装 ─ password-requests.ts

管理者が依頼一覧から 「再発行(ISSUED)」 または 「拒否(REJECTED)」 を行えるようにする Server Action を新規作成します。
部署単位での権限検証・競合防止・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 │ クリック(再発行) 34issuePasswordRequestAction() 56 ├─ 認証チェック(Cookie) 7 ├─ 部署・権限検証(getEffectiveRole) 8 ├─ status = "PENDING" の依頼をロック 9 ├─ 新パスワード生成 → argon2 でハッシュ化 10 ├─ User.hashedPassword を更新 11 ├─ PasswordRequest.status = "ISSUED" 12 └─ 依頼者へメール送信(passwordIssued)

○状態遷移まとめ

対象備考
PasswordRequestPENDINGISSUEDprocessedAt / processedBy を記録
PasswordRequestPENDINGREJECTED管理者操作で明示的拒否
User(旧ハッシュ)(argon2idハッシュ)新パスワード適用後に通知メール

○実装ポイント

項目内容
権限チェック同一部署 + ADMIN相当(priority≥100) のみ許可
競合対策updateMany({ where: { id, status: "PENDING" } }) で二重操作防止
メール通知送信失敗はログのみ。処理結果は成功扱い
戻り値{ ok: true } / { ok: false; message } のシンプル構成
UI反映DataTable 側で即時状態更新+トースト通知

○まとめ

  • 1トランザクション完結:依頼とユーザー更新を同時に安全反映。
  • 存在秘匿の維持:部署・ユーザー未解決の依頼は処理対象外。
  • 失敗許容設計:メール送信例外は致命ではなく運用再送可能。
これにより、管理画面からの「再発行」「拒否」操作をサーバサイドで安全に完結させることができます。

メールテンプレートの追加 ─ templates.ts

パスワード再発行処理の完了時に依頼者へ通知するため、src/lib/email/templates.tspasswordIssued() 関数を追加します。
このテンプレートは、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") で整形
ログインURLbuildLoginUrl() により環境変数 APP_ORIGIN を利用して構築
国際化対応punycode ASCII メールもそのまま扱える設計
責務分離Server Action では送信のみ担当し、文面定義は templates.ts に統一

○メールテンプレート統合の狙い

既存の emailChangeApproveduserWelcome と同様に、件名・本文生成を テンプレート層で完結 させることで、 各 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役割
valuePwReqStatus[]選択値。空配列=全選択 と解釈(URL表現用)
onChange(next: PwReqStatus[]) => void選択変更通知(空→全選択の畳み込み含む)
optionsArray<PwReqStatus | { value; label }>一覧に出現した状態のみを渡す(柔軟にラベル差し替え可)
footerReact.ReactNodePopover のフッター(閉じるボタン等)

○振る舞い(UI/状態同期の要点)

シナリオ挙動
初期表示value=[] の場合、UI上は全状態にチェック(実効集合で扱う)
トグル選択選択集合を加除。全選択になったら [] に畳む(URL短縮)
検索ラベル/値を部分一致でフィルタ(CommandInput
すべて/クリア「すべて」=全状態に展開、「クリア」= [](=全選択表現)
視覚表示チェックボックス風UI(CommandItem + Check

○統合ポイント(どこで使うか)

ファイル追加箇所
columns.tsx「状態」列ヘッダーの Popover に StatusMultiSelectPw を配置(table.options.metapwStatusOptions/pwStatuses/setPwStatuses を受け取り)
data-table.tsxURL同期用のクエリステートに statuses を保持。空配列=すべて の解釈でフィルタを実施
このコンポーネントにより、「URL=単一情報源」で状態フィルタを再現でき、一覧の直リンク共有やリロード後の状態復元が自然に機能します。

型定義の拡張 ─ table-meta.d.ts

パスワード再発行一覧で使うメタ情報(フィルタや操作ハンドラ)を @tanstack/table-coreTableMeta に拡張します。既存の「メール変更申請」向けメタと干渉しないように、パスワード再発行専用のキー(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 {};

○変更概要(差分要点)

区分変更点目的
importPwReqStatus を追加インポートパスワード再発行の状態型を参照するため
申請系状態(共通)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.tsxURL同期ステートと 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}

○変更点の要約

観点変更前変更後
データ取得モック関数 listPasswordRequestsprisma.passwordRequest.findMany(部署絞り + 必要列のみ)
ガードguardHrefOrRedirect(導入済)継続使用し viewer 情報から部署を解決
Eメール表示そのまま表示punycode.toUnicode で人可読に復号
ロール表示モック値getEffectiveRole で Role/DepartmentRole を正規化(code/name/color)
フィルタ選択肢モック取得行から実在コードを動的生成(役割・状態)
ダウンロード可否固定viewer.canDownloadDataDataTable に伝播
付加列なしnote(備考)を行に含める

○データ整形フロー

ステップ処理出力
1viewer = guardHrefOrRedirect(...)ユーザID/権限(RSC)
2me = prisma.user.findUnique(...)所属 departmentId
3passwordRequest.findMany(...)依頼の生レコード(必要列のみ)
4punycode.toUnicode(emailPuny)表示用メール
5getEffectiveRole(...)roleCode/name/badgeColor
6行オブジェクトへ整形PasswordRequestRow[]
7roleOptions/statusOptions 生成フィルタUIに供給
8DataTable へ 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/noteuserId/email/userName/processedBy/note(hidden列qで集約)
操作列PENDING 以外も押下可status !== "PENDING" は操作ボタンを無効化
並び替え UIヘッダ文字のみSortButton コンポーネントで昇降順を明示
状態フィルタなしStatusMultiSelectPw(空配列=すべての意味でURL表現と整合)

○カラム構成(表示/機能)

カラムID表示ソートフィルタ備考
requestedAt日付レンジ依頼日時
userIddisplayId を等幅表示
userName
emailpuny→Unicode 復号済みを表示
note新規追加、最大幅でトリミング表示
roleCode(表示は name)複数選択badgeColor による色バッジ
status複数選択PENDING/ISSUED/REJECTED
processedAt日付レンジ処理日時
processedBy処理者表示
actions再発行/拒否(PENDINGのみ有効)
q(hidden)全文検索用(userId/email/userName/processedBy/note)

○実装のポイント

  • ヘッダUIの共通化HeaderWithFilterHeaderWithSort を用意し、アイコン・Popover・並び替えボタンの統一感を確保。
  • 状態フィルタの仕様StatusMultiSelectPw は「空配列=すべて」を採用し、URL表現とUIの初期表示(全選択相当)を両立。
  • 検索列の拡充:hidden 列 qnote を加え、備考もキーワード検索にヒットさせる。
  • アクセシビリティ/可読性:ロールは badgeColor がある場合のみセカンダリ表示&白文字にして可読性を担保。IDは等幅で視認性を向上。

○注意点

  • note は行の横幅を圧迫しやすいため、トリミング表示truncate)で折り返しを抑制。
  • フィルタは DataTable の meta から読み書きする設計。roleOptions / roles / setRolespwStatusOptions / pwStatuses / setPwStatusesrequestedRange / 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実行 → 成否トースト/ローカル行更新

○フィルタ/ソート仕様(実装ルール)

種別入力同期先振る舞い
キーワードqURLuserId/email/userName/processedBy/note に部分一致
ロールroles: string[]URL空=すべて。roleCode空行は「すべて扱い」に合流
状態statuses: PwReqStatus[]URL空=すべて。UIは全選択と同義で表示
依頼日時requestedRangeURL日単位(from/toを00:00〜23:59で判定)
処理日時processedRangeURL値なしは未フィルタ。processedAtがnullの場合は除外
並び順sortingローカル既定:依頼日時降順

○操作(Server Action連携)

操作呼び出し成功時のローカル反映失敗時の挙動
再発行issuePasswordRequestAction(FormData{id})status=ISSUEDprocessedAt=nowprocessedBy="(you)"エラートースト表示(行は不変)
拒否rejectPasswordRequestAction(FormData{id})status=REJECTEDprocessedAt=nowprocessedBy="(you)"エラートースト表示(行は不変)

○CSV出力の仕様

  • 可視列のみを対象(actions/qは除外)。
  • ラベルは日本語(例:依頼日時/ユーザID/備考/…)。
  • 日付は YYYY/MM/DD HH:mm、状態は「未処理/再発行済/拒否」に変換。
  • ファイル名:password_requests_YYYYMMDD_HHMMSS.csv

○注意点(運用・実装)

  • SSR時の列表示ギャップを避けるため、mount前は全列表示→mount後にURLの可視列へ揃えます。
  • pwStatusOptions実データから動的生成(一覧に存在しない状態は候補に出さない)。
  • 楽観更新はUI即時性優先。サーバ失敗時はトーストで通知し、必要に応じて再読み込みで整合を取る想定です。
この章では、以上7ファイルの改修を通じて、「依頼 → 管理画面受付 → 再発行通知」までの一連のサイクルを完成させました。
次章は、ついでの作業です。本記事の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.tsemailChangeVerifyemailChangeText を置換。const { subject, text } = emailChangeVerify(...)sendMail({ to, subject, text })
src/app/_actions/users/email-change-requests.tsemailChangeApproved承認完了の本人通知を統一APIで送信
src/app/_actions/users/create-user.tsuserWelcomeウェルカムメールの件名・本文生成をテンプレ化
src/app/_actions/profile/email-verify.tsadminEmailChangeVerifiedNotifyVERIFIED 到達時、実効 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構成 に移行し、セキュリティと運用効率の両立を実現しています。
txt
1[Before] [After] 2フォーム送信 → console.log 公開フォーム → PasswordRequest保存 34 管理者メール通知(adminPasswordForgotNotify) 56 管理画面で再発行/拒否(Server Action) 78 本人へパスワード通知(passwordIssued)
これにより、
  • フォーム入力時の部署コード+メールの突合
  • 登録ユーザが存在しない場合の「存在秘匿」処理
  • 管理者への依頼通知(メール)
  • Server Action 経由での再発行/拒否処理
  • 本人へのワンタイムパスワード通知
    までを統一的なルールで構築できました。

今回の実装ポイント(総括)

項目役割補足
PasswordRequest モデル再発行依頼をDBで管理部署・ユーザを解決できなくても保存
passwordForgotAction公開フォーム受付部署コード+メールで突合、存在秘匿対応
password-requests.ts管理者の再発行・拒否アクション権限チェック+argon2+トランザクション処理
data-table.tsx再発行一覧UIURL同期フィルタ+即時反映+CSV出力
templates.tsメール文面の一元化すべて { subject, text } 形式で統一
全体を通して、「内部と外部の接点」となる再発行フローを安全に整備できた点が最大の成果です。
データベース構造・通知テンプレート・UI の三層が揃ったことで、他の依頼系フロー(例:アカウント登録依頼や退職者申請など)への横展開も容易になりました。

次回予告

次回は、これまで構築してきた 管理画面フォーマットのデモ環境 を紹介し、 GitHub上で公開 していきます。 しばらく間が空くかもしれませんが、管理画面フォーマット開発の集大成として、 誰でも動かせるサンプル環境を仕上げていきます。

参考文献

区分資料名/URL内容概要
Prisma公式https://www.prisma.io/docsモデル定義・enum追加・リレーション管理に関するリファレンス
Next.js公式https://nextjs.org/docs/app/building-your-application/data-fetching/server-actionsServer Actionによる安全なサーバサイド処理の実装指針
shadcn/uihttps://ui.shadcn.com/UI構築で使用しているコンポーネントライブラリ
React Hook Formhttps://react-hook-form.com/ログイン・再発行フォームなどのバリデーション設計に利用
Zodhttps://zod.dev/入力検証やFormDataの安全なパース処理に活用
argon2公式実装https://github.com/ranisalt/node-argon2パスワードハッシュ処理(Argon2id)の採用元
date-fnshttps://date-fns.org/DataTable上での日時フォーマット処理に使用
punycodehttps://github.com/bestiejs/punycode.js国際化ドメインを含むメールアドレスの安全な変換処理
Sonnerhttps://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を学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。