diff --git a/.env.sample b/.env.sample index c12c7dc0..a3b2af72 100644 --- a/.env.sample +++ b/.env.sample @@ -36,6 +36,7 @@ MAIL_FROM_NAME=Claper # Claper configuration #ENABLE_ACCOUNT_CREATION=true +#EMAIL_CONFIRMATION=true #ALLOW_UNLINK_EXTERNAL_PROVIDER=false #LOGOUT_REDIRECT_URL=https://google.com #GS_JPG_RESOLUTION=300x300 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b71492..58107054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## v2.2.0 + +### Features + +- Add duplicate feature on finished events +- Add italian translation (thanks to @loviuz and @albanobattistella) +- Add EMAIL_CONFIRMATION environment variable to disable or enable email confirmation after registration + +### Fixes and improvements + +- Improve performance of global reactions +- Change QR Code background color to white +- Improve auto scroll of messages on the manager +- Fix pinning of questions +- Fix name picker being empty during a reconnect +- Change wording for more options dropdown and access +- Fix dropdown position to be on the front of other elements +- Owner and facilitators of the event can now join the attendee room before the event starts +- Fix email templates + ## v2.1.1 ### Fixes and improvements diff --git a/README.md b/README.md index 1f48c611..47b1ee9e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Claper has a two-sided mission: - The first one is to help these people presenting an idea or a message by giving them the opportunity to make their presentation unique and to have real-time feedback from their audience. - The second one is to help each participant to take their place, to be an actor in the presentation, in the meeting and to feel important and useful. -Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch +Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch, 🇮🇹 Italian ### Built With diff --git a/assets/js/app.js b/assets/js/app.js index 0df58fcb..30192bc9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -12,10 +12,12 @@ import airdatepickerLocaleFr from "air-datepicker/locale/fr"; import airdatepickerLocaleDe from "air-datepicker/locale/de"; import airdatepickerLocaleEs from "air-datepicker/locale/es"; import airdatepickerLocaleNl from "air-datepicker/locale/nl"; +import airdatepickerLocaleIt from "air-datepicker/locale/it"; import "moment/locale/de"; import "moment/locale/fr"; import "moment/locale/es"; import "moment/locale/nl"; +import "moment/locale/it"; import QRCodeStyling from "qr-code-styling"; import { Presenter } from "./presenter"; import { Manager } from "./manager"; @@ -23,7 +25,7 @@ import Split from "split-grid"; import { TourGuideClient } from "@sjmc11/tourguidejs/src/Tour"; window.moment = moment; -const supportedLocales = ["en", "fr", "de", "es", "nl"]; +const supportedLocales = ["en", "fr", "de", "es", "nl", "it"]; var locale = document.querySelector("html").getAttribute("lang") || @@ -44,6 +46,7 @@ let airdatepickerLocale = { de: airdatepickerLocaleDe, es: airdatepickerLocaleEs, nl: airdatepickerLocaleNl, + it: airdatepickerLocaleIt, }; let csrfToken = document .querySelector("meta[name='csrf-token']") @@ -169,18 +172,27 @@ Hooks.Scroll = { Hooks.ScrollIntoDiv = { mounted() { - this.scrollElement(true); - this.handleEvent("scroll", this.scrollElement.bind(this)); - }, - scrollElement(firstScroll) { - let t = this.el.parentElement; - if ( - firstScroll === true || - t.scrollHeight - t.scrollTop - t.clientHeight <= 100 - ) { - t.scrollTo({ top: t.scrollHeight, behavior: "smooth" }); + let useParent = this.el.dataset.useParent === "true"; + this.scrollElement = this.el.dataset.useParent === "true" ? this.el.parentElement : this.el; + this.checkIfAtBottom(); + this.scrollToBottom(true); + this.handleEvent("scroll", () => this.scrollToBottom()); + this.scrollElement.addEventListener("scroll", () => this.checkIfAtBottom()); + }, + checkIfAtBottom() { + this.isAtBottom = this.scrollElement.scrollHeight - this.scrollElement.scrollTop - this.scrollElement.clientHeight <= 30; + }, + scrollToBottom(force = false) { + if (force || this.isAtBottom) { + this.scrollElement.scrollTo({ top: this.scrollElement.scrollHeight, behavior: "smooth" }); } }, + updated() { + this.scrollToBottom(); + }, + destroyed() { + this.scrollElement.removeEventListener("scroll", () => this.checkIfAtBottom()); + } }; Hooks.NicknamePicker = { @@ -192,6 +204,12 @@ Hooks.NicknamePicker = { this.el.addEventListener("click", (e) => this.clicked(e)); }, + reconnected() { + let currentNickname = localStorage.getItem("nickname") || ""; + if (currentNickname.length > 0) { + this.pushEvent("set-nickname", { nickname: currentNickname }); + } + }, destroyed() { this.el.removeEventListener("click", (e) => this.clicked(e)); }, @@ -362,18 +380,37 @@ Hooks.OpenPresenter = { }, }; Hooks.GlobalReacts = { + svgCache: {}, + mounted() { + this.preloadSVGs(); this.handleEvent("global-react", (data) => { - var img = document.createElement("img"); - img.src = "/images/icons/" + data.type + ".svg"; - img.className = - "react-animation absolute transform opacity-0" + this.el.className; - this.el.appendChild(img); + const svgContent = this.svgCache[data.type]; + if (svgContent) { + const container = document.createElement("div"); + container.innerHTML = svgContent; + const svgElement = container.firstChild; + svgElement.classList.add("react-animation", "absolute", "transform", "opacity-0"); + svgElement.classList.add(...this.el.className.split(" ")); + this.el.appendChild(svgElement); + } }); this.handleEvent("reset-global-react", (data) => { this.el.innerHTML = ""; }); }, + + preloadSVGs() { + const svgTypes = ["heart", "hundred", "clap", "raisehand"]; + svgTypes.forEach(type => { + fetch(`/images/icons/${type}.svg`) + .then(response => response.text()) + .then(svgContent => { + this.svgCache[type] = svgContent; + }) + .catch(error => console.error(`Error loading SVG for ${type}:`, error)); + }); + } }; Hooks.JoinEvent = { mounted() { @@ -461,10 +498,10 @@ Hooks.QRCode = { }, dotsOptions: { type: "square", - color: "#ffffff", + color: "#000000", }, backgroundOptions: { - color: "#000000", + color: "#ffffff", }, imageOptions: { crossOrigin: "anonymous", @@ -565,16 +602,6 @@ window.addEventListener("phx:page-loading-stop", (info) => { topbar.hide(); }); -const renderOnlineUsers = function (presences) { - let onlineUsers = Presence.list( - presences, - (_id, { metas: [user, ...rest] }) => { - return onlineUserTemplate(user); - } - ).join(""); - - document.querySelector("body").innerHTML = onlineUsers; -}; const onlineUserTemplate = function (user) { return ` @@ -587,7 +614,6 @@ const onlineUserTemplate = function (user) { let presences = {}; liveSocket.on("presence_state", (state) => { presences = Presence.syncState(presences, state); - renderOnlineUsers(presences); }); // connect if there are any LiveViews on the page diff --git a/assets/js/manager.js b/assets/js/manager.js index a70613f9..06d5ba63 100644 --- a/assets/js/manager.js +++ b/assets/js/manager.js @@ -63,8 +63,16 @@ export class Manager { let originalSnap = localStorage.getItem("preview-position"); if (originalSnap) { let snaps = originalSnap.split(":"); - preview.style.left = `${snaps[0]}px`; - preview.style.top = `${snaps[1]}px`; + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const previewWidth = preview.offsetWidth; + const previewHeight = preview.offsetHeight; + + const left = Math.min(Math.max(parseInt(snaps[0]), 0), windowWidth - previewWidth); + const top = Math.min(Math.max(parseInt(snaps[1]), 0), windowHeight - previewHeight); + + preview.style.left = `${left}px`; + preview.style.top = `${top}px`; } const startDrag = (e) => { diff --git a/config/dev.exs b/config/dev.exs index 0912595b..720a20b2 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -35,7 +35,8 @@ config :claper, ClaperWeb.Endpoint, ~r"priv/static/[^uploads].*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", ~r"lib/claper_web/(live|views)/.*(ex)$", - ~r"lib/claper_web/templates/.*(eex)$" + ~r"lib/claper_web/templates/.*(eex)$", + ~r"assets/.*\.(js|css)$" ] ] diff --git a/config/runtime.exs b/config/runtime.exs index 7dc94de1..06090c55 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -59,6 +59,10 @@ enable_account_creation = get_var_from_path_or_env(config_dir, "ENABLE_ACCOUNT_CREATION", "true") |> String.to_existing_atom() +email_confirmation = + get_var_from_path_or_env(config_dir, "EMAIL_CONFIRMATION", "false") + |> String.to_existing_atom() + pool_size = get_int_from_path_or_env(config_dir, "POOL_SIZE", 10) queue_target = get_int_from_path_or_env(config_dir, "QUEUE_TARGET", 5_000) @@ -150,6 +154,7 @@ config :claper, ClaperWeb.Endpoint, config :claper, enable_account_creation: enable_account_creation, + email_confirmation: email_confirmation, allow_unlink_external_provider: allow_unlink_external_provider, logout_redirect_url: logout_redirect_url diff --git a/lib/claper/accounts/user_notifier.ex b/lib/claper/accounts/user_notifier.ex index e836c94b..e489c48c 100644 --- a/lib/claper/accounts/user_notifier.ex +++ b/lib/claper/accounts/user_notifier.ex @@ -1,24 +1,24 @@ defmodule Claper.Accounts.UserNotifier do - import Swoosh.Email + # import Swoosh.Email alias Claper.Mailer # Delivers the email using the application mailer. - defp deliver(recipient, subject, body) do - from_name = Application.get_env(:claper, :mail)[:from_name] - from_email = Application.get_env(:claper, :mail)[:from] - - email = - new() - |> to(recipient) - |> from({from_name, from_email}) - |> subject(subject) - |> text_body(body) - - with {:ok, _metadata} <- Mailer.deliver(email) do - {:ok, email} - end - end + # defp deliver(recipient, subject, body) do + # from_name = Application.get_env(:claper, :mail)[:from_name] + # from_email = Application.get_env(:claper, :mail)[:from] + + # email = + # new() + # |> to(recipient) + # |> from({from_name, from_email}) + # |> subject(subject) + # |> text_body(body) + + # with {:ok, _metadata} <- Mailer.deliver(email) do + # {:ok, email} + # end + # end def deliver_magic_link(email, url) do email = ClaperWeb.Notifiers.UserNotifier.magic(email, url) @@ -40,40 +40,22 @@ defmodule Claper.Accounts.UserNotifier do Deliver instructions to confirm account. """ def deliver_confirmation_instructions(user, url) do - deliver(user.email, "Confirmation instructions", """ - - ============================== - - Hi #{user.email}, - - You can confirm your account by visiting the URL below: - - #{url} - - If you didn't create an account with us, please ignore this. + email = ClaperWeb.Notifiers.UserNotifier.confirm(user, url) - ============================== - """) + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end end @doc """ Deliver instructions to reset a user password. """ def deliver_reset_password_instructions(user, url) do - deliver(user.email, "Reset password instructions", """ - - ============================== - - Hi #{user.email}, - - You can reset your password by visiting the URL below: + email = ClaperWeb.Notifiers.UserNotifier.reset(user, url) - #{url} - - If you didn't request this change, please ignore this. - - ============================== - """) + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end end @doc """ diff --git a/lib/claper/events.ex b/lib/claper/events.ex index ed8ba60b..0146d39d 100644 --- a/lib/claper/events.ex +++ b/lib/claper/events.ex @@ -398,7 +398,7 @@ defmodule Claper.Events do attrs = Map.from_struct(original_event) - |> Map.drop([:id, :inserted_at, :updated_at, :presentation_file]) + |> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at]) |> Map.put(:leaders, []) |> Map.put(:code, "#{new_code}") |> Map.put(:name, "#{original_event.name} (Copy)") diff --git a/lib/claper_web/controllers/user_confirmation_controller.ex b/lib/claper_web/controllers/user_confirmation_controller.ex index a1980ae0..b17728c3 100644 --- a/lib/claper_web/controllers/user_confirmation_controller.ex +++ b/lib/claper_web/controllers/user_confirmation_controller.ex @@ -32,7 +32,7 @@ defmodule ClaperWeb.UserConfirmationController do {:ok, _} -> conn |> put_flash(:info, "User confirmed successfully.") - |> redirect(to: "/") + |> redirect(to: ~p"/users/log_in") :error -> # If there is a current user and the account was already confirmed, @@ -41,12 +41,12 @@ defmodule ClaperWeb.UserConfirmationController do # a warning message. case conn.assigns do %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> - redirect(conn, to: "/") + redirect(conn, to: ~p"/users/log_in") %{} -> conn |> put_flash(:error, "User confirmation link is invalid or it has expired.") - |> redirect(to: "/") + |> redirect(to: ~p"/") end end end diff --git a/lib/claper_web/controllers/user_oidc_auth.ex b/lib/claper_web/controllers/user_oidc_auth.ex index 9aaec128..d889f694 100644 --- a/lib/claper_web/controllers/user_oidc_auth.ex +++ b/lib/claper_web/controllers/user_oidc_auth.ex @@ -40,13 +40,19 @@ defmodule ClaperWeb.UserOidcAuth do conn |> UserAuth.log_in_user(oidc_user.user) else - {:error, _} -> + {:error, reason} -> conn - |> put_flash(:error, "Cannot authenticate user.") - |> redirect(to: ~p"/users/log_in") + |> put_status(:unauthorized) + |> put_view(ClaperWeb.ErrorView) + |> render("csrf_error.html", %{error: "Authentication failed: #{inspect(reason)}"}) end + end + def callback(conn, %{"error" => error} = _params) do conn + |> put_status(:unauthorized) + |> put_view(ClaperWeb.ErrorView) + |> render("csrf_error.html", %{error: "Authentication failed: #{error}"}) end defp config do diff --git a/lib/claper_web/controllers/user_registration_controller.ex b/lib/claper_web/controllers/user_registration_controller.ex index 2e9c6ece..4f81abe5 100644 --- a/lib/claper_web/controllers/user_registration_controller.ex +++ b/lib/claper_web/controllers/user_registration_controller.ex @@ -23,15 +23,20 @@ defmodule ClaperWeb.UserRegistrationController do def create(conn, %{"user" => user_params}) do case Accounts.register_user(user_params) do {:ok, user} -> - # {:ok, _} = - # Accounts.deliver_user_confirmation_instructions( - # user, - # &url(~p"/users/confirm/#{&1}") - # ) + if Application.get_env(:claper, :email_confirmation) do + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) - conn - |> put_flash(:info, "User created successfully.") - |> UserAuth.log_in_user(user) + conn + |> redirect(to: ~p"/users/register/confirm") + else + conn + |> put_flash(:info, "User created successfully.") + |> UserAuth.log_in_user(user) + end {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) diff --git a/lib/claper_web/controllers/user_session_controller.ex b/lib/claper_web/controllers/user_session_controller.ex index 81ecc219..dc1d10ae 100644 --- a/lib/claper_web/controllers/user_session_controller.ex +++ b/lib/claper_web/controllers/user_session_controller.ex @@ -43,7 +43,17 @@ defmodule ClaperWeb.UserSessionController do oidc_enabled = Application.get_env(:claper, :oidc)[:enabled] if user = Accounts.get_user_by_email_and_password(email, password) do - UserAuth.log_in_user(conn, user, user_params) + if Application.get_env(:claper, :email_confirmation) and !user.confirmed_at do + render(conn, "new.html", + error_message: + "You need to confirm your account before logging in. Please check your email for confirmation instructions.", + oidc_provider_name: oidc_provider_name, + oidc_logo_url: oidc_logo_url, + oidc_enabled: oidc_enabled + ) + else + UserAuth.log_in_user(conn, user, user_params) + end else render(conn, "new.html", error_message: "Invalid email or password", diff --git a/lib/claper_web/live/event_live/event_card_component.ex b/lib/claper_web/live/event_live/event_card_component.ex index cfbde2e6..29c60458 100644 --- a/lib/claper_web/live/event_live/event_card_component.ex +++ b/lib/claper_web/live/event_live/event_card_component.ex @@ -82,7 +82,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do phx-target={@myself} class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-primary-600 bg-primary-500" > - <%= gettext("Access") %> + <%= gettext("Join") %>