This commit is contained in:
2026-02-06 03:28:53 +08:00
parent 2537b9fe75
commit 8ed9b011f4
7 changed files with 141 additions and 29 deletions

View File

@@ -44,7 +44,15 @@
"language2": "Locale 2", "language2": "Locale 2",
"enterLanguageName": "Please enter language name", "enterLanguageName": "Please enter language name",
"edit": "Edit", "edit": "Edit",
"delete": "Delete" "delete": "Delete",
"permissionDenied": "You do not have permission to perform this action",
"error": {
"update": "You do not have permission to update this item.",
"delete": "You do not have permission to delete this item.",
"add": "You do not have permission to add items to this folder.",
"rename": "You do not have permission to rename this folder.",
"deleteFolder": "You do not have permission to delete this folder."
}
}, },
"home": { "home": {
"title": "Learn Languages", "title": "Learn Languages",

View File

@@ -44,7 +44,15 @@
"language2": "语言2", "language2": "语言2",
"enterLanguageName": "请输入语言名称", "enterLanguageName": "请输入语言名称",
"edit": "编辑", "edit": "编辑",
"delete": "删除" "delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"error": {
"update": "您没有权限更新此项目",
"delete": "您没有权限删除此项目",
"add": "您没有权限向此文件夹添加项目",
"rename": "您没有权限重命名此文件夹",
"deleteFolder": "您没有权限删除此文件夹"
}
}, },
"home": { "home": {
"title": "学语言", "title": "学语言",

View File

@@ -15,7 +15,7 @@ import { TSharedPair } from "@/shared/folder-type";
import { toast } from "sonner"; import { toast } from "sonner";
export function InFolder({ folderId }: { folderId: number; }) { export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]); const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false); const [openAddModal, setAddModal] = useState(false);
@@ -81,12 +81,14 @@ export function InFolder({ folderId }: { folderId: number; }) {
> >
{t("memorize")} {t("memorize")}
</GreenButton> </GreenButton>
<IconButton {!isReadOnly && (
onClick={() => { <IconButton
setAddModal(true); onClick={() => {
}} setAddModal(true);
icon={<Plus size={18} className="text-gray-700" />} }}
/> icon={<Plus size={18} className="text-gray-700" />}
/>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -113,6 +115,7 @@ export function InFolder({ folderId }: { folderId: number; }) {
<TextPairCard <TextPairCard
key={textPair.id} key={textPair.id}
textPair={textPair} textPair={textPair}
isReadOnly={isReadOnly}
onDel={() => { onDel={() => {
actionDeletePairById(textPair.id) actionDeletePairById(textPair.id)
.then(result => { .then(result => {

View File

@@ -9,12 +9,14 @@ import { toast } from "sonner";
interface TextPairCardProps { interface TextPairCardProps {
textPair: TSharedPair; textPair: TSharedPair;
isReadOnly: boolean;
onDel: () => void; onDel: () => void;
refreshTextPairs: () => void; refreshTextPairs: () => void;
} }
export function TextPairCard({ export function TextPairCard({
textPair, textPair,
isReadOnly,
onDel, onDel,
refreshTextPairs, refreshTextPairs,
}: TextPairCardProps) { }: TextPairCardProps) {
@@ -35,20 +37,24 @@ export function TextPairCard({
</div> </div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<button {!isReadOnly && (
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors" <>
onClick={() => setOpenUpdateModal(true)} <button
title={t("edit")} className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
> onClick={() => setOpenUpdateModal(true)}
<Edit size={14} /> title={t("edit")}
</button> >
<button <Edit size={14} />
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors" </button>
onClick={onDel} <button
title={t("delete")} className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
> onClick={onDel}
<Trash2 size={14} /> title={t("delete")}
</button> >
<Trash2 size={14} />
</button>
</>
)}
</div> </div>
</div> </div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4"> <div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">

View File

@@ -4,6 +4,7 @@ import { InFolder } from "./InFolder";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton"; import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
}: { }: {
@@ -16,9 +17,11 @@ export default async function FoldersPage({
if (!folder_id) { if (!folder_id) {
redirect("/folders"); redirect("/folders");
} }
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await actionGetUserIdByFolderId(Number(folder_id))).data !== session.user.id) { // Allow non-authenticated users to view folders (read-only mode)
return <p>{t("unauthorized")}</p>; const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
} const isOwner = session?.user?.id === folderUserId;
return <InFolder folderId={Number(folder_id)} />; const isReadOnly = !isOwner;
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
} }

View File

@@ -1,12 +1,39 @@
"use server"; "use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto"; import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository"; import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository";
import { validate } from "@/utils/validate"; import { validate } from "@/utils/validate";
import z from "zod"; import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant"; import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
/**
* Helper function to check if the current user is the owner of a folder
*/
async function checkFolderOwnership(folderId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
/**
* Helper function to check if the current user is the owner of a pair's folder
*/
async function checkPairOwnership(pairId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderId = await repoGetFolderIdByPairId(pairId);
if (!folderId) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
export async function actionGetPairsByFolderId(folderId: number) { export async function actionGetPairsByFolderId(folderId: number) {
try { try {
return { return {
@@ -25,6 +52,15 @@ export async function actionGetPairsByFolderId(folderId: number) {
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) { export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try { try {
// Check ownership
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to update this item.',
};
}
const validatedDto = validateActionInputUpdatePairById(dto); const validatedDto = validateActionInputUpdatePairById(dto);
await repoUpdatePairById(id, validatedDto); await repoUpdatePairById(id, validatedDto);
return { return {
@@ -58,6 +94,15 @@ export async function actionGetUserIdByFolderId(folderId: number) {
export async function actionDeleteFolderById(folderId: number) { export async function actionDeleteFolderById(folderId: number) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this folder.',
};
}
await repoDeleteFolderById(folderId); await repoDeleteFolderById(folderId);
return { return {
success: true, success: true,
@@ -74,6 +119,15 @@ export async function actionDeleteFolderById(folderId: number) {
export async function actionDeletePairById(id: number) { export async function actionDeletePairById(id: number) {
try { try {
// Check ownership
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this item.',
};
}
await repoDeletePairById(id); await repoDeletePairById(id);
return { return {
success: true, success: true,
@@ -122,6 +176,15 @@ export async function actionGetFoldersByUserId(userId: string) {
export async function actionCreatePair(dto: ActionInputCreatePair) { export async function actionCreatePair(dto: ActionInputCreatePair) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(dto.folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to add items to this folder.',
};
}
const validatedDto = validateActionInputCreatePair(dto); const validatedDto = validateActionInputCreatePair(dto);
await repoCreatePair(validatedDto); await repoCreatePair(validatedDto);
return { return {
@@ -175,6 +238,15 @@ export async function actionCreateFolder(userId: string, folderName: string) {
export async function actionRenameFolderById(id: number, newName: string) { export async function actionRenameFolderById(id: number, newName: string) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to rename this folder.',
};
}
const validatedNewName = validate( const validatedNewName = validate(
newName, newName,
z.string() z.string()

View File

@@ -122,3 +122,15 @@ export async function repoGetUserIdByFolderId(id: number) {
}); });
return folder?.userId; return folder?.userId;
} }
export async function repoGetFolderIdByPairId(pairId: number) {
const pair = await prisma.pair.findUnique({
where: {
id: pairId,
},
select: {
folderId: true,
},
});
return pair?.folderId;
}