snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
435
supabase/functions/users/index.ts
Normal file
435
supabase/functions/users/index.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
|
||||
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
|
||||
import { createErrorResponse } from "../_shared/utils.ts";
|
||||
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
|
||||
import { getUserSale } from "../_shared/getUserSale.ts";
|
||||
import {
|
||||
findInvalidEmail,
|
||||
MAX_SECONDARY_EMAILS,
|
||||
normalizeSecondaryEmails,
|
||||
} from "./secondaryEmails.ts";
|
||||
|
||||
async function updateSaleDisabled(user_id: string, disabled: boolean) {
|
||||
return await supabaseAdmin
|
||||
.from("sales")
|
||||
.update({ disabled: disabled ?? false })
|
||||
.eq("user_id", user_id);
|
||||
}
|
||||
|
||||
async function updateSaleAdministrator(
|
||||
user_id: string,
|
||||
administrator: boolean,
|
||||
) {
|
||||
const { data: sales, error: salesError } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.update({ administrator })
|
||||
.eq("user_id", user_id)
|
||||
.select("*");
|
||||
|
||||
if (!sales?.length || salesError) {
|
||||
console.error("Error updating user:", salesError);
|
||||
throw salesError ?? new Error("Failed to update sale");
|
||||
}
|
||||
return sales.at(0);
|
||||
}
|
||||
|
||||
async function createSale(
|
||||
user_id: string,
|
||||
data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
disabled: boolean;
|
||||
administrator: boolean;
|
||||
},
|
||||
) {
|
||||
const { data: sales, error: salesError } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.insert({ ...data, user_id })
|
||||
.select("*");
|
||||
|
||||
if (!sales?.length || salesError) {
|
||||
console.error("Error creating user:", salesError);
|
||||
throw salesError ?? new Error("Failed to create sale");
|
||||
}
|
||||
return sales.at(0);
|
||||
}
|
||||
|
||||
async function findEmailUsedByAnotherSale(
|
||||
emails: string[],
|
||||
excludeSalesId?: number,
|
||||
) {
|
||||
const isTakenBy = async (
|
||||
column: "email" | "secondary_emails",
|
||||
email: string,
|
||||
) => {
|
||||
const selected = supabaseAdmin.from("sales").select("id");
|
||||
let query =
|
||||
column === "email"
|
||||
? selected.eq("email", email)
|
||||
: selected.contains("secondary_emails", JSON.stringify([email]));
|
||||
|
||||
if (excludeSalesId !== undefined) {
|
||||
query = query.neq("id", excludeSalesId);
|
||||
}
|
||||
|
||||
const { data: matches, error: salesError } = await query.limit(1);
|
||||
|
||||
if (salesError) {
|
||||
console.error("Error fetching sales:", salesError);
|
||||
throw salesError;
|
||||
}
|
||||
|
||||
return Boolean(matches?.length);
|
||||
};
|
||||
|
||||
for (const email of emails) {
|
||||
if (
|
||||
(await isTakenBy("email", email)) ||
|
||||
(await isTakenBy("secondary_emails", email))
|
||||
) {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function updateSaleSecondaryEmails(
|
||||
user_id: string,
|
||||
secondary_emails: string[],
|
||||
) {
|
||||
const { error: salesError } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.update({ secondary_emails })
|
||||
.eq("user_id", user_id);
|
||||
|
||||
if (salesError) {
|
||||
console.error("Error updating user:", salesError);
|
||||
throw salesError;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSaleAvatar(user_id: string, avatar: string) {
|
||||
const { data: sales, error: salesError } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.update({ avatar })
|
||||
.eq("user_id", user_id)
|
||||
.select("*");
|
||||
|
||||
if (!sales?.length || salesError) {
|
||||
console.error("Error updating user:", salesError);
|
||||
throw salesError ?? new Error("Failed to update sale");
|
||||
}
|
||||
return sales.at(0);
|
||||
}
|
||||
|
||||
async function inviteUser(req: Request, currentUserSale: any) {
|
||||
const { email, password, first_name, last_name, disabled, administrator } =
|
||||
await req.json();
|
||||
|
||||
if (!currentUserSale.administrator) {
|
||||
return createErrorResponse(401, "Not Authorized");
|
||||
}
|
||||
|
||||
const takenEmail = await findEmailUsedByAnotherSale(
|
||||
email ? [email.trim().toLowerCase()] : [],
|
||||
);
|
||||
if (takenEmail) {
|
||||
return createErrorResponse(
|
||||
409,
|
||||
`Email already used by another user: ${takenEmail}`,
|
||||
{ code: "email_taken", email: takenEmail },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error: userError } = await supabaseAdmin.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
user_metadata: { first_name, last_name },
|
||||
});
|
||||
|
||||
let user = data?.user;
|
||||
|
||||
if (!user && userError?.code === "email_exists") {
|
||||
// This may happen if users cleared their database but not the users
|
||||
// We have to create the sale directly
|
||||
const { data, error } = await supabaseAdmin.rpc("get_user_id_by_email", {
|
||||
email,
|
||||
});
|
||||
|
||||
if (!data || error) {
|
||||
console.error(
|
||||
`Error inviting user: error=${error ?? "could not fetch users for email"}`,
|
||||
);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
|
||||
user = data[0];
|
||||
try {
|
||||
const { data: existingSale, error: salesError } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.select("*")
|
||||
.eq("user_id", user.id);
|
||||
if (salesError) {
|
||||
return createErrorResponse(salesError.status, salesError.message, {
|
||||
code: salesError.code,
|
||||
});
|
||||
}
|
||||
if (existingSale.length > 0) {
|
||||
return createErrorResponse(
|
||||
400,
|
||||
"A sales for this email already exists",
|
||||
);
|
||||
}
|
||||
|
||||
const sale = await createSale(user.id, {
|
||||
email,
|
||||
password,
|
||||
first_name,
|
||||
last_name,
|
||||
disabled,
|
||||
administrator,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: sale,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
(error as any).status ?? 500,
|
||||
(error as Error).message,
|
||||
{
|
||||
code: (error as any).code,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (userError) {
|
||||
console.error(`Error inviting user: user_error=${userError}`);
|
||||
return createErrorResponse(userError.status, userError.message, {
|
||||
code: userError.code,
|
||||
});
|
||||
}
|
||||
if (!data?.user) {
|
||||
console.error("Error inviting user: undefined user");
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
const { error: emailError } =
|
||||
await supabaseAdmin.auth.admin.inviteUserByEmail(email);
|
||||
|
||||
if (emailError) {
|
||||
console.error(`Error inviting user, email_error=${emailError}`);
|
||||
return createErrorResponse(500, "Failed to send invitation mail");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSaleDisabled(user.id, disabled);
|
||||
const sale = await updateSaleAdministrator(user.id, administrator);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: sale,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error patching sale:", e);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
async function patchUser(req: Request, currentUserSale: any) {
|
||||
const {
|
||||
sales_id,
|
||||
email,
|
||||
secondary_emails,
|
||||
first_name,
|
||||
last_name,
|
||||
avatar,
|
||||
administrator,
|
||||
disabled,
|
||||
} = await req.json();
|
||||
const { data: sale } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.select("*")
|
||||
.eq("id", sales_id)
|
||||
.single();
|
||||
|
||||
if (!sale) {
|
||||
return createErrorResponse(404, "Not Found");
|
||||
}
|
||||
|
||||
// Users can only update their own profile unless they are an administrator
|
||||
if (!currentUserSale.administrator && currentUserSale.id !== sale.id) {
|
||||
return createErrorResponse(401, "Not Authorized");
|
||||
}
|
||||
|
||||
if (email && email.trim().toLowerCase() !== sale.email.toLowerCase()) {
|
||||
const takenEmail = await findEmailUsedByAnotherSale(
|
||||
[email.trim().toLowerCase()],
|
||||
sales_id,
|
||||
);
|
||||
if (takenEmail) {
|
||||
return createErrorResponse(
|
||||
409,
|
||||
`Email already used by another user: ${takenEmail}`,
|
||||
{ code: "email_taken", email: takenEmail },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedSecondaryEmails: string[] | undefined;
|
||||
if (secondary_emails !== undefined) {
|
||||
normalizedSecondaryEmails = normalizeSecondaryEmails(secondary_emails);
|
||||
if (!normalizedSecondaryEmails) {
|
||||
return createErrorResponse(400, "secondary_emails must be an array", {
|
||||
code: "invalid_secondary_emails_payload",
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSecondaryEmails.length > MAX_SECONDARY_EMAILS) {
|
||||
return createErrorResponse(
|
||||
400,
|
||||
`No more than ${MAX_SECONDARY_EMAILS} secondary emails are allowed`,
|
||||
{ code: "too_many_secondary_emails" },
|
||||
);
|
||||
}
|
||||
|
||||
const invalidEmail = findInvalidEmail(normalizedSecondaryEmails);
|
||||
if (invalidEmail) {
|
||||
return createErrorResponse(
|
||||
400,
|
||||
`Invalid secondary email: ${invalidEmail}`,
|
||||
{ code: "invalid_secondary_email", email: invalidEmail },
|
||||
);
|
||||
}
|
||||
|
||||
const takenEmail = await findEmailUsedByAnotherSale(
|
||||
normalizedSecondaryEmails,
|
||||
sales_id,
|
||||
);
|
||||
if (takenEmail) {
|
||||
return createErrorResponse(
|
||||
409,
|
||||
`Secondary email already used by another user: ${takenEmail}`,
|
||||
{ code: "secondary_email_taken", email: takenEmail },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const primaryEmail = (email ?? sale.email).trim().toLowerCase();
|
||||
const effectiveSecondaryEmails =
|
||||
normalizedSecondaryEmails ??
|
||||
(Array.isArray(sale.secondary_emails) ? sale.secondary_emails : []);
|
||||
|
||||
if (effectiveSecondaryEmails.includes(primaryEmail)) {
|
||||
return createErrorResponse(
|
||||
409,
|
||||
`Secondary email is already the main address: ${primaryEmail}`,
|
||||
{ code: "secondary_email_is_primary", email: primaryEmail },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error: userError } =
|
||||
await supabaseAdmin.auth.admin.updateUserById(sale.user_id, {
|
||||
email,
|
||||
ban_duration: disabled ? "87600h" : "none",
|
||||
user_metadata: { first_name, last_name },
|
||||
});
|
||||
|
||||
if (!data?.user || userError) {
|
||||
console.error("Error patching user:", userError);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
|
||||
try {
|
||||
if (avatar) {
|
||||
await updateSaleAvatar(data.user.id, avatar);
|
||||
}
|
||||
|
||||
if (normalizedSecondaryEmails) {
|
||||
await updateSaleSecondaryEmails(data.user.id, normalizedSecondaryEmails);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error patching sale:", e);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
|
||||
// Only administrators can update the administrator and disabled status
|
||||
if (!currentUserSale.administrator) {
|
||||
const { data: new_sale } = await supabaseAdmin
|
||||
.from("sales")
|
||||
.select("*")
|
||||
.eq("id", sales_id)
|
||||
.single();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: new_sale,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...corsHeaders,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSaleDisabled(data.user.id, disabled);
|
||||
const sale = await updateSaleAdministrator(data.user.id, administrator);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: sale,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...corsHeaders,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error patching sale:", e);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) =>
|
||||
OptionsMiddleware(req, async (req) =>
|
||||
AuthMiddleware(req, async (req) =>
|
||||
UserMiddleware(req, async (req, user) => {
|
||||
const currentUserSale = await getUserSale(user);
|
||||
if (!currentUserSale) {
|
||||
return createErrorResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method === "POST") {
|
||||
return await inviteUser(req, currentUserSale);
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
return await patchUser(req, currentUserSale);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Unhandled error in the users function:", e);
|
||||
return createErrorResponse(500, "Internal Server Error");
|
||||
}
|
||||
|
||||
return createErrorResponse(405, "Method Not Allowed");
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
74
supabase/functions/users/secondaryEmails.test.ts
Normal file
74
supabase/functions/users/secondaryEmails.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findInvalidEmail, normalizeSecondaryEmails } from "./secondaryEmails";
|
||||
|
||||
describe("normalizeSecondaryEmails", () => {
|
||||
it("returns undefined for a non-array, so the caller rejects instead of wiping", () => {
|
||||
expect(normalizeSecondaryEmails(undefined)).toBe(undefined);
|
||||
expect(normalizeSecondaryEmails(null)).toBe(undefined);
|
||||
expect(normalizeSecondaryEmails("john@perso.com")).toBe(undefined);
|
||||
expect(normalizeSecondaryEmails({ 0: "john@perso.com" })).toBe(undefined);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty array, which clears the addresses", () => {
|
||||
expect(normalizeSecondaryEmails([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("trims and lowercases every address", () => {
|
||||
expect(normalizeSecondaryEmails([" John@Perso.COM "])).toEqual([
|
||||
"john@perso.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes addresses that differ only by case or spacing", () => {
|
||||
expect(
|
||||
normalizeSecondaryEmails(["john@perso.com", " JOHN@perso.com "]),
|
||||
).toEqual(["john@perso.com"]);
|
||||
});
|
||||
|
||||
it("drops empty entries and non-string values", () => {
|
||||
expect(
|
||||
normalizeSecondaryEmails(["john@perso.com", "", " ", 42, null]),
|
||||
).toEqual(["john@perso.com"]);
|
||||
});
|
||||
|
||||
it("preserves the order of the remaining addresses", () => {
|
||||
expect(
|
||||
normalizeSecondaryEmails(["b@perso.com", "a@perso.com", "c@perso.com"]),
|
||||
).toEqual(["b@perso.com", "a@perso.com", "c@perso.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInvalidEmail", () => {
|
||||
it("returns undefined when every address is well formed", () => {
|
||||
expect(
|
||||
findInvalidEmail(["john@perso.com", "j.doe+tag@sub.domain.co.uk"]),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for an empty list", () => {
|
||||
expect(findInvalidEmail([])).toBe(undefined);
|
||||
});
|
||||
|
||||
it("reports an address with no domain", () => {
|
||||
expect(findInvalidEmail(["john@perso.com", "not-an-email"])).toBe(
|
||||
"not-an-email",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an address with no top level domain", () => {
|
||||
expect(findInvalidEmail(["john@localhost"])).toBe("john@localhost");
|
||||
});
|
||||
|
||||
it("reports an address containing a space", () => {
|
||||
expect(findInvalidEmail(["john doe@perso.com"])).toBe("john doe@perso.com");
|
||||
});
|
||||
|
||||
it("reports the first offending address so the caller can name it", () => {
|
||||
expect(findInvalidEmail(["bad-one", "bad-two"])).toBe("bad-one");
|
||||
});
|
||||
|
||||
it("rejects a comma, which would split the PostgREST or() filter", () => {
|
||||
expect(findInvalidEmail(["john,doe@perso.com"])).toBe("john,doe@perso.com");
|
||||
});
|
||||
});
|
||||
22
supabase/functions/users/secondaryEmails.ts
Normal file
22
supabase/functions/users/secondaryEmails.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const EMAIL_PATTERN = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
|
||||
|
||||
export const MAX_SECONDARY_EMAILS = 10;
|
||||
|
||||
export const normalizeSecondaryEmails = (
|
||||
secondaryEmails: unknown,
|
||||
): string[] | undefined => {
|
||||
if (!Array.isArray(secondaryEmails)) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
secondaryEmails
|
||||
.filter((email): email is string => typeof email === "string")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const findInvalidEmail = (emails: string[]): string | undefined =>
|
||||
emails.find((email) => !EMAIL_PATTERN.test(email));
|
||||
Reference in New Issue
Block a user