diff --git a/.env.sample b/.env.sample index a5a92423..d41a448a 100644 --- a/.env.sample +++ b/.env.sample @@ -2,9 +2,7 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SENDER_EMAIL= DISCORD_INVITE= -SES_REGION= -SMTP_USER= -SMTP_PASS= -SMTP_HOST= -PUBLIC_BLOCKED_URLS= +SES_REGION= +SES_ACCESS= +SES_SECRET= PUBLIC_HOST_URL= diff --git a/astro.config.mjs b/astro.config.mjs index 3bd3fc93..e3f3e016 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -9,6 +9,6 @@ import netlify from "@astrojs/netlify"; export default defineConfig({ site: import.meta.env.PUBLIC_HOST_URL, integrations: [tailwind(), icon(), react()], - output: "static", + output: "server", adapter: netlify(), }); diff --git a/package.json b/package.json index 60a30e6d..3f75948d 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,13 @@ "dependencies": { "@astrojs/netlify": "^5.1.3", "@astrojs/react": "^3.0.10", + "@aws-sdk/client-ses": "^3.750.0", "@fontsource-variable/inter": "^5.0.16", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.1", "@supabase/supabase-js": "^2.39.6", "@types/react": "^18.2.58", "@types/react-dom": "^18.2.19", - "emailjs": "^4.0.3", "flowbite": "^2.3.0", "heroicons": "^2.1.1", "micromodal": "^0.4.10", diff --git a/src/components/confirmationModal.jsx b/src/components/confirmationModal.jsx index 8caa3236..0d0fa148 100644 --- a/src/components/confirmationModal.jsx +++ b/src/components/confirmationModal.jsx @@ -1,4 +1,4 @@ -export default function ConfirmationModal({ placeHolder, closeModal }) { +export default function ConfirmationModal({ title, content, closeModal }) { return (
@@ -18,7 +18,8 @@ export default function ConfirmationModal({ placeHolder, closeModal }) { d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> -

{placeHolder}

+

{title}

+

{content}

Register @@ -71,6 +72,11 @@ const headerClass = sticky
`, + }, }, - ], - }; + }, + }); try { - client.send(message, function (err, message) { - console.log(err || message); - }); - - return null; + await ses.send(params); + console.log("Email sent successfully"); } catch (error) { - return error; + console.error("Error sending email:", error); } }; + +const checkAlreadyInTeam = async (email: string) => { + const { data, error } = await supabase + .from("participants") + .select("team_code") + .eq("email", email); + return data && data.length > 0 && data[0].team_code; +} \ No newline at end of file diff --git a/src/pages/api/teams/join.ts b/src/pages/api/teams/join.ts index 2a4f0207..bc091927 100644 --- a/src/pages/api/teams/join.ts +++ b/src/pages/api/teams/join.ts @@ -1,6 +1,6 @@ import { createClient } from "@supabase/supabase-js"; +import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; import type { APIRoute } from "astro"; -import { SMTPClient } from "emailjs"; export const prerender = false; @@ -9,22 +9,22 @@ const supabase = createClient( import.meta.env.SUPABASE_ANON_KEY, ); -const senderEmail = import.meta.env.SENDER_EMAIL; -const client = new SMTPClient({ - user: import.meta.env.SMTP_USER, - password: import.meta.env.SMTP_PASS, - host: import.meta.env.SMTP_HOST, - ssl: true, +const ses = new SESClient({ + region: import.meta.env.SES_REGION, + credentials: { + accessKeyId: import.meta.env.SES_ACCESS, + secretAccessKey: import.meta.env.SES_SECRET, + }, }); +const senderEmail = import.meta.env.SENDER_EMAIL; + export const POST: APIRoute = async ({ request }) => { const formData = await request.formData(); - let errors: String[] = []; // forms validation const valid = await validateForms(formData, errors); - if (!valid) { return new Response( JSON.stringify({ @@ -38,15 +38,37 @@ export const POST: APIRoute = async ({ request }) => { const email = formData.get("email")?.toString() ?? ""; const team_code = formData.get("code")?.toString().replace("#", ""); - const insertion_msg = await supabase - .from("participants") - .update({ team_code: team_code }) - .eq("email", email) - .select(); + // check if the participant is already in a team + const team_code_already = await checkAlreadyInTeam(email); + if (team_code_already) { + errors.push("You are already in a team. You can't join another one."); + return new Response( + JSON.stringify({ + message: { errors: errors }, + status: 400, + }), + { status: 400 }, + ); + } + + const insertion_msg = await supabase.rpc("update_participant_team", { + participant_email: email, + new_team_code: team_code, + }); + if (insertion_msg.error) { + console.error(insertion_msg.error); let msg = "There was an error joining the team. Try again later."; + if ( + insertion_msg.error.message.includes( + 'violates check constraint "teams_num_team_mem_check"', + ) + ) { + msg = "The team is already full. Try joining or creating another team."; + } else if (insertion_msg.error.message.includes("Team already paid")) { + msg = "The team is already paid. You can't join it."; + } errors.push(msg); - return new Response( JSON.stringify({ message: { errors: errors }, @@ -56,19 +78,29 @@ export const POST: APIRoute = async ({ request }) => { ); } - // get the team name to send the email - const { data: data, error: error } = await supabase + // get the team name and created_by to send the emails + const { data, error } = await supabase .from("teams") - .select("name") + .select("name, created_by, num_team_mem, total_value_payment") .eq("code", team_code); + let team_name = null; + if (data && data.length > 0) { - const team_name = data[0].name; - sendTeamEntryEmail(email, team_name); + team_name = data[0].name; + await sendTeamEntryEmail(email, team_name); + await sendNotificationEmail( + team_name, + data[0].created_by, + data[0].num_team_mem, + data[0].total_value_payment, + email + ); } return new Response( JSON.stringify({ + message: { team_name: team_name }, status: 200, }), { status: 200 }, @@ -77,7 +109,6 @@ export const POST: APIRoute = async ({ request }) => { const validateForms = async (formData: FormData, errors: String[]) => { let valid = true; - const email = formData.get("email")?.toString() ?? ""; const confirmation = formData .get("confirmation") @@ -97,65 +128,68 @@ const validateForms = async (formData: FormData, errors: String[]) => { ); valid = false; } - if (participants && participants.length === 0) { errors.push( "The email and confirmation code do not match. Please try again.", ); valid = false; } - - // confirm that the number of elements in the team is less than or equal to 5 - const team_code = formData.get("code")?.toString().replace("#", ""); - const { data: team_members, error: team_error } = await supabase - .from("participants") - .select("email") - .eq("team_code", team_code); - - if (team_error) { - console.error(team_error); - errors.push( - "There was an error connecting to the server. Try again later.", - ); - valid = false; - } - - if (team_members && team_members.length >= 5) { - errors.push( - "The team is already full. Try joining or creating another team.", - ); - valid = false; - } - return valid; }; -const sendTeamEntryEmail = async (to: string, team_name: string) => { - const message = { - text: "", - from: senderEmail.toString(), - to: to, - subject: "[BugsByte] Team entry confirmation", - attachment: [ - { - data: `

Hello again 👋

-
-

You just entered a new team - ${team_name}

-

Looking forward to seeing you soon!

-

Organization team 🪲

-
`.toString(), - alternative: true, - }, - ], +const sendEmail = async (to: string, subject: string, body: string) => { + const params = { + Source: senderEmail, + Destination: { + ToAddresses: [to], + }, + Message: { + Subject: { Data: subject }, + Body: { Html: { Data: body } }, + }, }; - try { - client.send(message, function (err, message) { - console.log(err || message); - }); - - return null; + await ses.send(new SendEmailCommand(params)); } catch (error) { - return error; + console.error("Error sending email:", error); } }; + +const sendTeamEntryEmail = async (to: string, team_name: string) => { + const body = `

Hello again 👋

+
+

You just entered a new team - ${team_name}

+

Looking forward to seeing you soon!

+

Organization team 🪲

+
`; + await sendEmail(to, "[BugsByte] Team entry confirmation", body); +}; + +const sendNotificationEmail = async ( + team_name: string, + email: string, + num_team_mem: number, + total_value_payment: number, + new_member_email: string, +) => { + const body = `

Hello again 👋

+
+

A new member just joined the team ${team_name}

+

New member: ${new_member_email}

+

Number of team members: ${num_team_mem}

+

Remember that you or other team members need to pay the total value of the team to confirm the registration at CeSIUM (DI 1.04).

+

When the payment is done, the team will be closed and no more members will be able to join it.

+

Total value: ${total_value_payment}€

+

Looking forward to seeing you soon!

+

Organization team 🪲

+
`; + await sendEmail(email, "[BugsByte] New team member", body); +}; + +const checkAlreadyInTeam = async (email: string) => { + const { data, error } = await supabase + .from("participants") + .select("team_code") + .eq("email", email); + return data && data.length > 0 && data[0].team_code; +} \ No newline at end of file diff --git a/src/hidden_pages/register.astro b/src/pages/register.astro similarity index 100% rename from src/hidden_pages/register.astro rename to src/pages/register.astro diff --git a/supabase/config.toml b/supabase/config.toml index dfaf112f..3fce1a18 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,6 +1,6 @@ # A string used to distinguish different Supabase projects on the same host. Defaults to the # working directory name when running `supabase init`. -project_id = "ares" +project_id = "ares-2025" [api] enabled = true @@ -8,7 +8,7 @@ enabled = true port = 54321 # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API # endpoints. public and storage are always included. -schemas = ["public", "storage", "graphql_public"] +schemas = ["public", "graphql_public", "storage"] # Extra schemas to add to the search_path of every request. public is always included. extra_search_path = ["public", "extensions"] # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size @@ -69,10 +69,8 @@ file_size_limit = "50MiB" enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +site_url = "http://localhost:3000" additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # If disabled, the refresh token will never expire. enable_refresh_token_rotation = true @@ -84,6 +82,12 @@ enable_signup = true # Allow/disallow testing manual linking of accounts enable_manual_linking = false +[auth.mfa] +max_enrolled_factors = 10 +[auth.mfa.totp] +enroll_enabled = true +verify_enabled = true + [auth.email] # Allow/disallow new user signups via email to your project. enable_signup = true @@ -98,13 +102,19 @@ enable_confirmations = false # subject = "You have been invited" # content_path = "./supabase/templates/invite.html" +secure_password_change = false +max_frequency = "1m0s" +otp_length = 6 +otp_expiry = 3600 +[auth.email.template] + [auth.sms] # Allow/disallow new user signups via SMS to your project. enable_signup = true # If enabled, users need to confirm their phone number before signing in. enable_confirmations = false # Template for sending OTP to users -template = "Your code is {{ .Code }} ." +template = "Your code is {{ .Code }}" # Use pre-defined map of phone number to OTP for testing. [auth.sms.test_otp] diff --git a/supabase/migrations/20240228233435_participants.sql b/supabase/migrations/20240228233435_participants.sql index 248de9ff..a5168968 100644 --- a/supabase/migrations/20240228233435_participants.sql +++ b/supabase/migrations/20240228233435_participants.sql @@ -1,6 +1,5 @@ create table public.participants ( - id bigint generated by default as identity, created_at timestamp with time zone not null default now(), name character varying null, email character varying not null, @@ -11,6 +10,8 @@ create table vegan boolean null default false, notes text null, confirmation text null, - constraint participants_pkey primary key (id, email), - constraint participants_confirmation_key unique (confirmation) + team_code character varying null, + constraint participants_confirmation_key unique (confirmation), + constraint participants_pkey + primary key (email) ) tablespace pg_default; diff --git a/supabase/migrations/20240325144051_create_teams.sql b/supabase/migrations/20240325144051_create_teams.sql index f75b5f66..9d931b42 100644 --- a/supabase/migrations/20240325144051_create_teams.sql +++ b/supabase/migrations/20240325144051_create_teams.sql @@ -3,5 +3,8 @@ create table code varchar primary key, created_at timestamp with time zone not null default now(), name character varying null, + num_team_mem smallint not null default 1 check (num_team_mem > 0 and num_team_mem < 6), + total_value_payment numeric null, + paid boolean not null default false, constraint teams_name_key unique (name) ) tablespace pg_default; diff --git a/supabase/migrations/20240325144539_alter_participants.sql b/supabase/migrations/20240325144539_alter_participants.sql deleted file mode 100644 index a9fb67a7..00000000 --- a/supabase/migrations/20240325144539_alter_participants.sql +++ /dev/null @@ -1,18 +0,0 @@ --- First, drop the existing primary key constraint -ALTER TABLE public.participants -DROP CONSTRAINT participants_pkey; - --- Next, drop the id column -ALTER TABLE public.participants -DROP COLUMN id; - --- Then, add the team_code column -ALTER TABLE public.participants -ADD COLUMN team_code VARCHAR, -ADD CONSTRAINT fk_team_code - FOREIGN KEY (team_code) - REFERENCES public.teams(code); - --- Finally, add the email column as the primary key -ALTER TABLE public.participants -ADD CONSTRAINT participants_pkey PRIMARY KEY (email); diff --git a/supabase/migrations/20240325162040_create_team_function.sql b/supabase/migrations/20240325162040_create_team_function.sql index 24ff7b69..a34a5cde 100644 --- a/supabase/migrations/20240325162040_create_team_function.sql +++ b/supabase/migrations/20240325162040_create_team_function.sql @@ -4,8 +4,8 @@ language plpgsql as $$ begin - insert into public.teams (code, name) - values (new_team_code, team_name); + insert into public.teams (code, name, created_by) + values (new_team_code, team_name, member_email); update public.participants set team_code = new_team_code diff --git a/supabase/migrations/20250214170818_add_fk_participants.sql b/supabase/migrations/20250214170818_add_fk_participants.sql new file mode 100644 index 00000000..f8cbe944 --- /dev/null +++ b/supabase/migrations/20250214170818_add_fk_participants.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.participants + ADD CONSTRAINT fk_team_code + FOREIGN KEY (team_code) + REFERENCES public.teams(code); \ No newline at end of file diff --git a/supabase/migrations/20250215235554_fix_function_update_participant.sql b/supabase/migrations/20250215235554_fix_function_update_participant.sql new file mode 100644 index 00000000..0718ce88 --- /dev/null +++ b/supabase/migrations/20250215235554_fix_function_update_participant.sql @@ -0,0 +1,17 @@ +CREATE OR REPLACE FUNCTION update_participant_team( + participant_email VARCHAR, + new_team_code VARCHAR +) RETURNS VOID AS $$ +BEGIN + -- Update the participant's team_code + UPDATE public.participants + SET team_code = new_team_code + WHERE email = participant_email; + + -- Update the num_team_mem in teams + UPDATE public.teams + SET num_team_mem = num_team_mem + 1, + total_value_payment = (num_team_mem + 1) * 2 + WHERE code = new_team_code; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/supabase/migrations/20250217182558_add_team_created_by.sql b/supabase/migrations/20250217182558_add_team_created_by.sql new file mode 100644 index 00000000..f1af0fa4 --- /dev/null +++ b/supabase/migrations/20250217182558_add_team_created_by.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.teams + ADD COLUMN created_by VARCHAR REFERENCES public.participants(email); \ No newline at end of file diff --git a/supabase/migrations/20250217191047_create_files_bucket.sql b/supabase/migrations/20250217191047_create_files_bucket.sql new file mode 100644 index 00000000..f1fc3c79 --- /dev/null +++ b/supabase/migrations/20250217191047_create_files_bucket.sql @@ -0,0 +1,8 @@ +INSERT INTO storage.buckets (id, name) + VALUES ('files', 'files'); + +CREATE POLICY "Allow unauthenticated users to upload files" + ON storage.objects + FOR INSERT + TO anon + WITH CHECK (bucket_id = 'files'::text); \ No newline at end of file diff --git a/supabase/migrations/20250217202424_update_participant_team.sql b/supabase/migrations/20250217202424_update_participant_team.sql new file mode 100644 index 00000000..6601653f --- /dev/null +++ b/supabase/migrations/20250217202424_update_participant_team.sql @@ -0,0 +1,22 @@ +CREATE OR REPLACE FUNCTION update_participant_team( + participant_email VARCHAR, + new_team_code VARCHAR +) RETURNS VOID AS $$ +BEGIN + -- Check if team paid collumn is true + IF (SELECT paid FROM public.teams WHERE code = new_team_code) THEN + RAISE EXCEPTION 'Team already paid'; + END IF; + + -- Update the participant's team_code + UPDATE public.participants + SET team_code = new_team_code + WHERE email = participant_email; + + -- Update the num_team_mem in teams + UPDATE public.teams + SET num_team_mem = num_team_mem + 1, + total_value_payment = (num_team_mem + 1) * 2 + WHERE code = new_team_code; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file