diff --git a/android/app/build.gradle b/android/app/build.gradle index 7f7b991d..f8732fcc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,7 +36,7 @@ if (flutterVersionName == null) { android { namespace = "fstapp.fstapp" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "25.1.8937393" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/assets/translations/cs.json b/assets/translations/cs.json index 8080f206..019977ce 100644 --- a/assets/translations/cs.json +++ b/assets/translations/cs.json @@ -164,7 +164,6 @@ "Welcome in {name}!": "Vítejte v aplikaci {name}!", "Invite": "Pozvat", "Invited": "Pozván", - "Users will get invitation via e-mail.": "Uživatelé obdrží pozvánku e-mailem.", "Invite users who have already been invited?": "Pozvat i uživatele, kteří již byli pozváni?", "Invited: {user}.": "Pozván: {user}.", "Token is not valid.": "Token není platný.", @@ -258,5 +257,8 @@ "Food": "Jídlo", "To create food, fill in the title, unique code, and the reference of the event.": "Pro vytvoření jídla vyplňte název, jedinečný kód a referenci na program.", "Not specified": "Nespecifikováno", + "Users will get a sign in code via e-mail.": "Uživatelé obdrží přihlašovací kód e-mailem.", + "Users ({count}) invited successfully.": "Uživatelé ({count}) byli úspěšně pozváni.", + "Progress": "Postup", "_": "_" } \ No newline at end of file diff --git a/assets/translations/de.json b/assets/translations/de.json index 5aaf9a17..73ef7d59 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -164,7 +164,6 @@ "Welcome in {name}!": "Willkommen in {name}!", "Invite": "Einladen", "Invited": "Eingeladen", - "Users will get invitation via e-mail.": "Benutzer erhalten eine Einladung per E-Mail.", "Invite users who have already been invited?": "Benutzer einladen, die bereits eingeladen wurden?", "Invited: {user}.": "Eingeladen: {user}.", "Token is not valid.": "Token ist ungültig.", @@ -258,5 +257,8 @@ "Food": "Essen", "To create food, fill in the title, unique code, and the reference of the event.": "Um Essen zu erstellen, füllen Sie den Titel, den eindeutigen Code und die Referenz des Programms aus.", "Not specified": "Nicht angegeben", + "Users will get a sign in code via e-mail.": "Benutzer erhalten einen Anmeldecode per E-Mail.", + "Users ({count}) invited successfully.": "Benutzer ({count}) wurden erfolgreich eingeladen.", + "Progress": "Fortschritt", "_": "_" } diff --git a/assets/translations/en.json b/assets/translations/en.json index 5d319f63..b708edf2 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -164,7 +164,6 @@ "Welcome in {name}!": "Welcome in {name}!", "Invite": "Invite", "Invited": "Invited", - "Users will get invitation via e-mail.": "Users will get invitation via e-mail.", "Invite users who have already been invited?": "Invite users who have already been invited?", "Invited: {user}.": "Invited: {user}.", "Token is not valid.": "Token is not valid.", @@ -258,5 +257,8 @@ "Food": "Food", "To create food, fill in the title, unique code, and the reference of the event.": "To create food, fill in the title, unique code, and the reference of the event.", "Not specified": "Not specified", + "Users will get a sign in code via e-mail.": "Users will get a sign in code via e-mail.", + "Users ({count}) invited successfully.": "Users ({count}) invited successfully.", + "Progress": "Progress", "_":"_" } \ No newline at end of file diff --git a/assets/translations/pl.json b/assets/translations/pl.json index cd5c2b73..cc98e94e 100644 --- a/assets/translations/pl.json +++ b/assets/translations/pl.json @@ -164,7 +164,6 @@ "Welcome in {name}!": "Witaj w {name}!", "Invite": "Zaproś", "Invited": "Zaproszony", - "Users will get invitation via e-mail.": "Użytkownicy otrzymają zaproszenie drogą e-mailową.", "Invite users who have already been invited?": "Zaprosić użytkowników, którzy już zostali zaproszeni?", "Invited: {user}.": "Zaproszony: {user}.", "Token is not valid.": "Token nie jest ważny.", @@ -257,5 +256,8 @@ "Food": "Jedzenie", "To create food, fill in the title, unique code, and the reference of the event.": "Aby utworzyć jedzenie, wypełnij tytuł, unikalny kod i odniesienie do programu.", "Not specified": "Nieokreślony", + "Users will get a sign in code via e-mail.": "Użytkownicy otrzymają kod logowania za pomocą e-maila.", + "Users ({count}) invited successfully.": "Użytkownicy ({count}) zostali pomyślnie zaproszeni.", + "Progress": "Postęp", "_": "_" } \ No newline at end of file diff --git a/assets/translations/sk.json b/assets/translations/sk.json index 58bf7178..f6213180 100644 --- a/assets/translations/sk.json +++ b/assets/translations/sk.json @@ -165,7 +165,6 @@ "Invite": "Pozvať", "You can sign in from {time}.": "Môžeš sa prihlásiť od času {time}.", "Invited": "Pozvaný", - "Users will get invitation via e-mail.": "Používatelia dostanú pozvánku e-mailom.", "Invite users who have already been invited?": "Pozvať používateľov, ktorí už boli pozvaní?", "Invited: {user}.": "Pozvaný: {user}.", "Token is not valid.": "Token nie je platný.", @@ -258,5 +257,8 @@ "Food": "Jedlo", "To create food, fill in the title, unique code, and the reference of the event.": "Na vytvorenie jedla vyplňte názov, jedinečný kód a referenciu na program.", "Not specified": "Neurčené", + "Users will get a sign in code via e-mail.": "Používatelia dostanú prihlasovací kód e-mailom.", + "Users ({count}) invited successfully.": "Používatelia ({count}) boli úspešne pozvaní.", + "Progress": "Postup", "_": "_" } \ No newline at end of file diff --git a/assets/translations/uk.json b/assets/translations/uk.json index 496a5916..9f048fae 100644 --- a/assets/translations/uk.json +++ b/assets/translations/uk.json @@ -164,7 +164,6 @@ "Welcome in {name}!": "Ласкаво просимо до {name}!", "Invite": "Запросити", "Invited": "Запрошено", - "Users will get invitation via e-mail.": "Користувачі отримають запрошення по електронній пошті.", "Invite users who have already been invited?": "Запросити користувачів, які вже були запрошені?", "Invited: {user}.": "Запрошено: {user}.", "Token is not valid.": "Токен недійсний.", @@ -258,5 +257,8 @@ "Food": "Їжа", "To create food, fill in the title, unique code, and the reference of the event.": "Щоб створити їжу, заповніть назву, унікальний код та посилання на програму.", "Not specified": "Не вказано", + "Users will get a sign in code via e-mail.": "Користувачі отримають код для входу на електронну пошту.", + "Users ({count}) invited successfully.": "Користувачі ({count}) успішно запрошені.", + "Progress": "Прогрес", "_": "_" } diff --git a/lib/dataModels/UserInfoModel.dart b/lib/dataModels/UserInfoModel.dart index f68422b0..9c0e6843 100644 --- a/lib/dataModels/UserInfoModel.dart +++ b/lib/dataModels/UserInfoModel.dart @@ -14,7 +14,6 @@ class UserInfoModel { String? sex; String? role; String? phone; - String? accommodation; DateTime? birthDate; bool? isAdmin = false; bool? isEditor = false; @@ -61,7 +60,6 @@ class UserInfoModel { this.isAdmin, this.isEditor, this.phone, - this.accommodation, this.accommodationPlace, this.userGroup, this.occasionUser, @@ -78,12 +76,6 @@ class UserInfoModel { email: json[emailReadonlyColumn]??json[Tb.user_info.data]?[Tb.occasion_users.data_email]??json["email"], name: json[nameColumn], surname: json[surnameColumn], - //todo remove - phone: json[phoneColumn], - //todo remove - role: json[roleColumn], - //todo remove - accommodation: json[accommodationColumn], accommodationPlace: json[placeColumn]!=null?PlaceModel.fromJson(json[placeColumn]):null, userGroup: json[userGroupColumn]!=null?UserGroupInfoModel.fromJson(json[userGroupColumn]):null, occasionUser: json[occasionUserColumn]!=null?OccasionUserModel.fromJson(json[occasionUserColumn]):null, @@ -143,18 +135,6 @@ class UserInfoModel { return "Not specified".tr(); } - bool importedEquals(Map u) { - return - u[emailReadonlyColumn].toString().trim().toLowerCase() == email - && u[nameColumn].toString().trim() == name - && u[surnameColumn].toString().trim() == surname - && u[accommodationColumn].toString().trim() == accommodation - && u[roleColumn].toString().trim() == role - && u[phoneColumn].toString().trim() == phone - //todo fix - //&& ((u.containsKey(birthDateColumn) && u[birthDateColumn] != null) ? DateTime.parse(u[birthDateColumn]):null) == birthDate - && (u[sexColumn].toString().trim().toLowerCase().startsWith("m") ? "male" : "female") == sex; - } @override int get hashCode { diff --git a/lib/dataServices/AuthService.dart b/lib/dataServices/AuthService.dart index 5834d0d9..e72d0fed 100644 --- a/lib/dataServices/AuthService.dart +++ b/lib/dataServices/AuthService.dart @@ -40,7 +40,11 @@ class AuthService { } static Future resetPasswordForEmail(String email) async { - return await _supabase.functions.invoke("email", body: {"email": email, "organization": AppConfig.organization}); + return await _supabase.functions.invoke("send-reset-password-link", body: {"email": email, "organization": AppConfig.organization}); + } + + static Future sendSignInCode(OccasionUserModel ou) async { + return await _supabase.functions.invoke("send-sign-in-code", body: {"oc": ou.occasion, "usr": ou.user,}); } static UserInfoModel? currentUser; diff --git a/lib/dataServices/DataService.dart b/lib/dataServices/DataService.dart index ea3f4a59..d74a71a0 100644 --- a/lib/dataServices/DataService.dart +++ b/lib/dataServices/DataService.dart @@ -41,7 +41,6 @@ class ImportService { Tb.occasion_users.data_name: userInfo.name, Tb.occasion_users.data_surname: userInfo.surname, Tb.occasion_users.data_phone: userInfo.phone, - Tb.occasion_users.data_accommodation: userInfo.accommodation, Tb.occasion_users.data_birthDate: userInfo.birthDate?.toIso8601String(), Tb.occasion_users.data_isInvited: true, } diff --git a/lib/pages/AdministrationOccasion/UsersTab.dart b/lib/pages/AdministrationOccasion/UsersTab.dart index 9110f982..4ad11d90 100644 --- a/lib/pages/AdministrationOccasion/UsersTab.dart +++ b/lib/pages/AdministrationOccasion/UsersTab.dart @@ -133,15 +133,46 @@ class _UsersTabState extends State { } Future _processInvites(List users, SingleTableDataGrid dataGrid) async { - var confirm = await DialogHelper.showConfirmationDialogAsync(context, "Invite".tr(), "${"Users will get invitation via e-mail.".tr()} (${users.length}):\n${users.map((u) => u.toBasicString()).join(",\n")}"); + var confirm = await DialogHelper.showConfirmationDialogAsync( + context, + "Invite".tr(), + "${"Users will get a sign in code invitation via e-mail.".tr()} (${users.length}):\n${users.map((u) => u.toBasicString()).join(",\n")}" + ); + if (confirm) { + ValueNotifier invitedCount = ValueNotifier(0); + + // Show the progress dialog and update the count via the notifier + DialogHelper.showProgressDialogAsync( + context, + "Invite".tr(), + users.length, + invitedCount, + ); + for (var user in users) { - await AuthService.resetPasswordForEmail(user.data![Tb.occasion_users.data_email]); - user.data![Tb.occasion_users.data_isInvited] = true; - await DbUsers.updateOccasionUser(user); - ToastHelper.Show(context, "Invited: {user}.".tr(namedArgs: {"user": user.data![Tb.occasion_users.data_email]})); + await AuthService.sendSignInCode(user); + invitedCount.value++; + + // Show toast notification after each invitation + ToastHelper.Show( + context, + "Invited: {user}.".tr(namedArgs: {"user": user.data![Tb.occasion_users.data_email]}), + ); } + + // Close the dialog after all users are invited + Navigator.of(context).pop(); + await loadUsers(); // Reload users and force rebuild + + // Display final information dialog + await DialogHelper.showInformationDialogAsync( + context, + "Invite".tr(), + "Users ({count}) invited successfully.".tr(namedArgs: {"count": invitedCount.value.toString()}), + ); + } } } diff --git a/lib/pages/SignupPage.dart b/lib/pages/SignupPage.dart index b94bbb5b..8fb3d3b5 100644 --- a/lib/pages/SignupPage.dart +++ b/lib/pages/SignupPage.dart @@ -1,7 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/cupertino.dart'; import 'package:fstapp/RouterService.dart'; -import 'package:fstapp/appConfig.dart'; import 'package:fstapp/dataServices/AuthService.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fstapp/services/FormHelper.dart'; @@ -69,7 +68,7 @@ class _SignupPageState extends State { text: TextSpan( children: [ TextSpan( - style: const TextStyle(fontSize: 18, color: Colors.black), + style: TextStyle(fontSize: 18, color: ThemeConfig.blackColor(context)), text: "Almost done! Your credentials for signing in to the app have been sent to your email {email}. Please check your inbox to complete the registration.".tr(namedArgs: {"email": fieldsData?["email"]}), ), const WidgetSpan( diff --git a/lib/services/DialogHelper.dart b/lib/services/DialogHelper.dart index 01c9687a..f77f013f 100644 --- a/lib/services/DialogHelper.dart +++ b/lib/services/DialogHelper.dart @@ -303,4 +303,34 @@ class DialogHelper{ ); return result; } + + static Future showProgressDialogAsync( + BuildContext context, + String title, + int total, + ValueNotifier progressNotifier, + ) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (context, progress, _) { + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${"Progress".tr()}: $progress/$total"), + SizedBox(height: 20), + LinearProgressIndicator(value: total > 0 ? progress / total : 0), + ], + ), + ); + }, + ); + }, + ); + } } \ No newline at end of file diff --git a/lib/services/MailerSendHelper.dart b/lib/services/MailerSendHelper.dart index c8ddffa3..7acd5aed 100644 --- a/lib/services/MailerSendHelper.dart +++ b/lib/services/MailerSendHelper.dart @@ -14,7 +14,7 @@ class MailerSendHelper{ allVars.addAll(_getSalutationPresalutation(recipient)); await ImportService.emailMailerSend(recipient.email!, "templateid", allVars); - ToastHelper.Show("E-mail with credentials was sent to: {email}.".tr(namedArgs: {"email":recipient.email!})); + //ToastHelper.Show("E-mail with credentials was sent to: {email}.".tr(namedArgs: {"email":recipient.email!})); await Future.delayed(const Duration(milliseconds: 6000)); } diff --git a/supabase/functions/_shared/emailClient.ts b/supabase/functions/_shared/emailClient.ts new file mode 100644 index 00000000..3048be4f --- /dev/null +++ b/supabase/functions/_shared/emailClient.ts @@ -0,0 +1,69 @@ +// _shared/emailClient.ts +import { SMTPClient } from "https://deno.land/x/denomailer/mod.ts"; + +const _SMTP_HOSTNAME = Deno.env.get("SMTP_HOSTNAME")!; +const _SMTP_USER_NAME = Deno.env.get("SMTP_USER_NAME")!; +const _SMTP_USER_PASSWORD = Deno.env.get("SMTP_USER_PASSWORD")!; +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; + +const smtpClient = new SMTPClient({ + connection: { + hostname: _SMTP_HOSTNAME, + port: 465, + tls: true, + auth: { + username: _SMTP_USER_NAME, + password: _SMTP_USER_PASSWORD, + }, + }, +}); + +export async function sendEmail({ + to, + subject, + html, + from = `${_DEFAULT_EMAIL}`, +}: { + to: string; + subject: string; + html: string; + from?: string; +}) { + try { + await smtpClient.send({ + from, + to, + subject, + html, + }); + console.log("Email sent successfully to:", to); + } catch (error) { + console.error("Failed to send email:", error); + } finally { + await smtpClient.close(); + } +} + +export async function sendEmailWithSubs({ + to, + subject, + content, + subs, + from = `${_DEFAULT_EMAIL}`, +}: { + to: string; + subject: string; + content: string; + subs: Record; + from?: string; +}) { + // Replace placeholders in content with values from subs + let html = content; + for (const [key, value] of Object.entries(subs)) { + const placeholder = `{{${key}}}`; + html = html.replaceAll(placeholder, value); + } + + // Use the sendEmail function to send the processed email + await sendEmail({ to, subject, html, from }); +} diff --git a/supabase/functions/email/index.ts b/supabase/functions/email/index.ts index 5859867a..f40f132f 100644 --- a/supabase/functions/email/index.ts +++ b/supabase/functions/email/index.ts @@ -1,11 +1,7 @@ -import { SMTPClient } from "https://deno.land/x/denomailer/mod.ts"; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4' - -const _SMTP_HOSTNAME = Deno.env.get('SMTP_HOSTNAME')!; -const _SMTP_USER_NAME = Deno.env.get('SMTP_USER_NAME')!; -const _SMTP_USER_PASSWORD = Deno.env.get('SMTP_USER_PASSWORD')!; -const _DEFAULT_EMAIL = Deno.env.get('DEFAULT_EMAIL')!; +import { sendEmailWithSubs } from "../_shared/emailClient.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4'; +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; const _supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! @@ -22,12 +18,9 @@ Deno.serve(async (req) => { } const reqData = await req.json(); - console.log(reqData); - - const userEmail = reqData.email != null ? reqData.email.toLowerCase() : "bujnmi@gmail.com"; + const userEmail = reqData.email ? reqData.email.toLowerCase() : "bujnmi@gmail.com"; const organization = reqData.organization; - // Fetch organization data to get appName and defaultUrl const orgData = await _supabase .from("organizations") .select("data") @@ -46,7 +39,6 @@ Deno.serve(async (req) => { const appName = orgConfig.APP_NAME || "DefaultAppName"; const defaultUrl = orgConfig.DEFAULT_URL || "http://default.url"; - // Search for user by email and organization in user_info const userData = await _supabase .from("user_info") .select() @@ -76,7 +68,6 @@ Deno.serve(async (req) => { "token": token, }); - // Fetch email template for the organization const template = await _supabase .from("email_templates") .select() @@ -92,29 +83,19 @@ Deno.serve(async (req) => { }); } - let html = template.data.html; - html = html.replaceAll(`{{.ResetPasswordLink}}`, `${defaultUrl}/#/resetPassword?token=${token}`); - const client = new SMTPClient({ - connection: { - hostname: _SMTP_HOSTNAME, - port: 465, - tls: true, - auth: { - username: _SMTP_USER_NAME, - password: _SMTP_USER_PASSWORD, - }, - }, - }); + const resetPasswordLink = `${defaultUrl}/#/resetPassword?token=${token}`; + const subs = { + resetPasswordLink: resetPasswordLink, + }; - await client.send({ - from: `${appName} | Festapp <${_DEFAULT_EMAIL}>`, + await sendEmailWithSubs({ to: userEmail, subject: template.data.subject, - html: html, + content: template.data.html, + subs, + from: `${appName} | Festapp <${_DEFAULT_EMAIL}>`, }); - await client.close(); - await _supabase .from("log_emails") .insert({ diff --git a/supabase/functions/register/index.ts b/supabase/functions/register/index.ts index 3a83fde9..d78282fc 100644 --- a/supabase/functions/register/index.ts +++ b/supabase/functions/register/index.ts @@ -1,10 +1,7 @@ -import { SMTPClient } from "https://deno.land/x/denomailer/mod.ts"; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4' +import { sendEmailWithSubs } from "../_shared/emailClient.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4'; -const _SMTP_HOSTNAME = Deno.env.get('SMTP_HOSTNAME')!; -const _SMTP_USER_NAME = Deno.env.get('SMTP_USER_NAME')!; -const _SMTP_USER_PASSWORD = Deno.env.get('SMTP_USER_PASSWORD')!; -const _DEFAULT_EMAIL = Deno.env.get('DEFAULT_EMAIL')!; +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; const _supabase = createClient( Deno.env.get('SUPABASE_URL')!, @@ -22,10 +19,9 @@ Deno.serve(async (req) => { } const reqData = await req.json(); - const userEmail = reqData.email.trim();; + const userEmail = reqData.email.trim(); const organizationId = reqData.organization; - // Retrieve organization-specific settings for APP_NAME and DEFAULT_URL const orgData = await _supabase .from("organizations") .select("data") @@ -44,8 +40,7 @@ Deno.serve(async (req) => { const appName = orgConfig.APP_NAME; const defaultUrl = orgConfig.DEFAULT_URL; - // Check if any required config values are missing - if (!appName || !defaultUrl || !_SMTP_HOSTNAME || !_SMTP_USER_NAME || !_SMTP_USER_PASSWORD) { + if (!appName || !defaultUrl) { console.error("Required configuration is missing."); return new Response( JSON.stringify({ error: "Missing required configuration" }), @@ -56,24 +51,22 @@ Deno.serve(async (req) => { ); } - // Check if the email already exists in user_info - const userData = await _supabase - .from("user_info") - .select() - .eq("email_readonly", userEmail) - .eq("organization", organizationId) // Add organization ID condition - .maybeSingle(); + const userData = await _supabase + .from("user_info") + .select() + .eq("email_readonly", userEmail) + .eq("organization", organizationId) + .maybeSingle(); - if (userData.data != null) { - return new Response(JSON.stringify({ "email": userEmail, "code": 409 }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - status: 200, - }); - } + if (userData.data != null) { + return new Response(JSON.stringify({ "email": userEmail, "code": 409 }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + } const code = Math.floor(100000 + Math.random() * 900000).toString(); - // Create user using the stored procedure const { data } = await _supabase.rpc("create_user_in_organization_with_data", { org: organizationId, email: userEmail, @@ -81,41 +74,25 @@ Deno.serve(async (req) => { data: reqData, }); - const userId = data; - console.log(userId); - - // Fetch email template const template = await _supabase .from("email_templates") .select() .eq("id", "SIGN_IN_CODE") .single(); - let html = template.data.html; - html = html.replaceAll(`{{code}}`, code); - html = html.replaceAll(`{{email}}`, userEmail); - - const client = new SMTPClient({ - connection: { - hostname: _SMTP_HOSTNAME, - port: 465, - tls: true, - auth: { - username: _SMTP_USER_NAME, - password: _SMTP_USER_PASSWORD, - }, - }, - }); + const subs = { + code: code, + email: userEmail, + }; - await client.send({ - from: `${appName} | Festapp <${_DEFAULT_EMAIL}>`, + await sendEmailWithSubs({ to: userEmail, subject: template.data.subject, - html: html, + content: template.data.html, + subs, + from: `${appName} | Festapp <${_DEFAULT_EMAIL}>`, }); - await client.close(); - await _supabase .from("log_emails") .insert({ diff --git a/supabase/functions/send-reset-password-link/index.ts b/supabase/functions/send-reset-password-link/index.ts new file mode 100644 index 00000000..f40f132f --- /dev/null +++ b/supabase/functions/send-reset-password-link/index.ts @@ -0,0 +1,112 @@ +import { sendEmailWithSubs } from "../_shared/emailClient.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4'; + +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; +const _supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +); + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + const reqData = await req.json(); + const userEmail = reqData.email ? reqData.email.toLowerCase() : "bujnmi@gmail.com"; + const organization = reqData.organization; + + const orgData = await _supabase + .from("organizations") + .select("data") + .eq("id", organization) + .single(); + + if (orgData.error || !orgData.data) { + console.error("Organization data not found."); + return new Response(JSON.stringify({ error: "Organization data not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const orgConfig = orgData.data.data; + const appName = orgConfig.APP_NAME || "DefaultAppName"; + const defaultUrl = orgConfig.DEFAULT_URL || "http://default.url"; + + const userData = await _supabase + .from("user_info") + .select() + .eq("organization", organization) + .ilike("email_readonly", userEmail) + .maybeSingle(); + + if (userData.data == null) { + return new Response(JSON.stringify({ "email": userEmail }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + } + + const userId = userData.data.id; + const token = crypto.randomUUID(); + + await _supabase + .from("user_reset_token") + .delete() + .eq("user", userId); + + await _supabase + .from("user_reset_token") + .insert({ + "user": userId, + "token": token, + }); + + const template = await _supabase + .from("email_templates") + .select() + .eq("id", "RESET_PASSWORD") + .eq("organization", organization) + .single(); + + if (template.error || !template.data) { + console.error("Template not found for the specified organization."); + return new Response(JSON.stringify({ error: "Template not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const resetPasswordLink = `${defaultUrl}/#/resetPassword?token=${token}`; + const subs = { + resetPasswordLink: resetPasswordLink, + }; + + await sendEmailWithSubs({ + to: userEmail, + subject: template.data.subject, + content: template.data.html, + subs, + from: `${appName} | Festapp <${_DEFAULT_EMAIL}>`, + }); + + await _supabase + .from("log_emails") + .insert({ + "from": _DEFAULT_EMAIL, + "to": userEmail, + "template": template.data.id, + "organization": organization + }); + + return new Response(JSON.stringify({ "email": userEmail }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); +}); diff --git a/supabase/functions/send-sign-in-code/index.ts b/supabase/functions/send-sign-in-code/index.ts new file mode 100644 index 00000000..3552acb0 --- /dev/null +++ b/supabase/functions/send-sign-in-code/index.ts @@ -0,0 +1,134 @@ +import { sendEmailWithSubs } from "../_shared/emailClient.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4'; + +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +Deno.serve(async (req) => { + try { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: req.headers.get('Authorization')! } } } + ); + + const reqData = await req.json(); + const userId = reqData.usr; // ID of the user to invite + const occasionId = reqData.oc; // ID of the occasion + + + const code = Math.floor(100000 + Math.random() * 900000).toString(); + // this function will also check if requester is manager on the occasion or if requester is admin + const { data: answer, error: passwordSetError } = await supabase.rpc("set_user_password", + { + usr: userId, + oc: occasionId, + password: code + }); + + if (passwordSetError || !answer) { + console.error("Password change has failed."); + return new Response(JSON.stringify({ error: "Password change fail" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 403, + }); + } + + const occasionUser = await supabase + .from("occasion_users") + .select("data") + .eq("user", userId) + .eq("occasion", occasionId) + .single(); + + if (!occasionUser.data) { + console.error("User is not part of the occasion."); + return new Response(JSON.stringify({ error: "User is not part of the occasion" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const { data: occasionData, error: occasionError } = await supabase + .from("occasions") + .select("organization") + .eq("id", occasionId) + .single(); + + if (occasionError) { + console.error("Occasion not found."); + return new Response(JSON.stringify({ error: "Occasion not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const organizationId = occasionData.organization; + + // Fetch email template based on the organization + const template = await supabase + .from("email_templates") + .select() + .eq("organization", organizationId) + .eq("id", "SIGN_IN_CODE") + .single(); + + if (template.error || !template.data) { + console.error("Email template not found for the organization."); + return new Response(JSON.stringify({ error: "Email template not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const subs = { + code: code, + email: occasionUser.data.data.email, // Access the user's email from the data field + }; + + await sendEmailWithSubs({ + to: occasionUser.data.data.email, // Use the user’s email from the data field + subject: template.data.subject, + content: template.data.html, + subs, + from: `Festapp <${_DEFAULT_EMAIL}>`, + }); + + occasionUser.data.data.is_invited = true; + const { error: updateError } = await supabase + .from("occasion_users") + .update({ data: occasionUser.data.data }) // Preserve other data, update only is_invited + .eq("user", userId) + .eq("occasion", occasionId) + .select() + .single(); + + if (updateError) { + console.error("Failed to update is_invited status:", updateError); + return new Response(JSON.stringify({ error: "Failed to update invitation status" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }); + } + + return new Response(JSON.stringify({ "user": userId, "code": 200 }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + + } catch (error) { + console.error("Unexpected error:", error); + return new Response(JSON.stringify({ error: "Unexpected error occurred" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }); + } +});