...
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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": "学语言",
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user