diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..14a6785 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import './modern.css' layer(theme); \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 5e24308..92da708 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2,7 +2,7 @@ @import 'animate.css/animate.min.css'; @import 'tailwindcss'; -@import './theme.css'; +@import './theme.css' layer(theme); @layer base { *, @@ -302,6 +302,20 @@ url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */ } +@font-face { + font-family: 'Montserrat'; + font-style: normal; + font-weight: 100 300 400 500 700 900; + src: url('/fonts/Montserrat/Montserrat-VariableFont_wght.ttf'); +} + +@font-face { + font-family: 'Montserrat'; + font-style: italic; + font-weight: 100 300 400 500 700 900; + src: url('/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf'); +} + .bg-gradient-animate { background: linear-gradient(-45deg, var(--color-secondary-500), var(--color-secondary-600), var(--color-primary-400), var(--color-primary-400)); background-size: 400% 400%; diff --git a/assets/css/modern.css b/assets/css/modern.css new file mode 100644 index 0000000..9dc3070 --- /dev/null +++ b/assets/css/modern.css @@ -0,0 +1,152 @@ +@theme { + /* Colors */ + /* Main colors */ + --color-*: initial; + --color-sky-blue: #81EBFE; + --color-teal: #14BFDB; + --color-azure: #29ACED; + --color-mauve: #8611ED; + --color-purple: #B80FEF; + --color-navy: #140553; + + /* Light colors */ + --color-cold-pink: #F3DEFA; + --color-warm-pink: #FFD2F6; + --color-yeallaw: #FFEAC2; + --color-pistacio: #D4FFF4; + --color-sky-tern: #C8EDFF; + --color-tealy: #81EBFE; + --color-earl-night: #0A7BB4; + --color-dark-night: #184992; + + /* Gradient */ + --gradient-primary: 123deg, var(--color-sky-blue) -36%, var(--color-teal) -12%, var(--color-azure) 21%, var(--color-mauve) 81%, var(--color-purple) 130%; + --gradient-secondary: 123deg, rgba(129, 235, 254, 0.9) -36%, rgba(20, 191, 219, 0.9) -12%, rgba(41, 172, 237, 0.9) 21%, rgba(134, 17, 237, 0.9) 81%, rgba(184, 15, 239, 0.9) 130%; + + /* Grayscale */ + --color-white: #FFFFFF; + --color-black: #191919; + --color-gray-20: #CCCCCC; + --color-gray-40: #9F9F9F; + --color-gray-60: #737373; + --color-gray-80: #464646; + --color-gray-100: #191919; + --color-gray-120: #000000; + + --color-platinum: #F0F0F0; + --color-platinum-20: #FFFFFF; + --color-platinum-40: #FCFCFC; + --color-platinum-60: #F8F8F8; + --color-platinum-80: #F4F4F4; + --color-platinum-100: #F0F0F0; + --color-platinum-120: #EEEEEE; + + /* Typography */ + --font-display: "Montserrat", sans-serif; + + /* Font Sizes - Desktop */ + --text-h1: 80px; + --leading-h1: 120%; + --font-weight-h1: 900; + + --text-h2: 40px; + --leading-h2: 120%; + --font-weight-h2: 700; + + --text-h3: 32px; + --leading-h3: 120%; + --font-weight-h3: 600; + + --text-h4: 24px; + --leading-h4: 120%; + --font-weight-h4: 500; + + --text-h5: 18px; + --leading-h5: 120%; + --font-weight-h5: 600; + + --text-h6: 16px; + --leading-h6: 120%; + --font-weight-h6: 500; + + --text-subheading: 20px; + --leading-subheading: 150%; + --font-weight-subheading: 300; + + --text-body-bold: 16px; + --leading-body-bold: 150%; + --font-weight-body-bold: 700; + + --text-body: 16px; + --leading-body: 150%; + --font-weight-body: 400; + + --text-small-body-bold: 14px; + --leading-small-body-bold: 150%; + --font-weight-small-body-bold: 600; + + --text-small-body: 14px; + --leading-small-body: 150%; + --font-weight-small-body: 400; + + --text-caption: 14px; + --leading-caption: 150%; + --font-weight-caption: 300; + + /* Font Sizes - Mobile */ + --text-mobile-h1: 32px; + --leading-mobile-h1: 120%; + --font-weight-mobile-h1: 900; + + --text-mobile-h2: 28px; + --leading-mobile-h2: 120%; + --font-weight-mobile-h2: 700; + + --text-mobile-h3: 22px; + --leading-mobile-h3: 120%; + --font-weight-mobile-h3: 600; + + --text-mobile-h4: 18px; + --leading-mobile-h4: 120%; + --font-weight-mobile-h4: 500; + + --text-mobile-h5: 16px; + --leading-mobile-h5: 120%; + --font-weight-mobile-h5: 600; + + --text-mobile-h6: 14px; + --leading-mobile-h6: 120%; + --font-weight-mobile-h6: 500; + + /* Spacing */ + --spacing-0: 0px; + --spacing-4: 4px; + --spacing-8: 8px; + --spacing-12: 12px; + --spacing-16: 16px; + --spacing-20: 20px; + --spacing-24: 24px; + --spacing-28: 28px; + --spacing-32: 32px; + --spacing-36: 36px; + --spacing-40: 40px; + --spacing-44: 44px; + --spacing-48: 48px; + --spacing-52: 52px; + --spacing-56: 56px; + --spacing-60: 60px; + --spacing-64: 64px; + --spacing-68: 68px; + --spacing-72: 72px; + --spacing-76: 76px; + --spacing-80: 80px; + --spacing-128: 128px; + + /* Font Weights */ + --font-weight-regular: 400; + --font-weight-bold: 700; + + /* Shadows */ + --shadow-3xl: 0px 902px 253px 0px rgba(65, 69, 124, 0.00), 0px 577px 231px 0px rgba(65, 69, 124, 0.01), 0px 325px 195px 0px rgba(65, 69, 124, 0.05), 0px 144px 144px 0px rgba(65, 69, 124, 0.09), 0px 36px 79px 0px rgba(65, 69, 124, 0.10) + +} \ No newline at end of file diff --git a/assets/css/theme.css b/assets/css/theme.css index dc8f31a..10987a5 100644 --- a/assets/css/theme.css +++ b/assets/css/theme.css @@ -1,4 +1,4 @@ -/* Theme variables based on tailwind.config.js */ + @theme { /* Primary Colors (water-blue) */ --color-primary-50: #E3F2FD; @@ -75,6 +75,7 @@ /* Font Family */ --font-family-sans: 'Roboto', sans-serif; --font-family-serif: 'Merriweather', serif; + --font-display: 'Montserrat', sans-serif; /* Box Shadows */ --shadow-base: 0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06); diff --git a/config/config.exs b/config/config.exs index 2a1dcbf..51120c3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,6 +44,13 @@ config :tailwind, --output=priv/static/assets/app.css ), cd: Path.expand("..", __DIR__) + ], + admin: [ + args: ~w( + --input=assets/css/admin.css + --output=priv/static/assets/admin.css + ), + cd: Path.expand("..", __DIR__) ] # Configure esbuild (the version is required) diff --git a/config/dev.exs b/config/dev.exs index 3c68d69..41198be 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -14,6 +14,7 @@ config :claper, ClaperWeb.Endpoint, # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}, + tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]}, sass: { DartSass, :install_and_run, diff --git a/config/runtime.exs b/config/runtime.exs index d4dfdff..e5b490a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -128,7 +128,7 @@ allow_unlink_external_provider = logout_redirect_url = get_var_from_path_or_env(config_dir, "LOGOUT_REDIRECT_URL", nil) -languages = +languages = get_var_from_path_or_env(config_dir, "LANGUAGES", "en,fr,es") |> String.split(",") |> Enum.map(&String.trim/1) diff --git a/lib/claper/accounts.ex b/lib/claper/accounts.ex index 9bc56e4..4ad903c 100644 --- a/lib/claper/accounts.ex +++ b/lib/claper/accounts.ex @@ -43,7 +43,6 @@ defmodule Claper.Accounts do |> Repo.get_by(email: email) end - @doc """ Gets a user by email and creates a new user if the user does not exist. @@ -853,7 +852,9 @@ defmodule Claper.Accounts do """ def assign_role(%User{} = user, role_name) when is_binary(role_name) do case get_role_by_name(role_name) do - nil -> {:error, :role_not_found} + nil -> + {:error, :role_not_found} + role -> user |> Ecto.Changeset.change(%{role_id: role.id}) @@ -890,7 +891,9 @@ defmodule Claper.Accounts do """ def list_users_by_role(role_name) when is_binary(role_name) do case get_role_by_name(role_name) do - nil -> [] + nil -> + [] + role -> User |> where([u], u.role_id == ^role.id and is_nil(u.deleted_at)) diff --git a/lib/claper/accounts/guardian.ex b/lib/claper/accounts/guardian.ex index 6f3a303..618dcf9 100644 --- a/lib/claper/accounts/guardian.ex +++ b/lib/claper/accounts/guardian.ex @@ -3,15 +3,15 @@ defmodule Claper.Accounts.Guardian do Implementation module for Guardian authentication. This module handles JWT token generation and validation for user authentication. """ - + defmodule Plug do @moduledoc """ Plug helpers for Guardian authentication in tests. """ - + @doc """ Sign in a user to a conn. - + ## Parameters - conn: The connection - user: The user to sign in diff --git a/lib/claper/accounts/oidc/provider.ex b/lib/claper/accounts/oidc/provider.ex index 2459692..ba1c10f 100644 --- a/lib/claper/accounts/oidc/provider.ex +++ b/lib/claper/accounts/oidc/provider.ex @@ -3,19 +3,19 @@ defmodule Claper.Accounts.Oidc.Provider do import Ecto.Changeset @type t :: %__MODULE__{ - id: integer(), - name: String.t(), - issuer: String.t(), - client_id: String.t(), - client_secret: String.t(), - redirect_uri: String.t(), - scope: String.t(), - active: boolean(), - response_type: String.t(), - response_mode: String.t(), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() - } + id: integer(), + name: String.t(), + issuer: String.t(), + client_id: String.t(), + client_secret: String.t(), + redirect_uri: String.t(), + scope: String.t(), + active: boolean(), + response_type: String.t(), + response_mode: String.t(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } schema "oidc_providers" do field :name, :string @@ -36,10 +36,22 @@ defmodule Claper.Accounts.Oidc.Provider do """ def changeset(provider, attrs) do provider - |> cast(attrs, [:name, :issuer, :client_id, :client_secret, :redirect_uri, :scope, :active, :response_type, :response_mode]) + |> cast(attrs, [ + :name, + :issuer, + :client_id, + :client_secret, + :redirect_uri, + :scope, + :active, + :response_type, + :response_mode + ]) |> validate_required([:name, :issuer, :client_id, :client_secret, :redirect_uri]) |> validate_format(:issuer, ~r/^https?:\/\//, message: "must start with http:// or https://") - |> validate_format(:redirect_uri, ~r/^https?:\/\//, message: "must start with http:// or https://") + |> validate_format(:redirect_uri, ~r/^https?:\/\//, + message: "must start with http:// or https://" + ) |> unique_constraint(:name) end end diff --git a/lib/claper/accounts/role.ex b/lib/claper/accounts/role.ex index 10ab3e6..d039093 100644 --- a/lib/claper/accounts/role.ex +++ b/lib/claper/accounts/role.ex @@ -3,12 +3,12 @@ defmodule Claper.Accounts.Role do import Ecto.Changeset @type t :: %__MODULE__{ - id: integer(), - name: String.t(), - permissions: map(), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() - } + id: integer(), + name: String.t(), + permissions: map(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } schema "roles" do field :name, :string diff --git a/lib/claper/accounts/user.ex b/lib/claper/accounts/user.ex index 232c11f..d76e159 100644 --- a/lib/claper/accounts/user.ex +++ b/lib/claper/accounts/user.ex @@ -95,7 +95,7 @@ defmodule Claper.Accounts.User do defp validate_admin_password(changeset, opts) do password = get_change(changeset, :password) - + # Only validate password if it's provided if password && password != "" do changeset diff --git a/lib/claper/admin.ex b/lib/claper/admin.ex index 8ed9baf..49fe8ec 100644 --- a/lib/claper/admin.ex +++ b/lib/claper/admin.ex @@ -84,7 +84,6 @@ defmodule Claper.Admin do result = Repo.query!(sql, [period_sql_value, start_date, end_date]) - user_counts = result.rows |> Enum.map(fn [period, count] -> @@ -95,9 +94,11 @@ defmodule Claper.Admin do # Format data for charts labels = Enum.map(date_range, &format_date_label(&1, period)) - values = Enum.map(date_range, fn date -> - Map.get(user_counts, truncate_date(date, period), 0) - end) + + values = + Enum.map(date_range, fn date -> + Map.get(user_counts, truncate_date(date, period), 0) + end) %{ labels: labels, @@ -154,9 +155,11 @@ defmodule Claper.Admin do # Format data for charts labels = Enum.map(date_range, &format_date_label(&1, period)) - values = Enum.map(date_range, fn date -> - Map.get(event_counts, truncate_date(date, period), 0) - end) + + values = + Enum.map(date_range, fn date -> + Map.get(event_counts, truncate_date(date, period), 0) + end) %{ labels: labels, @@ -215,18 +218,22 @@ defmodule Claper.Admin do seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second) %{ - users_today: User + users_today: + User |> where([u], is_nil(u.deleted_at)) |> where([u], u.inserted_at >= ^twenty_four_hours_ago) |> Repo.aggregate(:count, :id), - events_today: Event + events_today: + Event |> where([e], e.inserted_at >= ^twenty_four_hours_ago) |> Repo.aggregate(:count, :id), - users_this_week: User + users_this_week: + User |> where([u], is_nil(u.deleted_at)) |> where([u], u.inserted_at >= ^seven_days_ago) |> Repo.aggregate(:count, :id), - events_this_week: Event + events_this_week: + Event |> where([e], e.inserted_at >= ^seven_days_ago) |> Repo.aggregate(:count, :id) } @@ -240,13 +247,17 @@ defmodule Claper.Admin do |> Date.range(NaiveDateTime.to_date(end_date)) |> Enum.to_list() |> case do - dates when period == :day -> dates - dates when period == :week -> dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1) - dates when period == :month -> dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys() + dates when period == :day -> + dates + + dates when period == :week -> + dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1) + + dates when period == :month -> + dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys() end end - defp period_sql(period) do case period do :day -> "day" @@ -265,13 +276,24 @@ defmodule Claper.Admin do defp truncate_date(date, period) do naive_date = NaiveDateTime.new!(date, ~T[00:00:00]) + case period do - :day -> NaiveDateTime.truncate(naive_date, :second) + :day -> + NaiveDateTime.truncate(naive_date, :second) + :week -> days_to_subtract = Date.day_of_week(date) - 1 - date |> Date.add(-days_to_subtract) |> NaiveDateTime.new!(~T[00:00:00]) |> NaiveDateTime.truncate(:second) + + date + |> Date.add(-days_to_subtract) + |> NaiveDateTime.new!(~T[00:00:00]) + |> NaiveDateTime.truncate(:second) + :month -> - date |> Date.beginning_of_month() |> NaiveDateTime.new!(~T[00:00:00]) |> NaiveDateTime.truncate(:second) + date + |> Date.beginning_of_month() + |> NaiveDateTime.new!(~T[00:00:00]) + |> NaiveDateTime.truncate(:second) end end diff --git a/lib/claper_web/components.ex b/lib/claper_web/components.ex new file mode 100644 index 0000000..de1b502 --- /dev/null +++ b/lib/claper_web/components.ex @@ -0,0 +1,21 @@ +defmodule ClaperWeb.Components do + @moduledoc """ + Provides UI components for the Claper application. + + ## Usage + + Import this module in your views or live views: + + import ClaperWeb.Components + + Then use the components: + + <.input id="email" name="email" type="email" placeholder="Enter your email" /> + <.button variant="primary">Submit + <.ui_label for="email">Email Address + """ + + defdelegate input(assigns), to: ClaperWeb.Components.Input + defdelegate button(assigns), to: ClaperWeb.Components.Button + defdelegate ui_label(assigns), to: ClaperWeb.Components.Label, as: :label +end diff --git a/lib/claper_web/components/button.ex b/lib/claper_web/components/button.ex new file mode 100644 index 0000000..67e21eb --- /dev/null +++ b/lib/claper_web/components/button.ex @@ -0,0 +1,88 @@ +defmodule ClaperWeb.Components.Button do + use ClaperWeb, :view_component + + attr :type, :string, default: "button" + + attr :variant, :string, + default: "primary", + values: ~w(primary secondary emphasis outline danger) + + attr :size, :string, default: "base", values: ~w(small base) + attr :disabled, :boolean, default: false + attr :loading, :boolean, default: false + attr :class, :string, default: "" + attr :rest, :global, include: ~w(form name value) + + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + defp button_classes(variant, size, disabled, loading, custom_class) do + base_classes = + "inline-flex items-center justify-center rounded-full transition-all duration-150 ease-in-out" + + variant_classes = + case variant do + "primary" -> + "bg-navy text-white hover:bg-navy/80" + + "secondary" -> + "bg-platinum text-navy hover:bg-platinum-80" + + "outline" -> + "bg-transparent text-navy ring-2 ring-navy hover:ring-3" + + "danger" -> + "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500" + + "emphasis" -> + "text-white bg-linear-(--gradient-primary) hover:bg-linear-(--gradient-secondary)" + + _ -> + "" + end + + size_classes = + case size do + "small" -> "px-12 py-4 font-small-body-bold text-small-body-bold" + "base" -> "px-16 py-8 font-small-body-bold text-small-body-bold md:px-24 md:py-12 md:font-body-bold md:text-body-bold" + _ -> "" + end + + state_classes = + cond do + disabled or loading -> "opacity-50 cursor-not-allowed" + true -> "cursor-pointer" + end + + "#{base_classes} #{variant_classes} #{size_classes} #{state_classes} #{custom_class}" + end +end diff --git a/lib/claper_web/components/input.ex b/lib/claper_web/components/input.ex new file mode 100644 index 0000000..033f773 --- /dev/null +++ b/lib/claper_web/components/input.ex @@ -0,0 +1,69 @@ +defmodule ClaperWeb.Components.Input do + use ClaperWeb, :view_component + alias ClaperWeb.Components.Label + + attr :id, :string, required: true + attr :name, :string, required: true + attr :label, :string, default: nil + attr :type, :string, default: "text" + attr :value, :string, default: "" + attr :placeholder, :string, default: "" + attr :required, :boolean, default: false + attr :disabled, :boolean, default: false + attr :errors, :list, default: [] + attr :class, :string, default: "" + attr :rest, :global, include: ~w(autocomplete pattern minlength maxlength) + + def input(assigns) do + ~H""" +
+ <%= if @label do %> + {@label} + <% end %> + +
+ +
+ + <%= if @errors != [] do %> +
+ <%= for error <- @errors do %> +

{error}

+ <% end %> +
+ <% end %> +
+ """ + end + + defp input_classes(value) do + invalid_classes = "invalid:ring-2 invalid:ring-red-600" + + disabled_classes = + "disabled:bg-platinum-80 disabled:ring-1 disabled:ring-gray-40 disabled:text-gray-60" + + base_classes = + "w-full rounded-full transition-all duration-100 outline-none !font-display px-16 py-8 font-small-body text-small-body md:px-24 md:py-12 md:text-body md:font-body" + + empty_classes = + if value == "" or is_nil(value) do + "bg-platinum-20 text-black placeholder-gray-60 ring-1 ring-platinum-80" + else + "bg-white text-gray-900 ring ring-navy-500" + end + + focus_classes = "focus:bg-white focus:text-gray-900 focus:ring-secondary-500 focus:ring-2" + + "#{base_classes} #{empty_classes} #{focus_classes} #{invalid_classes} #{disabled_classes}" + end +end diff --git a/lib/claper_web/components/label.ex b/lib/claper_web/components/label.ex new file mode 100644 index 0000000..5319140 --- /dev/null +++ b/lib/claper_web/components/label.ex @@ -0,0 +1,25 @@ +defmodule ClaperWeb.Components.Label do + use ClaperWeb, :view_component + + attr :for, :string, required: true + attr :required, :boolean, default: false + attr :class, :string, default: "" + + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + defp label_classes(custom_class) do + base_classes = "block text-sm font-medium text-gray-700 mb-2" + "#{base_classes} #{custom_class}" + end +end diff --git a/lib/claper_web/helpers/csv_exporter.ex b/lib/claper_web/helpers/csv_exporter.ex index c8fa9a4..e8d7606 100644 --- a/lib/claper_web/helpers/csv_exporter.ex +++ b/lib/claper_web/helpers/csv_exporter.ex @@ -96,7 +96,16 @@ defmodule ClaperWeb.Helpers.CSVExporter do - CSV formatted string """ def export_events_to_csv(events) do - headers = ["Name", "Code", "Owner", "Started At", "Expired At", "Audience Peak", "Date Created"] + headers = [ + "Name", + "Code", + "Owner", + "Started At", + "Expired At", + "Audience Peak", + "Date Created" + ] + fields = [:name, :code, :user_email, :started_at, :expired_at, :audience_peak, :inserted_at] to_csv(events, headers, fields) diff --git a/lib/claper_web/live/admin_live/components_demo_live.ex b/lib/claper_web/live/admin_live/components_demo_live.ex new file mode 100644 index 0000000..f91ea0b --- /dev/null +++ b/lib/claper_web/live/admin_live/components_demo_live.ex @@ -0,0 +1,182 @@ +defmodule ClaperWeb.AdminLive.ComponentsDemoLive do + use ClaperWeb, :live_view + import ClaperWeb.Components, only: [input: 1, button: 1, ui_label: 1] + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:email, "") + |> assign(:name, "") + |> assign(:filled_input, "Hey bonjour") + |> assign(:loading, false)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+

Component Library Demo

+ + +
+

Input Components

+ +
+ +
+

Empty Input

+ <.input + id="empty-input" + name="email" + type="email" + value={@email} + class="my-2" + placeholder="Enter your email" + phx-change="update_email" + /> +
+ + +
+

Input with Label

+ <.input + id="name-input" + name="name" + label="Your Name" + value={@name} + placeholder="Hey" + phx-change="update_name" + /> +
+ + +
+

Filled Input

+ <.input + id="filled-input" + name="message" + value={@filled_input} + placeholder="Type something..." + phx-change="update_filled" + /> +
+ + +
+

Input with Error

+ <.input + id="error-input" + name="required" + label="Required Field" + value="" + placeholder="This field is required" + errors={["This field cannot be empty"]} + required={true} + /> +
+ + +
+

Disabled Input

+ <.input id="disabled-input" name="disabled" value="Cannot edit this" disabled={true} /> +
+
+
+ + +
+

Button Components

+ +
+ +
+

Button Variants

+
+ <.button variant="primary">Primary Button + <.button variant="secondary">Secondary Button + <.button variant="outline">Outline Button + <.button variant="danger">Danger Button + <.button variant="emphasis">Emphasis Button +
+
+ + +
+

Button Sizes

+
+ <.button size="small">Small + <.button size="base">Base +
+
+ + +
+

Button States

+
+ <.button disabled={true}>Disabled + <.button loading={@loading} phx-click="toggle_loading"> + {if @loading, do: "Loading...", else: "Click to Load"} + +
+
+ + +
+

Form Submit Button

+
+ <.input + id="submit-input" + name="submit_value" + placeholder="Type and submit..." + class="flex-1" + /> + <.button type="submit" variant="primary">Submit +
+
+
+
+ + +
+

Label Components

+ +
+
+ <.ui_label for="demo-1">Regular Label + +
+ +
+ <.ui_label for="demo-2" required={true}>Required Label + +
+
+
+
+
+ """ + end + + @impl true + def handle_event("update_email", %{"email" => email}, socket) do + {:noreply, assign(socket, :email, email)} + end + + def handle_event("update_name", %{"name" => name}, socket) do + {:noreply, assign(socket, :name, name)} + end + + def handle_event("update_filled", %{"message" => message}, socket) do + {:noreply, assign(socket, :filled_input, message)} + end + + def handle_event("toggle_loading", _params, socket) do + {:noreply, assign(socket, :loading, !socket.assigns.loading)} + end + + def handle_event("submit_form", %{"submit_value" => value}, socket) do + {:noreply, put_flash(socket, :info, "Form submitted with: #{value}")} + end +end diff --git a/lib/claper_web/live/admin_live/dashboard_live.ex b/lib/claper_web/live/admin_live/dashboard_live.ex index 4b08772..983f593 100644 --- a/lib/claper_web/live/admin_live/dashboard_live.ex +++ b/lib/claper_web/live/admin_live/dashboard_live.ex @@ -32,12 +32,15 @@ defmodule ClaperWeb.AdminLive.DashboardLive do def handle_event("change_period", %{"period" => period}, socket) do period_atom = String.to_atom(period) - days_back = case period_atom do - :day -> 30 - :week -> 84 # 12 weeks - :month -> 365 # 12 months - _ -> 30 - end + days_back = + case period_atom do + :day -> 30 + # 12 weeks + :week -> 84 + # 12 months + :month -> 365 + _ -> 30 + end socket = socket diff --git a/lib/claper_web/live/admin_live/dashboard_live.html.heex b/lib/claper_web/live/admin_live/dashboard_live.html.heex index 09da572..3b3b96b 100644 --- a/lib/claper_web/live/admin_live/dashboard_live.html.heex +++ b/lib/claper_web/live/admin_live/dashboard_live.html.heex @@ -9,49 +9,88 @@
Total Users
-
<%= @stats.total_users %>
-
0 do "text-green-600" else "text-red-600" end}><%= if @growth_metrics.users_growth >= 0 do %>+<% end %><%= @growth_metrics.users_growth %>% vs last month
+
{@stats.total_users}
+
+ 0 do + "text-green-600" + else + "text-red-600" + end + }> + <%= if @growth_metrics.users_growth >= 0 do %> + + + <% end %> + {@growth_metrics.users_growth}% + + vs last month +
- +
Total Events
-
<%= @stats.total_events %>
-
0 do "text-green-600" else "text-red-600" end}><%= if @growth_metrics.events_growth >= 0 do %>+<% end %><%= @growth_metrics.events_growth %>% vs last month
+
{@stats.total_events}
+
+ 0 do + "text-green-600" + else + "text-red-600" + end + }> + <%= if @growth_metrics.events_growth >= 0 do %> + + + <% end %> + {@growth_metrics.events_growth}% + + vs last month +
- +
Active Events
-
<%= @stats.active_events %>
+
{@stats.active_events}
Currently running
- +

User Growth

30 days

- + +
- +

Event Creation

30 days

- + +
-
- +

Recent Events

- +
@@ -68,24 +107,40 @@ <%= for event <- @recent_events do %> <% now = NaiveDateTime.utc_now() %> - <% status = cond do - is_nil(event.expired_at) == false -> {"Completed", "text-gray-600"} - NaiveDateTime.compare(event.started_at, now) == :gt -> {"Scheduled", "text-blue-600"} - true -> {"Active", "text-green-600"} - end %> + <% status = + cond do + is_nil(event.expired_at) == false -> + {"Completed", "text-gray-600"} + + NaiveDateTime.compare(event.started_at, now) == :gt -> + {"Scheduled", "text-blue-600"} + + true -> + {"Active", "text-green-600"} + end %> - - - - - - + + + + + +
<%= event.name %><%= event.code %><%= event.user.email %><%= Calendar.strftime(event.started_at, "%b %d, %Y") %><%= event.audience_peak %><%= elem(status, 0) %>{event.name}{event.code}{event.user.email} + {Calendar.strftime(event.started_at, "%b %d, %Y")} + {event.audience_peak} + {elem(status, 0)} +
- <.link href={~p"/admin/events/#{event}"} class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"> + <.link + href={~p"/admin/events/#{event}"} + class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors" + > View - <.link href={~p"/admin/events/#{event}/edit"} class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"> + <.link + href={~p"/admin/events/#{event}/edit"} + class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors" + > Edit
@@ -103,4 +158,4 @@
- \ No newline at end of file + diff --git a/lib/claper_web/live/admin_live/event_live.ex b/lib/claper_web/live/admin_live/event_live.ex index 37e6277..941ef41 100644 --- a/lib/claper_web/live/admin_live/event_live.ex +++ b/lib/claper_web/live/admin_live/event_live.ex @@ -8,13 +8,12 @@ defmodule ClaperWeb.AdminLive.EventLive do @impl true def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Admin - Events") - |> assign(:events, list_events()) - |> assign(:search, "") - |> assign(:current_sort, %{field: :name, order: :asc}) - } + {:ok, + socket + |> assign(:page_title, "Admin - Events") + |> assign(:events, list_events()) + |> assign(:search, "") + |> assign(:current_sort, %{field: :name, order: :asc})} end @impl true @@ -69,37 +68,40 @@ defmodule ClaperWeb.AdminLive.EventLive do csv_content = CSVExporter.export_events_to_csv(socket.assigns.events) {:noreply, - socket - |> put_flash(:info, "Events exported successfully") - |> push_event("download_csv", %{filename: filename, content: csv_content}) - } + socket + |> put_flash(:info, "Events exported successfully") + |> push_event("download_csv", %{filename: filename, content: csv_content})} end @impl true def handle_event("sort", %{"field" => field}, socket) do field = String.to_existing_atom(field) current_sort = socket.assigns.current_sort - direction = if current_sort.field == field && current_sort.order == :asc, do: :desc, else: :asc - + + direction = + if current_sort.field == field && current_sort.order == :asc, do: :desc, else: :asc + events = sort_events(socket.assigns.events, field, direction) current_sort = %{field: field, order: direction} {:noreply, - socket - |> assign(:events, events) - |> assign(:current_sort, current_sort) - } + socket + |> assign(:events, events) + |> assign(:current_sort, current_sort)} end @impl true def handle_info({:table_action, action, event, _event_id}, socket) do case action do - :view -> + :view -> {:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}")} - :edit -> + + :edit -> {:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}/edit")} - :delete -> + + :delete -> {:ok, _} = Claper.Events.delete_event(event) + {:noreply, socket |> put_flash(:info, "Event deleted successfully") @@ -112,6 +114,7 @@ defmodule ClaperWeb.AdminLive.EventLive do end defp search_events(search) when search == "", do: list_events() + defp search_events(search) do Admin.list_all_events(%{"search" => search}) end @@ -125,8 +128,14 @@ defmodule ClaperWeb.AdminLive.EventLive do event.name, event.code, if(event.user, do: event.user.email, else: "No owner"), - if(event.started_at, do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"), else: "Not started"), - if(event.expired_at, do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"), else: "Not expired"), + if(event.started_at, + do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"), + else: "Not started" + ), + if(event.expired_at, + do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"), + else: "Not expired" + ), event.audience_peak || 0, {:safe, render_event_actions(event)} ] @@ -152,24 +161,50 @@ defmodule ClaperWeb.AdminLive.EventLive do def sort_indicator(assigns) do %{current_sort: current_sort, field: field} = assigns - + ~H""" <%= if current_sort.field == field do %> <%= if current_sort.order == :asc do %> - - + + <% else %> - - + + <% end %> <% else %> - - + + <% end %> """ end - end diff --git a/lib/claper_web/live/admin_live/event_live.html.heex b/lib/claper_web/live/admin_live/event_live.html.heex index 5f97fe7..4e29c27 100644 --- a/lib/claper_web/live/admin_live/event_live.html.heex +++ b/lib/claper_web/live/admin_live/event_live.html.heex @@ -5,9 +5,23 @@

Events

- <.link navigate={~p"/admin/events/new"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - - + <.link + navigate={~p"/admin/events/new"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > + + New Event @@ -22,8 +36,18 @@
-
- - + +
- - - - - - <%= if Enum.empty?(@events) do %> - + <% else %> <%= for event <- @events do %>
+ + + Owner - + - + - + @@ -84,34 +142,56 @@
No events found + No events found +
- <%= event.name %> + {event.name} - <%= event.code %> + {event.code} - <%= if event.user, do: event.user.email, else: "No owner" %> + {if event.user, do: event.user.email, else: "No owner"} - <%= if event.started_at, do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"), else: "Not started" %> + {if event.started_at, + do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"), + else: "Not started"} - <%= if event.expired_at, do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"), else: "Not expired" %> + {if event.expired_at, + do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"), + else: "Not expired"} - <%= event.audience_peak || 0 %> + {event.audience_peak || 0}
- <.link navigate={~p"/admin/events/#{event}"} class="text-indigo-600 hover:text-indigo-900">View - <.link navigate={~p"/admin/events/#{event}/edit"} class="text-indigo-600 hover:text-indigo-900">Edit - + <.link + navigate={~p"/admin/events/#{event}"} + class="text-indigo-600 hover:text-indigo-900" + > + View + + <.link + navigate={~p"/admin/events/#{event}/edit"} + class="text-indigo-600 hover:text-indigo-900" + > + Edit + + Delete
@@ -132,10 +212,16 @@

Event Details

- <.link navigate={~p"/admin/events"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/events"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Events - <.link navigate={~p"/admin/events/#{@event}/edit"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/events/#{@event}/edit"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Edit
@@ -145,53 +231,57 @@
-

<%= @event.name %>

-

Event code: <%= @event.code %>

+

{@event.name}

+

Event code: {@event.code}

Name
-
<%= @event.name %>
+
{@event.name}
Code
-
<%= @event.code %>
+
{@event.code}
Owner
- <%= if @event.user, do: @event.user.email, else: "No owner" %> + {if @event.user, do: @event.user.email, else: "No owner"}
Started At
- <%= if @event.started_at, do: Calendar.strftime(@event.started_at, "%Y-%m-%d %H:%M"), else: "Not started" %> + {if @event.started_at, + do: Calendar.strftime(@event.started_at, "%Y-%m-%d %H:%M"), + else: "Not started"}
Expired At
- <%= if @event.expired_at, do: Calendar.strftime(@event.expired_at, "%Y-%m-%d %H:%M"), else: "Not expired" %> + {if @event.expired_at, + do: Calendar.strftime(@event.expired_at, "%Y-%m-%d %H:%M"), + else: "Not expired"}
Audience Peak
- <%= @event.audience_peak || 0 %> attendees + {@event.audience_peak || 0} attendees
Created At
- <%= Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M")}
Last Updated
- <%= Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M")}
@@ -205,7 +295,10 @@

New Event

- <.link navigate={~p"/admin/events"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/events"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Events
@@ -231,7 +324,10 @@

Edit Event

- <.link navigate={~p"/admin/events"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/events"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Events
@@ -251,4 +347,4 @@
-<% end %> \ No newline at end of file +<% end %> diff --git a/lib/claper_web/live/admin_live/event_live/form_component.ex b/lib/claper_web/live/admin_live/event_live/form_component.ex index 95235eb..3ff52c7 100644 --- a/lib/claper_web/live/admin_live/event_live/form_component.ex +++ b/lib/claper_web/live/admin_live/event_live/form_component.ex @@ -7,13 +7,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do def render(assigns) do ~H"""
- <.form - for={@form} - id="event-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > + <.form for={@form} id="event-form" phx-target={@myself} phx-change="validate" phx-submit="save">
<.live_component module={ClaperWeb.AdminLive.FormFieldComponent} @@ -27,7 +21,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do width_class="sm:col-span-6" description="A unique name for this event" /> - + <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} id="event-code" @@ -40,7 +34,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do width_class="sm:col-span-3" description="A unique code for participants to join this event" /> - + <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} id="event-started-at" @@ -51,7 +45,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do required={true} width_class="sm:col-span-3" /> - + <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} id="event-expired-at" @@ -63,7 +57,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do width_class="sm:col-span-3" description="When this event expires (optional)" /> - + <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} id="event-audience-peak" @@ -94,7 +88,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do phx-disable-with="Saving..." class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > - <%= if @action == :new, do: "Create Event", else: "Update Event" %> + {if @action == :new, do: "Create Event", else: "Update Event"}
@@ -166,4 +160,4 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do end defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/form_field_component.ex b/lib/claper_web/live/admin_live/form_field_component.ex index c6652ab..7d450b8 100644 --- a/lib/claper_web/live/admin_live/form_field_component.ex +++ b/lib/claper_web/live/admin_live/form_field_component.ex @@ -5,28 +5,44 @@ defmodule ClaperWeb.AdminLive.FormFieldComponent do def render(assigns) do ~H"""
- <%= label @form, @field, @label, class: "block text-sm font-medium text-gray-700" %> + {label(@form, @field, @label, class: "block text-sm font-medium text-gray-700")}
<%= case @type do %> <% "text" -> %> - <%= text_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - placeholder: @placeholder, - required: @required] ++ @extra_attrs %> - + {text_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + placeholder: @placeholder, + required: @required + ] ++ @extra_attrs + )} <% "email" -> %> - <%= email_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - placeholder: @placeholder, - required: @required] ++ @extra_attrs %> - + {email_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + placeholder: @placeholder, + required: @required + ] ++ @extra_attrs + )} <% "password" -> %>
- <%= password_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md pr-10", - placeholder: @placeholder, - required: @required, - id: "password-field-#{@field}"] ++ @extra_attrs %> + {password_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md pr-10", + placeholder: @placeholder, + required: @required, + id: "password-field-#{@field}" + ] ++ @extra_attrs + )}
- <% "textarea" -> %> - <%= textarea @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - placeholder: @placeholder, - required: @required, - rows: @rows] ++ @extra_attrs %> - + {textarea( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + placeholder: @placeholder, + required: @required, + rows: @rows + ] ++ @extra_attrs + )} <% "select" -> %> - <%= select @form, @field, @select_options, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - prompt: @prompt || "Select an option", - required: @required] ++ @extra_attrs %> - + {select( + @form, + @field, + @select_options, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + prompt: @prompt || "Select an option", + required: @required + ] ++ @extra_attrs + )} <% "checkbox" -> %>
- <%= checkbox @form, @field, - [class: "h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"] ++ @extra_attrs %> - <%= @checkbox_label || @label %> + {checkbox( + @form, + @field, + [class: "h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"] ++ + @extra_attrs + )} + {@checkbox_label || @label}
- <% "date" -> %> - <%= date_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - required: @required] ++ @extra_attrs %> - + {date_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + required: @required + ] ++ @extra_attrs + )} <% "datetime" -> %> - <%= datetime_local_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - required: @required] ++ @extra_attrs %> - + {datetime_local_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + required: @required + ] ++ @extra_attrs + )} <% "file" -> %>
- <%= if @selected_file, do: @selected_file, else: "No file chosen" %> + {if @selected_file, do: @selected_file, else: "No file chosen"}
- <% _ -> %> - <%= text_input @form, @field, - [class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", - placeholder: @placeholder, - required: @required] ++ @extra_attrs %> + {text_input( + @form, + @field, + [ + class: + "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md", + placeholder: @placeholder, + required: @required + ] ++ @extra_attrs + )} <% end %> - - <%= error_tag @form, @field %> - + + {error_tag(@form, @field)} + <%= if @description do %> -

<%= @description %>

+

{@description}

<% end %>
@@ -105,7 +154,7 @@ defmodule ClaperWeb.AdminLive.FormFieldComponent do @impl true def update(assigns, socket) do - socket = + socket = socket |> assign(assigns) |> assign_new(:placeholder, fn -> "" end) @@ -130,4 +179,4 @@ defmodule ClaperWeb.AdminLive.FormFieldComponent do %Phoenix.LiveView.JS{} |> Phoenix.LiveView.JS.dispatch("toggle-password", to: "##{field_id}") end -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/modal_component.ex b/lib/claper_web/live/admin_live/modal_component.ex index 78f71e8..2fc3003 100644 --- a/lib/claper_web/live/admin_live/modal_component.ex +++ b/lib/claper_web/live/admin_live/modal_component.ex @@ -5,29 +5,31 @@ defmodule ClaperWeb.AdminLive.ModalComponent do @impl true def render(assigns) do ~H""" - - +
<%= if @confirm_action do %> - <% end %> - + <%= if @cancel_action do %> - <% end %> - + <%= if @custom_actions do %> - <%= render_slot(@custom_actions) %> + {render_slot(@custom_actions)} <% end %>
@@ -110,7 +112,7 @@ defmodule ClaperWeb.AdminLive.ModalComponent do @impl true def update(assigns, socket) do - socket = + socket = socket |> assign(assigns) |> assign_new(:show, fn -> false end) @@ -191,4 +193,4 @@ defmodule ClaperWeb.AdminLive.ModalComponent do cancel_action: nil } end -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/oidc_provider_live.ex b/lib/claper_web/live/admin_live/oidc_provider_live.ex index 5bdd3f4..8ca8817 100644 --- a/lib/claper_web/live/admin_live/oidc_provider_live.ex +++ b/lib/claper_web/live/admin_live/oidc_provider_live.ex @@ -7,13 +7,12 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do @impl true def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Admin - OIDC Providers") - |> assign(:providers, list_providers()) - |> assign(:search, "") - |> assign(:current_sort, %{field: :name, order: :asc}) - } + {:ok, + socket + |> assign(:page_title, "Admin - OIDC Providers") + |> assign(:providers, list_providers()) + |> assign(:search, "") + |> assign(:current_sort, %{field: :name, order: :asc})} end @impl true @@ -26,15 +25,15 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do |> assign(:page_title, "OIDC Providers") |> assign(:provider, nil) end - + defp apply_action(socket, :show, %{"id" => id}) do provider = Oidc.get_provider!(id) - + socket |> assign(:page_title, "OIDC Provider Details") |> assign(:provider, provider) end - + defp apply_action(socket, :new, _params) do socket |> assign(:page_title, "New OIDC Provider") @@ -73,25 +72,27 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do @impl true def handle_event("sort", %{"field" => field}, socket) do %{current_sort: %{field: current_field, order: current_order}} = socket.assigns - - {field, order} = + + {field, order} = if current_field == String.to_existing_atom(field) do {current_field, if(current_order == :asc, do: :desc, else: :asc)} else {String.to_existing_atom(field), :asc} end - + providers = sort_providers(socket.assigns.providers, field, order) - - {:noreply, - socket - |> assign(:providers, providers) - |> assign(:current_sort, %{field: field, order: order}) - } + + {:noreply, + socket + |> assign(:providers, providers) + |> assign(:current_sort, %{field: field, order: order})} end @impl true - def handle_info({ClaperWeb.AdminLive.OidcProviderLive.FormComponent, {:saved, _provider}}, socket) do + def handle_info( + {ClaperWeb.AdminLive.OidcProviderLive.FormComponent, {:saved, _provider}}, + socket + ) do {:noreply, assign(socket, :providers, list_providers())} end @@ -102,7 +103,10 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do {:noreply, socket - |> put_flash(:info, "Provider #{if updated_provider.active, do: "activated", else: "deactivated"} successfully") + |> put_flash( + :info, + "Provider #{if updated_provider.active, do: "activated", else: "deactivated"} successfully" + ) |> assign(:providers, list_providers())} end @@ -111,6 +115,7 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do end defp search_providers(search) when search == "", do: list_providers() + defp search_providers(search) do search_term = "%#{search}%" Oidc.search_providers(search_term) @@ -120,24 +125,50 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive do Enum.sort_by(providers, &Map.get(&1, field), order) end - defp sort_indicator(current_sort, field) do assigns = %{current_sort: current_sort, field: field} - + ~H""" <%= if @current_sort.field == @field do %> <%= if @current_sort.order == :asc do %> - - + + <% else %> - - + + <% end %> <% else %> - - + + <% end %> """ diff --git a/lib/claper_web/live/admin_live/oidc_provider_live.html.heex b/lib/claper_web/live/admin_live/oidc_provider_live.html.heex index f8bf6a5..916450d 100644 --- a/lib/claper_web/live/admin_live/oidc_provider_live.html.heex +++ b/lib/claper_web/live/admin_live/oidc_provider_live.html.heex @@ -5,9 +5,23 @@

OIDC Providers

- <.link navigate={~p"/admin/oidc_providers/new"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - - + <.link + navigate={~p"/admin/oidc_providers/new"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > + + New Provider @@ -22,8 +36,18 @@
-
- - + +
- - - - <%= if Enum.empty?(@providers) do %> - + <% else %> <%= for provider <- @providers do %>
+ + + - + @@ -75,19 +111,25 @@
No OIDC providers found + No OIDC providers found +
- <%= provider.name %> + {provider.name} - <%= provider.issuer %> + {provider.issuer} - - <%= Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M")}
- <.link navigate={~p"/admin/oidc_providers/#{provider}"} class="text-indigo-600 hover:text-indigo-900"> + <.link + navigate={~p"/admin/oidc_providers/#{provider}"} + class="text-indigo-600 hover:text-indigo-900" + > View - <.link navigate={~p"/admin/oidc_providers/#{provider}/edit"} class="text-indigo-600 hover:text-indigo-900"> + <.link + navigate={~p"/admin/oidc_providers/#{provider}/edit"} + class="text-indigo-600 hover:text-indigo-900" + > Edit - + Delete
@@ -125,17 +179,22 @@ - <% :show -> %>

OIDC Provider Details

- <.link navigate={~p"/admin/oidc_providers"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/oidc_providers"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Providers - <.link navigate={~p"/admin/oidc_providers/#{@provider}/edit"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/oidc_providers/#{@provider}/edit"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Edit
@@ -145,30 +204,38 @@
-

<%= @provider.name %>

+

{@provider.name}

OIDC Provider

Name
-
<%= @provider.name %>
+
{@provider.name}
Issuer
-
<%= @provider.issuer %>
+
+ {@provider.issuer} +
Client ID
-
<%= @provider.client_id %>
+
+ {@provider.client_id} +
Response Type
-
<%= @provider.response_type %>
+
+ {@provider.response_type} +
Scope
-
<%= @provider.scope %>
+
+ {@provider.scope} +
Status
@@ -187,13 +254,13 @@
Created At
- <%= Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M")}
Last Updated
- <%= Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M")}
@@ -201,14 +268,16 @@
- <% :new -> %>

New OIDC Provider

- <.link navigate={~p"/admin/oidc_providers"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/oidc_providers"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Providers
@@ -228,14 +297,16 @@
- <% :edit -> %>

Edit OIDC Provider

- <.link navigate={~p"/admin/oidc_providers"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/oidc_providers"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Providers
@@ -255,4 +326,4 @@
-<% end %> \ No newline at end of file +<% end %> diff --git a/lib/claper_web/live/admin_live/oidc_provider_live/form_component.ex b/lib/claper_web/live/admin_live/oidc_provider_live/form_component.ex index a27d081..40573b0 100644 --- a/lib/claper_web/live/admin_live/oidc_provider_live/form_component.ex +++ b/lib/claper_web/live/admin_live/oidc_provider_live/form_component.ex @@ -97,7 +97,11 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do field={:response_type} type="select" label="Response Type" - select_options={[{"Authorization Code", "code"}, {"Implicit", "token"}, {"Hybrid", "code token"}]} + select_options={[ + {"Authorization Code", "code"}, + {"Implicit", "token"}, + {"Hybrid", "code token"} + ]} width_class="sm:col-span-3" description="OAuth 2.0 response type (defaults to 'code')" /> @@ -142,7 +146,7 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do phx-disable-with="Saving..." class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > - <%= if @action == :new, do: "Create Provider", else: "Update Provider" %> + {if @action == :new, do: "Create Provider", else: "Update Provider"}
@@ -214,4 +218,4 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do end defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/search_filter_component.ex b/lib/claper_web/live/admin_live/search_filter_component.ex index 6af7861..9380f58 100644 --- a/lib/claper_web/live/admin_live/search_filter_component.ex +++ b/lib/claper_web/live/admin_live/search_filter_component.ex @@ -12,50 +12,53 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do
-
- + <%= if @filters && length(@filters) > 0 do %>
<%= for filter <- @filters do %> - <% end %>
<% end %> - - - + <%= if @show_clear and (@search_value || has_active_filters?(@filter_values)) do %> - <% end %> - + <%= if @new_path do %> <.link navigate={@new_path} class="ml-3 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > - <%= @new_label || "New" %> + {@new_label || "New"} <% end %> - + <%= if @custom_actions do %> - <%= render_slot(@custom_actions) %> + {render_slot(@custom_actions)} <% end %> @@ -106,7 +108,7 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do @impl true def update(assigns, socket) do - socket = + socket = socket |> assign(assigns) |> assign_new(:search_placeholder, fn -> "Search..." end) @@ -124,18 +126,31 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do @impl true def handle_event("search", %{"search" => search_value}, socket) do - send(self(), {:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}) + send( + self(), + {:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}} + ) + {:noreply, assign(socket, search_value: search_value)} end def handle_event("search_change", %{"search" => search_value}, socket) do - send(self(), {:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}) + send( + self(), + {:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}} + ) + {:noreply, assign(socket, search_value: search_value)} end def handle_event("filter_change", %{"filter" => filter_name, "value" => filter_value}, socket) do filter_values = Map.put(socket.assigns.filter_values, filter_name, filter_value) - send(self(), {:search_filter_changed, %{search: socket.assigns.search_value, filters: filter_values}}) + + send( + self(), + {:search_filter_changed, %{search: socket.assigns.search_value, filters: filter_values}} + ) + {:noreply, assign(socket, filter_values: filter_values)} end @@ -145,11 +160,16 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do end def handle_event("export_csv", _params, socket) do - send(self(), {:export_csv_requested, %{search: socket.assigns.search_value, filters: socket.assigns.filter_values}}) + send( + self(), + {:export_csv_requested, + %{search: socket.assigns.search_value, filters: socket.assigns.filter_values}} + ) + {:noreply, socket} end defp has_active_filters?(filter_values) do Enum.any?(filter_values, fn {_key, value} -> value != nil and value != "" end) end -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/table_actions_component.ex b/lib/claper_web/live/admin_live/table_actions_component.ex index 26d1887..4ccd225 100644 --- a/lib/claper_web/live/admin_live/table_actions_component.ex +++ b/lib/claper_web/live/admin_live/table_actions_component.ex @@ -17,7 +17,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do View <% end %> - + <%= if @edit_enabled do %> <% end %> - + <%= if @delete_enabled do %> <% end %> - + <%= if @duplicate_enabled do %> <% end %> - + <%= if @archive_enabled do %> <% end %> - + <%= if @toggle_enabled do %> <% end %> - + <%= if @dropdown_actions && length(@dropdown_actions) > 0 do %>
- + <%= if @dropdown_open do %>
@@ -125,7 +142,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do <%= if action[:icon] do %> <% end %> - <%= action.label %> + {action.label} <% end %>
@@ -133,9 +150,9 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do <% end %>
<% end %> - + <%= if @custom_actions do %> - <%= render_slot(@custom_actions) %> + {render_slot(@custom_actions)} <% end %>
""" @@ -148,7 +165,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do @impl true def update(assigns, socket) do - socket = + socket = socket |> assign(assigns) |> assign_new(:view_enabled, fn -> false end) @@ -212,11 +229,14 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do def handle_event("dropdown_action", %{"action" => action_key}, socket) do action = Enum.find(socket.assigns.dropdown_actions, &(&1.key == action_key)) - + if action do - send(self(), {:table_action, String.to_atom(action_key), socket.assigns.item, socket.assigns.item_id}) + send( + self(), + {:table_action, String.to_atom(action_key), socket.assigns.item, socket.assigns.item_id} + ) end - + {:noreply, assign(socket, dropdown_open: false)} end -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/table_component.ex b/lib/claper_web/live/admin_live/table_component.ex index 9359836..cc4e20b 100644 --- a/lib/claper_web/live/admin_live/table_component.ex +++ b/lib/claper_web/live/admin_live/table_component.ex @@ -7,190 +7,202 @@ defmodule ClaperWeb.AdminLive.TableComponent do
- - - <%= for {header, _index} <- Enum.with_index(@headers) do %> - + + <%= for {header, _index} <- Enum.with_index(@headers) do %> + + <% end %> + + + + <%= if length(@rows) > 0 do %> + <%= for {row, row_index} <- Enum.with_index(@rows) do %> + + <%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %> + <% end %> - - - <% end %> - - - - <%= if length(@rows) > 0 do %> - <%= for {row, row_index} <- Enum.with_index(@rows) do %> - - <%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %> - + <% end %> + <% else %> + + - <% end %> +

+ {@empty_title || "No items found"} +

+

+ {@empty_message || "There are no items to display."} +

+ <%= if @empty_action do %> +
+ {render_slot(@empty_action)} +
+ <% end %> + + <% end %> - <% else %> - - - - <% end %> - +
-
- <%= if is_binary(header), do: header, else: header.label %> - <%= if @sortable && header.sortable do %> - <%= case @sort_config do %> - <% %{field: field, direction: :asc} when field == header.field -> %> - - <% %{field: field, direction: :desc} when field == header.field -> %> - - <% _ -> %> - +
+
+ {if is_binary(header), do: header, else: header.label} + <%= if @sortable && header.sortable do %> + <%= case @sort_config do %> + <% %{field: field, direction: :asc} when field == header.field -> %> + + <% %{field: field, direction: :desc} when field == header.field -> %> + + <% _ -> %> + + <% end %> <% end %> +
+
+ <%= case cell_content do %> + <% {:safe, content} -> %> + {raw(content)} + <% content when is_binary(content) -> %> + {content} + <% content -> %> + {to_string(content)} + <% end %> +
- <%= case cell_content do %> - <% {:safe, content} -> %> - <%= raw(content) %> - <% content when is_binary(content) -> %> - <%= content %> - <% content -> %> - <%= to_string(content) %> +
+
+ <%= if @empty_icon do %> + <% end %> -
-
- <%= if @empty_icon do %> - - <% end %> -

- <%= @empty_title || "No items found" %> -

-

- <%= @empty_message || "There are no items to display." %> -

- <%= if @empty_action do %> -
- <%= render_slot(@empty_action) %> -
- <% end %> -
-
<%= if @pagination do %> -
-
- <%= if @pagination.page_number > 1 do %> - - <% else %> - - Previous - - <% end %> - - <%= if @pagination.page_number < @pagination.total_pages do %> - - <% else %> - - Next - - <% end %> -
- - <% end %>
""" @@ -203,7 +215,7 @@ defmodule ClaperWeb.AdminLive.TableComponent do @impl true def update(assigns, socket) do - socket = + socket = socket |> assign(assigns) |> assign_new(:sortable, fn -> false end) @@ -222,16 +234,16 @@ defmodule ClaperWeb.AdminLive.TableComponent do @impl true def handle_event("sort", %{"field" => field}, socket) do current_sort = socket.assigns.sort_config - - new_direction = + + new_direction = if current_sort.field == field and current_sort.direction == :asc do :desc else :asc end - + sort_config = %{field: field, direction: new_direction} - + send(self(), {:table_sort_changed, sort_config}) {:noreply, assign(socket, sort_config: sort_config)} end @@ -267,4 +279,4 @@ defmodule ClaperWeb.AdminLive.TableComponent do end_page = min(pagination.total_pages, pagination.page_number + 2) start_page..end_page end -end \ No newline at end of file +end diff --git a/lib/claper_web/live/admin_live/user_live.ex b/lib/claper_web/live/admin_live/user_live.ex index 363413a..6a038f1 100644 --- a/lib/claper_web/live/admin_live/user_live.ex +++ b/lib/claper_web/live/admin_live/user_live.ex @@ -10,12 +10,11 @@ defmodule ClaperWeb.AdminLive.UserLive do @impl true def mount(_params, _session, socket) do {:ok, - socket - |> assign(:page_title, "Admin - Users") - |> assign(:users, list_users()) - |> assign(:search, "") - |> assign(:current_sort, %{field: :email, order: :asc}) - } + socket + |> assign(:page_title, "Admin - Users") + |> assign(:users, list_users()) + |> assign(:search, "") + |> assign(:current_sort, %{field: :email, order: :asc})} end @impl true @@ -70,10 +69,9 @@ defmodule ClaperWeb.AdminLive.UserLive do csv_content = CSVExporter.export_users_to_csv(socket.assigns.users) {:noreply, - socket - |> put_flash(:info, "Users exported successfully") - |> push_event("download_csv", %{filename: filename, content: csv_content}) - } + socket + |> put_flash(:info, "Users exported successfully") + |> push_event("download_csv", %{filename: filename, content: csv_content})} end @impl true @@ -83,21 +81,23 @@ defmodule ClaperWeb.AdminLive.UserLive do current_sort = %{field: field, order: direction} {:noreply, - socket - |> assign(:users, users) - |> assign(:current_sort, current_sort) - } + socket + |> assign(:users, users) + |> assign(:current_sort, current_sort)} end @impl true def handle_info({:table_action, action, user, _user_id}, socket) do case action do - :view -> + :view -> {:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}")} - :edit -> + + :edit -> {:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}/edit")} - :delete -> + + :delete -> {:ok, _} = Accounts.delete_user(user) + {:noreply, socket |> put_flash(:info, "User deleted successfully") @@ -105,12 +105,12 @@ defmodule ClaperWeb.AdminLive.UserLive do end end - def list_users do Admin.list_all_users() end defp search_users(search) when search == "", do: list_users() + defp search_users(search) do Admin.list_all_users(%{"search" => search}) end @@ -131,9 +131,11 @@ defmodule ClaperWeb.AdminLive.UserLive do defp status_badge(user) do if user.confirmed_at do - {:safe, ~s(Confirmed)} + {:safe, + ~s(Confirmed)} else - {:safe, ~s(Unconfirmed)} + {:safe, + ~s(Unconfirmed)} end end @@ -161,17 +163,44 @@ defmodule ClaperWeb.AdminLive.UserLive do ~H""" <%= if current_sort.field == field do %> <%= if current_sort.order == :asc do %> - - + + <% else %> - - + + <% end %> <% else %> - - + + <% end %> """ diff --git a/lib/claper_web/live/admin_live/user_live.html.heex b/lib/claper_web/live/admin_live/user_live.html.heex index 3092dc9..4d58234 100644 --- a/lib/claper_web/live/admin_live/user_live.html.heex +++ b/lib/claper_web/live/admin_live/user_live.html.heex @@ -5,9 +5,23 @@

Users

- <.link navigate={~p"/admin/users/new"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - - + <.link + navigate={~p"/admin/users/new"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > + + New User @@ -22,8 +36,18 @@
-
- - + +
- - - - <%= if Enum.empty?(@users) do %> - + <% else %> <%= for user <- @users do %>
+ + - + - + @@ -75,16 +121,18 @@
No users found + No users found +
- <%= user.email %> + {user.email} - <%= if user.role, do: user.role.name, else: "No role" %> + {if user.role, do: user.role.name, else: "No role"} <%= if user.confirmed_at do %> @@ -98,13 +146,29 @@ <% end %> - <%= Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}
- <.link navigate={~p"/admin/users/#{user}"} class="text-indigo-600 hover:text-indigo-900">View - <.link navigate={~p"/admin/users/#{user}/edit"} class="text-indigo-600 hover:text-indigo-900">Edit - + <.link + navigate={~p"/admin/users/#{user}"} + class="text-indigo-600 hover:text-indigo-900" + > + View + + <.link + navigate={~p"/admin/users/#{user}/edit"} + class="text-indigo-600 hover:text-indigo-900" + > + Edit + + Delete
@@ -125,10 +189,16 @@

User Details

- <.link navigate={~p"/admin/users"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/users"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Users - <.link navigate={~p"/admin/users/#{@user}/edit"} class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/users/#{@user}/edit"} + class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Edit
@@ -138,19 +208,19 @@
-

<%= @user.email %>

+

{@user.email}

User Account

Email
-
<%= @user.email %>
+
{@user.email}
Role
- <%= if @user.role, do: @user.role.name, else: "No role" %> + {if @user.role, do: @user.role.name, else: "No role"}
@@ -170,22 +240,22 @@
Created At
- <%= Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M")}
Last Updated
- <%= Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M") %> + {Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M")}
<%= if @user.confirmed_at do %> -
-
Confirmed At
-
- <%= Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M") %> -
-
+
+
Confirmed At
+
+ {Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M")} +
+
<% end %>
@@ -198,7 +268,10 @@

New User

- <.link navigate={~p"/admin/users"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/users"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Users
@@ -224,7 +297,10 @@

Edit User

- <.link navigate={~p"/admin/users"} class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + <.link + navigate={~p"/admin/users"} + class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > Back to Users
@@ -244,4 +320,4 @@
-<% end %> \ No newline at end of file +<% end %> diff --git a/lib/claper_web/live/admin_live/user_live/form_component.ex b/lib/claper_web/live/admin_live/user_live/form_component.ex index 77e6eb6..e4dda82 100644 --- a/lib/claper_web/live/admin_live/user_live/form_component.ex +++ b/lib/claper_web/live/admin_live/user_live/form_component.ex @@ -7,13 +7,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do def render(assigns) do ~H"""
- <.form - for={@form} - id="user-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > + <.form for={@form} id="user-form" phx-target={@myself} phx-change="validate" phx-submit="save">
<.live_component module={ClaperWeb.AdminLive.FormFieldComponent} @@ -27,7 +21,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do width_class="sm:col-span-6" description="User's email address (must be unique)" /> - + <%= if @action == :new do %> <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} @@ -42,7 +36,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do description="Initial password for the user" /> <% end %> - + <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} id="user-role-id" @@ -55,8 +49,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do width_class="sm:col-span-3" description="User's access level" /> - - + <%= if @action == :edit do %> <.live_component module={ClaperWeb.AdminLive.FormFieldComponent} @@ -87,7 +80,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do phx-disable-with="Saving..." class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > - <%= if @action == :new, do: "Create User", else: "Update User" %> + {if @action == :new, do: "Create User", else: "Update User"}
@@ -99,8 +92,8 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do @impl true def update(%{user: user} = assigns, socket) do changeset = Accounts.change_user(user) - - role_options = + + role_options = Accounts.list_roles() |> Enum.map(&{String.capitalize(&1.name), &1.id}) @@ -115,7 +108,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do def handle_event("validate", %{"user" => user_params}, socket) do # Convert confirmed checkbox to confirmed_at datetime user_params = maybe_convert_confirmed_field(user_params) - + changeset = socket.assigns.user |> Accounts.change_user(user_params) @@ -167,9 +160,9 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do defp assign_form(socket, %Ecto.Changeset{} = changeset) do # Add virtual field for confirmed status form = to_form(changeset, as: :user) - + # For edit forms, set the confirmed checkbox based on confirmed_at - form = + form = if socket.assigns.action == :edit do confirmed = !is_nil(changeset.data.confirmed_at) params = Map.put(form.params || %{}, "confirmed", confirmed) @@ -177,24 +170,26 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do else form end - + assign(socket, :form, form) end defp maybe_convert_confirmed_field(user_params) do case Map.get(user_params, "confirmed") do - "true" -> + "true" -> user_params |> Map.delete("confirmed") |> Map.put("confirmed_at", NaiveDateTime.utc_now()) + "false" -> user_params - |> Map.delete("confirmed") + |> Map.delete("confirmed") |> Map.put("confirmed_at", nil) + _ -> user_params end end defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end \ No newline at end of file +end diff --git a/lib/claper_web/live/user_settings_live/show.html.heex b/lib/claper_web/live/user_settings_live/show.html.heex index 804f950..e455e1c 100644 --- a/lib/claper_web/live/user_settings_live/show.html.heex +++ b/lib/claper_web/live/user_settings_live/show.html.heex @@ -262,7 +262,7 @@ {"Italiano", "it"}, {"Nederlands", "nl"} ] - |> Enum.filter(fn {_name, code} -> + |> Enum.filter(fn {_name, code} -> code in Application.get_env(:claper, :languages, ["en", "fr", "es"]) end) } diff --git a/lib/claper_web/plugs/admin_required_plug.ex b/lib/claper_web/plugs/admin_required_plug.ex index f7b0353..9160803 100644 --- a/lib/claper_web/plugs/admin_required_plug.ex +++ b/lib/claper_web/plugs/admin_required_plug.ex @@ -1,19 +1,19 @@ defmodule ClaperWeb.Plugs.AdminRequiredPlug do @moduledoc """ Plug to ensure that the current user has admin role. - + This plug should be used after the authentication plug to ensure that only admin users can access certain routes. """ - + import Plug.Conn import Phoenix.Controller - + use Phoenix.VerifiedRoutes, endpoint: ClaperWeb.Endpoint, router: ClaperWeb.Router, statics: ClaperWeb.static_paths() - + alias Claper.Accounts def init(opts), do: opts diff --git a/lib/claper_web/router.ex b/lib/claper_web/router.ex index cb348d7..3aa59db 100644 --- a/lib/claper_web/router.ex +++ b/lib/claper_web/router.ex @@ -13,7 +13,7 @@ defmodule ClaperWeb.Router do plug(:fetch_current_user) plug(ClaperWeb.Plugs.Locale) end - + pipeline :admin_required do plug(ClaperWeb.Plugs.AdminRequiredPlug) end @@ -169,28 +169,30 @@ defmodule ClaperWeb.Router do delete("/users/log_out", UserSessionController, :delete) end - + # Admin panel routes - LiveView implementation live_session :admin, root_layout: {ClaperWeb.LayoutView, :admin} do scope "/admin", ClaperWeb.AdminLive do pipe_through [:browser, :require_authenticated_user, :admin_required] - + live "/", DashboardLive, :index - + live "/users", UserLive, :index live "/users/new", UserLive, :new live "/users/:id/edit", UserLive, :edit live "/users/:id", UserLive, :show - + live "/events", EventLive, :index live "/events/new", EventLive, :new live "/events/:id/edit", EventLive, :edit live "/events/:id", EventLive, :show - + live "/oidc_providers", OidcProviderLive, :index live "/oidc_providers/new", OidcProviderLive, :new live "/oidc_providers/:id/edit", OidcProviderLive, :edit live "/oidc_providers/:id", OidcProviderLive, :show + + live "/components", ComponentsDemoLive, :index end end end diff --git a/lib/claper_web/templates/layout/admin.html.heex b/lib/claper_web/templates/layout/admin.html.heex index 271041a..32c62ad 100644 --- a/lib/claper_web/templates/layout/admin.html.heex +++ b/lib/claper_web/templates/layout/admin.html.heex @@ -7,98 +7,192 @@ {csrf_meta_tag()} <.live_title suffix=" · Claper">{assigns[:page_title] || "Claper"} + - - + - + - \ No newline at end of file + diff --git a/lib/claper_web/validators/admin_form_validator.ex b/lib/claper_web/validators/admin_form_validator.ex index 009802c..99ee72e 100644 --- a/lib/claper_web/validators/admin_form_validator.ex +++ b/lib/claper_web/validators/admin_form_validator.ex @@ -1,92 +1,92 @@ defmodule ClaperWeb.Validators.AdminFormValidator do @moduledoc """ Provides validation functions for admin panel forms. - + This module contains helper functions to validate input data for admin forms before processing, providing consistent validation across the admin panel. """ - + @doc """ Validates OIDC provider data. - + Returns {:ok, validated_params} or {:error, errors} """ def validate_oidc_provider(params) do errors = [] - + errors = if String.trim(params["name"]) == "" do [{:name, "Name cannot be blank"} | errors] else errors end - + errors = if !is_valid_url?(params["issuer"]) do [{:issuer, "Issuer must be a valid URL"} | errors] else errors end - + errors = if String.trim(params["client_id"]) == "" do [{:client_id, "Client ID cannot be blank"} | errors] else errors end - + errors = if String.trim(params["client_secret"]) == "" do [{:client_secret, "Client Secret cannot be blank"} | errors] else errors end - + if Enum.empty?(errors) do {:ok, params} else {:error, errors} end end - + @doc """ Validates event data. - + Returns {:ok, validated_params} or {:error, errors} """ def validate_event(params) do errors = [] - + errors = if String.trim(params["name"]) == "" do [{:name, "Name cannot be blank"} | errors] else errors end - + errors = if String.trim(params["code"]) == "" do [{:code, "Code cannot be blank"} | errors] else errors end - + if Enum.empty?(errors) do {:ok, params} else {:error, errors} end end - + @doc """ Validates user data. - + Returns {:ok, validated_params} or {:error, errors} """ def validate_user(params) do errors = [] - + errors = if String.trim(params["email"]) == "" do [{:email, "Email cannot be blank"} | errors] @@ -97,23 +97,25 @@ defmodule ClaperWeb.Validators.AdminFormValidator do errors end end - + if Enum.empty?(errors) do {:ok, params} else {:error, errors} end end - + # Private helper functions - + defp is_valid_url?(nil), do: false + defp is_valid_url?(url) do uri = URI.parse(url) uri.scheme != nil && uri.host != nil && uri.host =~ "." end - + defp is_valid_email?(nil), do: false + defp is_valid_email?(email) do Regex.match?(~r/^[^\s]+@[^\s]+\.[^\s]+$/, email) end diff --git a/lib/claper_web/views/admin/shared_view.ex b/lib/claper_web/views/admin/shared_view.ex index be39965..d98ab30 100644 --- a/lib/claper_web/views/admin/shared_view.ex +++ b/lib/claper_web/views/admin/shared_view.ex @@ -1,6 +1,6 @@ defmodule ClaperWeb.Admin.SharedView do use ClaperWeb, :view - + @doc """ Renders shared components for the admin panel. """ diff --git a/mix.exs b/mix.exs index 884a540..c90adab 100644 --- a/mix.exs +++ b/mix.exs @@ -136,6 +136,7 @@ defmodule Claper.MixProject do "assets.deploy": [ "cmd --cd assets npm install", "tailwind default --minify", + "tailwind admin --minify", "esbuild default --minify", "sass default --no-source-map --style=compressed", "phx.digest" diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 7fe834f..bdb9ae7 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -18,6 +18,7 @@ alias Claper.Repo if !Repo.get_by(Role, name: "admin") do %Role{name: "admin", permissions: %{"all" => true}} |> Repo.insert!() + IO.puts("Created admin role") end @@ -25,6 +26,7 @@ end if !Repo.get_by(Role, name: "user") do %Role{name: "user", permissions: %{}} |> Repo.insert!() + IO.puts("Created user role") end diff --git a/priv/static/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf b/priv/static/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..9f89c9d Binary files /dev/null and b/priv/static/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf differ diff --git a/priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf b/priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf new file mode 100644 index 0000000..df7379c Binary files /dev/null and b/priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf differ diff --git a/test/claper/accounts/role_test.exs b/test/claper/accounts/role_test.exs index f3407ff..149f11e 100644 --- a/test/claper/accounts/role_test.exs +++ b/test/claper/accounts/role_test.exs @@ -7,7 +7,7 @@ defmodule Claper.Accounts.RoleTest do describe "roles" do @valid_user_attrs %{email: "test@example.com", password: "Password123!"} - + setup do # Ensure admin and user roles exist {:ok, _admin_role} = Accounts.create_role(%{name: "admin"}) @@ -48,17 +48,17 @@ defmodule Claper.Accounts.RoleTest do # Ensure admin and user roles exist {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) {:ok, user_role} = Accounts.create_role(%{name: "user"}) - + # Create a test user {:ok, user} = Accounts.create_user(%{email: "test@example.com", password: "Password123!"}) - + %{user: user, admin_role: admin_role, user_role: user_role} end test "assign_role/2 assigns a role to a user", %{user: user, admin_role: admin_role} do assert {:ok, updated_user} = Accounts.assign_role(user, admin_role) assert updated_user.role_id == admin_role.id - + # Verify through a fresh database query fresh_user = Repo.get(User, user.id) |> Repo.preload(:role) assert fresh_user.role.name == "admin" @@ -67,34 +67,42 @@ defmodule Claper.Accounts.RoleTest do test "get_user_role/1 returns the role of a user", %{user: user, user_role: user_role} do # Assign a role first {:ok, user} = Accounts.assign_role(user, user_role) - + # Test getting the role role = Accounts.get_user_role(user) assert role.id == user_role.id assert role.name == "user" end - test "list_users_by_role/1 returns users with a specific role", %{user: user, admin_role: admin_role} do + test "list_users_by_role/1 returns users with a specific role", %{ + user: user, + admin_role: admin_role + } do # Create another user with a different role - {:ok, user2} = Accounts.create_user(%{email: "another@example.com", password: "Password123!"}) + {:ok, user2} = + Accounts.create_user(%{email: "another@example.com", password: "Password123!"}) + {:ok, _} = Accounts.assign_role(user, admin_role) - + # Get users with admin role admin_users = Accounts.list_users_by_role("admin") assert length(admin_users) == 1 assert hd(admin_users).id == user.id - + # Verify user2 is not in the list assert user2.id not in Enum.map(admin_users, & &1.id) end - test "user_has_role?/2 checks if a user has a specific role", %{user: user, admin_role: admin_role} do + test "user_has_role?/2 checks if a user has a specific role", %{ + user: user, + admin_role: admin_role + } do # Initially user has no role refute Accounts.user_has_role?(user, "admin") - + # Assign admin role {:ok, user} = Accounts.assign_role(user, admin_role) - + # Now user should have admin role assert Accounts.user_has_role?(user, "admin") refute Accounts.user_has_role?(user, "user") @@ -103,10 +111,10 @@ defmodule Claper.Accounts.RoleTest do test "promote_to_admin/1 promotes a user to admin", %{user: user} do # Initially user should not be admin refute Accounts.user_has_role?(user, "admin") - + # Promote to admin {:ok, user} = Accounts.promote_to_admin(user) - + # Verify promotion assert Accounts.user_has_role?(user, "admin") end @@ -115,10 +123,10 @@ defmodule Claper.Accounts.RoleTest do # First promote to admin {:ok, user} = Accounts.promote_to_admin(user) assert Accounts.user_has_role?(user, "admin") - + # Then demote {:ok, user} = Accounts.demote_from_admin(user) - + # Verify demotion refute Accounts.user_has_role?(user, "admin") assert Accounts.user_has_role?(user, "user") diff --git a/test/claper_web/controllers/admin/event_controller_test.exs b/test/claper_web/controllers/admin/event_controller_test.exs index e4a231a..fbadef0 100644 --- a/test/claper_web/controllers/admin/event_controller_test.exs +++ b/test/claper_web/controllers/admin/event_controller_test.exs @@ -24,21 +24,21 @@ defmodule ClaperWeb.Admin.EventControllerTest do # Create roles {:ok, user_role} = Accounts.create_role(%{name: "user"}) {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) - + # Create an admin user {:ok, admin} = Accounts.create_user(%{email: "admin@example.com", password: "Password123!"}) {:ok, admin} = Accounts.assign_role(admin, admin_role) - + # Create a test event {:ok, event} = Events.create_event(@valid_event_attrs) - + # Create a conn with admin logged in - admin_conn = + admin_conn = build_conn() |> Map.replace!(:secret_key_base, ClaperWeb.Endpoint.config(:secret_key_base)) |> init_test_session(%{}) |> Accounts.Guardian.Plug.sign_in(admin) - + %{admin: admin, event: event, admin_conn: admin_conn} end @@ -51,7 +51,7 @@ defmodule ClaperWeb.Admin.EventControllerTest do test "exports events as CSV", %{admin_conn: conn, event: event} do conn = get(conn, Routes.admin_event_path(conn, :index, format: "csv")) - + assert response_content_type(conn, :csv) assert response(conn, 200) =~ "Name,Description,Start Date,End Date,Status" assert response(conn, 200) =~ event.name @@ -84,12 +84,14 @@ defmodule ClaperWeb.Admin.EventControllerTest do end test "validates event data before creating", %{admin_conn: conn} do - invalid_date_attrs = Map.merge(@valid_event_attrs, %{ - name: "Invalid Date Event", - start_date: ~N[2023-01-01 12:00:00], - end_date: ~N[2023-01-01 10:00:00] # End before start - }) - + invalid_date_attrs = + Map.merge(@valid_event_attrs, %{ + name: "Invalid Date Event", + start_date: ~N[2023-01-01 12:00:00], + # End before start + end_date: ~N[2023-01-01 10:00:00] + }) + conn = post(conn, Routes.admin_event_path(conn, :create), event: invalid_date_attrs) assert html_response(conn, 200) =~ "New Event" assert html_response(conn, 200) =~ "End date must be after start date" @@ -123,9 +125,10 @@ defmodule ClaperWeb.Admin.EventControllerTest do test "validates event data before updating", %{admin_conn: conn, event: event} do invalid_date_attrs = %{ start_date: ~N[2023-01-01 12:00:00], - end_date: ~N[2023-01-01 10:00:00] # End before start + # End before start + end_date: ~N[2023-01-01 10:00:00] } - + conn = put(conn, Routes.admin_event_path(conn, :update, event), event: invalid_date_attrs) assert html_response(conn, 200) =~ "Edit Event" assert html_response(conn, 200) =~ "End date must be after start date" @@ -136,7 +139,7 @@ defmodule ClaperWeb.Admin.EventControllerTest do test "deletes chosen event", %{admin_conn: conn, event: event} do conn = delete(conn, Routes.admin_event_path(conn, :delete, event)) assert redirected_to(conn) == Routes.admin_event_path(conn, :index) - + assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.id) end diff --git a/test/claper_web/controllers/admin/oidc_provider_controller_test.exs b/test/claper_web/controllers/admin/oidc_provider_controller_test.exs index 7632321..d53d455 100644 --- a/test/claper_web/controllers/admin/oidc_provider_controller_test.exs +++ b/test/claper_web/controllers/admin/oidc_provider_controller_test.exs @@ -25,21 +25,21 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do # Create roles {:ok, user_role} = Accounts.create_role(%{name: "user"}) {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) - + # Create an admin user {:ok, admin} = Accounts.create_user(%{email: "admin@example.com", password: "Password123!"}) {:ok, admin} = Accounts.assign_role(admin, admin_role) - + # Create a test OIDC provider {:ok, provider} = Oidc.create_provider(@valid_provider_attrs) - + # Create a conn with admin logged in - admin_conn = + admin_conn = build_conn() |> Map.replace!(:secret_key_base, ClaperWeb.Endpoint.config(:secret_key_base)) |> init_test_session(%{}) |> Accounts.Guardian.Plug.sign_in(admin) - + %{admin: admin, provider: provider, admin_conn: admin_conn} end @@ -52,7 +52,7 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do test "exports providers as CSV", %{admin_conn: conn, provider: provider} do conn = get(conn, Routes.admin_oidc_provider_path(conn, :index, format: "csv")) - + assert response_content_type(conn, :csv) assert response(conn, 200) =~ "Name,Issuer,Client ID,Active" assert response(conn, 200) =~ provider.name @@ -72,7 +72,9 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do describe "create provider" do test "redirects to show when data is valid", %{admin_conn: conn} do new_provider_attrs = Map.put(@valid_provider_attrs, :name, "New Test Provider") - conn = post(conn, Routes.admin_oidc_provider_path(conn, :create), provider: new_provider_attrs) + + conn = + post(conn, Routes.admin_oidc_provider_path(conn, :create), provider: new_provider_attrs) assert %{id: id} = redirected_params(conn) assert redirected_to(conn) == Routes.admin_oidc_provider_path(conn, :show, id) @@ -88,13 +90,17 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do end test "validates provider data before creating", %{admin_conn: conn} do - invalid_url_attrs = Map.merge(@valid_provider_attrs, %{ - name: "Invalid URL Provider", - issuer: "invalid-url", # Not a valid URL - redirect_uri: "also-invalid" - }) - - conn = post(conn, Routes.admin_oidc_provider_path(conn, :create), provider: invalid_url_attrs) + invalid_url_attrs = + Map.merge(@valid_provider_attrs, %{ + name: "Invalid URL Provider", + # Not a valid URL + issuer: "invalid-url", + redirect_uri: "also-invalid" + }) + + conn = + post(conn, Routes.admin_oidc_provider_path(conn, :create), provider: invalid_url_attrs) + assert html_response(conn, 200) =~ "Add New OIDC Provider" assert html_response(conn, 200) =~ "must start with http" end @@ -110,7 +116,11 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do describe "update provider" do test "redirects when data is valid", %{admin_conn: conn, provider: provider} do - conn = put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), provider: @update_attrs) + conn = + put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), + provider: @update_attrs + ) + assert redirected_to(conn) == Routes.admin_oidc_provider_path(conn, :show, provider) conn = get(conn, Routes.admin_oidc_provider_path(conn, :show, provider)) @@ -118,18 +128,27 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do end test "renders errors when data is invalid", %{admin_conn: conn, provider: provider} do - conn = put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), provider: @invalid_attrs) + conn = + put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), + provider: @invalid_attrs + ) + assert html_response(conn, 200) =~ "Edit OIDC Provider" assert html_response(conn, 200) =~ "can't be blank" end test "validates provider data before updating", %{admin_conn: conn, provider: provider} do invalid_url_attrs = %{ - issuer: "invalid-url", # Not a valid URL + # Not a valid URL + issuer: "invalid-url", redirect_uri: "also-invalid" } - - conn = put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), provider: invalid_url_attrs) + + conn = + put(conn, Routes.admin_oidc_provider_path(conn, :update, provider), + provider: invalid_url_attrs + ) + assert html_response(conn, 200) =~ "Edit OIDC Provider" assert html_response(conn, 200) =~ "must start with http" end @@ -139,7 +158,7 @@ defmodule ClaperWeb.Admin.OidcProviderControllerTest do test "deletes chosen provider", %{admin_conn: conn, provider: provider} do conn = delete(conn, Routes.admin_oidc_provider_path(conn, :delete, provider)) assert redirected_to(conn) == Routes.admin_oidc_provider_path(conn, :index) - + assert_raise Ecto.NoResultsError, fn -> Oidc.get_provider!(provider.id) end diff --git a/test/claper_web/controllers/admin/user_controller_test.exs b/test/claper_web/controllers/admin/user_controller_test.exs index b6a5bba..8944fb2 100644 --- a/test/claper_web/controllers/admin/user_controller_test.exs +++ b/test/claper_web/controllers/admin/user_controller_test.exs @@ -13,22 +13,22 @@ defmodule ClaperWeb.Admin.UserControllerTest do # Create roles {:ok, user_role} = Accounts.create_role(%{name: "user"}) {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) - + # Create an admin user {:ok, admin} = Accounts.create_user(%{email: "admin@example.com", password: "Password123!"}) {:ok, admin} = Accounts.assign_role(admin, admin_role) - + # Create a regular user for testing {:ok, user} = Accounts.create_user(@valid_user_attrs) {:ok, user} = Accounts.assign_role(user, user_role) - + # Create a conn with admin logged in - admin_conn = + admin_conn = build_conn() |> Map.replace!(:secret_key_base, ClaperWeb.Endpoint.config(:secret_key_base)) |> init_test_session(%{}) |> Accounts.Guardian.Plug.sign_in(admin) - + %{admin: admin, user: user, admin_conn: admin_conn} end @@ -42,7 +42,7 @@ defmodule ClaperWeb.Admin.UserControllerTest do test "exports users as CSV", %{admin_conn: conn} do conn = get(conn, Routes.admin_user_path(conn, :index, format: "csv")) - + assert response_content_type(conn, :csv) assert response(conn, 200) =~ "Email,Name,Role,Created At" assert response(conn, 200) =~ "admin@example.com" @@ -59,7 +59,10 @@ defmodule ClaperWeb.Admin.UserControllerTest do describe "create user" do test "redirects to show when data is valid", %{admin_conn: conn} do - conn = post(conn, Routes.admin_user_path(conn, :create), user: %{email: "new@example.com", password: "Password123!"}) + conn = + post(conn, Routes.admin_user_path(conn, :create), + user: %{email: "new@example.com", password: "Password123!"} + ) assert %{id: id} = redirected_params(conn) assert redirected_to(conn) == Routes.admin_user_path(conn, :show, id) @@ -101,7 +104,7 @@ defmodule ClaperWeb.Admin.UserControllerTest do test "deletes chosen user", %{admin_conn: conn, user: user} do conn = delete(conn, Routes.admin_user_path(conn, :delete, user)) assert redirected_to(conn) == Routes.admin_user_path(conn, :index) - + # Verify user is deleted (or soft-deleted depending on implementation) assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) @@ -113,7 +116,7 @@ defmodule ClaperWeb.Admin.UserControllerTest do test "promotes user to admin", %{admin_conn: conn, user: user} do conn = post(conn, Routes.admin_user_path(conn, :promote, user)) assert redirected_to(conn) == Routes.admin_user_path(conn, :index) - + # Verify user is now admin updated_user = Repo.get(User, user.id) |> Repo.preload(:role) assert updated_user.role.name == "admin" @@ -124,19 +127,19 @@ defmodule ClaperWeb.Admin.UserControllerTest do test "demotes admin to regular user", %{admin_conn: conn, admin: admin, user: user} do # First promote the test user to admin {:ok, user} = Accounts.promote_to_admin(user) - + # Then demote conn = post(conn, Routes.admin_user_path(conn, :demote, user)) assert redirected_to(conn) == Routes.admin_user_path(conn, :index) - + # Verify user is now a regular user updated_user = Repo.get(User, user.id) |> Repo.preload(:role) assert updated_user.role.name == "user" - + # Cannot demote the last admin conn = post(conn, Routes.admin_user_path(conn, :demote, admin)) assert get_flash(conn, :error) =~ "Cannot demote the last admin" - + # Verify admin is still admin updated_admin = Repo.get(User, admin.id) |> Repo.preload(:role) assert updated_admin.role.name == "admin" diff --git a/test/claper_web/helpers/csv_exporter_test.exs b/test/claper_web/helpers/csv_exporter_test.exs index 652955d..2233919 100644 --- a/test/claper_web/helpers/csv_exporter_test.exs +++ b/test/claper_web/helpers/csv_exporter_test.exs @@ -13,61 +13,63 @@ defmodule ClaperWeb.Helpers.CSVExporterTest do # Create roles {:ok, user_role} = Repo.insert(%Role{name: "user"}) {:ok, admin_role} = Repo.insert(%Role{name: "admin"}) - + # Create users - {:ok, user1} = Repo.insert(%User{ - email: "user1@example.com", - uuid: Ecto.UUID.generate(), - role_id: user_role.id, - inserted_at: ~N[2023-01-01 10:00:00], - hashed_password: "hashed_password", - is_randomized_password: false - }) - - {:ok, user2} = Repo.insert(%User{ - email: "admin@example.com", - uuid: Ecto.UUID.generate(), - role_id: admin_role.id, - inserted_at: ~N[2023-01-02 10:00:00], - hashed_password: "hashed_password", - is_randomized_password: false - }) - + {:ok, user1} = + Repo.insert(%User{ + email: "user1@example.com", + uuid: Ecto.UUID.generate(), + role_id: user_role.id, + inserted_at: ~N[2023-01-01 10:00:00], + hashed_password: "hashed_password", + is_randomized_password: false + }) + + {:ok, user2} = + Repo.insert(%User{ + email: "admin@example.com", + uuid: Ecto.UUID.generate(), + role_id: admin_role.id, + inserted_at: ~N[2023-01-02 10:00:00], + hashed_password: "hashed_password", + is_randomized_password: false + }) + %{users: [user1, user2], user_role: user_role, admin_role: admin_role} end - + test "exports users to CSV format", %{users: users} do users = Repo.preload(users, :role) csv = CSVExporter.export_users_to_csv(users) - + # CSV should have a header row and two data rows lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 3 - + # Check header header = List.first(lines) assert header =~ "Email" assert header =~ "Name" assert header =~ "Role" assert header =~ "Created At" - + # Check data rows assert Enum.at(lines, 1) =~ "user1@example.com" assert Enum.at(lines, 1) =~ "User One" assert Enum.at(lines, 1) =~ "user" - + assert Enum.at(lines, 2) =~ "admin@example.com" assert Enum.at(lines, 2) =~ "Admin User" assert Enum.at(lines, 2) =~ "admin" end - + test "handles empty user list" do csv = CSVExporter.export_users_to_csv([]) - + # CSV should only have a header row lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 1 - + # Check header header = List.first(lines) assert header =~ "Email" @@ -79,36 +81,38 @@ defmodule ClaperWeb.Helpers.CSVExporterTest do describe "export_events_to_csv/1" do setup do # Create events - {:ok, event1} = Repo.insert(%Event{ - name: "Event One", - uuid: Ecto.UUID.generate(), - code: "event1", - started_at: ~N[2023-01-01 10:00:00], - expired_at: ~N[2023-01-01 12:00:00], - audience_peak: 10, - inserted_at: ~N[2023-01-01 09:00:00] - }) - - {:ok, event2} = Repo.insert(%Event{ - name: "Event Two", - uuid: Ecto.UUID.generate(), - code: "event2", - started_at: ~N[2023-01-02 10:00:00], - expired_at: ~N[2023-01-02 12:00:00], - audience_peak: 20, - inserted_at: ~N[2023-01-01 09:30:00] - }) - + {:ok, event1} = + Repo.insert(%Event{ + name: "Event One", + uuid: Ecto.UUID.generate(), + code: "event1", + started_at: ~N[2023-01-01 10:00:00], + expired_at: ~N[2023-01-01 12:00:00], + audience_peak: 10, + inserted_at: ~N[2023-01-01 09:00:00] + }) + + {:ok, event2} = + Repo.insert(%Event{ + name: "Event Two", + uuid: Ecto.UUID.generate(), + code: "event2", + started_at: ~N[2023-01-02 10:00:00], + expired_at: ~N[2023-01-02 12:00:00], + audience_peak: 20, + inserted_at: ~N[2023-01-01 09:30:00] + }) + %{events: [event1, event2]} end - + test "exports events to CSV format", %{events: events} do csv = CSVExporter.export_events_to_csv(events) - + # CSV should have a header row and two data rows lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 3 - + # Check header header = List.first(lines) assert header =~ "Name" @@ -116,24 +120,24 @@ defmodule ClaperWeb.Helpers.CSVExporterTest do assert header =~ "Start Date" assert header =~ "End Date" assert header =~ "Status" - + # Check data rows assert Enum.at(lines, 1) =~ "Event One" assert Enum.at(lines, 1) =~ "First test event" assert Enum.at(lines, 1) =~ "active" - + assert Enum.at(lines, 2) =~ "Event Two" assert Enum.at(lines, 2) =~ "Second test event" assert Enum.at(lines, 2) =~ "completed" end - + test "handles empty event list" do csv = CSVExporter.export_events_to_csv([]) - + # CSV should only have a header row lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 1 - + # Check header header = List.first(lines) assert header =~ "Name" @@ -145,71 +149,73 @@ defmodule ClaperWeb.Helpers.CSVExporterTest do describe "export_oidc_providers_to_csv/1" do setup do # Create providers - {:ok, provider1} = Repo.insert(%Provider{ - name: "Provider One", - issuer: "https://example1.com", - client_id: "client1", - client_secret: "secret1", - redirect_uri: "https://app.example.com/callback1", - scope: "openid email", - active: true, - inserted_at: ~N[2023-01-01 09:00:00] - }) - - {:ok, provider2} = Repo.insert(%Provider{ - name: "Provider Two", - issuer: "https://example2.com", - client_id: "client2", - client_secret: "secret2", - redirect_uri: "https://app.example.com/callback2", - scope: "openid profile", - active: false, - inserted_at: ~N[2023-01-01 09:30:00] - }) - + {:ok, provider1} = + Repo.insert(%Provider{ + name: "Provider One", + issuer: "https://example1.com", + client_id: "client1", + client_secret: "secret1", + redirect_uri: "https://app.example.com/callback1", + scope: "openid email", + active: true, + inserted_at: ~N[2023-01-01 09:00:00] + }) + + {:ok, provider2} = + Repo.insert(%Provider{ + name: "Provider Two", + issuer: "https://example2.com", + client_id: "client2", + client_secret: "secret2", + redirect_uri: "https://app.example.com/callback2", + scope: "openid profile", + active: false, + inserted_at: ~N[2023-01-01 09:30:00] + }) + %{providers: [provider1, provider2]} end - + test "exports providers to CSV format", %{providers: providers} do csv = CSVExporter.export_oidc_providers_to_csv(providers) - + # CSV should have a header row and two data rows lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 3 - + # Check header header = List.first(lines) assert header =~ "Name" assert header =~ "Issuer" assert header =~ "Client ID" assert header =~ "Active" - + # Client secret should not be included for security refute header =~ "Client Secret" - + # Check data rows assert Enum.at(lines, 1) =~ "Provider One" assert Enum.at(lines, 1) =~ "https://example1.com" assert Enum.at(lines, 1) =~ "client1" assert Enum.at(lines, 1) =~ "true" - + assert Enum.at(lines, 2) =~ "Provider Two" assert Enum.at(lines, 2) =~ "https://example2.com" assert Enum.at(lines, 2) =~ "client2" assert Enum.at(lines, 2) =~ "false" - + # Client secrets should not be included in the CSV refute csv =~ "secret1" refute csv =~ "secret2" end - + test "handles empty provider list" do csv = CSVExporter.export_oidc_providers_to_csv([]) - + # CSV should only have a header row lines = String.split(csv, "\r\n", trim: true) assert length(lines) == 1 - + # Check header header = List.first(lines) assert header =~ "Name" diff --git a/test/claper_web/plugs/admin_required_plug_test.exs b/test/claper_web/plugs/admin_required_plug_test.exs index 21a57d7..b7566d0 100644 --- a/test/claper_web/plugs/admin_required_plug_test.exs +++ b/test/claper_web/plugs/admin_required_plug_test.exs @@ -1,68 +1,68 @@ defmodule ClaperWeb.AdminRequiredPlugTest do use ClaperWeb.ConnCase - + alias ClaperWeb.Plugs.AdminRequiredPlug alias Claper.Accounts alias Claper.Accounts.User - + @valid_user_attrs %{email: "user@example.com", password: "Password123!"} @valid_admin_attrs %{email: "admin@example.com", password: "Password123!"} - + setup do # Create roles {:ok, _user_role} = Accounts.create_role(%{name: "user"}) {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) - + # Create a regular user {:ok, user} = Accounts.create_user(@valid_user_attrs) - + # Create an admin user {:ok, admin} = Accounts.create_user(@valid_admin_attrs) {:ok, admin} = Accounts.assign_role(admin, admin_role) - + %{user: user, admin: admin} end - + describe "init/1" do test "returns options unchanged" do assert AdminRequiredPlug.init([]) == [] end end - + describe "call/2" do test "allows access to admin users", %{conn: conn, admin: admin} do # Log in as admin - conn = + conn = conn |> sign_in_user(admin) |> AdminRequiredPlug.call([]) - + # Should pass through without redirect refute conn.halted end - + test "redirects non-admin users", %{conn: conn, user: user} do # Log in as regular user - conn = + conn = conn |> sign_in_user(user) |> AdminRequiredPlug.call([]) - + # Should be halted and redirected assert conn.halted assert redirected_to(conn) =~ "/" assert get_flash(conn, :error) =~ "You must be an administrator" end - + test "redirects unauthenticated users", %{conn: conn} do conn = AdminRequiredPlug.call(conn, []) - + # Should be halted and redirected assert conn.halted assert redirected_to(conn) =~ "/users/log_in" end end - + defp sign_in_user(conn, user) do conn |> Map.replace!(:secret_key_base, ClaperWeb.Endpoint.config(:secret_key_base)) diff --git a/test/claper_web/router/admin_routes_test.exs b/test/claper_web/router/admin_routes_test.exs index 3e00f7f..d2628b7 100644 --- a/test/claper_web/router/admin_routes_test.exs +++ b/test/claper_web/router/admin_routes_test.exs @@ -15,39 +15,42 @@ defmodule ClaperWeb.Router.AdminRoutesTest do # Create roles {:ok, user_role} = Accounts.create_role(%{name: "user"}) {:ok, admin_role} = Accounts.create_role(%{name: "admin"}) - + # Create a regular user {:ok, user} = Accounts.create_user(%{email: "user@example.com", password: "Password123!"}) {:ok, user} = Accounts.assign_role(user, user_role) - + # Create an admin user {:ok, admin} = Accounts.create_user(%{email: "admin@example.com", password: "Password123!"}) {:ok, admin} = Accounts.assign_role(admin, admin_role) - + %{user: user, admin: admin} end describe "admin routes access restrictions" do test "admin user can access all admin routes", %{conn: conn, admin: admin} do # Log in as admin - conn = + conn = conn |> sign_in_user(admin) - + # Test each admin route for route <- @admin_routes do conn = get(conn, route) - assert conn.status in [200, 302], "Admin should be able to access #{route}, got status #{conn.status}" + + assert conn.status in [200, 302], + "Admin should be able to access #{route}, got status #{conn.status}" + refute get_flash(conn, :error) =~ "You must be an administrator" end end - + test "regular user cannot access admin routes", %{conn: conn, user: user} do # Log in as regular user - conn = + conn = conn |> sign_in_user(user) - + # Test each admin route for route <- @admin_routes do conn = get(conn, route) @@ -56,7 +59,7 @@ defmodule ClaperWeb.Router.AdminRoutesTest do assert redirected_to(conn) == "/" end end - + test "unauthenticated user cannot access admin routes", %{conn: conn} do # Test each admin route for route <- @admin_routes do diff --git a/test/claper_web/validators/admin_form_validator_test.exs b/test/claper_web/validators/admin_form_validator_test.exs index f09d3e1..cc1ddb0 100644 --- a/test/claper_web/validators/admin_form_validator_test.exs +++ b/test/claper_web/validators/admin_form_validator_test.exs @@ -13,40 +13,40 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do "password" => "Password123!", "name" => "Test User" } - + assert {:ok, validated} = AdminFormValidator.validate_user(valid_attrs) assert validated["email"] == "test@example.com" assert validated["name"] == "Test User" assert Map.has_key?(validated, "password") end - + test "returns error with invalid email" do invalid_attrs = %{ "email" => "not-an-email", "password" => "Password123!", "name" => "Test User" } - + assert {:error, errors} = AdminFormValidator.validate_user(invalid_attrs) assert "must be a valid email address" in errors end - + test "returns error with weak password" do invalid_attrs = %{ "email" => "test@example.com", "password" => "short", "name" => "Test User" } - + assert {:error, errors} = AdminFormValidator.validate_user(invalid_attrs) assert "password must be at least 8 characters" in errors end - + test "returns error with missing required fields" do invalid_attrs = %{ "name" => "Test User" } - + assert {:error, errors} = AdminFormValidator.validate_user(invalid_attrs) assert "email is required" in errors end @@ -62,35 +62,36 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do "timezone" => "UTC", "status" => "active" } - + assert {:ok, validated} = AdminFormValidator.validate_event(valid_attrs) assert validated["name"] == "Test Event" assert validated["description"] == "Test event description" end - + test "returns error with end date before start date" do invalid_attrs = %{ "name" => "Test Event", "description" => "Test event description", "start_date" => "2023-01-01T12:00:00", - "end_date" => "2023-01-01T10:00:00", # End before start + # End before start + "end_date" => "2023-01-01T10:00:00", "timezone" => "UTC", "status" => "active" } - + assert {:error, errors} = AdminFormValidator.validate_event(invalid_attrs) assert "end date must be after start date" in errors end - + test "returns error with missing required fields" do invalid_attrs = %{ "description" => "Test event description" } - + assert {:error, errors} = AdminFormValidator.validate_event(invalid_attrs) assert "name is required" in errors end - + test "returns error with invalid status" do invalid_attrs = %{ "name" => "Test Event", @@ -100,7 +101,7 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do "timezone" => "UTC", "status" => "invalid_status" } - + assert {:error, errors} = AdminFormValidator.validate_event(invalid_attrs) assert "status must be one of: active, draft, completed, cancelled" in errors end @@ -117,13 +118,13 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do "scope" => "openid email profile", "active" => "true" } - + assert {:ok, validated} = AdminFormValidator.validate_oidc_provider(valid_attrs) assert validated["name"] == "Test Provider" assert validated["issuer"] == "https://example.com" assert validated["client_id"] == "test_client_id" end - + test "returns error with invalid URLs" do invalid_attrs = %{ "name" => "Test Provider", @@ -133,17 +134,17 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do "redirect_uri" => "invalid-url", "scope" => "openid email profile" } - + assert {:error, errors} = AdminFormValidator.validate_oidc_provider(invalid_attrs) assert "issuer must be a valid URL starting with http:// or https://" in errors assert "redirect_uri must be a valid URL starting with http:// or https://" in errors end - + test "returns error with missing required fields" do invalid_attrs = %{ "name" => "Test Provider" } - + assert {:error, errors} = AdminFormValidator.validate_oidc_provider(invalid_attrs) assert "issuer is required" in errors assert "client_id is required" in errors @@ -155,42 +156,46 @@ defmodule ClaperWeb.Validators.AdminFormValidatorTest do test "adds validation errors to user changeset" do changeset = User.changeset(%User{}, %{email: "test@example.com", password: "Password123!"}) validation_errors = ["Custom error 1", "Custom error 2"] - + result = AdminFormValidator.add_validation_errors(changeset, validation_errors) - + assert %Ecto.Changeset{} = result assert result.errors != changeset.errors assert length(result.errors) > length(changeset.errors) - + # Extract error messages error_messages = Enum.map(result.errors, fn {_, {msg, _}} -> msg end) assert "Custom error 1" in error_messages assert "Custom error 2" in error_messages end - + test "adds validation errors to event changeset" do - changeset = Event.changeset(%Event{}, %{name: "Test Event", description: "Test description"}) + changeset = + Event.changeset(%Event{}, %{name: "Test Event", description: "Test description"}) + validation_errors = ["Custom event error"] - + result = AdminFormValidator.add_validation_errors(changeset, validation_errors) - + assert %Ecto.Changeset{} = result assert result.errors != changeset.errors - + # Extract error messages error_messages = Enum.map(result.errors, fn {_, {msg, _}} -> msg end) assert "Custom event error" in error_messages end - + test "adds validation errors to provider changeset" do - changeset = Provider.changeset(%Provider{}, %{name: "Test Provider", issuer: "https://example.com"}) + changeset = + Provider.changeset(%Provider{}, %{name: "Test Provider", issuer: "https://example.com"}) + validation_errors = ["Custom provider error"] - + result = AdminFormValidator.add_validation_errors(changeset, validation_errors) - + assert %Ecto.Changeset{} = result assert result.errors != changeset.errors - + # Extract error messages error_messages = Enum.map(result.errors, fn {_, {msg, _}} -> msg end) assert "Custom provider error" in error_messages diff --git a/test/claper_web/views/admin/shared_view_test.exs b/test/claper_web/views/admin/shared_view_test.exs index fd4b2aa..d03d096 100644 --- a/test/claper_web/views/admin/shared_view_test.exs +++ b/test/claper_web/views/admin/shared_view_test.exs @@ -4,7 +4,7 @@ defmodule ClaperWeb.Admin.SharedViewTest do # Import Phoenix.LiveViewTest for testing LiveView components import Phoenix.LiveViewTest import Phoenix.Component - + alias ClaperWeb.Admin.SharedView describe "modal component" do @@ -15,13 +15,13 @@ defmodule ClaperWeb.Admin.SharedViewTest do show: true, return_to: "/admin" } - + content = ~H""" """ - + html = render_component(SharedView, "modal.html", Map.put(assigns, :inner_content, content)) - + assert html =~ "test-modal" assert html =~ "Test Modal" assert html =~ "Test Content" @@ -29,7 +29,7 @@ defmodule ClaperWeb.Admin.SharedViewTest do assert html =~ "modal-header" assert html =~ "modal-content" end - + test "modal is hidden when show is false" do assigns = %{ id: "test-modal", @@ -37,13 +37,13 @@ defmodule ClaperWeb.Admin.SharedViewTest do show: false, return_to: "/admin" } - + content = ~H""" """ - + html = render_component(SharedView, "modal.html", Map.put(assigns, :inner_content, content)) - + assert html =~ "hidden" end end @@ -58,9 +58,9 @@ defmodule ClaperWeb.Admin.SharedViewTest do ], id: "test-table" } - + html = render_component(SharedView, "table.html", assigns) - + assert html =~ "test-table" assert html =~ "Name" assert html =~ "Email" @@ -72,7 +72,7 @@ defmodule ClaperWeb.Admin.SharedViewTest do assert html =~ "jane@example.com" assert html =~ "User" end - + test "renders empty table message when no rows" do assigns = %{ headers: ["Name", "Email", "Role"], @@ -80,9 +80,9 @@ defmodule ClaperWeb.Admin.SharedViewTest do id: "empty-table", empty_message: "No data available" } - + html = render_component(SharedView, "table.html", assigns) - + assert html =~ "empty-table" assert html =~ "No data available" end @@ -95,9 +95,9 @@ defmodule ClaperWeb.Admin.SharedViewTest do placeholder: "Search users...", search_term: "john" } - + html = render_component(SharedView, "search.html", assigns) - + assert html =~ "search-form" assert html =~ "Search users..." assert html =~ "value=\"john\"" @@ -114,20 +114,24 @@ defmodule ClaperWeb.Admin.SharedViewTest do type: "text", required: true } - + html = render_component(SharedView, "form_field.html", assigns) - + assert html =~ "form-group" assert html =~ "Name" assert html =~ "required" assert html =~ "type=\"text\"" end - + test "renders field with error" do - form = Phoenix.HTML.FormData.to_form(%Phoenix.HTML.Form{ - errors: [name: {"can't be blank", []}] - }, []) - + form = + Phoenix.HTML.FormData.to_form( + %Phoenix.HTML.Form{ + errors: [name: {"can't be blank", []}] + }, + [] + ) + assigns = %{ form: form, field: :name, @@ -135,9 +139,9 @@ defmodule ClaperWeb.Admin.SharedViewTest do type: "text", required: true } - + html = render_component(SharedView, "form_field.html", assigns) - + assert html =~ "form-group" assert html =~ "Name" assert html =~ "error-text" @@ -154,9 +158,9 @@ defmodule ClaperWeb.Admin.SharedViewTest do %{title: "Edit User", active: true} ] } - + html = render_component(SharedView, "breadcrumbs.html", assigns) - + assert html =~ "breadcrumbs" assert html =~ "Dashboard" assert html =~ "Users" @@ -172,21 +176,21 @@ defmodule ClaperWeb.Admin.SharedViewTest do assigns = %{ flash: %{"info" => "Operation successful"} } - + html = render_component(SharedView, "flash.html", assigns) - + assert html =~ "flash-container" assert html =~ "flash-info" assert html =~ "Operation successful" end - + test "renders error flash message" do assigns = %{ flash: %{"error" => "Operation failed"} } - + html = render_component(SharedView, "flash.html", assigns) - + assert html =~ "flash-container" assert html =~ "flash-error" assert html =~ "Operation failed"