テーマ
Design: 管理画面
要件は requirements.md、ビジュアルは ui-design.md 参照。本ドキュメントは管理画面の 技術設計 を定義する。
アーキテクチャ概要
[運営者]
↓
[/admin/login] → 認証 (NextAuth Credentials Provider または別系統)
↓ ログイン成功 → セッションCookie
[/admin/candidates] → 候補者一覧 (Server Component)
↓
[/admin/candidates/new] → 新規登録フォーム (Client Component)
[/admin/candidates/[id]/edit] → 編集フォーム (Client Component)
↓ Server Action 呼び出し
[Server Action]
├ Prisma で DB操作
├ 画像BLOBの登録・更新
└ revalidateTag でキャッシュ無効化ディレクトリ構成
app/
├ admin/
│ ├ layout.tsx # 管理者認証チェック、管理画面共通レイアウト
│ ├ login/
│ │ └ page.tsx # 管理者ログイン画面
│ ├ candidates/
│ │ ├ page.tsx # 候補者一覧 (Server Component)
│ │ ├ new/
│ │ │ └ page.tsx # 新規登録フォーム (Client Component)
│ │ └ [id]/
│ │ └ edit/
│ │ └ page.tsx # 編集フォーム (Client Component)
│ └ _actions/
│ ├ candidates.ts # Candidate関連の Server Actions
│ └ images.ts # CandidateImage関連の Server Actions
├ api/
│ └ images/
│ └ [id]/
│ └ route.ts # 画像配信エンドポイント (公開)
features/
└ admin/
├ hooks/
│ └ useAdminAuth.ts # 管理者認証チェック用フック
├ services/
│ └ adminCandidateService.ts # 管理画面向けクエリ
└ types/
└ index.ts
components/
└ admin/
├ CandidateList.tsx # 候補者一覧テーブル
├ CandidateForm.tsx # 共通の編集フォーム
├ ImageUploader.tsx # 画像アップロード(複数対応)
└ AdminNavbar.tsx # 管理画面ナビ
scripts/
└ seed-candidates.ts # 初期一括投入スクリプト認証設計
採用方針: NextAuth.js の Credentials Provider
一般ユーザー向けの Facebook / Instagram 認証とは別系統で、管理者向けにメールアドレス + パスワードによる認証を提供する。
ts
// auth.ts (NextAuth設定)
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Facebook from 'next-auth/providers/facebook';
import Instagram from 'next-auth/providers/instagram';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Facebook({ /* ユーザー向け */ }),
Instagram({ /* ユーザー向け */ }),
Credentials({
name: 'admin',
credentials: {
email: { label: 'メールアドレス', type: 'email' },
password: { label: 'パスワード', type: 'password' },
},
async authorize(credentials) {
const admin = await prisma.adminUser.findUnique({
where: { email: credentials.email as string },
});
if (!admin) return null;
const valid = await bcrypt.compare(credentials.password as string, admin.passwordHash);
if (!valid) return null;
return { id: admin.id, email: admin.email, role: 'admin' };
},
}),
],
callbacks: {
async session({ session, token }) {
session.user.role = token.role as string | undefined;
return session;
},
async jwt({ token, user }) {
if (user) token.role = (user as any).role;
return token;
},
},
});管理者認証用テーブル(AdminUser)
データモデルに以下のテーブルを追加(data-model/design.md の Prisma スキーマに追記する想定):
prisma
model AdminUser {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.Text
passwordHash String @db.Text
name String @db.Text
createdAt DateTime @default(now()) @db.Timestamptz()
lastLoginAt DateTime? @db.Timestamptz()
@@map("admin_users")
}管理者の追加は Seedスクリプトで行う(管理画面からは追加しない、運用上の前提)。
ts
// prisma/seed.ts に追記
import bcrypt from 'bcrypt';
const passwordHash = await bcrypt.hash(process.env.ADMIN_INITIAL_PASSWORD!, 10);
await prisma.adminUser.create({
data: {
email: process.env.ADMIN_INITIAL_EMAIL!,
passwordHash,
name: '運営担当',
},
});認証チェック
app/admin/layout.tsx で全管理画面に認証チェックを適用:
tsx
// app/admin/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user || session.user.role !== 'admin') {
redirect('/admin/login');
}
return <AdminLayoutShell>{children}</AdminLayoutShell>;
}/admin/login だけは認証不要なので、別ルートグループに置くか、layout内で例外処理する。
Server Actions 設計
候補者の作成
ts
// app/admin/_actions/candidates.ts
'use server';
import { z } from 'zod';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { revalidateTag } from 'next/cache';
const CandidateInput = z.object({
name: z.string().min(1),
region: z.string(),
age: z.number().int().min(0),
height: z.number().int().min(0),
occupation: z.string(),
hobbies: z.array(z.string()),
specialties: z.array(z.string()),
motto: z.string(),
dream: z.string(),
message: z.string(),
displayOrder: z.number().int(),
});
async function ensureAdmin() {
const session = await auth();
if (!session?.user || session.user.role !== 'admin') {
throw new Error('Unauthorized');
}
}
export async function createCandidate(input: z.infer<typeof CandidateInput>) {
await ensureAdmin();
const data = CandidateInput.parse(input);
const candidate = await prisma.candidate.create({ data });
revalidateTag('candidates');
return candidate;
}
export async function updateCandidate(id: string, input: z.infer<typeof CandidateInput>) {
await ensureAdmin();
const data = CandidateInput.parse(input);
const candidate = await prisma.candidate.update({ where: { id }, data });
revalidateTag('candidates');
return candidate;
}
export async function deleteCandidate(id: string) {
await ensureAdmin();
// 投票が記録されているか確認
const voteCount = await prisma.vote.count({ where: { candidateId: id } });
if (voteCount > 0) {
throw new Error('投票が記録されている候補者は削除できません');
}
await prisma.candidate.delete({ where: { id } }); // 画像も連鎖削除される
revalidateTag('candidates');
}画像のアップロード
ファイルアップロードは Server Action で FormData を受け取る方式が標準的。
ts
// app/admin/_actions/images.ts
'use server';
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function uploadCandidateImage(formData: FormData) {
await ensureAdmin();
const candidateId = formData.get('candidateId') as string;
const order = Number(formData.get('order'));
const file = formData.get('file') as File;
if (!ALLOWED_MIME.includes(file.type)) {
throw new Error('対応していないファイル形式です');
}
if (file.size > MAX_SIZE) {
throw new Error('ファイルサイズが大きすぎます (上限 5MB)');
}
const buffer = Buffer.from(await file.arrayBuffer());
const image = await prisma.candidateImage.create({
data: {
candidateId,
data: buffer,
mime: file.type,
order,
},
});
revalidateTag('candidates');
return image;
}
export async function replaceCandidateImage(imageId: string, formData: FormData) {
await ensureAdmin();
const file = formData.get('file') as File;
if (!ALLOWED_MIME.includes(file.type)) {
throw new Error('対応していないファイル形式です');
}
if (file.size > MAX_SIZE) {
throw new Error('ファイルサイズが大きすぎます (上限 5MB)');
}
const buffer = Buffer.from(await file.arrayBuffer());
const image = await prisma.candidateImage.update({
where: { id: imageId },
data: {
data: buffer,
mime: file.type,
// updatedAt が自動更新される → URLのキャッシュバストが効く
},
});
revalidateTag('candidates');
return image;
}
export async function deleteCandidateImage(imageId: string) {
await ensureAdmin();
await prisma.candidateImage.delete({ where: { id: imageId } });
revalidateTag('candidates');
}
export async function reorderCandidateImages(
candidateId: string,
orderedImageIds: string[],
) {
await ensureAdmin();
await prisma.$transaction(
orderedImageIds.map((imageId, order) =>
prisma.candidateImage.update({
where: { id: imageId },
data: { order },
})
)
);
revalidateTag('candidates');
}画像配信エンドポイント
data-model/design.md で定義済み。再掲:
ts
// app/api/images/[id]/route.ts
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const image = await prisma.candidateImage.findUnique({
where: { id: params.id },
select: { data: true, mime: true, updatedAt: true },
});
if (!image) {
return new Response('Not Found', { status: 404 });
}
return new Response(image.data, {
headers: {
'Content-Type': image.mime,
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${params.id}-${image.updatedAt.getTime()}"`,
},
});
}このエンドポイントは認証不要。画像IDは UUID で推測困難なため、URLが流出しない限り無断アクセスは現実的に困難。
クライアントコンポーネント
CandidateForm(共通フォーム)
新規登録・編集の両方で使用する。
tsx
// components/admin/CandidateForm.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createCandidate, updateCandidate } from '@/app/admin/_actions/candidates';
interface Props {
initial?: CandidateInput & { id?: string };
}
export function CandidateForm({ initial }: Props) {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
setIsPending(true);
setError(null);
try {
const input = parseFormData(formData);
if (initial?.id) {
await updateCandidate(initial.id, input);
} else {
await createCandidate(input);
}
router.push('/admin/candidates');
} catch (e: any) {
setError(e.message);
setIsPending(false);
}
}
return (
<form action={handleSubmit}>
{/* 各フィールド */}
</form>
);
}ImageUploader(画像アップロード)
複数画像のアップロード、並び替え、削除を扱う。
tsx
'use client';
import { useState } from 'react';
import { uploadCandidateImage, replaceCandidateImage, deleteCandidateImage, reorderCandidateImages } from '@/app/admin/_actions/images';
interface Props {
candidateId: string;
initialImages: { id: string; order: number; updatedAt: Date }[];
}
export function ImageUploader({ candidateId, initialImages }: Props) {
const [images, setImages] = useState(initialImages);
// ドラッグ&ドロップによる並び替え (react-dnd or dnd-kit)
// ファイル選択 → アップロード処理
// 各画像の削除ボタン・差し替えボタン
// ...
}@dnd-kit/core などの並び替えライブラリを使うと実装が単純になる。
初期データ一括投入スクリプト
data-model/design.md で定義済み。scripts/seed-candidates.ts で CSV と画像ファイル群を読み込んで投入する。
実行方法:
bash
pnpm seed:candidates既存候補者がある場合は警告を出して中断、--force フラグで強制実行する設計を推奨。
エラーハンドリング
サーバー側
ensureAdmin()で未認証なら例外を投げる- 入力バリデーション失敗で
ZodError - 画像形式・サイズエラー
- DB制約違反(投票がある候補者の削除)
クライアント側
- Server Action の例外を
try/catchで捕捉 - エラーメッセージをフォーム上部に表示
- Toast(
sonner)でも併用通知
キャッシュ戦略
steering/tech.md の「キャッシュポリシー(全画面共通)」に従う。
| 操作 | 無効化対象 |
|---|---|
| 候補者の作成・編集・削除 | revalidateTag('candidates') |
| 画像の追加・差し替え・削除 | revalidateTag('candidates') |
| 画像の並び替え | revalidateTag('candidates') |
| 画像本体のキャッシュ | URLパラメータ ?v={updatedAt} で自動的にバスト |
セキュリティ考慮事項
steering/tech.md の「セキュリティポリシー(全画面共通)」を遵守。本spec固有の追加事項:
- 管理者ログインのレート制限: ログイン失敗が連続した場合は一時的にロック
- CSRF対策: NextAuth.js のセッション管理で標準対応
- アップロードファイルの検証: MIME型・サイズ・実際のファイル内容(マジックナンバー)で多重検証
- 管理者操作の監査ログ: 重要操作(候補者の作成・削除、画像の差し替え)はログに記録
- 管理者パスワード: bcrypt でハッシュ化して保存(
bcryptライブラリ使用)
テスト戦略
| 対象 | テスト |
|---|---|
| 認証チェック | 未認証で /admin/* にアクセス → リダイレクト確認 |
| Server Actions | Vitest で各アクションを単体テスト |
| 画像アップロード | 不正MIME・サイズ超過の拒否を確認 |
| 削除制約 | 投票ありの候補者削除拒否 |
| 一括投入スクリプト | テスト用CSV+画像での投入確認 |
| 並び替え | 順序変更後の order 値の確認 |
関連ドキュメント
requirements.md— 機能要件ui-design.md— ビジュアル仕様../data-model/design.md— Prismaスキーマ、画像配信エンドポイント../../steering/tech.md— 技術スタック、キャッシュポリシー、セキュリティポリシー