![[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-users%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #9 前編部署別ロール対応 ─ ユーザ管理の改修
DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正
初回公開日
最終更新日
0. はじめに
前回の記事(#8 前編・後編)では、部署ごとにロールをカスタマイズできる DepartmentRole テーブル を導入し、管理UIとServer Actionを整備しました。
これにより「共通のRoleを基盤にしながら、部署単位で上書き・拡張できる仕組み」が完成しました。
これにより「共通のRoleを基盤にしながら、部署単位で上書き・拡張できる仕組み」が完成しました。
今回はその続きとして、既存の ユーザ管理 と プロフィール表示 を DepartmentRole に対応させていきます。
従来は
従来は
Role
テーブルのみを直接参照していましたが、改修後は「Role と DepartmentRole を組み合わせた実効ロール」を扱うようにします。本記事の改修対象
以下の3点を中心に修正を行います。
対象機能 | 改修内容 |
---|---|
ユーザ一覧 | 実効ロールを表示・フィルタ対象にする |
ユーザ登録・更新 | 単一セレクトから Role/DepartmentRole を一元的に選択(priority昇順で表示) |
プロフィール表示 | ロールラベルを実効ロールに統一 |
読み進める前に
本記事は「管理画面フォーマット開発編」の一部として進めています。
前回の記事までで DepartmentRole のDB設計とUI操作 が完成していることを前提としています。
まだ読んでいない方は、下記を先に参照してください。
前回の記事までで DepartmentRole のDB設計とUI操作 が完成していることを前提としています。
まだ読んでいない方は、下記を先に参照してください。
- 【管理画面フォーマット開発編 #8 前編】 部署別ロール ─ DepartmentRoleテーブル導入とDB設計
- 【管理画面フォーマット開発編 #8 後編】 部署別ロール ─ 管理UIとServer Action実装
1. 実効ロール取得ユーティリティの共通化
ここでは、ユーザに割り当てられた
これにより、ユーザ一覧・登録/更新フォーム・プロフィール表示など、複数の箇所で一貫したロール解決が可能になります。
Role
と DepartmentRole
を横断して「実効ロール」を解決する仕組みを整えます。これにより、ユーザ一覧・登録/更新フォーム・プロフィール表示など、複数の箇所で一貫したロール解決が可能になります。
実装のゴール
複数テーブルに分散しているロール情報を 統合的に参照できる関数 を用意します。
- ユーザ一覧 → 一覧表の「ロール列」やフィルタで利用
- ユーザ登録・更新 → セレクトボックス候補の生成に利用
- プロフィール表示 → ロールラベルの統一に利用
この「共通化」によって、個別のUIでロール解決を都度書かずに済み、保守性が大幅に向上します。
txt
1### ロールの構造イメージ
2
3 ┌─────────────┐
4 │ User │
5 │─────────────│
6 │ roleId? │───┐
7 │ departmentRoleId? │
8 └─────────────┘ │
9 ▼
10 ┌─────────────┐ ┌───────────────────┐
11 │ Role │ │ DepartmentRole │
12 │─────────────│ │───────────────────│
13 │ code │ │ code / name │
14 │ name │ │ roleId (override) │
15 │ priority │ │ custom? override? │
16 └─────────────┘ └───────────────────┘
17
18→ 最終的に「EffectiveRole」に正規化してUIへ渡す
EffectiveRoleの仕様
実効ロールは UI/権限制御で共通的に扱えるよう、以下のプロパティを持ちます。
フィールド | 説明 |
---|---|
code | ロール識別子(custom は DepartmentRole.code、override は Role.code) |
name | 表示名(override があればそれを優先) |
priority | 優先度(RBAC 判定に使用) |
badgeColor | バッジ色(override があればそれを優先) |
canEditData | 書き込み権限の有無 |
canDownloadData | データ出力権限の有無 |
isEnabledInDepartment | 部署単位で有効化されているか |
source | 由来(role / override / custom ) |
この型を返す関数群を
src/lib/auth/effective-role.ts
や src/lib/roles/effective.ts
にまとめる方針です。実装方針
-
ユーザ単位の解決
getEffectiveRole(user)
で、User に保存されているroleId
/departmentRoleId
を元に、統合済みのEffectiveRole
を返す。 -
セレクト候補の生成
getAssignableRoles(departmentId)
で、その部署で利用可能な全てのRole
とDepartmentRole
を走査し、セレクトボックス用の配列を返す。 -
共通の型定義を利用
いずれもEffectiveRole
型に正規化するため、UI 側では「どのテーブル由来か」を意識せず処理できる。
これにより、ユーザ一覧・登録/更新・プロフィールといった異なる画面であっても、一貫して「実効ロール」を参照できます。
2. セレクトボックス候補の供給(getAssignableRolesAction)
次に、ユーザ登録・更新フォームで利用する ロールのセレクトボックス候補 を整えます。
これにより、Role / DepartmentRole のどちらを選んでも、ユーザには「1つのロール」として割り当てられるようになります。
これにより、Role / DepartmentRole のどちらを選んでも、ユーザには「1つのロール」として割り当てられるようになります。
目的
- 部署ごとに利用可能な Role / DepartmentRole を収集
- priority 昇順で並べた単一配列を返す
- フォーム側は 「コードと表示名」 を意識するだけで良い
候補は「部署ごとの有効化状態」や「override/custom の差異」を内部で吸収し、UIには共通形式で渡します。
戻り値の仕様
セレクトボックス候補は次の形式に揃えます。
フィールド | 内容 |
---|---|
value | 役割の一意キー(Role.code または DepartmentRole.code ) |
label | 表示名(Role.name か override/custom 名称) |
priority | 優先度(昇順ソート用、セレクトには直接出さない) |
source | 由来(role / override / custom ) |
UI 側では
value
と label
だけを利用すればよく、どのテーブル由来かを意識せずに済みます。候補収集の流れ
txt
1Role (全体)
2 ├─ code=ADMIN, priority=100
3 ├─ code=EDITOR, priority=50
4 └─ code=VIEWER, priority=10
5DepartmentRole (部署A)
6 ├─ override(EDITOR) name="部内編集者"
7 └─ custom code="ANALYST", priority=20
8-------------------------------
9getAssignableRolesAction()
10 → [{value:"VIEWER",label:"閲覧者"},
11 {value:"ANALYST",label:"分析担当"},
12 {value:"EDITOR",label:"部内編集者"},
13 {value:"ADMIN",label:"管理者"}]
実装
Server Action として
この関数は SSR/Server Action どちらからも呼べるため、ユーザ登録ページ・更新ページ双方で共通利用できます。
getAssignableRolesAction()
を実装します。この関数は SSR/Server Action どちらからも呼べるため、ユーザ登録ページ・更新ページ双方で共通利用できます。
ts
1// src/app/_actions/department-roles/get-assignable-roles.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { getEffectiveRole } from "@/lib/auth/effective-role";
7
8export type AssignableRoleOption = {
9 value: string; // "role:<id>" | "dr:<id>"
10 label: string; // 表示ラベル
11 priority: number; // 並び替え用
12 disabled?: boolean; // DRが無効な場合
13};
14
15export async function getAssignableRolesAction(): Promise<
16 | { ok: true; options: AssignableRoleOption[]; departmentId: string }
17 | { ok: false; message: string }
18> {
19 // 1) セッションとユーザー情報
20 const s = await lookupSessionFromCookie();
21 if (!s.ok) return { ok: false, message: "Unauthorized" };
22
23 const me = await prisma.user.findUnique({
24 where: { id: s.userId },
25 select: {
26 id: true,
27 departmentId: true,
28 roleId: true,
29 departmentRoleId: true,
30 },
31 });
32 if (!me) return { ok: false, message: "User not found" };
33
34 // 2) ADMINガード
35 let eff = null;
36 if (me.departmentRoleId) {
37 eff = await getEffectiveRole({
38 departmentId: me.departmentId,
39 departmentRoleId: me.departmentRoleId,
40 });
41 } else if (me.roleId) {
42 eff = await getEffectiveRole({
43 departmentId: me.departmentId,
44 roleId: me.roleId, // non-null 分岐
45 });
46 } else {
47 // XOR かつ両方 null は不正状態
48 return { ok: false, message: "Forbidden" };
49 }
50 if (!eff || eff.priority < 100) {
51 return { ok: false, message: "Forbidden" };
52 }
53
54 const departmentId = me.departmentId;
55
56 // 3) Role と DepartmentRole を取得
57 const [roles, droles] = await Promise.all([
58 prisma.role.findMany({
59 where: { isActive: true },
60 select: { id: true, code: true, name: true, priority: true },
61 orderBy: [{ priority: "asc" }, { code: "asc" }],
62 }),
63 prisma.departmentRole.findMany({
64 where: { departmentId },
65 select: {
66 id: true,
67 roleId: true, // ★ 追加:override 検出のために roleId を取得
68 isEnabled: true,
69 role: { select: { code: true, name: true, priority: true } },
70 nameOverride: true,
71 code: true,
72 name: true,
73 priority: true,
74 },
75 }),
76 ]);
77
78 // 4) override 対象の Role を候補から除外(isEnabled に関わらず除外)
79 const overriddenRoleIds = new Set(
80 droles
81 .filter((dr) => dr.roleId) // override のみ抽出
82 .map((dr) => dr.roleId as string), // roleId は非 null に確定
83 );
84 const rolesAfterFilter = roles.filter((r) => !overriddenRoleIds.has(r.id));
85
86 // 5) Option 化
87 const roleOpts: AssignableRoleOption[] = rolesAfterFilter.map((r) => ({
88 value: `role:${r.id}`,
89 label: `${r.name} (${r.code})`,
90 priority: r.priority,
91 }));
92
93 const drOpts: AssignableRoleOption[] = droles.map((dr) => {
94 const isCustom = !dr.role;
95 const label = isCustom
96 ? `${dr.name ?? dr.code ?? "CUSTOM"} (${dr.code})`
97 : `${dr.nameOverride ?? dr.role!.name} (${dr.role!.code})`;
98 const prio = isCustom ? (dr.priority ?? 0) : dr.role!.priority;
99 return {
100 value: `dr:${dr.id}`,
101 label,
102 priority: prio,
103 disabled: !dr.isEnabled,
104 };
105 });
106
107 const options = [...roleOpts, ...drOpts].sort(
108 (a, b) => a.priority - b.priority,
109 );
110 return { ok: true, options, departmentId };
111}
今後の利用箇所
-
ユーザ登録ページ (#3章)
Page
コンポーネント内で呼び出し、roleOptions
をUserForm
へ渡す。 -
ユーザ更新ページ (#4章)
初期値のロール解決時も含め、同様にgetAssignableRolesAction()
を呼ぶ。
これで、候補生成は一元化され、UI側は
次章では、この候補を実際に利用しながら ユーザ登録の保存処理(XOR保存) を実装していきます。
value
と label
だけを意識すればよい 形になりました。次章では、この候補を実際に利用しながら ユーザ登録の保存処理(XOR保存) を実装していきます。
3. ユーザ登録のXOR保存(createUserAction)
この章では「ユーザ新規登録」を DepartmentRole 対応 に改修します。
単一セレクトで
単一セレクトで
"role:<uuid>" | "dr:<uuid>"
を選ばせ、その値を XOR 保存(roleId
か departmentRoleId
のどちらか一方)します。変更ポイントの整理
観点 | 変更内容 | 根拠/目的 |
---|---|---|
候補取得 | 2章の getAssignableRolesAction() を SSR で呼ぶ | DepartmentRole を含む候補を統一供給 |
フォーム | Zod の roleCode を XOR 文字列("role:..." /"dr:..." )として検証・バインド | 余計なダミーフィールドを排除し、単一セレクトに直結 |
サーバ | roleCode をパースして XOR を解決 → roleId か departmentRoleId を保存 | DB の XOR 制約と整合 |
Prisma | User.roleId を nullable に変更 | XOR 実現のため(departmentRoleId を持つケース) |
前章のとおり
以降では「フォーム」「サーバアクション」に絞って実装を示します。
src/lib/users/schema.ts
は最新化済み(roleCode
が XOR 文字列を許容)。以降では「フォーム」「サーバアクション」に絞って実装を示します。
txt
1### 全体像(値の流れ)
2
3[SSR] getAssignableRolesAction()
4 └─ options = [{ value: "role:<id>", ... }, { value: "dr:<id>", ... }, ...](priority 昇順・disabled 反映)
5
6[Page] /users/new/page.tsx
7 └─ options を Client に渡す
8
9[Client] /users/new/client.tsx
10 └─ <UserForm roleOptions=options ... />
11
12[Form] src/components/users/user-form.tsx
13 └─ Select を roleCode に直バインド(Zod で検証)
14
15[Action] /_actions/users/create-user.ts
16 ├─ userCreateSchema で通常項目を検証
17 ├─ roleCode をパース → XOR 解決(roleId or departmentRoleId)
18 └─ User 生成(Welcome メールは任意)
変更方針と差分サマリ
ユーザ登録に関する変更点を表で整理します。
ファイル | 役割 | 変更内容 |
---|---|---|
src/app/(protected)/users/new/page.tsx | SSR | 2章の getAssignableRolesAction() を呼び出し、候補を Client に渡す(DB直叩きは廃止) |
src/app/(protected)/users/new/client.tsx | Client | フォームから selectedRole を受け取り、createUser に一緒に渡す。トーストのロール表示も label ベースに |
src/components/users/user-form.tsx | Form | ロール項目を 単一セレクト に差し替え(AssignableRoleOption[] をそのまま描画)。Zod スキーマはそのまま(roleCode は使わない) |
src/app/_actions/users/create-user.ts | Action | 受け取った selectedRole を "role:" / "dr:" でパースし XOR 保存。roleCode→roleId の解決は撤去 |
以降は、実際に組み込んだソース(抜粋)と要点解説です。
Prismaスキーマの変更
roleId
とdepartmentRoleId
のどちら一方に値があればよくなりますので、roleId
を任意項目へ変更します。prisam
1// prisma/schema.prisma(User モデルの差分)
2model User {
3 // ...
4- roleId String
5+ roleId String? // ← nullable に変更
6 // ...
7- role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
8+ role Role? @relation(fields: [roleId], references: [id], onDelete: Restrict)
9 // ...
10}
zsh
1npx prisma migrate dev --name make-user-roleid-nullable
2npx prisma generate
zodスキーマの変更(users/schema.ts)
ユーザ関連のZodスキーマには、まだロールをDB連携する前のmockデータの頃の定義が残っているので、これを合わせて変更します。
ts
1// src/lib/users/schema.ts
2import { z } from "zod";
3import { toAsciiEmailSafe } from "@/lib/email/normalize";
4
5/** ── 入力ルール(数字はあとから見直しやすいよう定数化) ── */
6export const NAME_MAX = 100 as const;
7export const PASSWORD_MIN = 15 as const;
8export const PASSWORD_MAX = 128 as const;
9
10/** 追記:── アバター画像のクライアント検証(UIのみ) ── */
11export const MAX_IMAGE_MB = 1 as const; // Slackをまねて軽量運用
12export const IMAGE_MAX_PX = 1024 as const; // 最大許容ピクセル(UIで非同期チェック)
13export const IMAGE_RECOMMENDED_PX = 512 as const;
14
15/** 共通フィールドの最小ルール */
16const nameSchema = z
17 .string()
18 .min(1, "氏名を入力してください")
19 .max(NAME_MAX, `${NAME_MAX}文字以内で入力してください`);
20
21// ★ 変更:transform + pipe で ASCII 化してから形式検証
22export const emailSchema = z
23 .string()
24 .transform((s) => toAsciiEmailSafe(s))
25 .pipe(z.email("メールアドレスの形式が正しくありません"));
26
27// パスワード用
28const passwordSchema = z
29 .string()
30 .min(PASSWORD_MIN, `${PASSWORD_MIN}文字以上で入力してください`)
31 .max(PASSWORD_MAX, `${PASSWORD_MAX}文字以内で入力してください`)
32 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
33 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
34 .regex(/[0-9]/, "数字を1文字以上含めてください。");
35
36/** ★ 新規:単一セレクトの XOR 値を検証("role:<uuid>" or "dr:<uuid>") */
37const UUID_RE =
38 /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
39
40export const assignedRoleSchema = z
41 .string()
42 .min(1, "ロールを選択してください")
43 .refine(
44 (v) => v.startsWith("role:") || v.startsWith("dr:"),
45 "ロールの指定が不正です",
46 )
47 .refine((v) => UUID_RE.test(v.split(":")[1] ?? ""), "ロールの指定が不正です");
48
49const phoneSchema = z
50 .string()
51 .max(50, "50文字以内で入力してください")
52 .optional();
53
54const remarksSchema = z
55 .string()
56 .max(255, "255文字以内で入力してください")
57 .optional();
58
59/** ── 新規作成用:password が必須 ── */
60export const userCreateSchema = z.object({
61 name: nameSchema,
62 email: emailSchema,
63 roleCode: assignedRoleSchema,
64 password: passwordSchema, // パスワード変更でも使うので共通化
65 isActive: z.boolean(),
66 phone: phoneSchema,
67 remarks: remarksSchema,
68});
69
70/** ── 編集用:displayId を表示専用で扱い、password は扱わない ── */
71export const userUpdateSchema = z.object({
72 displayId: z.string().min(1, "表示IDの取得に失敗しました"),
73 name: nameSchema,
74 email: emailSchema,
75 roleCode: assignedRoleSchema,
76 isActive: z.boolean(),
77 phone: phoneSchema,
78 remarks: remarksSchema,
79});
80
81/** 追記:── プロフィール(本人用): displayId は UI に出さない。role は「表示のみ」 ── */
82export const profileUpdateSchema = z.object({
83 name: nameSchema, //共通化したものを利用
84
85 // UIのみ: 画像ファイルの基本チェック(拡張子・容量)
86 avatarFile: z
87 .instanceof(File)
88 .optional()
89 .refine(
90 (file) =>
91 !file ||
92 ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(
93 file.type,
94 ),
95 "画像は png / jpeg / webp / gif のいずれかにしてください",
96 )
97 .refine(
98 (file) => !file || file.size <= MAX_IMAGE_MB * 1024 * 1024,
99 `画像サイズは ${MAX_IMAGE_MB}MB 以下にしてください`,
100 ),
101});
102
103/** 追記:── プロフィール(本人用)のメール変更フォーム(本人用/確認メールを送るだけ) ── */
104export const emailChangeSchema = (currentEmail: string) => {
105 const currentAscii = toAsciiEmailSafe(currentEmail);
106
107 return z.object({
108 newEmail: z
109 .string()
110 .transform((s) => toAsciiEmailSafe(s)) // 入力直後に punycode 化
111 .pipe(z.email("メールアドレスの形式が正しくありません")) // その結果を検証
112 .refine(
113 (v) => v !== currentAscii,
114 "現在のメールアドレスと同じです。別のメールアドレスを入力してください",
115 ),
116 });
117};
118
119/** ── パスワード変更(本人) ─────────────────── */
120export const passwordChangeSchema = z.object({
121 currentPassword: z.string().min(1, "現在のパスワードを入力してください"),
122 newPassword: passwordSchema, // 共通化したものを利用,
123});
124
125// 追加: 共通で使い回すためエクスポート
126export const accountIdSchema = z
127 .string()
128 .min(15, "アカウントIDは15文字以上で入力してください。")
129 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
130 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
131 .regex(/[0-9]/, "数字を1文字以上含めてください。");
132
133/** ── Zod から型を派生(z.infer を使う) ── */
134export type UserCreateValues = z.infer<typeof userCreateSchema>;
135export type UserUpdateValues = z.infer<typeof userUpdateSchema>;
136// 追記
137export type ProfileUpdateValues = z.infer<typeof profileUpdateSchema>;
138// emailChangeSchema は「関数」なので ReturnType で返り値スキーマを取り出してから infer
139export type EmailChangeValues = z.infer<ReturnType<typeof emailChangeSchema>>;
140export type PasswordChangeValues = z.infer<typeof passwordChangeSchema>;
roleCode
は"role:" もしくは "dr:" という値になります。
ページコンポーネントの変更(page.tsx)
tsx
1// src/app/(protected)/users/new/page.tsx
2import type { Metadata } from "next";
3import {
4 Breadcrumb,
5 BreadcrumbItem,
6 BreadcrumbLink,
7 BreadcrumbList,
8 BreadcrumbPage,
9 BreadcrumbSeparator,
10} from "@/components/ui/breadcrumb";
11import { Separator } from "@/components/ui/separator";
12import { SidebarTrigger } from "@/components/ui/sidebar";
13import Client from "./client";
14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
15// ★ 2章で用意した候補を使用
16import { getAssignableRolesAction } from "@/app/_actions/department-roles/get-assignable-roles";
17
18export const metadata: Metadata = {
19 title: "ユーザ新規登録",
20 description:
21 "共通フォーム(shadcn/ui + React Hook Form + Zod)でユーザを新規作成",
22};
23
24export default async function Page() {
25 await guardHrefOrRedirect("/users/new", "/");
26
27 // priority 昇順・disabled 反映済みの options を取得
28 const res = await getAssignableRolesAction();
29 if (!res.ok) {
30 // 権限不足などは任意ハンドリング(本稿では単純に 403 相当)
31 throw new Error(res.message || "Forbidden");
32 }
33
34 return (
35 <>
36 <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">
37 <div className="flex items-center gap-2 px-4">
38 <SidebarTrigger className="-ml-1" />
39 <Separator
40 orientation="vertical"
41 className="mr-2 data-[orientation=vertical]:h-4"
42 />
43 <Breadcrumb>
44 <BreadcrumbList>
45 <BreadcrumbItem className="hidden md:block">
46 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
47 </BreadcrumbItem>
48 <BreadcrumbSeparator className="hidden md:block" />
49 <BreadcrumbItem>
50 <BreadcrumbPage>ユーザ新規登録</BreadcrumbPage>
51 </BreadcrumbItem>
52 </BreadcrumbList>
53 </Breadcrumb>
54 </div>
55 </header>
56
57 <div className="max-w-xl p-4 pt-0">
58 {/* 単一セレクトにそのまま流し込める形 */}
59 <Client roleOptions={res.options} />
60 </div>
61 </>
62 );
63}
- 2章の
getAssignableRolesAction()
を読み込み、 サーバ側で priority 昇順 にした候補をそのまま Client へ受け渡します。 - ここで DB を直接叩く実装は廃止 し、DepartmentRole を含む候補の統一供給点に一本化しました。
クライアントコンポーネント(client.tsx)
tsx
1// src/app/(protected)/users/new/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import UserForm from "@/components/users/user-form";
6import type { UserCreateValues } from "@/lib/users/schema";
7import { createUser } from "@/app/_actions/users/create-user";
8import { toast } from "sonner";
9// 2章の型(value/label/priority/disabled)
10import type { AssignableRoleOption } from "@/app/_actions/department-roles/get-assignable-roles";
11
12type Props = {
13 roleOptions: AssignableRoleOption[];
14};
15
16export default function NewUserClient({ roleOptions }: Props) {
17 const router = useRouter();
18 // フォームからは通常項目(Zod)+ selectedRole(string)を受け取りたい
19 const handleSubmit = async (
20 values: UserCreateValues,
21 selectedRole: string | null,
22 selectedRoleLabel: string | null,
23 ) => {
24 const res = await createUser(values, selectedRole ?? "");
25 if (res.ok) {
26 toast.success("ユーザを作成しました", {
27 description: `${values.email}(ロール: ${selectedRoleLabel ?? "N/A"})`,
28 duration: 3000,
29 });
30 router.push("/users");
31 router.refresh();
32 return;
33 }
34 toast.error(res.message ?? "登録に失敗しました。入力内容を確認してください。", {
35 duration: 3500,
36 });
37 };
38
39 return (
40 <UserForm
41 mode="create"
42 roleOptions={roleOptions}
43 onSubmitWithRole={handleSubmit} // ← 追加の props(下の Form で説明)
44 onCancel={() => history.back()}
45 />
46 );
47}
- 既存の
onSubmit(values)
だけでは XOR 情報を運べないため、onSubmitWithRole(values, selectedRole, selectedRoleLabel)
を追加で受け付ける形にしています。 - ここで
selectedRole
は"role:<id>" | "dr:<id>"
の生値、selectedRoleLabel
はトースト表示用のラベルです。 - 既存の
UserCreateValues
(Zod)は 変更しません。XOR 部分は 別引数 として渡す方針です。
フォーム(user-form.tsx)
フォームでは Zod の
roleCode
にそのままバインド します。AssignableRoleOption[]
(2章のサーバアクションが返す構造)を Select に流し込み、disabled
は UI で非選択 にします。- 既存の
RoleCode
型やダミーフィールドは不要(roleCode
は XOR 文字列)。 - 送信時に選択ラベルをトースト表示したい場合は、
watch("roleCode")
から 選択中のラベルを逆引き。 onSubmitWithRole
を使う場合でも、生の値はvalues.roleCode
から取得できるため、追加引数は「トースト用ラベル」に限定可能。
tsx
1// src/components/users/user-form.tsx(ロール欄のみ抜粋)
2import { useForm, useFormContext, useWatch } from "react-hook-form"; // useWatch を追加
3import type { AssignableRoleOption } from "@/app/_actions/department-roles/get-assignable-roles";
4
5type CreateProps = BaseProps & {
6 mode: "create";
7 // ★ 追加:XOR 引き渡し用
8 onSubmitWithRole?: (
9 values: UserCreateValues,
10 selectedRole: string | null,
11 selectedRoleLabel: string | null,
12 ) => void;
13 initialValues?: never;
14};
15
16function CreateForm({ roleOptions, onSubmitWithRole, onCancel }: CreateProps) {
17 const form = useForm<UserCreateValues>({
18 resolver: zodResolver(userCreateSchema),
19 defaultValues: {
20 name: "",
21 email: "",
22 password: "",
23 isActive: true,
24 phone: "",
25 remarks: "",
26 roleCode: "", // ★ XOR 文字列を直接持つ
27 },
28 mode: "onBlur",
29 });
30
31 // ★ 追加:選択中のロール("role:<id>" | "dr:<id>")
32 const roleCode = useWatch({ control: form.control, name: "roleCode" });
33 const selectedLabel =
34 roleOptions.find((o) => o.value === roleCode)?.label ?? null;
35
36 const handleSubmit = form.handleSubmit((values) => {
37 if (onSubmitWithRole) {
38 // トースト用にラベルだけ補助で渡す(生の値は values.roleCode に入っている)
39 onSubmitWithRole(values, values.roleCode || null, selectedLabel);
40 } else {
41 // onSubmit を使う構成ならここで values をそのまま渡す
42 // onSubmit?.(values);
43 console.warn("onSubmitWithRole が未指定です");
44 }
45 });
46
47 return (
48 <Form {...form}>
49 <form data-testid="user-form-create" onSubmit={handleSubmit}>
50 <Card className="w-full rounded-md">
51 <CardContent className="space-y-6 pt-1">
52 <NameField />
53 <EmailField />
54 <RoleFieldXor roleOptions={roleOptions} />
55 <PasswordField />
56 <IsActiveField />
57 <PhoneField />
58 <RemarksField />
59 </CardContent>
60
61 <CardFooter className="mt-4 flex gap-2">
62 <Button
63 type="button"
64 variant="outline"
65 onClick={onCancel}
66 data-testid="cancel-btn"
67 className="cursor-pointer"
68 >
69 キャンセル
70 </Button>
71 <Button
72 type="submit"
73 data-testid="submit-create"
74 className="cursor-pointer"
75 disabled={form.formState.isSubmitting}
76 >
77 登録する
78 </Button>
79 </CardFooter>
80 </Card>
81 </form>
82 </Form>
83 );
84}
85
86// ─省略
87
88// XOR 単一セレクト:roleCode に直バインド
89function RoleFieldXor({
90 roleOptions,
91}: {
92 roleOptions: AssignableRoleOption[];
93}) {
94 return (
95 <FormField
96 name="roleCode"
97 render={({ field }) => (
98 <FormItem>
99 <FormLabel className="font-semibold">ロール *</FormLabel>
100 <Select
101 value={field.value ?? ""}
102 onValueChange={(v) => field.onChange(v)}
103 >
104 <FormControl>
105 <SelectTrigger
106 aria-label="ロールを選択"
107 data-testid="role-trigger"
108 >
109 <SelectValue
110 placeholder="選択してください"
111 data-testid="role-value"
112 />
113 </SelectTrigger>
114 </FormControl>
115 <SelectContent data-testid="role-list">
116 {roleOptions.map((opt) => (
117 <SelectItem
118 key={opt.value}
119 value={opt.value}
120 disabled={opt.disabled}
121 >
122 {opt.label}
123 </SelectItem>
124 ))}
125 </SelectContent>
126 </Select>
127 <FormMessage data-testid="roleCode-error" />
128 </FormItem>
129 )}
130 />
131 );
132}
上記の変更で、フォームは
roleCode
に直接バインド されます。disabled
な DepartmentRole は選べないため、 サーバ側では存在/有効性の最終確認だけ を行えば十分です。サーバアクション(create-user.ts)
サーバアクション
互換性のため、第2引数
createUser
は、values.roleCode
(XOR 文字列)をパースして roleId
か departmentRoleId
へ割当 ます。互換性のため、第2引数
selectedRoleValue
が渡された場合はそちらを優先し、無ければ values.roleCode
を使います。ts
1// src/app/_actions/users/create-user.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { getUserSnapshot } from "@/lib/auth/user-snapshot";
7import { isDomainAllowed } from "@/lib/email/domain-allow";
8import { userCreateSchema, type UserCreateValues } from "@/lib/users/schema";
9import { sendMail } from "@/lib/mailer";
10import { userWelcomeText } from "@/lib/email/templates";
11import argon2 from "argon2";
12
13// ★ 追加:#2 章の XOR 候補値を扱うための型
14// "role:<id>" | "dr:<id>"
15type XorRoleValue = string; // 実体はセレクト value をそのまま受ける
16
17type ActionResult = { ok: true } | { ok: false; message: string };
18
19export async function createUser(
20 values: UserCreateValues,
21 selectedRoleValue?: XorRoleValue, // ← 互換用。なければ values.roleCode を使う
22): Promise<ActionResult> {
23 // 1) 認証
24 const ses = await lookupSessionFromCookie();
25 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
26
27 // 2) 権限
28 const snap = await getUserSnapshot(ses.userId);
29 if (!snap || !snap.canEditData) {
30 return { ok: false, message: "この操作を行う権限がありません。" };
31 }
32
33 // 3) 所属部署ID
34 const me = await prisma.user.findUnique({
35 where: { id: ses.userId },
36 select: { departmentId: true },
37 });
38 if (!me) return { ok: false, message: "ユーザ情報を取得できませんでした。" };
39
40 // 4) サーバ側の最終チェック(詳細は返さない)
41 const parsed = userCreateSchema.safeParse(values);
42 if (!parsed.success) {
43 return { ok: false, message: "入力内容を確認してください。" };
44 }
45 const { name, email, password, isActive, phone, remarks, roleCode } =
46 parsed.data;
47
48 // 5) email 正規化(trim のみ)
49 const normalizedEmail = email.trim();
50
51 // 6) 許可ドメイン
52 const domainOk = await isDomainAllowed(me.departmentId, normalizedEmail);
53 if (!domainOk) {
54 return { ok: false, message: "このドメインは許可されていません。" };
55 }
56
57 // 7) 部署内重複
58 const dup = await prisma.user.findFirst({
59 where: { departmentId: me.departmentId, email: normalizedEmail },
60 select: { id: true },
61 });
62 if (dup) {
63 return { ok: false, message: "このメールアドレスは既に登録されています。" };
64 }
65
66 // 8) XOR 解決("role:" or "dr:")
67 const xorValue = (selectedRoleValue ?? roleCode) || "";
68 let roleId: string | null = null;
69 let departmentRoleId: string | null = null;
70
71 if (xorValue.startsWith("role:")) {
72 roleId = xorValue.slice("role:".length);
73 } else if (xorValue.startsWith("dr:")) {
74 departmentRoleId = xorValue.slice("dr:".length);
75 } else {
76 return { ok: false, message: "ロールの指定が不正です。" };
77 }
78
79 // 9) 存在チェック(FK 例外をユーザに見せない)
80 if (roleId) {
81 const ok = await prisma.role.findUnique({
82 where: { id: roleId },
83 select: { id: true },
84 });
85 if (!ok) return { ok: false, message: "ロールの指定が不正です。" };
86 }
87 if (departmentRoleId) {
88 const dr = await prisma.departmentRole.findFirst({
89 where: { id: departmentRoleId, departmentId: me.departmentId },
90 select: { id: true, isEnabled: true },
91 });
92 if (!dr || !dr.isEnabled)
93 return { ok: false, message: "ロールの指定が不正です。" };
94 }
95
96 // 10) パスワードハッシュ
97 const hashedPassword = await argon2.hash(password);
98
99 // 11) 登録(XOR で保存)
100 let created: { name: string; department: { code: string } } | null = null;
101 try {
102 created = await prisma.user.create({
103 data: {
104 departmentId: me.departmentId,
105 roleId, // どちらかが null
106 departmentRoleId,
107 email: normalizedEmail,
108 hashedPassword,
109 name,
110 isActive,
111 phone: phone || null,
112 remarks: remarks || null,
113 failedLoginCount: 0,
114 },
115 select: {
116 name: true,
117 department: { select: { code: true } }, // ← メール本文で使用
118 },
119 });
120 } catch (e) {
121 console.error("[createUser] DB create failed:", e);
122 return { ok: false, message: "ユーザの登録に失敗しました。" };
123 }
124
125 // 12) メール送信(DB作成成功時のみ)
126
127 try {
128 const mailText = userWelcomeText({
129 name: created.name,
130 email: normalizedEmail,
131 departmentCode: created.department.code, // ← departmentId ではなく department.code
132 initialPassword: password, // フォームに入力/生成された値
133 });
134 await sendMail({
135 to: normalizedEmail,
136 subject: "【DELOGs】アカウント発行のお知らせ",
137 text: mailText,
138 });
139 } catch (e) {
140 // 送信失敗は致命にはしない(DBは作成済み)
141 console.error("[mailer] failed to send welcome mail", e);
142 }
143
144 return { ok: true };
145}
- 互換性のための第2引数:
selectedRoleValue
が来たら優先、無ければvalues.roleCode
を使用。段階的移行に便利。 - XOR の厳格化:DB 側には前回の Migration で
CHECK
制約を入れておくと二重安全(roleId
とdepartmentRoleId
の同時指定禁止)。 - 存在/有効チェック:
Role
は存在のみ、DepartmentRole
は 同一部署かつisEnabled
を確認。 - メール送信:本番運用では失敗を致命にせずログのみ。テスト環境ではコメントアウトでも可。
チェックリスト(登録画面の期待動作)
ケース | 入力 | 期待動作 |
---|---|---|
A | "role:<id>" を選択 | roleId=<id> , departmentRoleId=null で作成 |
B | "dr:<id>" (有効)を選択 | departmentRoleId=<id> , roleId=null で作成 |
C | "dr:<id>" (無効)を選択 | UI で選べない(disabled ) |
D | XOR 未選択 | Zod で弾かれ、roleCode-error にエラー表示 |
E | 他部署の dr:<id> を改竄 | サーバで弾かれ、汎用エラーメッセージ |
F | 既存メール重複 | サーバで弾かれ、汎用エラーメッセージ |
まとめ
- フォームは
roleCode
に直バインド(XOR 文字列)。 - サーバは
roleCode
をパースし XOR 保存(roleId
/departmentRoleId
のいずれか)。 - 2章の候補 API と 1章の実効ロールユーティリティにより、UI/サーバ/DB が一貫しました。
次章では「更新フォーム(Edit)」と
updateUserAction
を同方針で改修します。4. ユーザ更新のXOR保存(updateUserAction)
この章では「ユーザ更新(Edit)」を DepartmentRole 対応 に改修します。
新規登録と同じく、フォームでは
新規登録と同じく、フォームでは
"role:<uuid>" | "dr:<uuid>"
を単一セレクトで扱い、サーバ側では roleId
または departmentRoleId
のどちらか一方だけ を保存します。章のゴール
- 既存ユーザの 現在の割当(role / departmentRole)を XOR 文字列へ正規化 してフォーム初期値に反映。
- フォーム送信時は
values.roleCode
をパースし、 XOR を解決して更新 。 - 無効化された DepartmentRole を割り当て済みのユーザを編集するケースでも、初期値としては表示できるように配慮。
txt
1### 値の流れ(更新)
2
3[SSR] getAssignableRolesAction()
4 └─ options = [{ value: "role:<id>" }, { value: "dr:<id>" }, ...](priority昇順・無効DRはdisabled)
5
6[SSR] getUserForEdit(userId)
7 └─ DBの roleId / departmentRoleId を XOR 文字列へ正規化 → initialValues.roleCode にセット
8
9[Page] /users/[id]/edit/page.tsx
10 └─ options と initialValues を Clientへ渡す(必要なら現在割当の項目を options に補完)
11
12[Client] /users/[id]/edit/client.tsx
13 └─ <UserForm mode="edit" ... /> で初期値を描画、Submit で updateUser を呼ぶ
14
15[Action] /_actions/users/update-user.ts
16 ├─ userUpdateSchema で入力検証
17 ├─ roleCode をパース → XOR 解決(roleId or departmentRoleId)
18 └─ User を更新
変更ポイントの整理
観点 | 変更内容 | 補足 |
---|---|---|
初期値 | DBの roleId / departmentRoleId を XOR 文字列に正規化("role:<id>" or "dr:<id>" ) | UIの roleCode に直バインドできる形へ |
候補 | 2章の getAssignableRolesAction() を SSR で呼ぶ | override のある Role は除外済みで重複表示なし |
UI | フォームのロール欄を RoleFieldXor に統一 | FormField name="roleCode" へ直結 |
サーバ | updateUser で XOR を解決して更新 | 既存と同様に認可・重複メールチェックを実施 |
互換 | 割当済みのDRが 現在は無効 な場合でも、初期値表示のため options に一時的に 補完 | ただし disabled 表示にして再選択不可 |
新規同様、
src/lib/users/schema.ts
は roleCode
が XOR 文字列 を許容する最新の定義を使用します。ユーザごとのロール取得ツールの作成
ts
1// src/app/_actions/users/get-user-for-edit.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { getUserSnapshot } from "@/lib/auth/user-snapshot";
7
8export type UserForEdit = {
9 displayId: string;
10 name: string;
11 email: string;
12 isActive: boolean;
13 phone?: string | null;
14 remarks?: string | null;
15 /** フォーム直結用: "role:<id>" | "dr:<id>" */
16 roleCode: string;
17 /** options 補完用の現在割当(無効DRでも見せるため) */
18 currentAssignment?: { value: string; label: string; disabled?: boolean };
19};
20
21export async function getUserForEditAction(
22 displayId: string, // ★ page 側から渡される表示ID
23): Promise<{ ok: true; data: UserForEdit } | { ok: false; message: string }> {
24 // 1) 認証 & 権限(編集権限が無ければそもそも表示不可)
25 const ses = await lookupSessionFromCookie();
26 if (!ses.ok) return { ok: false, message: "Unauthorized" };
27 const snap = await getUserSnapshot(ses.userId);
28 if (!snap?.canEditData) return { ok: false, message: "Forbidden" };
29
30 // 2) ログインユーザの部署を取得
31 const me = await prisma.user.findUnique({
32 where: { id: ses.userId },
33 select: { departmentId: true },
34 });
35 if (!me) return { ok: false, message: "Not found" };
36
37 // 3) 対象ユーザを「同一部署」かつ「論理削除なし」で取得
38 const user = await prisma.user.findFirst({
39 where: {
40 displayId,
41 departmentId: me.departmentId, // ★ 部署縛り
42 deletedAt: null,
43 },
44 select: {
45 displayId: true,
46 name: true,
47 email: true, // DBは punycode ASCII(表示側で変換するならここで変換して返してもOK)
48 isActive: true,
49 phone: true,
50 remarks: true,
51 roleId: true,
52 role: { select: { id: true, code: true, name: true } },
53 departmentRoleId: true,
54 departmentRole: {
55 select: {
56 id: true,
57 isEnabled: true,
58 code: true,
59 name: true,
60 role: { select: { code: true, name: true } },
61 nameOverride: true,
62 },
63 },
64 },
65 });
66 if (!user) return { ok: false, message: "Not found" };
67
68 // 4) XOR 文字列へ正規化 & 現在割当のラベル化
69 let roleCode = "";
70 let currentAssignment: UserForEdit["currentAssignment"] | undefined;
71
72 if (user.departmentRoleId && user.departmentRole) {
73 roleCode = `dr:${user.departmentRoleId}`;
74 const base = user.departmentRole.role ?? null; // override の参照元
75 const label = base
76 ? `${user.departmentRole.nameOverride ?? base.name} (${base.code})`
77 : `${user.departmentRole.name ?? user.departmentRole.code ?? "CUSTOM"} (${user.departmentRole.code ?? "DR"})`;
78 currentAssignment = {
79 value: roleCode,
80 label,
81 disabled: !user.departmentRole.isEnabled,
82 };
83 } else if (user.roleId && user.role) {
84 roleCode = `role:${user.roleId}`;
85 currentAssignment = {
86 value: roleCode,
87 label: `${user.role.name} (${user.role.code})`,
88 disabled: false,
89 };
90 } else {
91 // XOR 的に両方空は異常だが、UI側で未選択扱いにしておく
92 roleCode = "";
93 }
94
95 return {
96 ok: true,
97 data: {
98 displayId: user.displayId,
99 name: user.name,
100 email: user.email,
101 isActive: user.isActive,
102 phone: user.phone,
103 remarks: user.remarks,
104 roleCode,
105 currentAssignment,
106 },
107 };
108}
上記は「編集フォームの初期値」を作る Server Action です。
無効な DepartmentRole が割当済みでも「表示だけはできる」よう
roleId
/ departmentRoleId
→ roleCode
(XOR 文字列) に正規化し、現在の割当ラベル も返しているのがポイントです。無効な DepartmentRole が割当済みでも「表示だけはできる」よう
currentAssignment
を返しています。ページコンポーネント(page.tsx)
tsx
1// src/app/(protected)/users/[displayId]/page.tsx
2import type { Metadata } from "next";
3import { notFound } from "next/navigation";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbLink,
8 BreadcrumbList,
9 BreadcrumbPage,
10 BreadcrumbSeparator,
11} from "@/components/ui/breadcrumb";
12import { Separator } from "@/components/ui/separator";
13import { SidebarTrigger } from "@/components/ui/sidebar";
14import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
15import Client from "./client";
16// ★ 追加:初期値と候補の取得は Server Action 経由に統一
17import { getUserForEditAction } from "@/app/_actions/users/get-user-for-edit";
18import { getAssignableRolesAction } from "@/app/_actions/department-roles/get-assignable-roles";
19
20export const metadata: Metadata = {
21 title: "ユーザ編集",
22 description: "共通フォーム(shadcn/ui + RHF + Zod)でユーザ情報を編集",
23};
24
25export default async function Page({
26 params,
27}: {
28 params: Promise<{ displayId: string }>;
29}) {
30 const { displayId } = await params;
31
32 await guardHrefOrRedirect(`/users/${displayId}`, "/");
33
34 const resUser = await getUserForEditAction(displayId); // ← displayId を渡す
35 if (!resUser.ok) {
36 // 対象が部署外/削除済み/未存在 → 404
37 notFound();
38 }
39
40 const resOpts = await getAssignableRolesAction(); // ADMIN 専用(2章の方針)
41 if (!resOpts.ok) {
42 // 候補を取れない = 表示すべきでない → 404 に寄せる or 403 を投げる
43 notFound();
44 }
45
46 // 現割当が options に無い場合は補完(無効DRなど)
47 const options = [...resOpts.options];
48 const cur = resUser.data.currentAssignment;
49 if (cur && !options.some((o) => o.value === cur.value)) {
50 options.unshift({ ...cur, priority: -1 });
51 }
52 return (
53 <>
54 <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">
55 <div className="flex items-center gap-2 px-4">
56 <SidebarTrigger className="-ml-1" />
57 <Separator
58 orientation="vertical"
59 className="mr-2 data-[orientation=vertical]:h-4"
60 />
61 <Breadcrumb>
62 <BreadcrumbList>
63 <BreadcrumbItem className="hidden md:block">
64 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
65 </BreadcrumbItem>
66 <BreadcrumbSeparator className="hidden md:block" />
67 <BreadcrumbItem>
68 <BreadcrumbPage>ユーザ情報編集({displayId})</BreadcrumbPage>
69 </BreadcrumbItem>
70 </BreadcrumbList>
71 </Breadcrumb>
72 </div>
73 </header>
74
75 <div className="max-w-xl p-4 pt-0">
76 <Client initialValues={resUser.data} roleOptions={options} />
77 </div>
78 </>
79 );
80}
- 現在割当が候補に無い(例:DepartmentRole が無効化済み)場合でも、初期値を表示できるよう options に補完 しています。
priority: -1
を与えて先頭に表示(ただしdisabled
)し、ユーザが再選択を促される UI にできます。
クライアントコンポーネント(client.tsx)
tsx
1// src/app/(protected)/users/[displayId]/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "sonner";
6import UserForm from "@/components/users/user-form";
7import type { UserUpdateValues } from "@/lib/users/schema";
8import {
9 updateUserAction,
10 deleteUserAction,
11} from "@/app/_actions/users/update-user";
12import type { AssignableRoleOption } from "@/app/_actions/department-roles/get-assignable-roles";
13
14// page.tsx から来る型(Server Action の返り値)
15type UserForEdit = {
16 displayId: string;
17 name: string;
18 email: string;
19 isActive: boolean;
20 phone?: string | null;
21 remarks?: string | null;
22 roleCode: string; // "role:<id>" | "dr:<id>"
23 currentAssignment?: { value: string; label: string; disabled?: boolean };
24};
25
26type Props = {
27 initialValues: UserForEdit; // ← ここは UserForEdit を受ける
28 roleOptions: AssignableRoleOption[];
29};
30
31export default function EditUserClient({ initialValues, roleOptions }: Props) {
32 const router = useRouter();
33
34 // UserForm に渡す直前で UserUpdateValues に整形(不要な currentAssignment を落とす)
35 const formInit: UserUpdateValues = {
36 displayId: initialValues.displayId,
37 name: initialValues.name,
38 email: initialValues.email,
39 roleCode: initialValues.roleCode, // XOR 文字列をそのまま
40 isActive: initialValues.isActive,
41 // RHF/Zod 的に undefined の方が扱いやすいので null を undefined に寄せる
42 phone: initialValues.phone ?? undefined,
43 remarks: initialValues.remarks ?? undefined,
44 };
45
46 return (
47 <UserForm
48 mode="edit"
49 initialValues={formInit}
50 roleOptions={roleOptions}
51 onSubmit={async (values) => {
52 const res = await updateUserAction(values);
53 if (res.ok) {
54 const roleLabel =
55 roleOptions.find((o) => o.value === values.roleCode)?.label ??
56 values.roleCode;
57
58 toast.success("ユーザを更新しました", {
59 description: `ID: ${values.displayId} / ${values.email} / ロール: ${roleLabel} / 有効: ${values.isActive ? "ON" : "OFF"}`,
60 duration: 3000,
61 });
62 router.push("/users");
63 router.refresh();
64 return;
65 }
66 toast.error(
67 res.message ?? "更新に失敗しました。入力内容を確認してください。",
68 { duration: 3500 },
69 );
70 }}
71 onCancel={() => history.back()}
72 onDelete={async () => {
73 const res = await deleteUserAction(initialValues.displayId);
74 if (res.ok) {
75 toast.success("ユーザを論理削除しました", {
76 description: `ID: ${initialValues.displayId}`,
77 });
78 router.push("/users");
79 router.refresh();
80 } else {
81 toast.error(res.message ?? "削除に失敗しました");
82 }
83 }}
84 />
85 );
86}
- 「更新」は新規と違い
onSubmit(values)
だけで十分です。 values.roleCode
は XOR 文字列なので、そのまま Server Action 側でパースして使えます。
フォーム(user-form.tsx)
tsx
1// src/components/users/user-form.tsx(差分:Edit 側も XOR セレクトに統一)
2
3// ─ 省略
4
5/* ===== Edit(編集) ===== */
6function EditForm({ roleOptions, onSubmit, onCancel, onDelete, initialValues }: EditProps) {
7 const form = useForm<UserUpdateValues>({
8 resolver: zodResolver(userUpdateSchema),
9 defaultValues: initialValues,
10 mode: "onBlur",
11 });
12
13 const handleSubmit = form.handleSubmit(onSubmit);
14
15 return (
16 <Form {...form}>
17 <form onSubmit={handleSubmit} data-testid="user-form-edit">
18 <Card className="w-full rounded-md">
19 <CardContent className="space-y-6 pt-1">
20 <DisplayIdField />
21 <Separator />
22 <NameField />
23 <EmailField />
24 {/* ★ ここを XOR セレクトに統一 */}
25 <RoleFieldXor roleOptions={roleOptions} />
26 <IsActiveField />
27 <PhoneField />
28 <RemarksField />
29 </CardContent>
30
31 <CardFooter className="mt-4 flex items-center justify-between">
32 <div className="flex gap-2">
33 <Button type="button" variant="outline" onClick={onCancel} data-testid="cancel-btn" className="cursor-pointer">キャンセル</Button>
34 <Button type="submit" data-testid="submit-update" className="cursor-pointer" disabled={form.formState.isSubmitting}>更新する</Button>
35 </div>
36 {onDelete && (
37 <AlertDialog>
38 <AlertDialogTrigger asChild>
39 <Button type="button" variant="destructive" data-testid="delete-open" className="cursor-pointer">削除する</Button>
40 </AlertDialogTrigger>
41 <AlertDialogContent>
42 <AlertDialogHeader>
43 <AlertDialogTitle>ユーザを論理削除しますか?</AlertDialogTitle>
44 <AlertDialogDescription>
45 この操作は DB では <code>deletedAt</code> を設定する「論理削除」です。
46 一覧からは非表示になります(復活は別途機能で対応)。
47 </AlertDialogDescription>
48 </AlertDialogHeader>
49 <AlertDialogFooter>
50 <AlertDialogCancel data-testid="delete-cancel">キャンセル</AlertDialogCancel>
51 <AlertDialogAction onClick={onDelete} data-testid="delete-confirm">削除する</AlertDialogAction>
52 </AlertDialogFooter>
53 </AlertDialogContent>
54 </AlertDialog>
55 )}
56 </CardFooter>
57 </Card>
58 </form>
59 </Form>
60 );
61}
62
63// ─ 省略
64
- Edit も Create と同じ
RoleFieldXor
を使い、name="roleCode"
で直バインドします。 - これで フォーム側の実装が新規/編集で統一 され、メンテしやすくなります。
サーバアクション(update-user.ts)
ts
1// src/app/_actions/users/update-user.ts
2"use server";
3
4import { prisma } from "@/lib/database";
5import { lookupSessionFromCookie } from "@/lib/auth/session";
6import { userUpdateSchema, type UserUpdateValues } from "@/lib/users/schema";
7import { isDomainAllowed } from "@/lib/email/domain-allow";
8import { toAsciiEmailSafe } from "@/lib/email/normalize";
9import { getEffectiveRole } from "@/lib/auth/effective-role";
10
11type ActionResult = { ok: true } | { ok: false; message: string };
12
13// ADMIN 相当のしきい値(前章と同じ基準を利用)
14const ADMIN_PRIORITY_THRESHOLD = 100;
15
16/* ─────────────────────────────────────────────
17 ユーティリティ
18 ───────────────────────────────────────────── */
19
20/** displayId から編集対象を部署縛りで取得(削除済みは除外) */
21async function findEditableUserByDisplayId(displayId: string) {
22 return prisma.user.findFirst({
23 where: { displayId, deletedAt: null },
24 select: {
25 id: true,
26 displayId: true,
27 email: true,
28 name: true,
29 departmentId: true,
30 isActive: true,
31 phone: true,
32 remarks: true,
33 roleId: true,
34 departmentRoleId: true,
35 },
36 });
37}
38
39/** 呼び出しユーザが ADMIN(実効ロール priority>=100)かつ同一部署かを確認 */
40async function requireAdminSameDepartmentXor(
41 userId: string,
42 departmentId: string,
43) {
44 const me = await prisma.user.findUnique({
45 where: { id: userId },
46 select: {
47 id: true,
48 isActive: true,
49 departmentId: true,
50 roleId: true,
51 departmentRoleId: true,
52 },
53 });
54 if (!me || !me.isActive)
55 return { ok: false as const, message: "ユーザが無効化されています。" };
56
57 if (me.departmentId !== departmentId)
58 return { ok: false as const, message: "越権操作です。" };
59
60 // 実効ロールを取得(両方 null の場合は eff=null 扱い)
61 let eff = null;
62 if (me.departmentRoleId) {
63 eff = await getEffectiveRole({
64 departmentId: me.departmentId,
65 departmentRoleId: me.departmentRoleId,
66 });
67 } else if (me.roleId) {
68 eff = await getEffectiveRole({
69 departmentId: me.departmentId,
70 roleId: me.roleId,
71 });
72 }
73 if (!eff || eff.priority < ADMIN_PRIORITY_THRESHOLD)
74 return { ok: false as const, message: "権限がありません。" };
75
76 return { ok: true as const };
77}
78
79/** 部署内の“有効な管理者(実効 priority>=100)”の人数を数える */
80async function countActiveAdmins(departmentId: string) {
81 // role / override / custom すべてをカバー
82 return prisma.user.count({
83 where: {
84 departmentId,
85 deletedAt: null,
86 isActive: true,
87 OR: [
88 // Role 直付けで priority>=100
89 { role: { is: { priority: { gte: ADMIN_PRIORITY_THRESHOLD } } } },
90 // DepartmentRole: override(参照Roleのpriorityで判定、DRは有効)
91 {
92 departmentRole: {
93 is: {
94 isEnabled: true,
95 role: { is: { priority: { gte: ADMIN_PRIORITY_THRESHOLD } } },
96 },
97 },
98 },
99 // DepartmentRole: custom(DR.priorityで判定、DRは有効)
100 {
101 departmentRole: {
102 is: {
103 isEnabled: true,
104 roleId: null,
105 priority: { gte: ADMIN_PRIORITY_THRESHOLD },
106 },
107 },
108 },
109 ],
110 },
111 });
112}
113
114/** XOR 文字列を roleId / departmentRoleId に解決 */
115function parseXorRoleCode(roleCode: string | undefined) {
116 let roleId: string | null = null;
117 let departmentRoleId: string | null = null;
118 if (roleCode?.startsWith("role:")) {
119 roleId = roleCode.slice("role:".length);
120 } else if (roleCode?.startsWith("dr:")) {
121 departmentRoleId = roleCode.slice("dr:".length);
122 }
123 return { roleId, departmentRoleId };
124}
125
126/** 入力後の実効ロールを試算(isActive=false は非管理者扱い) */
127async function getEffectivePriorityAfterUpdate(params: {
128 departmentId: string;
129 roleId: string | null;
130 departmentRoleId: string | null;
131 isActive: boolean;
132}) {
133 if (!params.isActive) return -1; // 無効化されるなら管理者からは外れる
134 const { departmentId, roleId, departmentRoleId } = params;
135 // 両方 null の場合は非管理者扱い
136 if (!roleId && !departmentRoleId) return -1;
137 let eff = null;
138 if (departmentRoleId) {
139 eff = await getEffectiveRole({ departmentId, departmentRoleId });
140 } else if (roleId) {
141 eff = await getEffectiveRole({ departmentId, roleId });
142 }
143
144 return eff?.priority ?? -1;
145}
146
147/* ─────────────────────────────────────────────
148 ユーザ情報の更新(XOR 対応)
149 ───────────────────────────────────────────── */
150export async function updateUserAction(
151 values: UserUpdateValues,
152): Promise<ActionResult> {
153 // 1) 認証
154 const ses = await lookupSessionFromCookie();
155 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
156
157 // 2) 対象取得(削除済み除外)
158 const target = await findEditableUserByDisplayId(values.displayId);
159 if (!target) return { ok: false, message: "対象ユーザが見つかりません。" };
160
161 // 3) ADMIN & 同部署チェック(実効ロール)
162 {
163 const g = await requireAdminSameDepartmentXor(
164 ses.userId,
165 target.departmentId,
166 );
167 if (!g.ok) return g;
168 }
169
170 // 4) 入力検証(roleCode は XOR 文字列)
171 const parsed = userUpdateSchema.safeParse(values);
172 if (!parsed.success)
173 return { ok: false, message: "入力内容を確認してください。" };
174
175 const { name, email, roleCode, isActive, phone, remarks } = parsed.data;
176
177 // 5) email 正規化 & 許可ドメイン
178 const asciiEmail = toAsciiEmailSafe(email).trim();
179 {
180 const ok = await isDomainAllowed(target.departmentId, asciiEmail);
181 if (!ok)
182 return { ok: false, message: "このドメインは許可されていません。" };
183 }
184
185 // 6) メール重複(同部署・自身除外)
186 {
187 const dup = await prisma.user.findFirst({
188 where: {
189 departmentId: target.departmentId,
190 email: asciiEmail,
191 deletedAt: null,
192 id: { not: target.id },
193 },
194 select: { id: true },
195 });
196 if (dup)
197 return {
198 ok: false,
199 message: "このメールアドレスは既に登録されています。",
200 };
201 }
202
203 // 7) XOR 解決 & 存在チェック
204 const { roleId: newRoleId, departmentRoleId: newDepartmentRoleId } =
205 parseXorRoleCode(roleCode);
206
207 if (!newRoleId && !newDepartmentRoleId) {
208 return { ok: false, message: "ロールの指定が不正です。" };
209 }
210 if (newRoleId) {
211 const exists = await prisma.role.findUnique({
212 where: { id: newRoleId },
213 select: { id: true },
214 });
215 if (!exists) return { ok: false, message: "ロールの指定が不正です。" };
216 }
217 if (newDepartmentRoleId) {
218 const dr = await prisma.departmentRole.findFirst({
219 where: { id: newDepartmentRoleId, departmentId: target.departmentId },
220 select: { id: true, isEnabled: true },
221 });
222 if (!dr || !dr.isEnabled)
223 return { ok: false, message: "ロールの指定が不正です。" };
224 }
225
226 // 8) “唯一の管理者”保護
227 // 現在この部署に有効な管理者が何名いるか
228 const currentAdmins = await countActiveAdmins(target.departmentId);
229
230 // 対象ユーザの「今の」実効 priority
231 const currentEffPriority = await getEffectivePriorityAfterUpdate({
232 departmentId: target.departmentId,
233 roleId: target.roleId,
234 departmentRoleId: target.departmentRoleId,
235 isActive: target.isActive,
236 });
237 const targetIsAdminNow = currentEffPriority >= ADMIN_PRIORITY_THRESHOLD;
238
239 // 入力後の「未来の」実効 priority
240 const futureEffPriority = await getEffectivePriorityAfterUpdate({
241 departmentId: target.departmentId,
242 roleId: newRoleId,
243 departmentRoleId: newDepartmentRoleId,
244 isActive,
245 });
246 const targetIsAdminFuture = futureEffPriority >= ADMIN_PRIORITY_THRESHOLD;
247
248 if (targetIsAdminNow && !targetIsAdminFuture && currentAdmins <= 1) {
249 return {
250 ok: false,
251 message:
252 "この部署の有効な管理者がこの1名のみのため、管理者権限を外せません。別の管理者を追加してから再試行してください。",
253 };
254 }
255
256 // 9) 更新(XOR で保存)
257 await prisma.user.update({
258 where: { id: target.id },
259 data: {
260 name,
261 email: asciiEmail,
262 roleId: newRoleId,
263 departmentRoleId: newDepartmentRoleId,
264 isActive,
265 phone: phone || null,
266 remarks: remarks || null,
267 },
268 });
269
270 return { ok: true };
271}
272
273/* ─────────────────────────────────────────────
274 論理削除(deletedAt を設定)— “唯一の管理者”保護対応
275 ───────────────────────────────────────────── */
276export async function deleteUserAction(
277 displayId: string,
278): Promise<ActionResult> {
279 // 1) 認証
280 const ses = await lookupSessionFromCookie();
281 if (!ses.ok) return { ok: false, message: "認証が必要です。" };
282
283 // 2) 対象取得
284 const target = await findEditableUserByDisplayId(displayId);
285 if (!target) return { ok: false, message: "対象ユーザが見つかりません。" };
286
287 // 3) ADMIN & 同部署チェック
288 {
289 const g = await requireAdminSameDepartmentXor(
290 ses.userId,
291 target.departmentId,
292 );
293 if (!g.ok) return g;
294 }
295
296 // 4) “唯一の管理者”保護(削除で管理者がいなくならないか)
297 const currentAdmins = await countActiveAdmins(target.departmentId);
298 const targetEffPriority = await getEffectivePriorityAfterUpdate({
299 departmentId: target.departmentId,
300 roleId: target.roleId,
301 departmentRoleId: target.departmentRoleId,
302 isActive: target.isActive,
303 });
304 const targetIsAdminNow = targetEffPriority >= ADMIN_PRIORITY_THRESHOLD;
305
306 if (targetIsAdminNow && currentAdmins <= 1) {
307 return {
308 ok: false,
309 message:
310 "この部署の有効な管理者がこの1名のみのため削除できません。別の管理者を作成してから再試行してください。",
311 };
312 }
313
314 // 5) 論理削除
315 await prisma.user.update({
316 where: { id: target.id },
317 data: { deletedAt: new Date() },
318 });
319
320 return { ok: true };
321}
- 編集ではパスワードを扱わない 想定のため、
userUpdateSchema
は password を含みません。 roleCode
から XOR を解決し、roleId
/departmentRoleId
のいずれかに保存します。- DepartmentRole は 同一部署 & isEnabled を確認して安全側に倒します。
エッジケースと表示ルール
ケース | UI挙動 | サーバ挙動 |
---|---|---|
割当DRが現在は無効 | 初期値を disabled で表示しつつ見せる(options へ補完) | 更新時は別の有効候補を選び直さないとエラー |
override されている Role | Role 候補からは除外(2章の action が除去) | 代わりに override 側の DepartmentRole を選択可能 |
両方未設定(XOR破綻) | 初期値空(エラー文言を表示) | 更新不可(バリデーションで弾く) |
他部署の DR を改竄 | 選択できない | サーバで部署不一致を検出しエラー |
メール重複 | クライアントで可能な限り補助 | 最終的な重複チェックはサーバで実施 |
これらの扱いにより、UI/サーバ/DB ですべて整合した XOR モデル を安全に保てます。
まとめ
- Edit も Create と同じ UI/型(
roleCode
= XOR 文字列)に統一。 - SSR で 候補と初期値 を準備し、無効化 DR でも見失わないよう 補完。
- サーバは
updateUser
で XOR を厳密に解決し保存、部署域と有効性もチェック。
この統一により、新規・編集・プロフィール を通して「実効ロール」の整合が取れ、保守性と安全性が上がりました。次章では ユーザ一覧の実効ロール表示/フィルタ を仕上げます。
5. ユーザ一覧の実効ロール対応
本章では、ユーザ一覧ページ
従来は
/users
を「DepartmentRole 対応」へ改修します。従来は
User.role
を直接参照してロール情報を取得していましたが、DepartmentRole の導入により 実効ロール(EffectiveRole) を表示・フィルタに反映する必要があります。これにより、部署単位での override や custom role も一覧上で正しく扱えるようになります。
txt
1### 変更後の全体像
2
3 ┌────────────┐
4 │ User │
5 │────────────│
6 │ roleId? │───┐
7 │ departmentRoleId? │
8 └────────────┘ │
9 ▼
10 ┌─────────────┐ ┌───────────────────┐
11 │ Role │ │ DepartmentRole │
12 │─────────────│ │───────────────────│
13 │ code,name │ │ code,nameOverride │
14 │ priority │ │ isEnabled,custom? │
15 └─────────────┘ └───────────────────┘
16 ↓
17 ┌───────────────────────┐
18 │ EffectiveRole │
19 │───────────────────────│
20 │ code / name / priority│
21 │ badgeColor / source │
22 └───────────────────────┘
23 ↓
24 DataTableで表示・フィルタ
改修の目的
観点 | 変更前 | 変更後 |
---|---|---|
ロール取得 | User.role のみ参照 | User.role / User.departmentRole を統合 |
表示列 | Role.name, Role.code | EffectiveRole.name, EffectiveRole.code |
フィルタ | Role.code のみ | 実効ロール単位でのフィルタ |
CSV出力 | Role.name | EffectiveRole.name |
保守性 | DB構造依存 | getEffectiveRole() に統一 |
これにより、ロール体系が複雑化しても UI の構造は一切変わらず、
「実効ロールをどう表示・フィルタするか」 のみを共通ロジックで制御できるようになります。
「実効ロールをどう表示・フィルタするか」 のみを共通ロジックで制御できるようになります。
データ取得ロジックの変更
一覧ページ
/users/page.tsx
では、従来の prisma.user.findMany()
に加えてdepartmentRole
経由の情報を取得し、EffectiveRole として正規化します。tsx
1// src/app/(protected)/users/page.tsx
2import type { Metadata } from "next";
3import { SidebarTrigger } from "@/components/ui/sidebar";
4import {
5 Breadcrumb,
6 BreadcrumbItem,
7 BreadcrumbLink,
8 BreadcrumbList,
9 BreadcrumbPage,
10 BreadcrumbSeparator,
11} from "@/components/ui/breadcrumb";
12import { Separator } from "@/components/ui/separator";
13import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr";
14import { prisma } from "@/lib/database";
15import * as punycode from "punycode/";
16import { getEffectiveRole } from "@/lib/auth/effective-role"; // ★ 追加
17import DataTable from "./data-table";
18import { columns, type UserRow } from "./columns";
19
20export const metadata: Metadata = {
21 title: "ユーザ一覧",
22 description:
23 "Data table(shadcn/ui + @tanstack/react-table)でユーザ一覧を表示",
24};
25
26export default async function Page() {
27 // 1) ページ閲覧ガード(未ログイン/権限不足なら内部でredirect)
28 const viewer = await guardHrefOrRedirect("/users", "/");
29
30 // 2) 自分の departmentId を userId から解決(スナップショット非保持方針)
31 const me = await prisma.user.findUnique({
32 where: { id: viewer.userId },
33 select: { departmentId: true },
34 });
35 if (!me) return null; // 想定外
36
37 // 3) 部署内・未削除ユーザの取得(必要列のみ)
38 const usersRaw = await prisma.user.findMany({
39 where: { departmentId: me.departmentId, deletedAt: null },
40 orderBy: { createdAt: "desc" },
41 select: {
42 displayId: true,
43 name: true,
44 email: true,
45 isActive: true,
46 phone: true,
47 remarks: true,
48 createdAt: true,
49 updatedAt: true,
50 roleId: true,
51 departmentRoleId: true,
52 },
53 });
54
55 // ★ 実効ロールへ正規化
56 const users: UserRow[] = await Promise.all(
57 usersRaw.map(async (u) => {
58 // ★ ユニオンが確定するように分岐して渡す
59 const eff = u.departmentRoleId
60 ? await getEffectiveRole({
61 departmentId: me.departmentId,
62 departmentRoleId: u.departmentRoleId,
63 })
64 : u.roleId
65 ? await getEffectiveRole({
66 departmentId: me.departmentId,
67 roleId: u.roleId,
68 })
69 : null; // 両方 null は想定外だが安全側で null
70
71 return {
72 displayId: u.displayId,
73 name: u.name,
74 email: punycode.toUnicode(u.email),
75 roleCode: eff?.code ?? "",
76 roleName: eff?.name ?? "(不明)",
77 roleBadgeColor: eff?.badgeColor ?? null,
78 isActive: u.isActive,
79 phone: u.phone,
80 remarks: u.remarks,
81 createdAt: u.createdAt,
82 updatedAt: u.updatedAt,
83 };
84 }),
85 );
86
87 // 5) フィルタ用ロール選択肢(一覧に登場するロールだけ)
88 const roleOptions = Array.from(
89 new Map(
90 users.map((u) => [u.roleCode, { value: u.roleCode, label: u.roleName }]),
91 ).values(),
92 );
93
94 return (
95 <>
96 <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">
97 <div className="flex items-center gap-2 px-4">
98 <SidebarTrigger className="-ml-1" />
99 <Separator
100 orientation="vertical"
101 className="mr-2 data-[orientation=vertical]:h-4"
102 />
103 <Breadcrumb>
104 <BreadcrumbList>
105 <BreadcrumbItem className="hidden md:block">
106 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
107 </BreadcrumbItem>
108 <BreadcrumbSeparator className="hidden md:block" />
109 <BreadcrumbItem>
110 <BreadcrumbPage>ユーザ一覧</BreadcrumbPage>
111 </BreadcrumbItem>
112 </BreadcrumbList>
113 </Breadcrumb>
114 </div>
115 </header>
116
117 <div className="w-full max-w-[1729px] p-4 pt-0">
118 <DataTable
119 columns={columns}
120 data={users}
121 roleOptions={roleOptions}
122 canDownloadData={viewer.canDownloadData}
123 canEditData={viewer.canEditData}
124 />
125 </div>
126 </>
127 );
128}
getEffectiveRole()
を呼び出して、roleId
/departmentRoleId
どちらのケースでも一貫したEffectiveRole
を返すようにしています。roleBadgeColor
はEffectiveRole
側で解決された色をそのまま使用できるため、UI 側の条件分岐を削減できます。
型定義とDataTableの受け渡し
UserRow
型にも、EffectiveRole
情報を格納されるように変更となりますが、ソースコード自体は変更不要です。tsx
1// src/app/(protected)/users/columns.tsx(変更不要)
2export type UserRow = {
3 displayId: string;
4 name: string;
5 email: string;
6 /** 実効ロール */
7 roleCode: string;
8 roleName: string;
9 roleBadgeColor: string | null;
10 isActive: boolean;
11 phone: string | null;
12 remarks: string | null;
13 createdAt: Date;
14 updatedAt: Date;
15};
既存の構造を維持しつつ、
UI 層の改修はほぼ不要 になります。
roleCode
と roleName
を EffectiveRole 由来の値に置き換えるだけで、UI 層の改修はほぼ不要 になります。
実効ロールを表示・フィルタに統合
columns.tsx
のロール列はそのまま利用できます。内部では
Badge
として roleBadgeColor
を反映し、roleName
をラベルに表示します。また、DataTable 側では
roleOptions
に実効ロール一覧を渡すことで、 override/custom されたロールも自動的にフィルタ対象になります。txt
1### フィルタ動作のイメージ
2
3Role: "EDITOR"(共通)
4DepartmentRole: "部内編集者"(override)
5---------------------------------------
6ユーザA → Role:EDITOR → 表示名: 編集者
7ユーザB → DepartmentRole:部内編集者 → 表示名: 部内編集者
8---------------------------------------
9フィルタ「編集者」選択時 → A,Bともに表示
このように、ロールの命名や構造が異なっても「EffectiveRole」によって統一的に扱えるようになります。
CSV出力の修正
CSV出力部分でも、
roleName
が実効ロールの表示名になっているため、 既存の出力ロジックをそのまま流用できます。まとめ
項目 | 内容 |
---|---|
ロール統合 | Role と DepartmentRole を getEffectiveRole() で一元化 |
表示 | 実効ロール名・色を UserRow に保持 |
フィルタ | 実効ロール一覧を動的生成して利用 |
CSV | roleName を直接出力(EffectiveRole対応済み) |
メリット | 部署別ロールの差異を UI 側で意識せずに済む |
一覧ページは UI 変更最小限で DepartmentRole 対応を完了できました。
次章では、プロフィール画面 における実効ロールの統一表示へと進みます。
次章では、プロフィール画面 における実効ロールの統一表示へと進みます。
6. まとめと次回予告
今回の記事では、ユーザ管理の全体リファクタリングとして、
これにより、部署単位でのロール上書き(override)や独自追加(custom)を自然に扱えるようになり、UI・サーバ・DB の整合が大幅に改善されました。
Role
/DepartmentRole
両テーブルを横断的に扱う「実効ロール」モデルを導入しました。これにより、部署単位でのロール上書き(override)や独自追加(custom)を自然に扱えるようになり、UI・サーバ・DB の整合が大幅に改善されました。
今回の成果まとめ
区分 | 対応内容 | 主なポイント |
---|---|---|
ロール統合 | getEffectiveRole() を共通化 | Role / DepartmentRole の差異を吸収し、UI から透過的に利用可能に |
セレクト候補 | getAssignableRolesAction() を導入 | override を除外し、priority 昇順で統一配列を生成 |
新規登録 | createUserAction() の XOR 対応 | "role:<id>" または "dr:<id>" で保存対象を自動判定 |
更新処理 | updateUserAction() の XOR 対応 | 編集フォームも単一セレクトに統一し、ロール割当の整合を保持 |
一覧表示 | 実効ロールを反映 | バッジ表示・フィルタともに DepartmentRole 対応済み |
これらの統合により、「部署単位で柔軟な権限管理を行いながら、共通UIで統一的に操作できる」という理想的な状態が実現しました。
次回予告
次回は、同じ「部署別ロール対応」の後編として プロフィール管理の改修 に進みます。
既存のプロフィール画面では、氏名とアバター画像の変更に対応済みですが、
次回では以下の項目を中心に、ユーザ管理との整合を取る形で改修を行います。
次回では以下の項目を中心に、ユーザ管理との整合を取る形で改修を行います。
項目 | 改修内容 | 補足 |
---|---|---|
ロール表示 | Role/DepartmentRole に対応 | 実効ロール(EffectiveRole)として統一表示 |
電話番号 | 表示・編集フォームを追加 | DBカラム追加済みのため UI・Action のみ調整 |
メールアドレス変更導線 | 申請一覧ページと連携 | 一覧・フィルタ構成をユーザ管理と共通化 |
パスワード忘れ導線 | メール変更導線と同等構成 | 同一UI構造で統一 |
全体構成 | Server Actionの整理・再利用 | updateProfileAction などを共通仕様化 |
これにより、ユーザ本人が扱うプロフィール画面も
DepartmentRole 対応環境下での一貫した挙動 を実現します。
DepartmentRole 対応環境下での一貫した挙動 を実現します。
次回記事(予定タイトル)
【管理画面フォーマット開発編 #9 後編】部署別ロール対応 ─ プロフィール管理の改修
【管理画面フォーマット開発編 #9 後編】部署別ロール対応 ─ プロフィール管理の改修
部署ごとのロール管理をベースに、
本人向けUIにも共通ルールを適用していくフェーズに入ります。
本人向けUIにも共通ルールを適用していくフェーズに入ります。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装
部署ごとのロールを実際に操作できるように、Server Actionと管理画面UIを構築
2025/10/2公開
![[管理画面フォーマット開発編 #8 後編] 部署別ロール ─ 管理UIとServer Action実装のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計
グローバルで一貫したRoleテーブルを保ちながら、部署ごとにロールをカスタマイズするために「DepartmentRole」テーブルを新設
2025/9/29公開
![[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-db%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携する
ユーザ一覧表示・新規登録・編集フォームをDBと連動させ、ユーザデータを操作できる形へ
2025/9/28公開
![[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-users%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する
これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張
2025/9/23公開
![[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-rbac-guard%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #5] ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
2025/9/21公開
![[管理画面フォーマット開発編 #5] ユーザプロフィール更新のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero-thumbnail.jpg&w=1200&q=75)