![[管理画面フォーマット開発編 #5] ユーザプロフィール更新](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #5ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
初回公開日
最終更新日
0. はじめに
本記事では、ユーザプロフィール編集機能をさらに拡張し、以下の3点を新たに実装します。
- アバター画像の「削除」機能
- メールアドレスの変更申請(本人認証メール+管理者承認フロー付き)
- パスワード変更(既存構想を踏まえて仕上げ)
これにより、本人操作と管理者承認の両立を実現し、セキュリティと利便性を兼ね備えたプロフィール更新体験を提供できるようになります。
今回の拡張は、単なるUI改善にとどまらず、法人利用を想定した「業務システムらしい堅牢なアカウント管理」の基盤づくりに直結します。特にメールアドレス変更については、認証メールと承認画面を組み合わせたフローを構築し、誤操作や不正利用のリスクを大幅に抑制します。
以下の表に、前回までと今回追加する機能を整理します。
区分 | 前回までの実装 | 今回追加する機能 |
---|---|---|
アバター画像 | アップロードと表示 | 削除(未登録状態へ戻す) |
メールアドレス | プロフィール表示のみ | 認証メール+管理者承認フロー |
パスワード | UIの雛形のみ | 実際の変更処理を実装 |
このように「本人操作 → システム確認 → 管理者承認」という流れを設けることで、今後導入予定の RBAC(ロールベースアクセス制御) や httpOnly Cookie+middlewareによるセッション管理 と自然につながります。
次章以降では、それぞれの機能を UI設計 → バリデーション → Server Action実装 → 管理者UI の流れで具体的に解説していきます。
技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.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}
処理の流れは以下のとおりです。
- ユーザの
avatar
情報をDBから取得 - 該当ファイルがあれば削除(存在しない場合も無視)
- 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
6 ↓
7
8[管理者UI] --(承認/却下)--> [DB反映: email更新 or REJECTED]
データベース設計
メールアドレス変更は即時反映せず、申請テーブルに一時保存 → 状態遷移を管理 するのが安全です。さらに法人利用を想定し、部署(Department)単位で許可ドメインを複数登録できるホワイトリストを用意します。ドメインは 国際化ドメイン対応のため punycode(ASCII)で保存します。
テーブルの役割と主要カラム
テーブル名 | 主なカラム | 役割 |
---|---|---|
EmailChangeRequest | userId / departmentId / newEmailPuny / status / token / expiresAt | 申請の内容と進行状況を保持(本人認証・承認後に反映) |
AllowedEmailDomain | departmentId / 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 件以上ある部門は いずれか一致で許可 |
申請レコード作成 | 二重申請や期限切れを制御 | EmailChangeRequest に PENDING /VERIFIED … の status と expiresAt を保存 |
認証メール送信 | 本人の新メールアドレスでクリック検証 | 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_HOST | SMTP サーバホスト | smtp.example.com |
SMTP_PORT | SMTP ポート | 587 |
SMTP_USER | 認証ユーザー | apikey |
SMTP_PASS | 認証パスワード | xxxxxx |
MAIL_FROM | 送信者(From) | "DELOGs <no-reply@example.com>" |
APP_ORIGIN | アプリの外部 URL | https://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 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
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()
から現在のメールアドレスを取得してフォームに表示しています。- 送信は
FormData
でnewEmail
を渡すだけ。UI 側ではすでに punycode 化 を済ませておく設計ですが、サーバ側でも再度 toASCII して検証するため、日本語ドメインでも安全に通ります。
ここまでで「申請→メール送信」までが動作します。次の節では、メール内 URL の検証(トークン照合・有効期限チェック) を実装し、ステータスを
VERIFIED
に進めます。その後、4章で管理者 UI(APPROVE/REJECT
)へつなぎます。メール内 URL の検証とステータス更新
本節では、ユーザが受け取った認証メール内の URL をクリックした際に行われる トークン照合・有効期限チェック の仕組みを解説します。
さらに、本人確認が成功したタイミングで申請状態を
さらに、本人確認が成功したタイミングで申請状態を
VERIFIED
に進め、同時に 管理者(ADMIN 権限)へ通知メールを送る処理 も加えます。この仕組みにより、利用者は旧アドレスでログインしてから認証 URL を表示する必要があり、不正アクセスやリンク流出時の悪用リスクを低減できます。
txt
1【フロー図】
2
3[ユーザの受信BOX] --(URLクリック)--> [認証ページ /profile/email/verify]
4 |
5 ↓
6 [ログイン必須](旧アドレスでログイン中)
7 |
8 ↓
9 [トークン照合・期限確認] → 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
内でラッパー関数を経由して取得します(action
をfetch
経由にする方法でも可)。
同様に、プロフィール編集画面の
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=0 、lockedUntil=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
で照合・再ハッシュします。ロック/失敗カウントは、変更成功時にクリアします。
「他セッションの失効」は運用ポリシーに依存するため、コメントで雛形を付けました(
sessionId
や revokedAt
の扱いは既存実装に合わせてください)。クライアントコンポーネント(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
に更新(処理者/処理時刻を記録)
② 対象ユーザのemail
を newEmailPuny に置換
③(任意)同ユーザの他のアクティブ申請を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
して人間可読にします。 - 承認:
VERIFIED
→APPROVED
への条件付き更新(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置換を一体化することで、データ整合性を担保しています。
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 Documentation | https://nextjs.org/docs | App Router や Server Actions など最新仕様の確認 |
Prisma Docs | https://www.prisma.io/docs | Enum 定義やトランザクション利用の公式解説 |
Zod Docs | https://zod.dev | z.enum や z.union を使ったスキーマ設計 |
shadcn/ui | https://ui.shadcn.com | UI コンポーネントの利用とカスタマイズ方法 |
TailwindCSS Docs | https://tailwindcss.com/docs | クラスユーティリティのリファレンス |
認証・セキュリティ関連
項目 | リンク | 補足 |
---|---|---|
Argon2 Password Hashing | https://github.com/ranisalt/node-argon2 | パスワードハッシュの実装 |
OWASP Authentication Cheatsheet | https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html | 認証実装のベストプラクティス |
RFC 3492: Punycode | https://datatracker.ietf.org/doc/html/rfc3492 | 国際化ドメイン名を ASCII 化する規格 |
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
2025/9/16公開
![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
2025/9/12公開
![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
2025/9/10公開
![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero-thumbnail.jpg&w=1200&q=75)
JWTとロールでAPIを守る ─ RBAC導入とGuard関数実装
APIを安全にする鍵は「ロールベースの認可」。JWTのpayloadに含めたロール情報を活用し、Admin専用APIの実装を通じてRBACの基本を実践
2025/8/5公開
