mirror of
https://github.com/willchen96/mike.git
synced 2026-06-30 21:59:37 +02:00
Merge pull request #136 from willchen96/prevent-self-sharing
feat: prevent users from sharing projects and reviews with themselves
This commit is contained in:
commit
4290104cd0
4 changed files with 60 additions and 4 deletions
|
|
@ -83,6 +83,7 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
// POST /projects
|
// POST /projects
|
||||||
projectsRouter.post("/", requireAuth, async (req, res) => {
|
projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { name, cm_number, shared_with } = req.body as {
|
const { name, cm_number, shared_with } = req.body as {
|
||||||
name: string;
|
name: string;
|
||||||
cm_number?: string;
|
cm_number?: string;
|
||||||
|
|
@ -90,6 +91,23 @@ projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||||
};
|
};
|
||||||
if (!name?.trim())
|
if (!name?.trim())
|
||||||
return void res.status(400).json({ detail: "name is required" });
|
return void res.status(400).json({ detail: "name is required" });
|
||||||
|
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
||||||
|
const cleanedSharedWith: string[] = [];
|
||||||
|
const seenSharedEmails = new Set<string>();
|
||||||
|
if (Array.isArray(shared_with)) {
|
||||||
|
for (const raw of shared_with) {
|
||||||
|
if (typeof raw !== "string") continue;
|
||||||
|
const e = raw.trim().toLowerCase();
|
||||||
|
if (!e || seenSharedEmails.has(e)) continue;
|
||||||
|
if (normalizedUserEmail && e === normalizedUserEmail) {
|
||||||
|
return void res
|
||||||
|
.status(400)
|
||||||
|
.json({ detail: "You cannot share a project with yourself." });
|
||||||
|
}
|
||||||
|
seenSharedEmails.add(e);
|
||||||
|
cleanedSharedWith.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
|
|
@ -98,7 +116,7 @@ projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
cm_number: cm_number ?? null,
|
cm_number: cm_number ?? null,
|
||||||
shared_with: shared_with ?? [],
|
shared_with: cleanedSharedWith,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
|
|
@ -238,18 +256,25 @@ projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => {
|
||||||
// PATCH /projects/:projectId
|
// PATCH /projects/:projectId
|
||||||
projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (req.body.name != null) updates.name = req.body.name;
|
if (req.body.name != null) updates.name = req.body.name;
|
||||||
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
|
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
|
||||||
if (Array.isArray(req.body.shared_with)) {
|
if (Array.isArray(req.body.shared_with)) {
|
||||||
// Normalise: lowercase + dedupe + drop empties.
|
// Normalise: lowercase + dedupe + drop empties.
|
||||||
|
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const cleaned: string[] = [];
|
const cleaned: string[] = [];
|
||||||
for (const raw of req.body.shared_with) {
|
for (const raw of req.body.shared_with) {
|
||||||
if (typeof raw !== "string") continue;
|
if (typeof raw !== "string") continue;
|
||||||
const e = raw.trim().toLowerCase();
|
const e = raw.trim().toLowerCase();
|
||||||
if (!e || seen.has(e)) continue;
|
if (!e || seen.has(e)) continue;
|
||||||
|
if (normalizedUserEmail && e === normalizedUserEmail) {
|
||||||
|
return void res
|
||||||
|
.status(400)
|
||||||
|
.json({ detail: "You cannot share a project with yourself." });
|
||||||
|
}
|
||||||
seen.add(e);
|
seen.add(e);
|
||||||
cleaned.push(e);
|
cleaned.push(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -463,12 +463,18 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
||||||
// making the call. Normalize lowercase + dedupe + drop empties.
|
// making the call. Normalize lowercase + dedupe + drop empties.
|
||||||
let sharedWithUpdate: string[] | undefined;
|
let sharedWithUpdate: string[] | undefined;
|
||||||
if (Array.isArray(req.body.shared_with)) {
|
if (Array.isArray(req.body.shared_with)) {
|
||||||
|
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const cleaned: string[] = [];
|
const cleaned: string[] = [];
|
||||||
for (const raw of req.body.shared_with) {
|
for (const raw of req.body.shared_with) {
|
||||||
if (typeof raw !== "string") continue;
|
if (typeof raw !== "string") continue;
|
||||||
const e = raw.trim().toLowerCase();
|
const e = raw.trim().toLowerCase();
|
||||||
if (!e || seen.has(e)) continue;
|
if (!e || seen.has(e)) continue;
|
||||||
|
if (normalizedUserEmail && e === normalizedUserEmail) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: "You cannot share a tabular review with yourself.",
|
||||||
|
});
|
||||||
|
}
|
||||||
seen.add(e);
|
seen.add(e);
|
||||||
cleaned.push(e);
|
cleaned.push(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useDirectoryData } from "../shared/useDirectoryData";
|
||||||
import { FileDirectory } from "../shared/FileDirectory";
|
import { FileDirectory } from "../shared/FileDirectory";
|
||||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||||
import type { MikeProject } from "../shared/types";
|
import type { MikeProject } from "../shared/types";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -28,6 +29,8 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
|
||||||
|
|
||||||
const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open);
|
const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open);
|
||||||
|
|
||||||
|
|
@ -49,7 +52,9 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||||
const project = await createProject(
|
const project = await createProject(
|
||||||
name.trim(),
|
name.trim(),
|
||||||
cmNumber.trim() || undefined,
|
cmNumber.trim() || undefined,
|
||||||
sharedEmails,
|
ownEmail
|
||||||
|
? sharedEmails.filter((email) => email !== ownEmail)
|
||||||
|
: sharedEmails,
|
||||||
);
|
);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...[...selectedDocIds].map((id) => addDocumentToProject(project.id, id).catch(() => {})),
|
...[...selectedDocIds].map((id) => addDocumentToProject(project.id, id).catch(() => {})),
|
||||||
|
|
@ -137,6 +142,11 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||||
<EmailPillInput
|
<EmailPillInput
|
||||||
emails={sharedEmails}
|
emails={sharedEmails}
|
||||||
onChange={setSharedEmails}
|
onChange={setSharedEmails}
|
||||||
|
validate={async (email) =>
|
||||||
|
ownEmail && email === ownEmail
|
||||||
|
? "You cannot share a project with yourself."
|
||||||
|
: null
|
||||||
|
}
|
||||||
placeholder="Add colleagues by email…"
|
placeholder="Add colleagues by email…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -136,13 +136,22 @@ export function PeopleModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedNewEmail = newEmail.trim().toLowerCase();
|
const trimmedNewEmail = newEmail.trim().toLowerCase();
|
||||||
|
const normalizedCurrentUserEmail =
|
||||||
|
currentUserEmail?.trim().toLowerCase() ?? null;
|
||||||
const isValidEmail = EMAIL_RE.test(trimmedNewEmail);
|
const isValidEmail = EMAIL_RE.test(trimmedNewEmail);
|
||||||
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
||||||
const alreadyShared = sharedLower.includes(trimmedNewEmail);
|
const alreadyShared = sharedLower.includes(trimmedNewEmail);
|
||||||
const isOwnerEmail =
|
const isOwnerEmail =
|
||||||
!!ownerEmail && trimmedNewEmail === ownerEmail.toLowerCase();
|
!!ownerEmail && trimmedNewEmail === ownerEmail.toLowerCase();
|
||||||
|
const isSelfEmail =
|
||||||
|
!!normalizedCurrentUserEmail &&
|
||||||
|
trimmedNewEmail === normalizedCurrentUserEmail;
|
||||||
const canAdd =
|
const canAdd =
|
||||||
isValidEmail && !alreadyShared && !isOwnerEmail && busy === null;
|
isValidEmail &&
|
||||||
|
!alreadyShared &&
|
||||||
|
!isOwnerEmail &&
|
||||||
|
!isSelfEmail &&
|
||||||
|
busy === null;
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!canAdd || !onSharedWithChange) return;
|
if (!canAdd || !onSharedWithChange) return;
|
||||||
|
|
@ -249,10 +258,16 @@ export function PeopleModal({
|
||||||
{trimmedNewEmail} is the owner.
|
{trimmedNewEmail} is the owner.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isSelfEmail && !isOwnerEmail && trimmedNewEmail && (
|
||||||
|
<p className="mt-1.5 text-xs text-gray-400">
|
||||||
|
You cannot share this with yourself.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{trimmedNewEmail &&
|
{trimmedNewEmail &&
|
||||||
!isValidEmail &&
|
!isValidEmail &&
|
||||||
!alreadyShared &&
|
!alreadyShared &&
|
||||||
!isOwnerEmail && (
|
!isOwnerEmail &&
|
||||||
|
!isSelfEmail && (
|
||||||
<p className="mt-1.5 text-xs text-gray-400">
|
<p className="mt-1.5 text-xs text-gray-400">
|
||||||
Enter a valid email.
|
Enter a valid email.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue