DELOGs
[管理画面フォーマット開発編 #5] ユーザプロフィール更新

管理画面フォーマット開発編 #5
ユーザプロフィール更新

プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装

初回公開日

最終更新日

0. はじめに

本記事では、ユーザプロフィール編集機能をさらに拡張し、以下の3点を新たに実装します。
  • アバター画像の「削除」機能
  • メールアドレスの変更申請(本人認証メール+管理者承認フロー付き)
  • パスワード変更(既存構想を踏まえて仕上げ)
これにより、本人操作と管理者承認の両立を実現し、セキュリティと利便性を兼ね備えたプロフィール更新体験を提供できるようになります。
今回の拡張は、単なるUI改善にとどまらず、法人利用を想定した「業務システムらしい堅牢なアカウント管理」の基盤づくりに直結します。特にメールアドレス変更については、認証メールと承認画面を組み合わせたフローを構築し、誤操作や不正利用のリスクを大幅に抑制します。
以下の表に、前回までと今回追加する機能を整理します。
区分前回までの実装今回追加する機能
アバター画像アップロードと表示削除(未登録状態へ戻す)
メールアドレスプロフィール表示のみ認証メール+管理者承認フロー
パスワードUIの雛形のみ実際の変更処理を実装
このように「本人操作 → システム確認 → 管理者承認」という流れを設けることで、今後導入予定の RBAC(ロールベースアクセス制御)httpOnly Cookie+middlewareによるセッション管理 と自然につながります。
次章以降では、それぞれの機能を UI設計 → バリデーション → Server Action実装 → 管理者UI の流れで具体的に解説していきます。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #4】Server Actionで実装するアバター画像のアップロードと表示 までのソースコードを引き継いで追加・編集していきます。

1. アバター画像の削除機能

ここでは、既存のプロフィール編集画面に「登録画像を削除」機能を追加します。前回の記事でアップロードと表示が実装済みですが、実際の業務システムでは「不要になった画像を削除したい」という要望は必ず発生します。
今回の仕様では、「クリア」と「削除」ボタンを区別し、以下のように振る舞いを分けます。
ボタン動作
クリア画面上で選択したファイルをリセット。既存登録があれば復元。
登録画像を削除サーバ側の登録データを削除し、未登録状態へ変更する。
このようにすることで「うっかり選択をやり直したい」ケースと「登録自体を削除したい」ケースを明確に切り分けられます。

フロントエンド側のUI修正

既存の AvatarField コンポーネントに「登録画像を削除」ボタンを追加します。
tsx
1// src/components/profile/profile-form.tsx(抜粋) 2 3// ── 省略 4 5type Props = { 6 initial: ProfileInitial; 7 onSubmit: (values: ProfileUpdateValues) => void; 8 onCancel?: () => void; 9 onNavigateEmail: () => void; 10 onNavigatePassword: () => void; 11 onDelete: () => void; // ← 追加 12}; 13 14/* ========================= 15 本体(純粋なフォームに) 16 ========================= */ 17export default function ProfileForm({ 18 initial, 19 onSubmit, 20 onCancel, 21 onNavigateEmail, 22 onNavigatePassword, 23 onDelete, // ← 追加 24}: Props) { 25 26// ── 省略 27 28<CardContent className="space-y-6 pt-1"> 29 {/* アバター(FormMessage をこの中で出す) */} 30 <AvatarField 31 currentAvatarUrl={initial.currentAvatarUrl} 32 previewUrl={previewUrl} 33 onPick={async (file) => { ・・省略・・ }} 34 onClear={() => { ・・省略・・ }} 35 onDelete={onDelete} // ← ここで渡す 36 footerMessage={<FormMessage data-testid="avatar-error" />} 37 /> 38 39// ── 省略 40 41// アバター(内部で FormField を張る) 42function AvatarField({ 43 currentAvatarUrl, 44 previewUrl, 45 onPick, 46 onClear, 47 onDelete, // ← 追加 48 footerMessage, 49}: { 50 currentAvatarUrl?: string; 51 previewUrl: string | null; 52 onPick: (file: File | null) => void; 53 onClear: () => void; 54 onDelete: () => void; // ← 追加 55 footerMessage?: React.ReactNode; 56}) { 57 58// ── 省略 59 60{/* 下記ボタン群のレイアウトを整えつつ、削除ボタンを追加 */} 61 <div className="flex justify-between"> 62 <div className="flex gap-2"> 63 <Button 64 type="button" 65 variant="secondary" 66 size="sm" 67 className="cursor-pointer" 68 onClick={handleOpen} 69 > 70 画像を選択 71 </Button> 72 <Button 73 type="button" 74 variant="ghost" 75 size="sm" 76 className="cursor-pointer" 77 onClick={onClear} 78 data-testid="avatar-clear" 79 > 80 クリア 81 </Button> 82 </div> 83 <Button 84 type="button" 85 variant="destructive" 86 size="sm" 87 className="cursor-pointer" 88 onClick={onDelete} 89 data-testid="avatar-delete" 90 > 91 登録画像を削除 92 </Button> 93 </div> 94 95// ── 省略 96
ここでは variant="destructive" を指定し、利用者が「削除操作」であることを直感的に認識できるようにしています。
onDelete は親コンポーネントから渡すコールバックで、Server Action を呼び出す責務を持ちます。

サーバ側の処理フロー

削除処理はアップロードと同じく Server Action で実装します。AVATAR_DIR(/var/www/private/avatars) 配下のファイルを削除し、DB上の avatar カラムを null に更新します。
tsx
1// src/app/_actions/profile/avatar.ts(抜粋) 2// 下記を末尾に追加 3/** 4 * 自分自身のアバターを削除(DB: avatar=null → 旧ファイルを物理削除) 5 * - 引数で userId を受け取らない(権限昇格防止) 6 * - 旧ファイルが無くても成功扱い(冪等) 7 */ 8export async function deleteOwnAvatarAction(): Promise<ActionResult> { 9 const session = await lookupSessionFromCookie(); 10 if (!session.ok) return { ok: false, message: "認証が必要です" }; 11 12 try { 13 await ensureDir(AVATAR_DIR); 14 15 // 先に現在のファイル名を取得 16 const current = await prisma.user.findUnique({ 17 where: { id: session.userId }, 18 select: { avatar: true, isActive: true }, 19 }); 20 if (!current?.isActive) { 21 return { ok: false, message: "ユーザーが無効化されています" }; 22 } 23 // 何も登録されていなくても冪等に成功返し 24 const oldFileName = current.avatar ?? null; 25 26 // DB更新(トランザクションで確実に avatar=null) 27 await prisma.$transaction(async (tx) => { 28 await tx.user.update({ 29 where: { id: session.userId }, 30 data: { avatar: null }, 31 }); 32 }); 33 34 // 物理削除(DBコミット後) 35 if (oldFileName) { 36 const abs = join(AVATAR_DIR, oldFileName); 37 if (await fileExists(abs)) { 38 await unlink(abs).catch(() => {}); 39 } 40 } 41 42 return { ok: true }; 43 } catch (e) { 44 console.error(e); 45 return { ok: false, message: "アバター削除に失敗しました" }; 46 } 47}
処理の流れは以下のとおりです。
  1. ユーザの avatar 情報をDBから取得
  2. 該当ファイルがあれば削除(存在しない場合も無視)
  3. DBの avatar カラムを null に更新
このようにすることで、フロント側で未登録状態と同じ扱いに戻せます。

UIとServer Actionの接続

最後に、ProfileForm 側から deleteAvatar を呼び出すようにします。
tsx
1// src/app/(protected)/profile/client.tsx(修正:削除ハンドラ追加&Formへ渡す) 2"use client"; 3 4import { useTransition } from "react"; 5import { useRouter } from "next/navigation"; 6import { toast } from "sonner"; 7 8import ProfileForm from "@/components/profile/profile-form"; 9import type { ProfileUpdateValues } from "@/lib/users/schema"; 10import { useAuth } from "@/lib/auth/context"; 11import { updateProfileAction } from "@/app/_actions/profile/avatar"; 12import { refreshAuthSnapshotAction } from "@/app/_actions/auth/refresh"; 13import { deleteOwnAvatarAction } from "@/app/_actions/profile/avatar"; // ★ 追加 14 15export default function ProfileClient() { 16 const router = useRouter(); 17 const [pending, startTransition] = useTransition(); 18 19 const { user, ready, setUser } = useAuth(); 20 21 if (!ready) return null; 22 if (!user) return null; 23 24 const initial = { 25 name: user.name, 26 email: user.email, 27 roleCode: user.roleCode, 28 currentAvatarUrl: user.avatarUrl ?? undefined, 29 } as const; 30 31 const applyFreshSnapshot = async (msg?: string) => { 32 const snap = await refreshAuthSnapshotAction(); 33 if (snap.ok) { 34 const ts = Date.now(); 35 const updated = { 36 ...snap.user, 37 avatarUrl: 38 snap.user.avatarUrl != null 39 ? `${snap.user.avatarUrl}?ts=${ts}` 40 : null, 41 }; 42 setUser(updated); 43 } 44 if (msg) { 45 toast.success(msg, { duration: 3000 }); 46 } 47 }; 48 49 const handleSubmit = (values: ProfileUpdateValues) => { 50 if (pending) return; 51 startTransition(async () => { 52 try { 53 const fd = new FormData(); 54 fd.set("name", values.name); 55 if (values.avatarFile) fd.set("avatarFile", values.avatarFile); 56 57 const res = await updateProfileAction(fd); 58 if (!res.ok) { 59 toast.error(res.message ?? "プロフィールの更新に失敗しました"); 60 return; 61 } 62 await applyFreshSnapshot( 63 values.avatarFile 64 ? "プロフィールを更新しました(画像含む)" 65 : "プロフィールを更新しました", 66 ); 67 } catch (e) { 68 console.error(e); 69 toast.error( 70 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 71 ); 72 } 73 }); 74 }; 75 76 // ★ 追加:登録済みアバターの削除(DB反映) 77 const handleDeleteAvatar = () => { 78 if (pending) return; 79 startTransition(async () => { 80 try { 81 const res = await deleteOwnAvatarAction(); 82 if (!res.ok) { 83 toast.error(res.message ?? "アバターの削除に失敗しました"); 84 return; 85 } 86 await applyFreshSnapshot("アバターを削除しました"); 87 } catch (e) { 88 console.error(e); 89 toast.error( 90 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 91 ); 92 } 93 }); 94 }; 95 96 return ( 97 <ProfileForm 98 initial={initial} 99 onSubmit={handleSubmit} 100 onDelete={handleDeleteAvatar} // ★ 追加 101 onCancel={() => history.back()} 102 onNavigateEmail={() => router.push("/profile/email")} 103 onNavigatePassword={() => router.push("/profile/password")} 104 /> 105 ); 106}
  • deleteOwnAvatarAction() を呼ぶ handleDeleteAvatar を追加しました。
  • 成功後は refreshAuthSnapshotAction() で即時反映し、setUser() に渡すとサイドバーのアバター等も更新されます。
この章で、アバターの「登録」と「削除」の両方に対応できるようになり、プロフィール画像管理が一通り揃いました。次の章では、メールアドレス変更の認証フローを実装していきます。

2. メールアドレス変更フローの実装

本章では「ユーザプロフィール編集」における メールアドレス変更フロー を解説します。
単純な入力更新ではなく、以下のステップを踏むことで 本人確認+管理者承認 を担保します。
ステップ実施者内容
① 新しいメールアドレス申請ユーザUIから新アドレスを入力し、認証メールを受信
② 本人確認(リンククリック)ユーザ認証メール内のリンクをクリックして確認完了
③ 承認/却下管理者管理画面で申請を確認し、承認または却下を選択
④ 確定システムUserテーブルの email を更新(承認時のみ)
この仕組みにより、入力ミスや不正操作を未然に防ぐことが可能 となります。
txt
1【フロー図】 2 3[ユーザ入力] --(認証メール送信)--> [ユーザの受信BOX] 4[ユーザがリンクをクリック] --> [申請状態: VERIFIED] 5 67 8[管理者UI] --(承認/却下)--> [DB反映: email更新 or REJECTED]

データベース設計

メールアドレス変更は即時反映せず、申請テーブルに一時保存 → 状態遷移を管理 するのが安全です。さらに法人利用を想定し、部署(Department)単位で許可ドメインを複数登録できるホワイトリストを用意します。ドメインは 国際化ドメイン対応のため punycode(ASCII)で保存します。

テーブルの役割と主要カラム

テーブル名主なカラム役割
EmailChangeRequestuserId / departmentId / newEmailPuny / status / token / expiresAt申請の内容と進行状況を保持(本人認証・承認後に反映)
AllowedEmailDomaindepartmentId / domainAscii / isActive部署ごとの許可ドメイン(複数可)を punycode で保存し、変更申請時に照合
▼状態(status)の設計
意味
PENDING申請直後(未認証または認証直後で承認待ち)
VERIFIED本人がメールの認証 URL を踏み、本人確認済み
APPROVED管理者承認済み(反映待ち/反映済み)
REJECTED管理者により却下
EXPIRED期限切れ(メール内リンクの有効期限超過)
▼RDB の制約方針
  • 親(User / Department)側が削除されても ログ保全を優先 するため、子テーブルの外部キーは onDelete: Restrict にします(カスケードで消さない)。
  • AllowedEmailDomain部署 × ドメイン(punycode)でユニーク。同一部署内での重複を防止。
  • 国際化ドメインは UI 層で punycode へ変換して保存し、照合時も ASCII で行います。
txt
1# 簡易ERD(論理関係のみ) 2 3User (id) ───────┐ 4 │ 1 : n 5EmailChangeRequest ── userId (FK, Restrict) 6 7Department (id) ──┐ 8 │ 1 : n 9AllowedEmailDomain ─ departmentId (FK, Restrict) 10 11EmailChangeRequest 12 - departmentId (FK, Restrict) ← 申請時点の所属部署を明示 13 - newEmailPuny (punycode) 14 - status (enum) 15 - token (unique) 16 - expiresAt / createdAt / processedAt / processedBy

Prisma スキーマの変更

prisma
1// prisma/schema.prisma(追記・修正) 2 3// 既存モデルに “逆リレーション” を追加するだけなので、他の列はそのまま残してください。 4// すでに同名のフィールドがある場合は二重定義にならないよう名称を調整してください。 5 6enum EmailChangeStatus { 7 PENDING // 本人認証前 or 認証直後(承認待ち) 8 VERIFIED // 本人が認証URLを踏んだ 9 APPROVED // 管理者が承認 10 REJECTED // 管理者が却下 11 EXPIRED // 有効期限切れ 12} 13 14 15model User { 16 id String @id @default(uuid()) 17 // ...(既存: name, email, roleId など) 18 19 /// 逆リレーション: このユーザーが出したメール変更申請 20 emailChangeRequests EmailChangeRequest[] // ← 追加 21 22 // ...(既存のフィールド/インデックスはそのまま) 23} 24 25model Department { 26 id String @id @default(uuid()) 27 // ...(既存: name, code 等) 28 29 /// 逆リレーション: 部署に紐づく許可ドメイン一覧 30 allowedDomains AllowedEmailDomain[] // ← 追加 31 /// 逆リレーション: 部署配下ユーザーのメール変更申請 32 emailChangeRequests EmailChangeRequest[] // ← 追加(UIで部署選択がある前提) 33 34 // ...(既存のフィールド/インデックスはそのまま) 35} 36 37// ───────────────────────────────────────────── 38// 子側(今回新設): onDelete は Restrict(親削除を禁止) 39// ───────────────────────────────────────────── 40 41model EmailChangeRequest { 42 id String @id @default(uuid()) 43 userId String 44 departmentId String 45 newEmailPuny String // 変更後メール(punycodeで保存) 46 token String @unique 47 status EmailChangeStatus @default(PENDING) 48 49 // 有効期限と監査情報 50 expiresAt DateTime @db.Timestamptz 51 createdAt DateTime @default(now()) @db.Timestamptz 52 processedAt DateTime? @db.Timestamptz 53 processedBy String? 54 55 // 親が削除されてもログ保全を優先(Restrict) 56 user User @relation(fields: [userId], references: [id], onDelete: Restrict) 57 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 58 59 @@index([userId]) 60 @@index([departmentId]) 61 @@index([status]) 62 @@index([expiresAt]) 63} 64 65model AllowedEmailDomain { 66 id String @id @default(uuid()) 67 departmentId String 68 domainAscii String // punycode(ASCII)で保存 69 isActive Boolean @default(true) 70 71 createdAt DateTime @default(now()) @db.Timestamptz 72 updatedAt DateTime @updatedAt @db.Timestamptz 73 74 // 親が削除されてもドメイン定義は残す(Restrict) 75 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 76 77 // 同一部署内の重複を禁止 78 @@unique([departmentId, domainAscii]) 79 80 @@index([departmentId]) 81 @@index([isActive]) 82}
▼上記のポイント
  • 親側(User / Department)に配列の逆リレーションを追加
    → Prisma の型生成とナビゲーションが安定し、推論エラーを避けられます。
  • onDelete: Restrict は子側にだけ宣言
    → 親削除時に「参照が残っているから削除不可」となり、監査用の履歴が守られます。
  • domainAscii / newEmailPuny は punycode(ASCII)で保存 → UI では日本語ドメインを受け取り、保存前に punycode へ変換します。

マイグレーションとクライアント再生成

zsh
1npx prisma migrate dev --name add_email_change_and_allowed_domain_relations 2npx prisma generate
この時点で、申請許可ドメイン の器が整いました。次節以降では、UI からの入力値を punycode に正規化し、AllowedEmailDomain と突き合わせて EmailChangeRequest を作成するハンドラー、メール送信(認証用 URL 発行)までを実装します。

申請 API(Server Action)と認証メール送信

この節では、本人が入力した「新しいメールアドレス」を受け取り、申請レコードの作成→認証メールの送信までを一気に仕上げます。入力 UI は前節のフォームをそのまま利用し、サーバ側は Server Action とメール送信ユーティリティで構成します。
処理の要点は下表のとおりです。
区分目的実装ポイント
ドメイン許可判定部署ごとのホワイトリストに適合するかAllowedEmailDomain が 0 件の部門は 無制限許可、1 件以上ある部門は いずれか一致で許可
申請レコード作成二重申請や期限切れを制御EmailChangeRequestPENDING/VERIFIED… の statusexpiresAt を保存
認証メール送信本人の新メールアドレスでクリック検証Nodemailer を使い、ワンタイム token 付き URL を送信
文字コード日本語ドメイン対応UI で punycode 変換、サーバでも念のためドメイン側を toASCII して再検証
このあと管理者承認 UI(第4章)とつなげることで、本人認証→管理者承認→反映の三段階フローが完成します。
txt
1# 送信フロー 2 3[EmailChangeForm] 4 | 5 | newEmail(UIでpunycode化)submit 6 v 7[Server Action sendEmailChangeRequestAction] 8 |-- lookupSession → user / department を特定 9 |-- domain許可チェック(AllowedEmailDomain) 10 |-- EmailChangeRequest(PENDING, token, expiresAt) INSERT 11 |-- Nodemailerで認証URLを newEmail 宛に送信 12 | 13 v 14 (ユーザーの受信箱:後続 2-3 で /profile/email/verify を実装)

送信に必要なパッケージ導入

Nodemailer と punycode 変換(保険のサーバ側再変換用)を追加します。punycodeは未インストールなら、一緒にインストールします。
zsh
1# 送信周りの依存を追加 2npm i nodemailer punycode 3 4# 型(TS)を使う場合 5npm i -D @types/nodemailer @types/punycode

メール環境変数の定義

SMTP 経路は環境ごとに変わるため、.env に設定します。APP_ORIGIN はメール内リンクを組み立てるために必須です。
変数名意味
SMTP_HOSTSMTP サーバホストsmtp.example.com
SMTP_PORTSMTP ポート587
SMTP_USER認証ユーザーapikey
SMTP_PASS認証パスワードxxxxxx
MAIL_FROM送信者(From)"DELOGs <no-reply@example.com>"
APP_ORIGINアプリの外部 URLhttps://app.example.com

メール送信ユーティリティの作成

Nodemailer の Transport を共通化し、テキスト/HTML 両方を送る関数を用意します。
ts
1// src/lib/mailer.ts 2import nodemailer from "nodemailer"; 3 4const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM } = process.env; 5 6if (!SMTP_HOST || !SMTP_PORT || !MAIL_FROM) { 7 // 本番では logger へ 8 console.warn("[mailer] SMTP 環境変数が未設定です。メール送信は失敗します。"); 9} 10 11export type SendMailInput = { 12 to: string; 13 subject: string; 14 text: string; 15 html?: string; 16}; 17 18export async function sendMail(input: SendMailInput) { 19 const transporter = nodemailer.createTransport({ 20 host: SMTP_HOST, 21 port: Number(SMTP_PORT ?? 587), 22 secure: false, // 587/TLS 23 auth: 24 SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, 25 }); 26 27 await transporter.sendMail({ 28 from: MAIL_FROM, 29 to: input.to, 30 subject: input.subject, 31 text: input.text, 32 html: input.html ?? `<pre>${escapeHtml(input.text)}</pre>`, 33 }); 34} 35 36function escapeHtml(s: string) { 37 return s.replace( 38 /[&<>"']/g, 39 (c) => 40 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ 41 c 42 ]!, 43 ); 44}
上記のユーティリティは、SMTP 認証が不要な社内サーバでも動きます(SMTP_USER/PASS 未設定時は auth を省略)。失敗時の詳細は Nodemailer 側の例外で確認できます。

ドメイン許可チェック(AllowedEmailDomain)

UI では日本語ドメイン → punycode 変換して送信しますが、サーバ側でもドメインだけは toASCII 再変換して最終チェックします。AllowedEmailDomain の定義は 2-1 で追加済みです(レコードが 0 件なら無制限許可)。
ts
1// src/lib/email/domain-allow.ts 2import { prisma } from "@/lib/database"; 3import * as punycode from "punycode/"; 4 5function extractDomain(emailAscii: string): string { 6 const at = emailAscii.lastIndexOf("@"); 7 if (at < 0) throw new Error("Invalid email"); 8 return emailAscii.slice(at + 1).toLowerCase(); 9} 10 11/** 12 * 部署の許可ドメインをチェック 13 * - 部署に AllowedEmailDomain が 0 件なら無制限許可(true) 14 * - 1 件以上ある場合、domainAscii のいずれかに等しい場合のみ許可 15 */ 16export async function isDomainAllowed( 17 departmentId: string, 18 newEmailInput: string, 19): Promise<boolean> { 20 // 念のため domain 側のみ再 punycode(UIが toASCII済みでも idempotent) 21 const [local, domainRaw] = newEmailInput.split("@"); 22 if (!local || !domainRaw) return false; 23 const domainAscii = punycode.toASCII(domainRaw.trim()); 24 const normalized = `${local}@${domainAscii}`; // 返却値では使わないが体裁確認用 25 26 const list = await prisma.allowedEmailDomain.findMany({ 27 where: { departmentId, isActive: true }, 28 select: { domainAscii: true }, 29 }); 30 31 if (list.length === 0) return true; // 無制限許可モード 32 33 const domain = extractDomain(normalized); 34 return list.some((r) => r.domainAscii.toLowerCase() === domain); 35}

認証メールの本文テンプレート

本文はテキスト中心にし、クリック先 URL にワンタイムトークンを埋め込みます。APP_ORIGIN.env から取得します。
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 emailChangeText(params: { 12 newEmail: string; 13 token: string; 14 expiresAt: Date; 15}) { 16 const url = buildVerifyUrl(params.token); 17 const until = params.expiresAt.toLocaleString("ja-JP", { 18 timeZone: "Asia/Tokyo", 19 }); 20 21 return [ 22 `DELOGsシステムよりの自動返信です。このメールに返信いただいても応答できませんのでご了承ください。`, 23 "", 24 "メールアドレス変更の確認のため、以下のURLをクリックしてください。ログイン画面が表示される場合は、変更前のメールアドレスでログインしてください。", 25 "", 26 url, 27 "", 28 `上記URLの有効期限:${until} まで`, 29 "", 30 `変更予定のメールアドレス:${params.newEmail}`, 31 "", 32 "上記URLへアクセス後に管理者の承認が完了すると、メールアドレスの変更が完了します。", 33 "", 34 "", 35 "※氏名や変更前アドレスはセキュリティを考慮して記載していません。", 36 "※このメールに心当たりがない場合は破棄してください。", 37 ].join("\n"); 38}

申請レコード作成+認証メール送信(Server Action)

フォームの onSubmit から呼び出す Server Action を用意します。ここで セッション → ユーザー特定 → ドメイン許可判定 → 申請 INSERT → メール送信 を行います。
ts
1// src/app/_actions/profile/email-change.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { isDomainAllowed } from "@/lib/email/domain-allow"; 7import { sendMail } from "@/lib/mailer"; 8import { emailChangeText } from "@/lib/email/templates"; 9import * as punycode from "punycode/"; 10import crypto from "node:crypto"; 11 12export type ActionResult = { ok: true } | { ok: false; message?: string }; 13 14const REQUEST_TTL_HOURS = 24; // 有効期限 15 16function toAsciiEmail(input: string): string { 17 const [local, domain] = input.split("@"); 18 if (!local || !domain) throw new Error("Invalid email"); 19 return `${local}@${punycode.toASCII(domain)}`; 20} 21 22export async function sendEmailChangeRequestAction( 23 formData: FormData, 24): Promise<ActionResult> { 25 const session = await lookupSessionFromCookie(); 26 if (!session.ok) return { ok: false, message: "認証が必要です" }; 27 28 const rawNewEmail = (formData.get("newEmail") as string | null)?.trim(); 29 if (!rawNewEmail) 30 return { ok: false, message: "新しいメールアドレスが未入力です" }; 31 32 // 念のためサーバ側でも punycode 正規化 33 let newEmailAscii: string; 34 try { 35 newEmailAscii = toAsciiEmail(rawNewEmail); 36 } catch { 37 return { ok: false, message: "メールアドレス形式が不正です" }; 38 } 39 40 // ユーザー+部署を特定 41 const me = await prisma.user.findUnique({ 42 where: { id: session.userId }, 43 select: { 44 id: true, 45 email: true, 46 name: true, 47 departmentId: true, 48 isActive: true, 49 }, 50 }); 51 if (!me || !me.isActive) 52 return { ok: false, message: "ユーザーが見つかりません" }; 53 if (newEmailAscii.toLowerCase() === me.email.toLowerCase()) { 54 return { ok: false, message: "現在のメールアドレスと同一です" }; 55 } 56 // ドメイン許可チェック 57 const allowed = await isDomainAllowed(me.departmentId, newEmailAscii); 58 if (!allowed) { 59 return { ok: false, message: "このドメインのメールは申請できません" }; 60 } 61 // トークン&期限生成(推測困難な base64url 文字列) 62 const token = crypto.randomBytes(32).toString("base64url"); 63 const expiresAt = new Date(Date.now() + REQUEST_TTL_HOURS * 60 * 60 * 1000); 64 65 // 申請を作成(PENDING) 66 await prisma.emailChangeRequest.create({ 67 data: { 68 userId: me.id, 69 departmentId: me.departmentId, 70 oldEmailPuny: me.email, 71 newEmailPuny: newEmailAscii, // 右側は punycode 済 72 token, 73 status: "PENDING", 74 expiresAt, 75 }, 76 }); 77 78 // 認証メール送信(新しいアドレス宛) 79 await sendMail({ 80 to: newEmailAscii, 81 subject: "【DELOGs】メールアドレス変更の確認", 82 text: emailChangeText({ 83 newEmail: newEmailAscii, 84 token, 85 expiresAt, 86 }), 87 }); 88 89 return { ok: true }; 90}
  • 二重申請の扱い:本稿ではシンプルに「申請を積み上げる」実装です。運用上は「PENDING/VERIFIED で未失効のものがあれば更新」に寄せても構いません(ユニーク制約を追加する場合は @@unique([userId, status]) 等の検討が必要)。
  • トークンの強度randomBytes(32) の base64url は十分強度があります。更に厳密にしたい場合は JTI 付き JWT で署名しても OK です。
  • 期限EXPIRED への移行は、検証時に判定します。バッチで掃除する設計も可能です。

クライアント結線:フォームから Action を呼ぶ

EmailChangeClient を、Server Action を呼ぶ実装へ差し替えます。セッションから現在メールを取得し、送信後はトースト表示+プロフィールへ戻します。
tsx
1// src/app/(protected)/profile/email/client.tsx(差分:ステータス表示を追加) 2"use client"; 3 4import { useEffect, useState } from "react"; 5import { useRouter } from "next/navigation"; 6import { toast } from "sonner"; 7import { useAuth } from "@/lib/auth/context"; 8import EmailChangeForm from "@/components/profile/email-change-form"; 9import { EmailChangeStatusBanner } from "@/components/profile/email-change-status"; 10import type { EmailChangeValues } from "@/lib/users/schema"; 11import { sendEmailChangeRequestAction } from "@/app/_actions/profile/email-change"; 12import { 13 getMyEmailChangeStatus, 14 type MyEmailChangeSummary, 15} from "@/app/_actions/profile/email-status"; 16 17export default function EmailChangeClient() { 18 const router = useRouter(); 19 const { user, ready } = useAuth(); 20 const [summary, setSummary] = useState<MyEmailChangeSummary>({ 21 exists: false, 22 }); 23 24 useEffect(() => { 25 let mounted = true; 26 (async () => { 27 const s = await getMyEmailChangeStatus(); 28 if (mounted) setSummary(s); 29 })(); 30 return () => { 31 mounted = false; 32 }; 33 }, []); 34 35 if (!ready || !user) return null; 36 const currentEmail = user.email; 37 38 const onSubmit = async (values: EmailChangeValues) => { 39 const fd = new FormData(); 40 fd.set("newEmail", values.newEmail); 41 42 const res = await sendEmailChangeRequestAction(fd); 43 if (!res.ok) { 44 toast.error(res.message ?? "認証メールの送信に失敗しました"); 45 return; 46 } 47 toast.success("認証メールを送信しました", { 48 description: `送信先:${values.newEmail}`, 49 duration: 3500, 50 }); 51 router.push("/profile"); 52 }; 53 54 return ( 55 <> 56 <EmailChangeStatusBanner summary={summary} /> 57 <EmailChangeForm 58 currentEmail={currentEmail} 59 onSubmit={onSubmit} 60 onCancel={() => history.back()} 61 /> 62 </> 63 ); 64}
  • useAuth() から現在のメールアドレスを取得してフォームに表示しています。
  • 送信は FormDatanewEmail を渡すだけ。UI 側ではすでに punycode 化 を済ませておく設計ですが、サーバ側でも再度 toASCII して検証するため、日本語ドメインでも安全に通ります
ここまでで「申請→メール送信」までが動作します。次の節では、メール内 URL の検証(トークン照合・有効期限チェック) を実装し、ステータスを VERIFIED に進めます。その後、4章で管理者 UI(APPROVE/REJECT)へつなぎます。

メール内 URL の検証とステータス更新

本節では、ユーザが受け取った認証メール内の URL をクリックした際に行われる トークン照合・有効期限チェック の仕組みを解説します。
さらに、本人確認が成功したタイミングで申請状態を VERIFIED に進め、同時に 管理者(ADMIN 権限)へ通知メールを送る処理 も加えます。
この仕組みにより、利用者は旧アドレスでログインしてから認証 URL を表示する必要があり、不正アクセスやリンク流出時の悪用リスクを低減できます。
txt
1【フロー図】 2 3[ユーザの受信BOX] --(URLクリック)--> [認証ページ /profile/email/verify] 4 | 56 [ログイン必須](旧アドレスでログイン中) 7 | 89 [トークン照合・期限確認] → OKなら EmailChangeRequest.status = VERIFIED 10 | 11 +--(通知)--> [ADMIN全員へメール送信]

トークン検証の仕様

認証 URL のクリック時に行うチェック内容を表にまとめます。
チェック項目内容エラー時の挙動
ログイン状態旧メールアドレスでログイン中かを確認未ログインならログイン画面へリダイレクト
トークン存在EmailChangeRequest.token に一致するレコードがあるか「無効なURLです」を表示
有効期限expiresAt が現在時刻より未来か「期限切れ」として EXPIRED に更新
ステータスすでに APPROVED / REJECTEDエラー表示し進行不可
本人一致ログインユーザIDとリクエストの userId が一致するか一致しない場合は不正アクセスとして弾く
以上を全てクリアした場合、ステータスを VERIFIED に更新します。
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"; 7 8export type VerifyResult = { ok: true } | { ok: false; message: string }; 9 10export async function verifyEmailChangeAction( 11 token: string, 12): Promise<VerifyResult> { 13 const session = await lookupSessionFromCookie(); 14 if (!session.ok) { 15 return { ok: false, message: "認証にはログインが必要です" }; 16 } 17 18 const req = await prisma.emailChangeRequest.findUnique({ 19 where: { token }, 20 include: { user: true }, 21 }); 22 if (!req) 23 return { 24 ok: false, 25 message: "無効なURLです。認証URLを確認して再度アクセスしてください", 26 }; 27 if (req.userId !== session.userId) { 28 return { ok: false, message: "ログインユーザーが一致しません" }; 29 } 30 31 const now = new Date(); 32 if (req.expiresAt < now) { 33 await prisma.emailChangeRequest.update({ 34 where: { id: req.id }, 35 data: { status: "EXPIRED" }, 36 }); 37 return { ok: false, message: "このURLは有効期限が切れています" }; 38 } 39 40 if (["APPROVED", "REJECTED"].includes(req.status)) { 41 return { ok: false, message: "すでに処理済みの申請です" }; 42 } 43 44 // ステータスを VERIFIED に更新 45 await prisma.emailChangeRequest.update({ 46 where: { id: req.id }, 47 data: { status: "VERIFIED" }, 48 }); 49 50 // ADMIN 全員へ通知 51 const admins = await prisma.user.findMany({ 52 where: { 53 isActive: true, 54 departmentId: req.departmentId, 55 role: { 56 code: "ADMIN", 57 isActive: true, 58 }, 59 }, 60 select: { email: true }, 61 }); 62 63 for (const a of admins) { 64 await sendMail({ 65 to: a.email, 66 subject: "【DELOGs】メール変更リクエストが確認されました", 67 text: [ 68 `ユーザ ${req.user.name} (${req.user.email}) が新しいメールアドレスを認証しました。`, 69 "", 70 "管理者画面にて承認または却下を行ってください。", 71 ].join("\n"), 72 }); 73 } 74 75 return { ok: true }; 76}
上記コードでは以下の動作を行います。
  • 本人確認
    ログイン済みのユーザとリクエスト対象ユーザが一致するか検証します。
  • 期限チェック
    expiresAt が過去なら即座に EXPIRED へ遷移。
  • 状態遷移
    問題なければ VERIFIED へ更新。
  • 管理者通知
    roleCode = ADMIN のアクティブユーザ全員にメール通知します。
これにより、本人確認済みのリクエストが管理者に即時共有されるため、承認作業へスムーズに移れます。
tsx
1// src/app/(protected)/profile/email/verify/page.tsx(新規 or 置換:SSR) 2// 既存 middleware.ts が /profile 配下を保護している前提。 3// 未ログインなら middleware が / へリダイレクト、 4 5export const dynamic = "force-dynamic"; 6 7import { redirect } from "next/navigation"; 8import type { Metadata } from "next"; 9import { lookupSessionFromCookie } from "@/lib/auth/session"; 10import { verifyEmailChangeAction } from "@/app/_actions/profile/email-verify"; 11import { 12 Breadcrumb, 13 BreadcrumbItem, 14 BreadcrumbLink, 15 BreadcrumbList, 16 BreadcrumbPage, 17 BreadcrumbSeparator, 18} from "@/components/ui/breadcrumb"; 19import { Separator } from "@/components/ui/separator"; 20import { SidebarTrigger } from "@/components/ui/sidebar"; 21 22export const metadata: Metadata = { 23 title: "メールアドレス変更の確認", 24 description: "メール内の確認URLを検証して申請をVERIFIEDに進めます。", 25}; 26 27type Props = { searchParams: Promise<{ token?: string }> }; 28 29export default async function Page({ searchParams }: Props) { 30 // セッションは middleware でも検証されるが、ここでも明示チェック 31 const session = await lookupSessionFromCookie(); 32 if (!session.ok) { 33 redirect("/"); // 本プロジェクトのログイン導線に合わせる 34 } 35 36 const token = ((await searchParams).token ?? "").trim(); 37 const res = await verifyEmailChangeAction(token); 38 if (!res.ok) { 39 return ( 40 <Result ok={false} message={res.message ?? "検証に失敗しました。"} /> 41 ); 42 } 43 44 return ( 45 <Result 46 ok 47 message="本人確認が完了しました。管理者の承認後にメールアドレスが切り替わります。" 48 /> 49 ); 50} 51 52// 簡易な結果表示(UIはお好みで) 53function Result({ ok, message }: { ok: boolean; message: string }) { 54 return ( 55 <> 56 <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"> 57 <div className="flex items-center gap-2 px-4"> 58 <SidebarTrigger className="-ml-1" /> 59 <Separator 60 orientation="vertical" 61 className="mr-2 data-[orientation=vertical]:h-4" 62 /> 63 <Breadcrumb> 64 <BreadcrumbList> 65 <BreadcrumbItem className="hidden md:block"> 66 <BreadcrumbLink href="/profile">プロフィール</BreadcrumbLink> 67 </BreadcrumbItem> 68 <BreadcrumbSeparator className="hidden md:block" /> 69 <BreadcrumbItem className="hidden md:block"> 70 <BreadcrumbLink href="/profile/email"> 71 メールアドレスの変更 72 </BreadcrumbLink> 73 </BreadcrumbItem> 74 <BreadcrumbSeparator className="hidden md:block" /> 75 <BreadcrumbItem> 76 <BreadcrumbPage>メールアドレス変更の本人確認</BreadcrumbPage> 77 </BreadcrumbItem> 78 </BreadcrumbList> 79 </Breadcrumb> 80 </div> 81 </header> 82 <div className="max-w-xl p-4 pt-0"> 83 <h1 className="mb-3 text-lg font-semibold"> 84 {ok 85 ? "メールアドレス変更の本人確認完了" 86 : "メールアドレス変更の本人確認エラー"} 87 </h1> 88 <p className="text-muted-foreground">{message}</p> 89 </div> 90 </> 91 ); 92}
このクライアント実装では:
  • 認証URLの token を取得し、Server Action に渡して検証。
  • 成功時は「本人確認完了」と表示します。
  • 失敗時もエラーメッセージを表示します。
ただ、このままだと未ログイン状態で認証URLへアクセスした際の処理が足りません。

middleware.tsへの追記

現状では、保護対象にアクセスしたときにセッションがなければ、ログイン画面 (/) に ?continue=... を付与してリダイレクトします。 ログイン処理完了後にこの continue URL があればそこへ遷移させるようにすれば、 ログイン画面を挟んで認証URLへのアクセスが可能になります。
ts
1// src/middleware.ts 2import { NextResponse } from "next/server"; 3import type { NextRequest } from "next/server"; 4import { verifySessionJwt } from "@/lib/auth/jwt"; 5 6const LOGIN_PATH = "/"; 7 8// 保護対象パスのプレフィックス一覧 9const PROTECTED_PATHS = [ 10 "/changelog", 11 "/dashboard", 12 "/masters", 13 "/tutorial", 14 "/users", 15 "/profile", 16 "/avatar", 17]; 18 19export async function middleware(req: NextRequest) { 20 const { pathname, search } = req.nextUrl; 21 if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) { 22 const token = req.cookies.get( 23 process.env.SESSION_COOKIE_NAME ?? "session", 24 )?.value; 25 26 if (!token) { 27 const url = new URL(LOGIN_PATH, req.url); 28 url.searchParams.set("continue", pathname + search); // ← 元のURLを保持 29 return NextResponse.redirect(url); 30 } 31 32 try { 33 await verifySessionJwt(token); 34 return NextResponse.next(); 35 } catch { 36 const url = new URL(LOGIN_PATH, req.url); 37 url.searchParams.set("continue", pathname + search); 38 return NextResponse.redirect(url); 39 } 40 } 41 return NextResponse.next(); 42} 43 44export const config = { 45 matcher: [ 46 "/changelog/:path*", 47 "/dashboard/:path*", 48 "/masters/:path*", 49 "/tutorial/:path*", 50 "/users/:path*", 51 "/profile/:path*", 52 "/avatar/:path*", 53 ], 54};
上記では、保護対象にアクセスした未ログインユーザを /?continue=... へ送っています。
つぎに、この continue をログイン画面→ログイン実行の両方で引き継ぎます。

ログイン画面の修正

URLパラメータから次ページの指定を取得できるように変更します。
tsx
1// src/app/page.tsx 2import Image from "next/image"; 3import Link from "next/link"; 4import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 5import { Badge } from "@/components/ui/badge"; 6 7import LoginForm from "@/components/login/login-form"; // ログインフォームコンポーネント 8import { HandHelping } from "lucide-react"; 9 10type Props = { searchParams: Promise<{ continue?: string }> }; // 追加 11 12// URLパラメータから次ページの指定を取得できるように変更 13export default async function Page({ searchParams }: Props) { 14 const next = ((await searchParams).continue ?? "").toString(); // 追加 15 return ( 16 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10 dark:bg-neutral-800"> 17 <Card className="w-full max-w-md"> 18 <CardHeader> 19 <CardTitle className="flex items-end justify-between gap-2"> 20 {/* light用ロゴ(=ダークモード時に非表示) */} 21 <Image 22 src="/logo.svg" 23 alt="サイトロゴ" 24 width={160} 25 height={40} 26 className="h-[40px] w-[160px] dark:hidden" 27 /> 28 29 {/* dark用ロゴ(=ダークモード時に表示) */} 30 <Image 31 src="/logo-d.svg" 32 alt="サイトロゴ(ダーク)" 33 width={160} 34 height={40} 35 className="hidden h-[40px] w-[160px] dark:block" 36 /> 37 <div> 38 <Badge variant="secondary" className="rounded-full"> 39 Demo 40 </Badge> 41 <Badge className="rounded-full" variant="outline"> 42 UI Only 43 </Badge> 44 </div> 45 </CardTitle> 46 <p className="text-muted-foreground mt-2 text-sm"> 47 デモ用のログインページのため、各項目はデフォルトでバリデーションを通る値を入れています。 48 </p> 49 </CardHeader> 50 <CardContent className="-mt-4"> 51 {/* 変更: 次ページの指定をフォームへ送る */} 52 <LoginForm continueTo={next} /> 53 <Link 54 href="/password-forgot" 55 className="my-2 ml-auto flex items-center justify-end gap-2 text-sm" 56 > 57 パスワードをお忘れの方 58 <HandHelping /> 59 </Link> 60 </CardContent> 61 </Card> 62 </main> 63 ); 64}

ログインフォームの修正

tsx
1// src/components/login/login-form.tsx(抜粋) 2 3import { useRouter } from "next/navigation"; 4import { useState, useTransition, useMemo } from "react"; // useMemoを追加 5 6// ──省略 7 8type LoginValues = z.infer<typeof loginSchema>; 9 10// 追加:同一オリジン・アプリ内パスのみOKにする簡易サニタイズ 11function resolveNext(continueTo?: string): string { 12 if (!continueTo) return "/dashboard"; 13 try { 14 // フルURLで来ても弾く(プロトコル/ホストを含むのはNG) 15 // 先頭が "/" で始まり、"//" ではなく、":" を含まない(スキーム禁止)パスだけ許可 16 if ( 17 continueTo.startsWith("/") && 18 !continueTo.startsWith("//") && 19 !continueTo.includes(":") 20 ) { 21 return continueTo; 22 } 23 } catch { 24 /* noop */ 25 } 26 return "/dashboard"; 27} 28 29export default function LoginForm({ continueTo }: { continueTo?: string }) { 30 const [pending, startTransition] = useTransition(); 31 const [globalError, setGlobalError] = useState<string | null>(null); 32 const { setUser } = useAuth(); 33 const router = useRouter(); 34 35 // 追加:メモ化(不要なら毎回 resolveNext でもOK) 36 const nextUrl = useMemo(() => resolveNext(continueTo), [continueTo]); 37 38 const onSubmit = (values: LoginValues) => { 39 setGlobalError(null); 40 if (pending) return; 41 42 startTransition(async () => { 43 const res = await loginAction({ 44 accountId: values.accountId, 45 email: values.email, 46 password: values.password, 47 }); 48 49 if (!res || !("ok" in res)) { 50 // ──省略 51 } 52 53 if (!res.ok) { 54 // ──省略 55 } 56 57 // 成功時:Contextに保存してから遷移 58 setUser(res.user); 59 // ここを "/dashboard" 固定から、continue対応に変更 60 router.replace(nextUrl); 61 }); 62 }; 63 64// ──省略 65
ここで本人確認が完了し、管理者に通知メールが届いた時点で、管理者は承認/却下フロー(4章)へ進める準備が整います。ただ、変更手続き中に再度メールアドレス変更画面を開いたときに、現状のステータスがわかった方が親切です。これを追加します。

申請中ステータスの表示(/profile/email)

この節では、ユーザがメール変更の申請を出した後に /profile/email を開くと、現在の進捗(PENDING / VERIFIED )がひと目で分かるよう、ページ上部に「ステータスカード」を表示します。
申請が存在しない場合はカードを出さず、従来どおりフォームのみを表示します。
まずは「最新の申請1件」を返す Server Action を用意し、クライアント側で呼び出して描画します。
txt
1[EmailChangeForm ページ] 2 ├─ (Server Action) getMyLatestEmailChangeRequest 3 │ └─ EmailChangeRequest を userId で検索して最新1件を返す 4 └─ クライアントでステータスカードを表示(なければ非表示)

ステータスと文言の対応

下表の方針でメッセージを出し分けます。承認UIは4章で実装するため、ここでは「現状の案内」に徹します。
status見出し補足メッセージ(例)
PENDING申請受付済み(メール送信済み)新しいアドレス宛に送信した確認URLを踏んでください。
VERIFIED本人確認が完了しました管理者の承認待ちです。承認後にメールアドレスが切り替わります。
APPROVED承認が完了しました(再ログインで反映)変更が承認されました。現在の画面は旧メールのままの可能性があります。一度ログアウト/再ログインすると反映されます。
ts
1// src/app/_actions/profile/email-status.ts 2// 本人の最新 EmailChangeRequest を1件返すだけの Server Action 3"use server"; 4 5import { prisma } from "@/lib/database"; 6import { lookupSessionFromCookie } from "@/lib/auth/session"; 7import type { EmailChangeStatus } from "@prisma/client"; 8 9export type MyEmailChangeSummary = 10 | { exists: false } 11 | { 12 exists: true; 13 status: EmailChangeStatus; // "PENDING" | "VERIFIED" | "APPROVED" ... 14 expiresAt?: Date | null; // APPROVED のときは null でもOK 15 newEmail: string; // punycode(ASCII) 16 currentEmail: string; // いまのスナップショット(User.email, ASCII) 17 needsRelogin: boolean; // 承認済みかつ currentEmail !== newEmail 18 }; 19 20export async function getMyEmailChangeStatus(): Promise<MyEmailChangeSummary> { 21 const session = await lookupSessionFromCookie(); 22 if (!session.ok) return { exists: false }; 23 24 // 直近の申請(PENDING / VERIFIED / APPROVED)を対象にする 25 const r = await prisma.emailChangeRequest.findFirst({ 26 where: { 27 userId: session.userId, 28 status: { in: ["PENDING", "VERIFIED", "APPROVED"] }, 29 }, 30 orderBy: { createdAt: "desc" }, 31 select: { 32 status: true, 33 expiresAt: true, 34 newEmailPuny: true, 35 user: { select: { email: true } }, // スナップショット比較用 36 }, 37 }); 38 39 if (!r) return { exists: false }; 40 41 const currentEmail = r.user.email; // punycode ASCII(大小は保存方針どおり) 42 const needsRelogin = 43 r.status === "APPROVED" && currentEmail !== r.newEmailPuny; 44 45 return { 46 exists: true, 47 status: r.status, 48 // APPROVED では期限は意味がないので null に寄せて返す(UIで分岐しやすい) 49 expiresAt: r.status === "APPROVED" ? null : r.expiresAt, 50 newEmail: r.newEmailPuny, 51 currentEmail, 52 needsRelogin, 53 }; 54}
上記は「本人の最新1件」だけを返します。findFirst + orderBy: desc を使うことで最小限の I/O で済み、UI側のロジックも単純になります。
tsx
1// src/components/profile/email-change-status.tsx(例:表示専用) 2"use client"; 3 4import * as React from "react"; 5import * as punycode from "punycode/"; 6import { useAuth } from "@/lib/auth/context"; 7import type { MyEmailChangeSummary } from "@/app/_actions/profile/email-status"; 8 9export function EmailChangeStatusBanner({ 10 summary, 11}: { 12 summary: MyEmailChangeSummary; 13}) { 14 const { user } = useAuth(); 15 if (!summary.exists) return null; 16 17 const { status, expiresAt, newEmail } = summary; 18 const prettyNew = punycode.toUnicode(newEmail); 19 20 // ログイン中スナップショット(Context)は punycode ASCII 前提 21 const snapshotEmail = user?.email ?? null; 22 const isApproved = status === "APPROVED"; 23 const approvedButSnapshotOld = 24 isApproved && snapshotEmail && snapshotEmail !== newEmail; 25 26 // 表示用テキスト 27 const until = expiresAt 28 ? new Date(expiresAt).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }) 29 : null; 30 31 let heading = ""; 32 let body = ""; 33 let tone: "info" | "warn" | "success" = "info"; 34 35 switch (status) { 36 case "PENDING": 37 heading = "認証メールを送信しました"; 38 body = `${prettyNew}】への変更を進めるには、受信メールの確認URLをクリックしてください。${until ? `有効期限:${until}` : ""}`; 39 tone = "warn"; 40 break; 41 42 case "VERIFIED": 43 heading = "本人確認が完了しました"; 44 body = `${prettyNew}】への変更は、現在 管理者の承認待ちです。承認後に反映されます。`; 45 tone = "info"; 46 break; 47 48 case "APPROVED": 49 if (approvedButSnapshotOld) { 50 heading = "承認が完了しました(再ログインで反映)"; 51 body = 52 `${prettyNew}】への変更が承認されました。現在の画面は旧メールのままの可能性があります。` + 53 `一度ログアウト/再ログインすると反映されます。`; 54 tone = "success"; 55 } 56 break; 57 58 default: 59 return null; 60 } 61 62 if (!body) return null; 63 64 const toneClass = 65 tone === "success" 66 ? "border-emerald-300 bg-emerald-50 text-emerald-900" 67 : tone === "warn" 68 ? "border-amber-300 bg-amber-50 text-amber-900" 69 : "border-sky-300 bg-sky-50 text-sky-900"; 70 71 return ( 72 <div className={`mb-4 rounded-md border px-3 py-2 text-sm ${toneClass}`}> 73 <div className="font-medium">{heading}</div> 74 <div className="mt-1">{body}</div> 75 </div> 76 ); 77}
  • ここでは punycode.toUnicode() を使って、人間可読な表記に変換してから表示しています。
  • なんらかのリアクションが必要なときだけバナーを出し、status に応じて文言を切り替えます。
tsx
1// src/app/(protected)/profile/email/client.tsx(差分:ステータス表示を追加) 2"use client"; 3 4import { useEffect, useState } from "react"; 5import { useRouter } from "next/navigation"; 6import { toast } from "sonner"; 7import { useAuth } from "@/lib/auth/context"; 8import EmailChangeForm from "@/components/profile/email-change-form"; 9import { EmailChangeStatusBanner } from "@/components/profile/email-change-status"; 10import type { EmailChangeValues } from "@/lib/users/schema"; 11import { sendEmailChangeRequestAction } from "@/app/_actions/profile/email-change"; 12import { 13 getMyEmailChangeStatus, 14 type MyEmailChangeSummary, 15} from "@/app/_actions/profile/email-status"; 16 17export default function EmailChangeClient() { 18 const router = useRouter(); 19 const { user, ready } = useAuth(); 20 const [summary, setSummary] = useState<MyEmailChangeSummary>({ 21 exists: false, 22 }); 23 24 useEffect(() => { 25 let mounted = true; 26 (async () => { 27 const s = await getMyEmailChangeStatus(); 28 if (mounted) setSummary(s); 29 })(); 30 return () => { 31 mounted = false; 32 }; 33 }, []); 34 35 if (!ready || !user) return null; 36 const currentEmail = user.email; 37 38 const onSubmit = async (values: EmailChangeValues) => { 39 const fd = new FormData(); 40 fd.set("newEmail", values.newEmail); 41 42 const res = await sendEmailChangeRequestAction(fd); 43 if (!res.ok) { 44 toast.error(res.message ?? "認証メールの送信に失敗しました"); 45 return; 46 } 47 toast.success("認証メールを送信しました", { 48 description: `送信先:${values.newEmail}`, 49 duration: 3500, 50 }); 51 router.push("/profile"); 52 }; 53 54 return ( 55 <> 56 <EmailChangeStatusBanner summary={summary} /> 57 <EmailChangeForm 58 currentEmail={currentEmail} 59 onSubmit={onSubmit} 60 onCancel={() => history.back()} 61 /> 62 </> 63 ); 64}
  • 既存の EmailChangeClient でステータスを読み込み、フォームの上にバナーを表示します。
  • Server Action はクライアントから直接呼び出せないため、useEffect 内でラッパー関数を経由して取得します(actionfetch 経由にする方法でも可)。
同様に、プロフィール編集画面のsrc/app/(protected)/profile/client.tsxについてもステータスバナーを表示できるようにします。
tsx
1// src/app/(protected)/profile/client.tsx(差分:ステータス表示を追加) 2"use client"; 3 4import { useTransition, useEffect, useState } from "react"; 5import { useRouter } from "next/navigation"; 6import { toast } from "sonner"; 7 8import ProfileForm from "@/components/profile/profile-form"; 9import type { ProfileUpdateValues } from "@/lib/users/schema"; 10import { useAuth } from "@/lib/auth/context"; 11import { updateProfileAction } from "@/app/_actions/profile/avatar"; 12import { refreshAuthSnapshotAction } from "@/app/_actions/auth/refresh"; 13import { deleteOwnAvatarAction } from "@/app/_actions/profile/avatar"; // ★ 追加 14 15import { EmailChangeStatusBanner } from "@/components/profile/email-change-status"; 16import { 17 getMyEmailChangeStatus, 18 type MyEmailChangeSummary, 19} from "@/app/_actions/profile/email-status"; 20 21export default function ProfileClient() { 22 const router = useRouter(); 23 const [pending, startTransition] = useTransition(); 24 25 const { user, ready, setUser } = useAuth(); 26 const [summary, setSummary] = useState<MyEmailChangeSummary>({ 27 exists: false, 28 }); 29 30 useEffect(() => { 31 let mounted = true; 32 (async () => { 33 const s = await getMyEmailChangeStatus(); 34 if (mounted) setSummary(s); 35 })(); 36 return () => { 37 mounted = false; 38 }; 39 }, []); 40 41 if (!ready) return null; 42 if (!user) return null; 43 44 const initial = { 45 name: user.name, 46 email: user.email, 47 roleCode: user.roleCode, 48 currentAvatarUrl: user.avatarUrl ?? undefined, 49 } as const; 50 51 const applyFreshSnapshot = async (msg?: string) => { 52 const snap = await refreshAuthSnapshotAction(); 53 if (snap.ok) { 54 const ts = Date.now(); 55 const updated = { 56 ...snap.user, 57 avatarUrl: 58 snap.user.avatarUrl != null 59 ? `${snap.user.avatarUrl}?ts=${ts}` 60 : null, 61 }; 62 setUser(updated); 63 } 64 if (msg) { 65 toast.success(msg, { duration: 3000 }); 66 } 67 }; 68 69 const handleSubmit = (values: ProfileUpdateValues) => { 70 if (pending) return; 71 startTransition(async () => { 72 try { 73 const fd = new FormData(); 74 fd.set("name", values.name); 75 if (values.avatarFile) fd.set("avatarFile", values.avatarFile); 76 77 const res = await updateProfileAction(fd); 78 if (!res.ok) { 79 toast.error(res.message ?? "プロフィールの更新に失敗しました"); 80 return; 81 } 82 await applyFreshSnapshot( 83 values.avatarFile 84 ? "プロフィールを更新しました(画像含む)" 85 : "プロフィールを更新しました", 86 ); 87 } catch (e) { 88 console.error(e); 89 toast.error( 90 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 91 ); 92 } 93 }); 94 }; 95 96 // ★ 追加:登録済みアバターの削除(DB反映) 97 const handleDeleteAvatar = () => { 98 if (pending) return; 99 startTransition(async () => { 100 try { 101 const res = await deleteOwnAvatarAction(); 102 if (!res.ok) { 103 toast.error(res.message ?? "アバターの削除に失敗しました"); 104 return; 105 } 106 await applyFreshSnapshot("アバターを削除しました"); 107 } catch (e) { 108 console.error(e); 109 toast.error( 110 "予期せぬエラーが発生しました。時間をおいて再試行してください。", 111 ); 112 } 113 }); 114 }; 115 116 return ( 117 <> 118 <EmailChangeStatusBanner summary={summary} /> 119 <ProfileForm 120 initial={initial} 121 onSubmit={handleSubmit} 122 onDelete={handleDeleteAvatar} // ★ 追加 123 onCancel={() => history.back()} 124 onNavigateEmail={() => router.push("/profile/email")} 125 onNavigatePassword={() => router.push("/profile/password")} 126 /> 127 </> 128 ); 129}
これで、ユーザ側のメールアドレス変更の処理は完了です。管理者側の処理は4章で記載します。先に、ユーザによるパスワード変更を次章で実装しておきます。

3. パスワード変更(Server Action で実データを更新)

この章では、UI だけだったパスワード変更を サーバ実装まで完結 させます。
argon2 での照合・再ハッシュ・セッションの扱い・監査的な更新を含め、User.hashedPassword を実際に更新します。
txt
1【処理フロー(/profile/password)】 2 3[PasswordChangeForm] 4 | 5 | currentPassword, newPassword を submit 6 v 7[Server Action changeMyPasswordAction] 8 |-- セッション確認(本人のみ) 9 |-- DBから本人のハッシュ/ロック状態取得 10 |-- (ロック中なら中断) 11 |-- argon2.verify(現在PW) 失敗→即エラー 12 |-- 現在PWと新PWが同じならエラー 13 |-- argon2.hash(新PW) で再ハッシュ 14 |-- DB更新: 15 | - hashedPassword ← 新ハッシュ 16 | - failedLoginCount ← 0 17 | - lockedUntil ← null 18 | - (任意)passwordChangedAt ← now() 19 |-- (任意)他セッション失効 20 '-- 成功レスポンス → トースト → /profile へ遷移

仕様の整理

パスワード変更は、本人がログイン済みであることを前提に、以下のポリシーで動作させます。
項目方針
本人確認Server Action 内で Cookie セッションを検証し、userId を引数で受けない(権限昇格防止)
照合argon2.verify で現在パスワードを照合。失敗時はカウントの増分を行わず即エラー(「変更画面は本物の本人操作」前提のため)
同値チェック現在と新しいパスワードが同じならエラー
更新argon2.hash で新ハッシュを作成し、hashedPassword を更新
ロック変更成功時に failedLoginCount=0lockedUntil=null に戻す(ロック状態の解除も可能にする運用)
セッション成功後、他セッションを無効化するのが安全(任意)。ここでは「現在セッションのみ生かし、他は revoke」する例を付記
監査passwordChangedAt(任意フィールド)を持つなら更新。なければ将来の監査要件で追加を検討
注: ログイン時にすでに argon2 を採用済みなので、同じ方式で整合します。

パスワード更新用のサーバアクションの追加

ts
1// src/app/_actions/profile/password-change.ts(新規) 2// Server Action:本人のパスワードを変更(argon2 で再ハッシュ) 3 4"use server"; 5 6import { prisma } from "@/lib/database"; 7import argon2 from "argon2"; 8import { lookupSessionFromCookie } from "@/lib/auth/session"; 9import { passwordChangeSchema } from "@/lib/users/schema"; 10 11export type PasswordChangeResult = 12 | { ok: true } 13 | { ok: false; message?: string; fieldErrors?: Record<string, string> }; 14 15export async function changeMyPasswordAction( 16 formData: FormData, 17): Promise<PasswordChangeResult> { 18 // 1) セッション → 本人特定 19 const session = await lookupSessionFromCookie(); 20 if (!session.ok) { 21 return { ok: false, message: "認証が必要です" }; 22 } 23 24 // 2) 入力値(Server 側でも厳格に検証) 25 const parsed = passwordChangeSchema.safeParse({ 26 currentPassword: (formData.get("currentPassword") as string | null) ?? "", 27 newPassword: (formData.get("newPassword") as string | null) ?? "", 28 }); 29 if (!parsed.success) { 30 const fe: Record<string, string> = {}; 31 for (const issue of parsed.error.issues) { 32 const key = String(issue.path[0] ?? "newPassword"); 33 fe[key] = issue.message; 34 } 35 return { ok: false, fieldErrors: fe }; 36 } 37 const { currentPassword, newPassword } = parsed.data; 38 39 // 3) 本人レコード取得(最低限の項目) 40 const me = await prisma.user.findUnique({ 41 where: { id: session.userId }, 42 select: { 43 id: true, 44 email: true, 45 hashedPassword: true, 46 isActive: true, 47 // 任意列: passwordChangedAt を使う場合に備える 48 }, 49 }); 50 if (!me || !me.isActive) { 51 return { 52 ok: false, 53 message: "ユーザーが見つからないか、無効化されています", 54 }; 55 } 56 57 // 4) 現在パスワードの照合(変更画面なので失敗カウントは上げない) 58 const ok = await argon2.verify(me.hashedPassword, currentPassword); 59 if (!ok) { 60 return { 61 ok: false, 62 fieldErrors: { currentPassword: "現在のパスワードが違います" }, 63 }; 64 } 65 66 // 5) 同値チェック 67 const same = await argon2.verify(me.hashedPassword, newPassword); 68 if (same) { 69 return { 70 ok: false, 71 fieldErrors: { newPassword: "現在と同じパスワードは使えません" }, 72 }; 73 } 74 75 // 6) 再ハッシュ → DB更新 76 const newHash = await argon2.hash(newPassword); 77 78 await prisma.$transaction(async (tx) => { 79 await tx.user.update({ 80 where: { id: me.id }, 81 data: { 82 hashedPassword: newHash, 83 failedLoginCount: 0, 84 lockedUntil: null, 85 }, 86 }); 87 88 // --- 他セッション失効(現在セッションのみ生かす) --- 89 // 現在セッションIDは cookie->JWT -> middleware で jti を使っている想定。 90 // ここでは簡易に「自分のセッションを全削除→新セッション発行」をせず、 91 // '現在以外' のセッションを revoke する運用(列があるなら)。 92 await tx.session.updateMany({ 93 where: { userId: me.id, id: { not: session.sessionId } }, 94 data: { revokedAt: new Date() }, 95 }); 96 }); 97 98 return { ok: true }; 99}
上記 Server Action は、サーバ側でも必ず Zod で検証 し、argon2 で照合・再ハッシュします。
ロック/失敗カウントは、変更成功時にクリアします。
「他セッションの失効」は運用ポリシーに依存するため、コメントで雛形を付けました(sessionIdrevokedAt の扱いは既存実装に合わせてください)。

クライアントコンポーネント(profile/password/client.tsx)の更新

既存のUIにサーバアクションを呼びだす工程を追加します。
tsx
1// src/app/(protected)/profile/password/client.tsx(差し替え) 2// 既存 UI を Server Action に接続。成功時はトースト → /profile へ。 3 4"use client"; 5 6import { useRouter } from "next/navigation"; 7import PasswordChangeForm from "@/components/profile/password-change-form"; 8import { toast } from "sonner"; 9import { changeMyPasswordAction } from "@/app/_actions/profile/password-change"; 10 11export default function PasswordChangeClient() { 12 const router = useRouter(); 13 14 return ( 15 <div className="max-w-xl p-4 pt-0"> 16 <PasswordChangeForm 17 onSubmit={async (values) => { 18 const fd = new FormData(); 19 fd.set("currentPassword", values.currentPassword); 20 fd.set("newPassword", values.newPassword); 21 22 const res = await changeMyPasswordAction(fd); 23 if (!res.ok) { 24 if (res.fieldErrors?.currentPassword) { 25 toast.error(res.fieldErrors.currentPassword); 26 return; 27 } 28 if (res.fieldErrors?.newPassword) { 29 toast.error(res.fieldErrors.newPassword); 30 return; 31 } 32 toast.error(res.message ?? "パスワード変更に失敗しました"); 33 return; 34 } 35 36 toast.success("パスワードを変更しました", { 37 description: "次回ログインから新しいパスワードをご利用ください。", 38 duration: 3500, 39 }); 40 router.push("/profile"); 41 }} 42 onCancel={() => history.back()} 43 /> 44 </div> 45 ); 46}
UI の見た目はそのままに、changeMyPasswordAction を呼び出すだけの差分です。
サーバ側での検証結果は、できる限りフォームのエラーメッセージに寄せて返しているため、ユーザは原因を把握しやすくなります。

エラーとガード条件の対応表

非機能要件に基づき、代表的な失敗ケースと応答を整理しておきます。
ケース返却UI での扱い
未ログイン{ ok:false, message:"認証が必要です" }トースト or / へ誘導(middlewareで保護済なので基本到達しない)
現在PW不一致{ ok:false, fieldErrors:{ currentPassword } }現在PW欄のエラー表示
新PWが弱い(Zod NG){ ok:false, fieldErrors:{ newPassword } }新PW欄のエラー表示
新PW=現在PW{ ok:false, fieldErrors:{ newPassword } }新PW欄のエラー表示
DB更新失敗{ ok:false, message }トースト等の全体エラー
ロック方針:パスワード変更画面は本人が入れている前提のため、ここで失敗回数を増やす必要はありません
変更成功時にはカウントとロックをリセットしています。
これで、プロフィール機能の完成です。
次章では、メールアドレス変更の 管理者承認(APPROVE/REJECT)UI と反映処理 を実装し、本人確認 → 管理者承認 → 反映の最終段を仕上げます。

4. 管理者によるメールアドレス変更の承認

本章では、2章で「本人認証(VERIFIED)」まで進んだ申請を、 管理者(ADMIN) が承認/却下できる画面とサーバ処理を実装します。UIは「パスワード再発行依頼」のテーブルUIを参考にしつつ、 DB連携(Prisma)厳密なガード(部署/権限/状態) を追加します。
要点は次のとおりです。
  • 一覧は ログイン中アカウント(部署)配下 の申請のみを対象
  • 操作可能なのは status=VERIFIED のものだけ(本人確認済み)
  • 承認 時は、トランザクションで
    ① リクエストの状態を APPROVED に更新(処理者/処理時刻を記録)
    ② 対象ユーザの emailnewEmailPuny に置換
    ③(任意)同ユーザの他のアクティブ申請を REJECTED へクローズ
  • 却下 時は、リクエストを REJECTED に更新(理由は最小実装では省略)
以下、 画面構成 → ルーティング → Server Action → 一覧テーブル → 操作フロー の順で実装します。
txt
1【画面イメージ】 2 3/users/email-change-requests 4 ├─ ヘッダー(パンくず:ユーザ管理 > メール変更申請) 5 ├─ 検索・フィルタ 6 │ ├─ キーワード(アカウントID / 旧メール / 新メール / 氏名) 7 │ ├─ 状態(ALL / PENDING / VERIFIED / APPROVED / REJECTED) 8 │ └─ 期間(オプション、初期は未搭載) 9 ├─ テーブル(@tanstack/react-table) 10 │ ├─ 申請日時 / アカウントID / 対象ユーザ / 旧メール / 新メール / 状態 11 │ ├─ 本人確認日時 / 処理日時 / 処理者 12 │ └─ 操作:承認(APPROVE)/ 却下(REJECT)※VERIFIEDのみ有効 13 └─ ページング

ルーティングとページ骨子

この節では、ページのルートSSRのガード (ログイン必須+ADMIN権限+部署一致)を定義します。データ自体は Server Action から取得するため、ページは最小限の構成に留め、ヘッダー・パンくず・データテーブルを描画します。
tsx
1// src/app/(protected)/users/email-change-requests/page.tsx 2import { redirect } from "next/navigation"; 3import type { Metadata } from "next"; 4import { lookupSessionFromCookie } from "@/lib/auth/session"; 5import { SidebarTrigger } from "@/components/ui/sidebar"; 6import { 7 Breadcrumb, 8 BreadcrumbItem, 9 BreadcrumbLink, 10 BreadcrumbList, 11 BreadcrumbPage, 12 BreadcrumbSeparator, 13} from "@/components/ui/breadcrumb"; 14import { Separator } from "@/components/ui/separator"; 15 16import DataTable from "./data-table"; 17import { columns } from "./columns"; 18 19export const metadata: Metadata = { 20 title: "メールアドレス変更申請 | 管理画面レイアウト【DELOGs】", 21 description: "本人確認済み(VERIFIED)のメール変更申請を承認/却下します。", 22}; 23 24export default async function Page() { 25 const session = await lookupSessionFromCookie(); 26 if (!session.ok) redirect("/"); 27 28 return ( 29 <> 30 <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"> 31 <div className="flex items-center gap-2 px-4"> 32 <SidebarTrigger className="-ml-1" /> 33 <Separator 34 orientation="vertical" 35 className="mr-2 data-[orientation=vertical]:h-4" 36 /> 37 <Breadcrumb> 38 <BreadcrumbList> 39 <BreadcrumbItem className="hidden md:block"> 40 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 41 </BreadcrumbItem> 42 <BreadcrumbSeparator className="hidden md:block" /> 43 <BreadcrumbItem> 44 <BreadcrumbPage>メールアドレス変更申請</BreadcrumbPage> 45 </BreadcrumbItem> 46 </BreadcrumbList> 47 </Breadcrumb> 48 </div> 49 </header> 50 51 <div className="container p-4 pt-0"> 52 <DataTable columns={columns} /> 53 </div> 54 </> 55 ); 56}
上記は ページの骨子 です。テーブル本体はクライアントコンポーネント(data-table.tsx)で実装し、 データ取得・承認・却下 は Server Action 経由で行います。ここでの権限判定は「入口の粗いガード」であり、 Server Action側でも必ず再検証 します。

メールテンプレートの追加

管理者による承認が終わったら、申請したユーザへメール通知も行いたいので、メールテンプレートを追加したおきます。
ts
1// src/lib/email/templates.ts(末尾に追記) 2 3/** 4 * 承認完了メール(新メールアドレス宛) 5 */ 6export function emailChangeApprovedText(params: { 7 newEmail: string; // punycode (ASCII)のままでOK 8}) { 9 return [ 10 "DELOGsシステムより自動送信しています。このメールへの返信は受け付けていません。", 11 "", 12 "メールアドレス変更の申請が管理者により承認されました。", 13 "", 14 `■ 変更後メール:${params.newEmail}`, 15 "", 16 "以後のログイン・通知は上記の新しいメールアドレスが対象となります。", 17 "", 18 "※このメールに心当たりがない場合は、管理者へお問い合わせください。", 19 ].join("\n"); 20}

Server Action の設計

この節では、管理者用のアクションを _actions/users/email-change-requests.ts に集約し、以下を提供します。
関数名役割メモ
listEmailChangeRequestsAction一覧取得部署IDで絞り込み。検索・フィルタは FormData で受ける簡易実装
approveEmailChangeRequestAction承認VERIFIEDのみ可。Txでリクエスト承認+User.email更新
rejectEmailChangeRequestAction却下VERIFIED/PENDINGを拒否に。理由は最小実装では省略
重要なガード
  • ログイン必須、roleCode=ADMIN のみ許可
  • 同一部署 のリクエストに限定(越権操作を防ぐ)
  • approve 実行前に 状態・期限・本人確認済み を再検証
  • 競合対策として updateMany で状態を条件更新し、件数=1 を確認(楽観ロック)
ts
1// src/app/_actions/users/email-change-requests.ts 2"use server"; 3 4import { prisma } from "@/lib/database"; 5import { lookupSessionFromCookie } from "@/lib/auth/session"; 6import { sendMail } from "@/lib/mailer"; 7import { emailChangeApprovedText } from "@/lib/email/templates"; 8import { z } from "zod"; 9import { EmailChangeStatus } from "@prisma/client"; 10 11type ActionResult<T = unknown> = 12 | { ok: true; data?: T } 13 | { ok: false; message: string }; 14 15const listSchema = z.object({ 16 q: z.string().optional(), 17 status: z.union([z.literal("ALL"), z.enum(EmailChangeStatus)]).default("ALL"), 18}); 19 20export async function listEmailChangeRequestsAction( 21 formData: FormData, 22): Promise< 23 ActionResult< 24 Array<{ 25 id: string; 26 requestedAt: Date; 27 verifiedAt?: Date | null; 28 processedAt?: Date | null; 29 processedBy?: string | null; 30 accountId: string; // Department.code 31 userName: string; 32 oldEmail: string; 33 newEmail: string; // punycode (ASCII) 34 status: string; 35 }> 36 > 37> { 38 const session = await lookupSessionFromCookie(); 39 if (!session.ok) return { ok: false, message: "認証が必要です" }; 40 41 // ★ 追加: 自分の部署・権限を解決 42 const me = await prisma.user.findUnique({ 43 where: { id: session.userId }, 44 select: { 45 id: true, 46 name: true, 47 departmentId: true, 48 isActive: true, 49 role: { select: { code: true } }, 50 }, 51 }); 52 if (!me || !me.isActive) 53 return { ok: false, message: "ユーザーが無効化されています" }; 54 if (me.role.code !== "ADMIN") 55 return { ok: false, message: "権限がありません" }; 56 57 const parsed = listSchema.safeParse({ 58 q: (formData.get("q") as string | null) ?? "", 59 status: (formData.get("status") as string | null) ?? "ALL", 60 }); 61 if (!parsed.success) return { ok: false, message: "入力が不正です" }; 62 const { q, status } = parsed.data; 63 64 const whereStatus = status === "ALL" ? undefined : { status }; // Prisma Enum へ合わせる簡易指定 65 66 const rows = await prisma.emailChangeRequest.findMany({ 67 where: { 68 departmentId: me.departmentId, // ★ 部署限定(セッションではなく me から) 69 ...(whereStatus ? whereStatus : {}), 70 ...(q 71 ? { 72 OR: [ 73 { user: { name: { contains: q, mode: "insensitive" } } }, 74 { user: { email: { contains: q, mode: "insensitive" } } }, 75 { newEmailPuny: { contains: q, mode: "insensitive" } }, 76 { token: { contains: q, mode: "insensitive" } }, 77 ], 78 } 79 : {}), 80 }, 81 orderBy: { createdAt: "desc" }, 82 select: { 83 id: true, 84 createdAt: true, 85 processedAt: true, 86 processedBy: true, 87 status: true, 88 oldEmailPuny: true, 89 newEmailPuny: true, 90 user: { select: { name: true } }, 91 department: { select: { code: true } }, 92 }, 93 }); 94 95 const shaped = rows.map((r) => ({ 96 id: r.id, 97 requestedAt: r.createdAt, 98 // 本人確認日時の列が無いので簡易表示(必要なら schema に verifiedAt 追加を推奨) 99 verifiedAt: r.status === "VERIFIED" ? r.createdAt : null, 100 processedAt: r.processedAt, 101 processedBy: r.processedBy, 102 accountId: r.department.code, 103 userName: r.user.name, 104 oldEmail: r.oldEmailPuny, 105 newEmail: r.newEmailPuny, 106 status: r.status, 107 })); 108 109 return { ok: true, data: shaped }; 110} 111 112const idSchema = z.object({ id: z.string().uuid() }); 113 114export async function approveEmailChangeRequestAction( 115 formData: FormData, 116): Promise<ActionResult> { 117 const session = await lookupSessionFromCookie(); 118 if (!session.ok) return { ok: false, message: "認証が必要です" }; 119 120 // 自分の部署・権限・氏名を解決 121 const me = await prisma.user.findUnique({ 122 where: { id: session.userId }, 123 select: { 124 id: true, 125 name: true, 126 departmentId: true, 127 isActive: true, 128 role: { select: { code: true } }, 129 }, 130 }); 131 if (!me || !me.isActive) 132 return { ok: false, message: "ユーザーが無効化されています" }; 133 if (me.role.code !== "ADMIN") 134 return { ok: false, message: "権限がありません" }; 135 136 const parsed = idSchema.safeParse({ id: formData.get("id") }); 137 if (!parsed.success) return { ok: false, message: "IDが不正です" }; 138 139 const req = await prisma.emailChangeRequest.findUnique({ 140 where: { id: parsed.data.id }, 141 select: { 142 id: true, 143 status: true, 144 expiresAt: true, 145 userId: true, 146 departmentId: true, 147 oldEmailPuny: true, 148 newEmailPuny: true, 149 user: { select: { email: true } }, 150 }, 151 }); 152 if (!req) return { ok: false, message: "申請が見つかりません" }; 153 if (req.departmentId !== me.departmentId) 154 return { ok: false, message: "越権操作です" }; 155 156 if (req.status !== "VERIFIED") 157 return { ok: false, message: "本人確認済みの申請のみ承認できます" }; 158 159 // 競合対策: 条件付き更新 + 反映 160 const result = await prisma.$transaction(async (tx) => { 161 const updated = await tx.emailChangeRequest.updateMany({ 162 where: { id: req.id, status: "VERIFIED" }, 163 data: { 164 status: "APPROVED", 165 processedAt: new Date(), 166 processedBy: me.name ?? "ADMIN", 167 }, 168 }); 169 if (updated.count !== 1) 170 return { ok: false as const, message: "競合が発生しました" }; 171 172 // ユーザのメールを更新(punycode ASCII で保存する設計) 173 await tx.user.update({ 174 where: { id: req.userId }, 175 data: { email: req.newEmailPuny }, 176 }); 177 178 // 任意: 他の保留申請を自動クローズ 179 await tx.emailChangeRequest.updateMany({ 180 where: { 181 userId: req.userId, 182 id: { not: req.id }, 183 status: { in: ["PENDING", "VERIFIED"] }, 184 }, 185 data: { 186 status: "REJECTED", 187 processedAt: new Date(), 188 processedBy: "system", 189 }, 190 }); 191 192 return { ok: true as const }; 193 }); 194 195 if (!result.ok) return result; 196 197 // ★ 承認完了の通知メール(DBコミット後/失敗しても処理は成功させる) 198 try { 199 await sendMail({ 200 to: req.newEmailPuny, 201 subject: "【DELOGs】メールアドレス変更が承認されました", 202 text: emailChangeApprovedText({ 203 newEmail: req.newEmailPuny, 204 }), 205 }); 206 } catch (e) { 207 // ここでは握りつぶす(監査ログがあるなら logger へ) 208 console.error("[mail] approve notification failed:", e); 209 } 210 211 return { ok: true }; 212} 213 214export async function rejectEmailChangeRequestAction( 215 formData: FormData, 216): Promise<ActionResult> { 217 const session = await lookupSessionFromCookie(); 218 if (!session.ok) return { ok: false, message: "認証が必要です" }; 219 220 // ★ 追加: 自分の部署・権限・氏名を解決 221 const me = await prisma.user.findUnique({ 222 where: { id: session.userId }, 223 select: { 224 id: true, 225 name: true, 226 departmentId: true, 227 isActive: true, 228 role: { select: { code: true } }, 229 }, 230 }); 231 if (!me || !me.isActive) 232 return { ok: false, message: "ユーザーが無効化されています" }; 233 if (me.role.code !== "ADMIN") 234 return { ok: false, message: "権限がありません" }; 235 236 const parsed = idSchema.safeParse({ id: formData.get("id") }); 237 if (!parsed.success) return { ok: false, message: "IDが不正です" }; 238 239 const req = await prisma.emailChangeRequest.findUnique({ 240 where: { id: parsed.data.id }, 241 select: { id: true, status: true, departmentId: true }, 242 }); 243 if (!req) return { ok: false, message: "申請が見つかりません" }; 244 if (req.departmentId !== me.departmentId) 245 return { ok: false, message: "越権操作です" }; 246 if (!["PENDING", "VERIFIED"].includes(req.status)) 247 return { ok: false, message: "未処理の申請のみ却下できます" }; 248 249 const updated = await prisma.emailChangeRequest.updateMany({ 250 where: { id: req.id, status: { in: ["PENDING", "VERIFIED"] } }, 251 data: { 252 status: "REJECTED", 253 processedAt: new Date(), 254 processedBy: me.name ?? "ADMIN", 255 }, 256 }); 257 if (updated.count !== 1) return { ok: false, message: "競合が発生しました" }; 258 259 return { ok: true }; 260}
  • 一覧:部署スコープ・検索語・状態でフィルタ。newEmailPuny は punycode(ASCII)で返す前提です。表示時に toUnicode して人間可読にします。
  • 承認VERIFIEDAPPROVED への条件付き更新updateMany)で競合を検出、同Tx内で User.email を置き換えます。 申請ユーザへ完了通知を行います。
  • 却下PENDING/VERIFIED のみ対象。処理時刻・処理者も記録します。

一覧テーブル(クライアント)とカラム

ここでは、 [管理画面フォーマット制作編 #8] ログイン後404ページ + ログイン前のパスワード忘れ導線UI(https://delogs.jp/next-js/shadcn-ui/format-404-password-forgot) で作成した「パスワード再発行依頼」のUIと同じ構成で DB連携版 を作ります。DataTable はローカル状態にデータを持ち、 初回ロードと再取得 は Server Action から行います。操作ボタンは meta にハンドラを渡します。
まず、metaハンドラの拡張を行います。 onApproveを追加します。
ts
1// src/types/table-meta.d.ts 2import "@tanstack/table-core"; 3 4declare module "@tanstack/table-core" { 5 interface TableMeta<TData extends RowData> { 6 onMoveUp?: (id: string, _row?: TData) => void; 7 onMoveDown?: (id: string, _row?: TData) => void; 8 /** 依頼を「再発行済み」にする */ 9 onIssue?: (id: string, _row?: TData) => void | Promise<void>; 10 /** 依頼を「拒否」にする */ 11 onReject?: (id: string, _row?: TData) => void | Promise<void>; 12 /** ★ 追加:メール変更申請を「承認」する */ 13 onApprove?: (id: string, _row?: TData) => void | Promise<void>; 14 } 15} 16 17export {};
次に一覧用のカラムファイルを作成します。
tsx
1// src/app/(protected)/users/email-change-requests/columns.tsx 2"use client"; 3 4import type { ColumnDef } from "@tanstack/react-table"; 5import { format } from "date-fns"; 6import { ja } from "date-fns/locale"; 7import { Badge } from "@/components/ui/badge"; 8import { Button } from "@/components/ui/button"; 9import * as punycode from "punycode/"; 10import type { EmailChangeRow } from "./data-table"; 11 12function fmt(d?: Date | null) { 13 if (!d) return "-"; 14 return format(d, "yyyy/MM/dd HH:mm", { locale: ja }); 15} 16 17export const columns: ColumnDef<EmailChangeRow>[] = [ 18 { 19 accessorKey: "requestedAt", 20 header: "申請日時", 21 cell: ({ row }) => fmt(row.original.requestedAt), 22 }, 23 { accessorKey: "accountId", header: "アカウントID" }, 24 { accessorKey: "userName", header: "ユーザ名" }, 25 { 26 accessorKey: "oldEmail", 27 header: "旧メール", 28 cell: ({ row }) => punycode.toUnicode(row.original.oldEmail), 29 }, 30 { 31 accessorKey: "newEmail", 32 header: "新メール", 33 cell: ({ row }) => ( 34 <span title={row.original.newEmail}> 35 {punycode.toUnicode(row.original.newEmail)} 36 </span> 37 ), 38 }, 39 { 40 accessorKey: "status", 41 header: "状態", 42 cell: ({ row }) => { 43 const s = row.original.status; 44 if (s === "APPROVED") return <Badge>承認済</Badge>; 45 if (s === "REJECTED") return <Badge variant="destructive">却下</Badge>; 46 if (s === "VERIFIED") return <Badge variant="outline">本人確認済</Badge>; 47 if (s === "PENDING") return <Badge variant="outline">未認証</Badge>; 48 if (s === "EXPIRED") return <Badge variant="secondary">期限切れ</Badge>; 49 return <Badge variant="secondary">{s}</Badge>; 50 }, 51 }, 52 { 53 accessorKey: "processedAt", 54 header: "処理日時", 55 cell: ({ row }) => fmt(row.original.processedAt), 56 }, 57 { accessorKey: "processedBy", header: "処理者" }, 58 59 { 60 id: "actions", 61 header: "操作", 62 enableSorting: false, 63 enableResizing: false, 64 cell: ({ row, table }) => { 65 const r = row.original; 66 const canOperate = r.status === "VERIFIED"; 67 return ( 68 <div className="flex gap-2"> 69 <Button 70 size="sm" 71 disabled={!canOperate} 72 onClick={() => table.options.meta?.onApprove?.(r.id, r)} 73 data-testid={`approve-btn-${r.id}`} 74 className="cursor-pointer" 75 > 76 承認 77 </Button> 78 <Button 79 size="sm" 80 variant="outline" 81 disabled={!["PENDING", "VERIFIED"].includes(r.status)} 82 onClick={() => table.options.meta?.onReject?.(r.id, r)} 83 data-testid={`reject-btn-${r.id}`} 84 className="cursor-pointer" 85 > 86 却下 87 </Button> 88 </div> 89 ); 90 }, 91 }, 92];
  • 操作ボタンVERIFIED のときのみ「承認」を有効にし、PENDING/VERIFIED のときのみ「却下」を有効化しています。
  • punycodeは表示時に toUnicode。title属性にASCIIを残すと、運用時の照合(コピペ)にも便利です。
次に、データテーブルで絞りに利用するパーツを作成します。
zsh
1npx shadcn@latest add popover command
複数選択可能なセレクトボックスを上記を利用して作成します。
tsx
1// src/app/(protected)/users/email-change-requests/status-multi-select.tsx 2"use client"; 3 4import * as React from "react"; 5import { Check, ChevronDown } from "lucide-react"; 6import { Button } from "@/components/ui/button"; 7import { 8 Popover, 9 PopoverTrigger, 10 PopoverContent, 11} from "@/components/ui/popover"; 12import { 13 Command, 14 CommandGroup, 15 CommandItem, 16 CommandInput, 17 CommandEmpty, 18} from "@/components/ui/command"; 19import { Separator } from "@/components/ui/separator"; 20 21export type ReqStatus = 22 | "PENDING" 23 | "VERIFIED" 24 | "APPROVED" 25 | "REJECTED" 26 | "EXPIRED"; 27 28export const STATUS_LABEL: Record<ReqStatus, string> = { 29 PENDING: "未認証", 30 VERIFIED: "本人確認済", 31 APPROVED: "承認済", 32 REJECTED: "却下", 33 EXPIRED: "期限切れ", 34}; 35 36export const ALL_STATUSES: ReqStatus[] = [ 37 "PENDING", 38 "VERIFIED", 39 "APPROVED", 40 "REJECTED", 41 "EXPIRED", 42]; 43 44export function StatusMultiSelect({ 45 value, 46 onChange, 47 onApply, 48 disabled, 49}: { 50 value: ReqStatus[]; // 現在の選択(親から受け取る) 51 onChange: (next: ReqStatus[]) => void; // 適用前の一時変更用 52 onApply: () => void; // 「適用」押下で実行(fetch など) 53 disabled?: boolean; 54}) { 55 const [open, setOpen] = React.useState(false); 56 57 const toggle = (s: ReqStatus) => { 58 const set = new Set(value); 59 if (set.has(s)) { 60 set.delete(s); 61 } else { 62 set.add(s); 63 } 64 onChange(Array.from(set)); 65 }; 66 67 const buttonText = 68 value.length === 0 69 ? "状態: なし" 70 : value.length === ALL_STATUSES.length 71 ? "状態: すべて" 72 : (() => { 73 const labels = value.map((s) => STATUS_LABEL[s]); 74 return labels.length <= 2 75 ? `状態: ${labels.join(", ")}` 76 : `状態: ${labels.slice(0, 2).join(", ")}`; 77 })(); 78 79 return ( 80 <Popover open={open} onOpenChange={setOpen}> 81 <PopoverTrigger asChild> 82 <Button 83 type="button" 84 variant="outline" 85 size="sm" 86 disabled={disabled} 87 className="cursor-pointer" 88 > 89 {buttonText} 90 <ChevronDown className="ml-2 h-4 w-4 opacity-70" /> 91 </Button> 92 </PopoverTrigger> 93 <PopoverContent className="w-[320px] p-0" align="start"> 94 <div className="p-2"> 95 <Command shouldFilter> 96 <CommandInput placeholder="状態を検索…" /> 97 <CommandEmpty>該当する状態がありません</CommandEmpty> 98 <CommandGroup heading="状態を選択(複数可)"> 99 {ALL_STATUSES.map((s) => { 100 const checked = value.includes(s); 101 return ( 102 <CommandItem 103 key={s} 104 // CommandItem は選択で閉じがちだが、open を制御しているため閉じない 105 onSelect={(/* _ */) => toggle(s)} 106 className="flex items-center gap-2" 107 > 108 <span 109 className="flex h-5 w-5 items-center justify-center rounded border" 110 aria-checked={checked} 111 role="checkbox" 112 > 113 {checked ? <Check className="h-3 w-3" /> : null} 114 </span> 115 <span>{STATUS_LABEL[s]}</span> 116 </CommandItem> 117 ); 118 })} 119 </CommandGroup> 120 </Command> 121 </div> 122 123 <Separator /> 124 125 <div className="flex items-center justify-between p-2"> 126 <div className="flex gap-2"> 127 <Button 128 type="button" 129 variant="ghost" 130 size="sm" 131 onClick={() => onChange([...ALL_STATUSES])} 132 className="cursor-pointer" 133 > 134 すべて 135 </Button> 136 <Button 137 type="button" 138 variant="ghost" 139 size="sm" 140 onClick={() => onChange([])} 141 className="cursor-pointer" 142 > 143 クリア 144 </Button> 145 </div> 146 <div className="flex gap-2"> 147 <Button 148 type="button" 149 size="sm" 150 onClick={() => { 151 onApply(); // 親側で fetchRows などを呼ぶ 152 setOpen(false); 153 }} 154 className="cursor-pointer" 155 > 156 適用 157 </Button> 158 </div> 159 </div> 160 </PopoverContent> 161 </Popover> 162 ); 163}
最後にデータテーブルを作成します。
tsx
1// src/app/(protected)/users/email-change-requests/data-table.tsx 2"use client"; 3 4import * as React from "react"; 5import type { ColumnDef, SortingState } from "@tanstack/react-table"; 6import { 7 getCoreRowModel, 8 getPaginationRowModel, 9 getSortedRowModel, 10 useReactTable, 11 flexRender, 12} from "@tanstack/react-table"; 13import { Input } from "@/components/ui/input"; 14import { 15 Table, 16 TableBody, 17 TableCell, 18 TableHead, 19 TableHeader, 20 TableRow, 21} from "@/components/ui/table"; 22import { Button } from "@/components/ui/button"; 23import { toast } from "sonner"; 24import { columns as baseColumns } from "./columns"; 25import { 26 listEmailChangeRequestsAction, 27 approveEmailChangeRequestAction, 28 rejectEmailChangeRequestAction, 29} from "@/app/_actions/users/email-change-requests"; 30import { 31 StatusMultiSelect, 32 type ReqStatus, 33 ALL_STATUSES, 34} from "./status-multi-select"; // ★ 追加 35import { LoaderCircle } from "lucide-react"; 36 37type ReqStatusFilter = "ALL" | ReqStatus; 38 39export type EmailChangeRow = { 40 id: string; 41 requestedAt: Date; 42 verifiedAt?: Date | null; 43 processedAt?: Date | null; 44 processedBy?: string | null; 45 accountId: string; 46 userName: string; 47 oldEmail: string; // ASCII 48 newEmail: string; // ASCII 49 status: ReqStatus; 50}; 51 52export default function DataTable({ 53 columns, 54}: { 55 columns: ColumnDef<EmailChangeRow, unknown>[]; 56}) { 57 const [tableData, setTableData] = React.useState<EmailChangeRow[]>([]); 58 const [q, setQ] = React.useState(""); 59 const [statuses, setStatuses] = React.useState<ReqStatus[]>( 60 () => [...ALL_STATUSES], // 初期は全選択 61 ); 62 const [sorting, setSorting] = React.useState<SortingState>([ 63 { id: "requestedAt", desc: true }, 64 ]); 65 const [loading, setLoading] = React.useState(false); 66 67 const allSelected = statuses.length === ALL_STATUSES.length; 68 const noneSelected = statuses.length === 0; 69 70 const fetchRows = React.useCallback(async () => { 71 setLoading(true); 72 try { 73 // サーバ側の status パラメータを決定 74 let serverStatus: ReqStatusFilter = "ALL"; 75 if (!allSelected && statuses.length === 1) { 76 serverStatus = statuses[0]; 77 } else { 78 serverStatus = "ALL"; // 複数選択時は ALL を送り、クライアントで絞る 79 } 80 81 const fd = new FormData(); 82 fd.set("q", q); 83 fd.set("status", serverStatus); 84 85 const res = await listEmailChangeRequestsAction(fd); 86 if (!res.ok) { 87 toast.error(res.message); 88 return; 89 } 90 91 const rows = (res.data ?? []) as EmailChangeRow[]; 92 93 // クライアント側の再絞り込み(複数選択時のみ) 94 const filtered = 95 allSelected || serverStatus !== "ALL" 96 ? rows 97 : rows.filter((r) => statuses.includes(r.status)); 98 99 // 何も選ばれていなければ 0件にする 100 setTableData(noneSelected ? [] : filtered); 101 } finally { 102 setLoading(false); 103 } 104 }, [q, statuses, allSelected, noneSelected]); 105 106 // 初回・キーワード変更で再取得 107 React.useEffect(() => { 108 fetchRows(); 109 }, [fetchRows]); 110 111 const onApprove = React.useCallback( 112 async (id: string) => { 113 const fd = new FormData(); 114 fd.set("id", id); 115 const res = await approveEmailChangeRequestAction(fd); 116 if (!res.ok) { 117 toast.error(res.message); 118 return; 119 } 120 toast.success("申請を承認しました"); 121 fetchRows(); // 再読込 122 }, 123 [fetchRows], 124 ); 125 126 const onReject = React.useCallback( 127 async (id: string) => { 128 const fd = new FormData(); 129 fd.set("id", id); 130 const res = await rejectEmailChangeRequestAction(fd); 131 if (!res.ok) { 132 toast.error(res.message); 133 return; 134 } 135 toast.message("申請を却下しました"); 136 fetchRows(); 137 }, 138 [fetchRows], 139 ); 140 141 const decoratedColumns = React.useMemo(() => baseColumns, []); 142 143 const table = useReactTable({ 144 data: tableData, 145 columns: decoratedColumns, 146 state: { sorting }, 147 onSortingChange: setSorting, 148 getCoreRowModel: getCoreRowModel(), 149 getSortedRowModel: getSortedRowModel(), 150 getPaginationRowModel: getPaginationRowModel(), 151 initialState: { pagination: { pageIndex: 0, pageSize: 10 } }, 152 meta: { onApprove, onReject }, 153 }); 154 155 const filteredCount = tableData.length; 156 157 return ( 158 <div className="space-y-3"> 159 {/* 検索/フィルタ */} 160 <div className="flex flex-wrap items-center gap-3"> 161 <Input 162 name="filter-q" 163 value={q} 164 onChange={(e) => setQ(e.target.value)} 165 placeholder="アカウントID・旧/新メール・氏名・トークンで検索" 166 className="w-[280px] basis-full text-sm md:basis-auto" 167 aria-label="検索キーワード" 168 data-testid="filter-q" 169 /> 170 171 {/* 状態(複数選択・適用ボタンあり) */} 172 <StatusMultiSelect 173 value={statuses} 174 onChange={setStatuses} 175 onApply={fetchRows} 176 disabled={loading} 177 /> 178 179 <Button 180 variant="outline" 181 size="sm" 182 onClick={() => fetchRows()} 183 disabled={loading} 184 className="cursor-pointer" 185 > 186 再読込 187 </Button> 188 </div> 189 190 <div className="flex items-center justify-between"> 191 <div className="text-sm" data-testid="count"> 192 表示件数: {filteredCount}{loading ? "(読込中...)" : ""} 193 </div> 194 </div> 195 196 {/* テーブル */} 197 <div className="overflow-x-auto rounded-md border pb-1"> 198 <Table data-testid="email-change-table" className="w-full"> 199 <TableHeader className="bg-muted/50 text-xs"> 200 {table.getHeaderGroups().map((hg) => ( 201 <TableRow key={hg.id}> 202 {hg.headers.map((header) => ( 203 <TableHead 204 key={header.id} 205 style={{ width: header.column.getSize() }} 206 > 207 {header.isPlaceholder 208 ? null 209 : flexRender( 210 header.column.columnDef.header, 211 header.getContext(), 212 )} 213 </TableHead> 214 ))} 215 </TableRow> 216 ))} 217 </TableHeader> 218 <TableBody> 219 {loading ? ( 220 <TableRow> 221 <TableCell 222 colSpan={columns.length} 223 className="py-10 text-center" 224 > 225 <div className="text-muted-foreground flex items-center justify-center gap-2 text-sm"> 226 <LoaderCircle className="h-6 w-6 animate-spin" /> 227 データを読込中... 228 </div> 229 </TableCell> 230 </TableRow> 231 ) : table.getRowModel().rows.length ? ( 232 table.getRowModel().rows.map((row) => ( 233 <TableRow key={row.id} data-testid={`row-${row.original.id}`}> 234 {row.getVisibleCells().map((cell) => ( 235 <TableCell 236 key={cell.id} 237 style={{ width: cell.column.getSize() }} 238 > 239 {flexRender( 240 cell.column.columnDef.cell, 241 cell.getContext(), 242 )} 243 </TableCell> 244 ))} 245 </TableRow> 246 )) 247 ) : ( 248 <TableRow> 249 <TableCell 250 colSpan={columns.length} 251 className="text-muted-foreground py-10 text-center text-sm" 252 > 253 条件に一致する申請が見つかりませんでした。 254 </TableCell> 255 </TableRow> 256 )} 257 </TableBody> 258 </Table> 259 </div> 260 261 {/* ページング */} 262 <div className="flex items-center justify-end gap-2"> 263 <span className="text-muted-foreground text-sm"> 264 Page {table.getState().pagination.pageIndex + 1} /{" "} 265 {table.getPageCount() || 1} 266 </span> 267 <Button 268 variant="outline" 269 size="sm" 270 onClick={() => table.previousPage()} 271 disabled={!table.getCanPreviousPage()} 272 data-testid="page-prev" 273 className="cursor-pointer" 274 > 275 前へ 276 </Button> 277 <Button 278 variant="outline" 279 size="sm" 280 onClick={() => table.nextPage()} 281 disabled={!table.getCanNextPage()} 282 data-testid="page-next" 283 className="cursor-pointer" 284 > 285 次へ 286 </Button> 287 </div> 288 </div> 289 ); 290}
  • 初回マウント時に 一覧取得、操作後も fetchRows() で再取得します。
  • 表示するメールは ASCII(punycode)のまま渡ってくるため、カラム側で toUnicode に寄せて見やすくします。
txt
1【状態遷移】 2 3PENDING --(ユーザがリンク踏む)--> VERIFIED --(管理者が承認)--> APPROVED 4 └----------------(却下)-----> REJECTED 5 └-----------------------(期限切れ)-----------------> EXPIRED
下図のような画面になります。
メールアドレス変更の承認画面
本章では、本人確認済み(VERIFIED)のメール変更申請を、管理者承認/却下できるようにしました。
Server Action で厳密ガードし、Txで状態更新とUser.email置換を一体化することで、データ整合性を担保しています。

5. まとめと次回予告

ここまでの記事では、プロフィール編集機能を「実運用で耐えうるレベル」まで強化しました。
アバター・メールアドレス・パスワードといった基盤的な情報を扱う機能を、セキュリティと運用を意識した構成にまとめています。

今回のポイント

機能領域実装内容特筆すべき工夫
アバター画像アップロード・削除の両方を実装DB更新と物理削除を分離し、安全な冪等処理に
メールアドレス変更申請 → 認証 → 承認 → 確定の4段階punycode保存+UI表示時にUnicode変換。承認後は再ログインを促すUI
パスワード変更Server Actionでargon2を利用他セッション失効(任意)や失敗回数リセットを運用に組み込み

セキュリティと運用の確認点

本記事で導入した各機能は、以下の観点を特に重視しました。
  • 権限ガードの二重化:ページ遷移ガードとServer Action側チェックの併用
  • 状態遷移の整合性:Tx+条件付き更新で競合を検知し、User.email更新を原子的に担保
  • 監査性:processedAt / processedByを記録し、履歴管理を可能に
  • 国際化対応:DB保存はASCII punycode、UI表示はUnicodeと役割を分離

次回予告

次回は、ログイン時に取得したprioty値(コンテキスト化済み)を利用したRBACの調整を行っていきます。現状でも、メニューの表示・非表示はできていますが、各ページでも検証ロジックが必要になりますので、その調整です。

参考文献

今回の記事で取り上げた内容に関連する参考文献や資料を以下に整理します。

技術スタック関連

項目リンク補足
Next.js Documentationhttps://nextjs.org/docsApp Router や Server Actions など最新仕様の確認
Prisma Docshttps://www.prisma.io/docsEnum 定義やトランザクション利用の公式解説
Zod Docshttps://zod.devz.enumz.union を使ったスキーマ設計
shadcn/uihttps://ui.shadcn.comUI コンポーネントの利用とカスタマイズ方法
TailwindCSS Docshttps://tailwindcss.com/docsクラスユーティリティのリファレンス

認証・セキュリティ関連

項目リンク補足
Argon2 Password Hashinghttps://github.com/ranisalt/node-argon2パスワードハッシュの実装
OWASP Authentication Cheatsheethttps://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html認証実装のベストプラクティス
RFC 3492: Punycodehttps://datatracker.ietf.org/doc/html/rfc3492国際化ドメイン名を ASCII 化する規格
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。