Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions __tests__/access-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
type Viewer,
type Visibility,
canTouchFileSet,
canUploadToCategory,
canViewCategory,
categoryWhereFor,
fileSetWhereFor,
} from '@/lib/access-rules';
import { describe, expect, it } from 'vitest';

const VISIBILITIES: Visibility[] = ['private', 'internal', 'public'];

const admin: NonNullable<Viewer> = { id: 1, role: 'admin' };
const member: NonNullable<Viewer> = { id: 2, role: 'member' };
const otherMember: NonNullable<Viewer> = { id: 3, role: 'member' };
const anon: Viewer = null;

describe('categoryWhereFor(相册列表的服务端过滤条件)', () => {
it('匿名只能看到 public', () => {
expect(categoryWhereFor(anon)).toEqual({ visibility: 'public' });
});

it('管理员无过滤', () => {
expect(categoryWhereFor(admin)).toEqual({});
});

it('成员看到 internal 与 public,永远不含 private', () => {
const where = categoryWhereFor(member) as { visibility: { in: Visibility[] } };
expect(where.visibility.in).toEqual(['internal', 'public']);
expect(where.visibility.in).not.toContain('private');
});
});

describe('canViewCategory 裁决矩阵', () => {
const expected: Record<string, Record<Visibility, string>> = {
admin: { private: 'allow', internal: 'allow', public: 'allow' },
member: { private: 'not-found', internal: 'allow', public: 'allow' },
anon: { private: 'not-found', internal: 'login', public: 'allow' },
};

for (const [who, verdicts] of Object.entries(expected)) {
const viewer: Viewer = who === 'admin' ? admin : who === 'member' ? member : anon;
for (const visibility of VISIBILITIES) {
it(`${who} 访问 ${visibility} 相册 ⇒ ${verdicts[visibility]}`, () => {
expect(canViewCategory(viewer, { visibility })).toBe(verdicts[visibility]);
});
}
}

it('同一函数同时给出页面与 API 所需的两种信息:私有对被拒读者是 not-found 而非 login', () => {
expect(canViewCategory(anon, { visibility: 'private' })).toBe('not-found');
expect(canViewCategory(anon, { visibility: 'internal' })).toBe('login');
});
});

describe('canUploadToCategory', () => {
it('匿名不可上传任何相册', () => {
for (const visibility of VISIBILITIES) {
expect(canUploadToCategory(anon, { visibility })).toBe(false);
}
});

it('成员只能上传到 internal/public,私有相册仅管理员', () => {
expect(canUploadToCategory(member, { visibility: 'private' })).toBe(false);
expect(canUploadToCategory(member, { visibility: 'internal' })).toBe(true);
expect(canUploadToCategory(member, { visibility: 'public' })).toBe(true);
});

it('管理员可上传到任意相册', () => {
for (const visibility of VISIBILITIES) {
expect(canUploadToCategory(admin, { visibility })).toBe(true);
}
});
});

describe('fileSetWhereFor(文件集是 owner-relative,与相册不同)', () => {
it('管理员无过滤', () => {
expect(fileSetWhereFor(admin)).toEqual({});
});

it('成员的条件包含自己创建的集合,即使它是 private', () => {
expect(fileSetWhereFor(member)).toEqual({
OR: [{ visibility: 'internal' }, { visibility: 'public' }, { createdBy: 2 }],
});
});
});

describe('canTouchFileSet(读取与上传共用同一谓词)', () => {
const privateOwnedByMember = { visibility: 'private' as const, createdBy: member.id };
const privateOwnedByOther = { visibility: 'private' as const, createdBy: otherMember.id };

it('成员可以访问自己的私有文件集', () => {
expect(canTouchFileSet(member, privateOwnedByMember)).toBe(true);
});

it('成员不能访问他人的私有文件集', () => {
expect(canTouchFileSet(otherMember, privateOwnedByMember)).toBe(false);
expect(canTouchFileSet(member, privateOwnedByOther)).toBe(false);
});

it('internal/public 对任意登录成员开放', () => {
for (const visibility of ['internal', 'public'] as Visibility[]) {
expect(canTouchFileSet(member, { visibility, createdBy: otherMember.id })).toBe(true);
expect(canTouchFileSet(otherMember, { visibility, createdBy: member.id })).toBe(true);
}
});

it('管理员可访问任意文件集', () => {
for (const visibility of VISIBILITIES) {
expect(canTouchFileSet(admin, { visibility, createdBy: otherMember.id })).toBe(true);
}
});

it('谓词与列表过滤对"自己的私有集"结论一致', () => {
const where = fileSetWhereFor(member) as { OR: Array<Record<string, unknown>> };
expect(where.OR).toContainEqual({ createdBy: member.id });
expect(canTouchFileSet(member, privateOwnedByMember)).toBe(true);
});
});
194 changes: 194 additions & 0 deletions app/admin/_tab-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { AdminFileSets } from '@/components/admin-filesets';
import { AdminCategoriesTab } from '@/components/admin/admin-categories-tab';
import { AdminShareTab } from '@/components/admin/admin-share-tab';
import { AdminUsersTab } from '@/components/admin/admin-users-tab';
import type {
AdminTab,
CategoryItem,
FileSetItem,
ShareLinkItem,
UserItem,
} from '@/components/admin/types';
import { prisma } from '@/lib/db';
import { type SearchParams, clampPage, readInt, readString } from '@/lib/params';

const USER_PAGE_SIZES = [10, 20, 50] as const;
const USER_PAGE_DEFAULT = 20;
const SHARE_PAGE_SIZE = 20;

type CategoryRow = {
id: number;
name: string;
description: string | null;
createdAt: Date;
visibility: 'private' | 'internal' | 'public';
_count: { photos: number };
};

type UserRow = {
id: number;
username: string;
role: 'admin' | 'member';
status: 'pending' | 'active' | 'rejected';
createdAt: Date;
_count: { photos: number };
};

type ShareLinkRow = {
id: number;
categoryId: number;
token: string;
expiresAt: Date | null;
createdAt: Date;
category: { name: string };
};

type FileSetRow = {
id: number;
name: string;
description: string | null;
visibility: 'private' | 'internal' | 'public';
createdAt: Date;
_count: { files: number };
};

function toUserItem(row: UserRow): UserItem {
return {
id: row.id,
username: row.username,
role: row.role,
status: row.status,
photoCount: row._count.photos,
createdAt: row.createdAt.toISOString(),
};
}

/**
* 只查询当前 tab 需要的数据。切换 tab 因此是一次往返,
* 换来的是文档负载从"四张无界表"降到"一张表的一页"。
*/
export async function AdminTabContent({
tab,
params,
shareBaseUrl,
}: {
tab: AdminTab;
params: SearchParams;
shareBaseUrl: string;
}) {
if (tab === 'categories') {
const rows = (await prisma.category.findMany({
orderBy: { createdAt: 'desc' },
include: { _count: { select: { photos: true } } },
})) as CategoryRow[];

const categories: CategoryItem[] = rows.map(row => ({
id: row.id,
name: row.name,
description: row.description,
photoCount: row._count.photos,
createdAt: row.createdAt.toISOString(),
visibility: row.visibility,
}));

return <AdminCategoriesTab categories={categories} />;
}

if (tab === 'filesets') {
const rows = (await prisma.fileSet.findMany({
orderBy: { createdAt: 'desc' },
include: { _count: { select: { files: true } } },
})) as FileSetRow[];

const fileSets: FileSetItem[] = rows.map(row => ({
id: row.id,
name: row.name,
description: row.description,
visibility: row.visibility,
fileCount: row._count.files,
createdAt: row.createdAt.toISOString(),
}));

return <AdminFileSets filesets={fileSets} />;
}

if (tab === 'users') {
const query = readString(params, 'q').trim();
const rawRole = readString(params, 'role');
const role = rawRole === 'admin' || rawRole === 'member' ? rawRole : null;
const requestedPageSize = readInt(params, 'pageSize');
const pageSize =
requestedPageSize && (USER_PAGE_SIZES as readonly number[]).includes(requestedPageSize)
? requestedPageSize
: USER_PAGE_DEFAULT;

const where = {
status: 'active' as const,
...(role ? { role } : {}),
...(query ? { username: { contains: query } } : {}),
};

const total = (await prisma.user.count({ where })) as number;
const page = clampPage(readInt(params, 'p'), total, pageSize);

const [activeRows, pendingRows] = (await Promise.all([
prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { _count: { select: { photos: true } } },
}),
prisma.user.findMany({
where: { status: 'pending' },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { photos: true } } },
}),
])) as [UserRow[], UserRow[]];

return (
<AdminUsersTab
users={activeRows.map(toUserItem)}
pendingUsers={pendingRows.map(toUserItem)}
total={total}
page={page}
pageSize={pageSize}
query={query}
role={role ?? ''}
/>
);
}

const total = (await prisma.shareLink.count()) as number;
const page = clampPage(readInt(params, 'p'), total, SHARE_PAGE_SIZE);

const [rows, categoryRows] = (await Promise.all([
prisma.shareLink.findMany({
orderBy: { createdAt: 'desc' },
skip: (page - 1) * SHARE_PAGE_SIZE,
take: SHARE_PAGE_SIZE,
include: { category: { select: { name: true } } },
}),
prisma.category.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' } }),
])) as [ShareLinkRow[], Array<{ id: number; name: string }>];

const shareLinks: ShareLinkItem[] = rows.map(row => ({
id: row.id,
token: row.token,
categoryId: row.categoryId,
categoryName: row.category.name,
expiresAt: row.expiresAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}));

return (
<AdminShareTab
shareLinks={shareLinks}
categories={categoryRows}
shareBaseUrl={shareBaseUrl}
total={total}
page={page}
pageSize={SHARE_PAGE_SIZE}
/>
);
}
20 changes: 20 additions & 0 deletions app/admin/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { ErrorState } from '@/components/error-state';

export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<ErrorState
title="控制台加载失败"
description="无法读取管理数据,请重试。"
detail={process.env.NODE_ENV === 'production' ? error?.digest : error?.message}
reset={reset}
/>
);
}
Loading
Loading