DELOGs
[管理画面フォーマット開発編 #9 前編] 部署別ロール対応 ─ ユーザ管理の改修

管理画面フォーマット開発編 #9 前編
部署別ロール対応 ─ ユーザ管理の改修

DepartmentRole導入に伴い、ユーザ管理で「実効ロール」を参照するように修正

初回公開日

最終更新日

0. はじめに

前回の記事(#8 前編・後編)では、部署ごとにロールをカスタマイズできる DepartmentRole テーブル を導入し、管理UIとServer Actionを整備しました。
これにより「共通のRoleを基盤にしながら、部署単位で上書き・拡張できる仕組み」が完成しました。
今回はその続きとして、既存の ユーザ管理プロフィール表示 を DepartmentRole に対応させていきます。
従来は Role テーブルのみを直接参照していましたが、改修後は「Role と DepartmentRole を組み合わせた実効ロール」を扱うようにします。

本記事の改修対象

以下の3点を中心に修正を行います。
対象機能改修内容
ユーザ一覧実効ロールを表示・フィルタ対象にする
ユーザ登録・更新単一セレクトから Role/DepartmentRole を一元的に選択(priority昇順で表示)
プロフィール表示ロールラベルを実効ロールに統一

読み進める前に

本記事は「管理画面フォーマット開発編」の一部として進めています。
前回の記事までで DepartmentRole のDB設計とUI操作 が完成していることを前提としています。
まだ読んでいない方は、下記を先に参照してください。

1. 実効ロール取得ユーティリティの共通化

ここでは、ユーザに割り当てられた RoleDepartmentRole を横断して「実効ロール」を解決する仕組みを整えます。
これにより、ユーザ一覧・登録/更新フォーム・プロフィール表示など、複数の箇所で一貫したロール解決が可能になります。

実装のゴール

複数テーブルに分散しているロール情報を 統合的に参照できる関数 を用意します。
  • ユーザ一覧 → 一覧表の「ロール列」やフィルタで利用
  • ユーザ登録・更新 → セレクトボックス候補の生成に利用
  • プロフィール表示 → ロールラベルの統一に利用
この「共通化」によって、個別のUIでロール解決を都度書かずに済み、保守性が大幅に向上します。
txt
1### ロールの構造イメージ 2 3 ┌─────────────┐ 4 │ User │ 5 │─────────────│ 6 │ roleId? │───┐ 7 │ departmentRoleId? │ 8 └─────────────┘ │ 910 ┌─────────────┐ ┌───────────────────┐ 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.tssrc/lib/roles/effective.ts にまとめる方針です。

実装方針

  • ユーザ単位の解決
    getEffectiveRole(user) で、User に保存されている roleId / departmentRoleId を元に、統合済みの EffectiveRole を返す。
  • セレクト候補の生成
    getAssignableRoles(departmentId) で、その部署で利用可能な全ての RoleDepartmentRole を走査し、セレクトボックス用の配列を返す。
  • 共通の型定義を利用
    いずれも EffectiveRole 型に正規化するため、UI 側では「どのテーブル由来か」を意識せず処理できる。
これにより、ユーザ一覧・登録/更新・プロフィールといった異なる画面であっても、一貫して「実効ロール」を参照できます。

2. セレクトボックス候補の供給(getAssignableRolesAction)

次に、ユーザ登録・更新フォームで利用する ロールのセレクトボックス候補 を整えます。
これにより、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 側では valuelabel だけを利用すればよく、どのテーブル由来かを意識せずに済みます。

候補収集の流れ

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 として 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 コンポーネント内で呼び出し、roleOptionsUserForm へ渡す。
  • ユーザ更新ページ (#4章)
    初期値のロール解決時も含め、同様に getAssignableRolesAction() を呼ぶ。
これで、候補生成は一元化され、UI側は valuelabel だけを意識すればよい 形になりました。
次章では、この候補を実際に利用しながら ユーザ登録の保存処理(XOR保存) を実装していきます。

3. ユーザ登録のXOR保存(createUserAction)

この章では「ユーザ新規登録」を DepartmentRole 対応 に改修します。
単一セレクトで "role:<uuid>" | "dr:<uuid>" を選ばせ、その値を XOR 保存roleIddepartmentRoleId のどちらか一方)します。

変更ポイントの整理

観点変更内容根拠/目的
候補取得2章の getAssignableRolesAction() を SSR で呼ぶDepartmentRole を含む候補を統一供給
フォームZod の roleCodeXOR 文字列"role:..."/"dr:...")として検証・バインド余計なダミーフィールドを排除し、単一セレクトに直結
サーバroleCode をパースして XOR を解決 → roleIddepartmentRoleId を保存DB の XOR 制約と整合
PrismaUser.roleIdnullable に変更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.tsxSSR2章の getAssignableRolesAction() を呼び出し、候補を Client に渡す(DB直叩きは廃止)
src/app/(protected)/users/new/client.tsxClientフォームから selectedRole を受け取り、createUser に一緒に渡す。トーストのロール表示も label ベースに
src/components/users/user-form.tsxFormロール項目を 単一セレクト に差し替え(AssignableRoleOption[] をそのまま描画)。Zod スキーマはそのまま(roleCode は使わない)
src/app/_actions/users/create-user.tsAction受け取った selectedRole"role:" / "dr:" でパースし XOR 保存roleCode→roleId の解決は撤去
以降は、実際に組み込んだソース(抜粋)と要点解説です。

Prismaスキーマの変更

roleIddepartmentRoleIdのどちら一方に値があればよくなりますので、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)

サーバアクション createUser は、values.roleCode(XOR 文字列)をパースして roleIddepartmentRoleId へ割当 ます。
互換性のため、第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 制約を入れておくと二重安全(roleIddepartmentRoleId の同時指定禁止)。
  • 存在/有効チェックRole は存在のみ、DepartmentRole同一部署かつ isEnabled を確認。
  • メール送信:本番運用では失敗を致命にせずログのみ。テスト環境ではコメントアウトでも可。

チェックリスト(登録画面の期待動作)

ケース入力期待動作
A"role:<id>" を選択roleId=<id>, departmentRoleId=null で作成
B"dr:<id>"(有効)を選択departmentRoleId=<id>, roleId=null で作成
C"dr:<id>"(無効)を選択UI で選べない(disabled
DXOR 未選択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 / departmentRoleIdXOR 文字列に正規化("role:<id>" or "dr:<id>"UIの roleCode に直バインドできる形へ
候補2章の getAssignableRolesAction() を SSR で呼ぶoverride のある Role は除外済みで重複表示なし
UIフォームのロール欄を RoleFieldXor に統一FormField name="roleCode" へ直結
サーバupdateUserXOR を解決して更新既存と同様に認可・重複メールチェックを実施
互換割当済みのDRが 現在は無効 な場合でも、初期値表示のため options に一時的に 補完ただし disabled 表示にして再選択不可
新規同様、src/lib/users/schema.tsroleCode が 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 です。
roleId / departmentRoleIdroleCode(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 されている RoleRole 候補からは除外(2章の action が除去)代わりに override 側の DepartmentRole を選択可能
両方未設定(XOR破綻)初期値空(エラー文言を表示)更新不可(バリデーションで弾く)
他部署の DR を改竄選択できないサーバで部署不一致を検出しエラー
メール重複クライアントで可能な限り補助最終的な重複チェックはサーバで実施
これらの扱いにより、UI/サーバ/DB ですべて整合した XOR モデル を安全に保てます。

まとめ

  • Edit も Create と同じ UI/型(roleCode = XOR 文字列)に統一
  • SSR で 候補と初期値 を準備し、無効化 DR でも見失わないよう 補完
  • サーバは updateUserXOR を厳密に解決し保存、部署域と有効性もチェック。
この統一により、新規・編集・プロフィール を通して「実効ロール」の整合が取れ、保守性と安全性が上がりました。次章では ユーザ一覧の実効ロール表示/フィルタ を仕上げます。

5. ユーザ一覧の実効ロール対応

本章では、ユーザ一覧ページ /users を「DepartmentRole 対応」へ改修します。
従来は User.role を直接参照してロール情報を取得していましたが、DepartmentRole の導入により 実効ロール(EffectiveRole) を表示・フィルタに反映する必要があります。
これにより、部署単位での override や custom role も一覧上で正しく扱えるようになります。
txt
1### 変更後の全体像 2 3 ┌────────────┐ 4 │ User │ 5 │────────────│ 6 │ roleId? │───┐ 7 │ departmentRoleId? │ 8 └────────────┘ │ 910 ┌─────────────┐ ┌───────────────────┐ 11 │ Role │ │ DepartmentRole │ 12 │─────────────│ │───────────────────│ 13 │ code,name │ │ code,nameOverride │ 14 │ priority │ │ isEnabled,custom? │ 15 └─────────────┘ └───────────────────┘ 1617 ┌───────────────────────┐ 18 │ EffectiveRole │ 19 │───────────────────────│ 20 │ code / name / priority│ 21 │ badgeColor / source │ 22 └───────────────────────┘ 2324 DataTableで表示・フィルタ

改修の目的

観点変更前変更後
ロール取得User.role のみ参照User.role / User.departmentRole を統合
表示列Role.name, Role.codeEffectiveRole.name, EffectiveRole.code
フィルタRole.code のみ実効ロール単位でのフィルタ
CSV出力Role.nameEffectiveRole.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 を返すようにしています。
  • roleBadgeColorEffectiveRole 側で解決された色をそのまま使用できるため、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};
既存の構造を維持しつつ、roleCoderoleName を 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 が実効ロールの表示名になっているため、 既存の出力ロジックをそのまま流用できます。

まとめ

項目内容
ロール統合RoleDepartmentRolegetEffectiveRole() で一元化
表示実効ロール名・色を UserRow に保持
フィルタ実効ロール一覧を動的生成して利用
CSVroleName を直接出力(EffectiveRole対応済み)
メリット部署別ロールの差異を UI 側で意識せずに済む
一覧ページは UI 変更最小限で DepartmentRole 対応を完了できました。
次章では、プロフィール画面 における実効ロールの統一表示へと進みます。

6. まとめと次回予告

今回の記事では、ユーザ管理の全体リファクタリングとして、RoleDepartmentRole 両テーブルを横断的に扱う「実効ロール」モデルを導入しました。
これにより、部署単位でのロール上書き(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 対応環境下での一貫した挙動 を実現します。

次回記事(予定タイトル)
【管理画面フォーマット開発編 #9 後編】部署別ロール対応 ─ プロフィール管理の改修
部署ごとのロール管理をベースに、
本人向けUIにも共通ルールを適用していくフェーズに入ります。
この記事の執筆・編集担当
DE

松本 孝太郎

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

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