mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-02-24 04:01:04 +01:00
WIP
This commit is contained in:
2
assets/css/admin.css
Normal file
2
assets/css/admin.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import './modern.css' layer(theme);
|
||||
@@ -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%;
|
||||
|
||||
152
assets/css/modern.css
Normal file
152
assets/css/modern.css
Normal file
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
21
lib/claper_web/components.ex
Normal file
21
lib/claper_web/components.ex
Normal file
@@ -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</.button>
|
||||
<.ui_label for="email">Email Address</.ui_label>
|
||||
"""
|
||||
|
||||
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
|
||||
88
lib/claper_web/components/button.ex
Normal file
88
lib/claper_web/components/button.ex
Normal file
@@ -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"""
|
||||
<button
|
||||
type={@type}
|
||||
disabled={@disabled or @loading}
|
||||
class={button_classes(@variant, @size, @disabled, @loading, @class)}
|
||||
{@rest}
|
||||
>
|
||||
<%= if @loading do %>
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||
</circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<% end %>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
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
|
||||
69
lib/claper_web/components/input.ex
Normal file
69
lib/claper_web/components/input.ex
Normal file
@@ -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"""
|
||||
<div class={@class}>
|
||||
<%= if @label do %>
|
||||
<Label.label for={@id} class="ml-5">{@label}</Label.label>
|
||||
<% end %>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={@value}
|
||||
placeholder={@placeholder}
|
||||
required={@required}
|
||||
disabled={@disabled}
|
||||
class={input_classes(@value)}
|
||||
{@rest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @errors != [] do %>
|
||||
<div class="mt-1 ml-5">
|
||||
<%= for error <- @errors do %>
|
||||
<p class="text-xs text-red-600">{error}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
25
lib/claper_web/components/label.ex
Normal file
25
lib/claper_web/components/label.ex
Normal file
@@ -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"""
|
||||
<label for={@for} class={label_classes(@class)}>
|
||||
{render_slot(@inner_block)}
|
||||
<%= if @required do %>
|
||||
<span class="text-red-500 ml-1">*</span>
|
||||
<% end %>
|
||||
</label>
|
||||
"""
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
182
lib/claper_web/live/admin_live/components_demo_live.ex
Normal file
182
lib/claper_web/live/admin_live/components_demo_live.ex
Normal file
@@ -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"""
|
||||
<div class="min-h-screen bg-gray-50 p-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Component Library Demo</h1>
|
||||
|
||||
<!-- Input Components Section -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-6">Input Components</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 space-y-6">
|
||||
<!-- Empty Input State -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Empty Input</h3>
|
||||
<.input
|
||||
id="empty-input"
|
||||
name="email"
|
||||
type="email"
|
||||
value={@email}
|
||||
class="my-2"
|
||||
placeholder="Enter your email"
|
||||
phx-change="update_email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Focused Input State (with label) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Input with Label</h3>
|
||||
<.input
|
||||
id="name-input"
|
||||
name="name"
|
||||
label="Your Name"
|
||||
value={@name}
|
||||
placeholder="Hey"
|
||||
phx-change="update_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filled Input State -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Filled Input</h3>
|
||||
<.input
|
||||
id="filled-input"
|
||||
name="message"
|
||||
value={@filled_input}
|
||||
placeholder="Type something..."
|
||||
phx-change="update_filled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Input with Error -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Input with Error</h3>
|
||||
<.input
|
||||
id="error-input"
|
||||
name="required"
|
||||
label="Required Field"
|
||||
value=""
|
||||
placeholder="This field is required"
|
||||
errors={["This field cannot be empty"]}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Disabled Input -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Disabled Input</h3>
|
||||
<.input id="disabled-input" name="disabled" value="Cannot edit this" disabled={true} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Button Components Section -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-6">Button Components</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- Button Variants -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Button Variants</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<.button variant="primary">Primary Button</.button>
|
||||
<.button variant="secondary">Secondary Button</.button>
|
||||
<.button variant="outline">Outline Button</.button>
|
||||
<.button variant="danger">Danger Button</.button>
|
||||
<.button variant="emphasis">Emphasis Button</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Sizes -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Button Sizes</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<.button size="small">Small</.button>
|
||||
<.button size="base">Base</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button States -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Button States</h3>
|
||||
<div class="flex gap-4">
|
||||
<.button disabled={true}>Disabled</.button>
|
||||
<.button loading={@loading} phx-click="toggle_loading">
|
||||
{if @loading, do: "Loading...", else: "Click to Load"}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Submit Button -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-3">Form Submit Button</h3>
|
||||
<form phx-submit="submit_form" class="flex items-end gap-4">
|
||||
<.input
|
||||
id="submit-input"
|
||||
name="submit_value"
|
||||
placeholder="Type and submit..."
|
||||
class="flex-1"
|
||||
/>
|
||||
<.button type="submit" variant="primary">Submit</.button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Label Component Section -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 mb-6">Label Components</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 space-y-4">
|
||||
<div>
|
||||
<.ui_label for="demo-1">Regular Label</.ui_label>
|
||||
<input type="text" id="demo-1" class="mt-1 block w-full rounded-md border-gray-300" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<.ui_label for="demo-2" required={true}>Required Label</.ui_label>
|
||||
<input type="text" id="demo-2" class="mt-1 block w-full rounded-md border-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -9,49 +9,88 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<div class="bg-white p-6 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-600 mb-2">Total Users</div>
|
||||
<div class="text-4xl font-bold"><%= @stats.total_users %></div>
|
||||
<div class="text-sm text-gray-600 mt-2"><span class={if @growth_metrics.users_growth > 0 do "text-green-600" else "text-red-600" end}><%= if @growth_metrics.users_growth >= 0 do %>+<% end %><%= @growth_metrics.users_growth %>%</span> vs last month</div>
|
||||
<div class="text-4xl font-bold">{@stats.total_users}</div>
|
||||
<div class="text-sm text-gray-600 mt-2">
|
||||
<span class={
|
||||
if @growth_metrics.users_growth > 0 do
|
||||
"text-green-600"
|
||||
else
|
||||
"text-red-600"
|
||||
end
|
||||
}>
|
||||
<%= if @growth_metrics.users_growth >= 0 do %>
|
||||
+
|
||||
<% end %>
|
||||
{@growth_metrics.users_growth}%
|
||||
</span>
|
||||
vs last month
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white p-6 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-600 mb-2">Total Events</div>
|
||||
<div class="text-4xl font-bold"><%= @stats.total_events %></div>
|
||||
<div class="text-sm text-gray-600 mt-2"><span class={if @growth_metrics.events_growth > 0 do "text-green-600" else "text-red-600" end}><%= if @growth_metrics.events_growth >= 0 do %>+<% end %><%= @growth_metrics.events_growth %>%</span> vs last month</div>
|
||||
<div class="text-4xl font-bold">{@stats.total_events}</div>
|
||||
<div class="text-sm text-gray-600 mt-2">
|
||||
<span class={
|
||||
if @growth_metrics.events_growth > 0 do
|
||||
"text-green-600"
|
||||
else
|
||||
"text-red-600"
|
||||
end
|
||||
}>
|
||||
<%= if @growth_metrics.events_growth >= 0 do %>
|
||||
+
|
||||
<% end %>
|
||||
{@growth_metrics.events_growth}%
|
||||
</span>
|
||||
vs last month
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white p-6 rounded-lg border border-gray-200">
|
||||
<div class="text-sm text-gray-600 mb-2">Active Events</div>
|
||||
<div class="text-4xl font-bold"><%= @stats.active_events %></div>
|
||||
<div class="text-4xl font-bold">{@stats.active_events}</div>
|
||||
<div class="text-sm text-gray-600 mt-2">Currently running</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||
<div class="bg-white p-6 rounded-lg border border-gray-200">
|
||||
<h3 class="text-lg font-semibold">User Growth</h3>
|
||||
<h4 class="text-sm text-gray-600 mb-2">30 days</h4>
|
||||
<div class="h-72 relative">
|
||||
<canvas id="userGrowthChart" phx-hook="UserGrowthChart" data-labels={Jason.encode!(@users_chart_data.labels)} data-values={Jason.encode!(@users_chart_data.values)}></canvas>
|
||||
<canvas
|
||||
id="userGrowthChart"
|
||||
phx-hook="UserGrowthChart"
|
||||
data-labels={Jason.encode!(@users_chart_data.labels)}
|
||||
data-values={Jason.encode!(@users_chart_data.values)}
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white p-6 rounded-lg border border-gray-200">
|
||||
<h3 class="text-lg font-semibold">Event Creation</h3>
|
||||
<h4 class="text-sm text-gray-600 mb-2">30 days</h4>
|
||||
<div class="h-72 relative">
|
||||
<canvas id="eventCreationChart" phx-hook="EventCreationChart" data-labels={Jason.encode!(@events_chart_data.labels)} data-values={Jason.encode!(@events_chart_data.values)}></canvas>
|
||||
<canvas
|
||||
id="eventCreationChart"
|
||||
phx-hook="EventCreationChart"
|
||||
data-labels={Jason.encode!(@events_chart_data.labels)}
|
||||
data-values={Jason.encode!(@events_chart_data.values)}
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Recent Events Table -->
|
||||
<!-- Recent Events Table -->
|
||||
<div class="mt-12">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold">Recent Events</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
@@ -68,24 +107,40 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<%= 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 %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 text-sm"><%= event.name %></td>
|
||||
<td class="px-6 py-4 text-sm"><%= event.code %></td>
|
||||
<td class="px-6 py-4 text-sm"><%= event.user.email %></td>
|
||||
<td class="px-6 py-4 text-sm"><%= Calendar.strftime(event.started_at, "%b %d, %Y") %></td>
|
||||
<td class="px-6 py-4 text-sm"><%= event.audience_peak %></td>
|
||||
<td class={"px-6 py-4 text-sm font-medium #{elem(status, 1)}"}><%= elem(status, 0) %></td>
|
||||
<td class="px-6 py-4 text-sm">{event.name}</td>
|
||||
<td class="px-6 py-4 text-sm">{event.code}</td>
|
||||
<td class="px-6 py-4 text-sm">{event.user.email}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
{Calendar.strftime(event.started_at, "%b %d, %Y")}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">{event.audience_peak}</td>
|
||||
<td class={"px-6 py-4 text-sm font-medium #{elem(status, 1)}"}>
|
||||
{elem(status, 0)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<.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>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
@@ -103,4 +158,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -5,9 +5,23 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Events</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<.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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Event
|
||||
</.link>
|
||||
@@ -22,8 +36,18 @@
|
||||
<form phx-change="search" class="flex w-full md:w-1/2">
|
||||
<div class="relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
@@ -36,44 +60,78 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Events Table -->
|
||||
|
||||
<!-- Events Table -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="name" class="group inline-flex">
|
||||
Name
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :name}) %>
|
||||
Name {sort_indicator(%{current_sort: @current_sort, field: :name})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="code" class="group inline-flex">
|
||||
Code
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :code}) %>
|
||||
Code {sort_indicator(%{current_sort: @current_sort, field: :code})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Owner
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="started_at" class="group inline-flex">
|
||||
Started At
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :started_at}) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="started_at"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Started At {sort_indicator(%{
|
||||
current_sort: @current_sort,
|
||||
field: :started_at
|
||||
})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="expired_at" class="group inline-flex">
|
||||
Expired At
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :expired_at}) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="expired_at"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Expired At {sort_indicator(%{
|
||||
current_sort: @current_sort,
|
||||
field: :expired_at
|
||||
})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="audience_peak" class="group inline-flex">
|
||||
Audience Peak
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :audience_peak}) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="audience_peak"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Audience Peak {sort_indicator(%{
|
||||
current_sort: @current_sort,
|
||||
field: :audience_peak
|
||||
})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
@@ -84,34 +142,56 @@
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if Enum.empty?(@events) do %>
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">No events found</td>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for event <- @events do %>
|
||||
<tr id={"event-#{event.id}"}>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<%= event.name %>
|
||||
{event.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= event.code %>
|
||||
{event.code}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= if event.user, do: event.user.email, else: "No owner" %>
|
||||
{if event.user, do: event.user.email, else: "No owner"}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= 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"}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= 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"}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= event.audience_peak || 0 %>
|
||||
{event.audience_peak || 0}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<.link navigate={~p"/admin/events/#{event}"} class="text-indigo-600 hover:text-indigo-900">View</.link>
|
||||
<.link navigate={~p"/admin/events/#{event}/edit"} class="text-indigo-600 hover:text-indigo-900">Edit</.link>
|
||||
<a href="#" phx-click="delete" phx-value-id={event.id} data-confirm="Are you sure you want to delete this event?" class="text-red-600 hover:text-red-900">
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}"}
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}/edit"}
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Edit
|
||||
</.link>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={event.id}
|
||||
data-confirm="Are you sure you want to delete this event?"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
@@ -132,10 +212,16 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Event Details</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
@@ -145,53 +231,57 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 mt-4">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900"><%= @event.name %></h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Event code: <%= @event.code %></p>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">{@event.name}</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Event code: {@event.code}</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @event.name %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{@event.name}</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Code</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @event.code %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{@event.code}</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Owner</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= if @event.user, do: @event.user.email, else: "No owner" %>
|
||||
{if @event.user, do: @event.user.email, else: "No owner"}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Started At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= 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"}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Expired At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= 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"}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Audience Peak</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= @event.audience_peak || 0 %> attendees
|
||||
{@event.audience_peak || 0} attendees
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -205,7 +295,10 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">New Event</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -231,7 +324,10 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Edit Event</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -251,4 +347,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -7,13 +7,7 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.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">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<.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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,4 +160,4 @@ defmodule ClaperWeb.AdminLive.EventLive.FormComponent do
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,28 +5,44 @@ defmodule ClaperWeb.AdminLive.FormFieldComponent do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
|
||||
<%= 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")}
|
||||
<div class="mt-1">
|
||||
<%= 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" -> %>
|
||||
<div class="relative">
|
||||
<%= 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
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
@@ -35,63 +51,96 @@ defmodule ClaperWeb.AdminLive.FormFieldComponent do
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<% "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" -> %>
|
||||
<div class="flex items-center">
|
||||
<%= checkbox @form, @field,
|
||||
[class: "h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"] ++ @extra_attrs %>
|
||||
<span class="ml-2 text-sm text-gray-600"><%= @checkbox_label || @label %></span>
|
||||
{checkbox(
|
||||
@form,
|
||||
@field,
|
||||
[class: "h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"] ++
|
||||
@extra_attrs
|
||||
)}
|
||||
<span class="ml-2 text-sm text-gray-600">{@checkbox_label || @label}</span>
|
||||
</div>
|
||||
|
||||
<% "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" -> %>
|
||||
<div class="flex items-center">
|
||||
<label class="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<span>Choose file</span>
|
||||
<%= file_input @form, @field,
|
||||
[class: "sr-only",
|
||||
required: @required,
|
||||
phx_change: "file_selected",
|
||||
phx_target: @myself] ++ @extra_attrs %>
|
||||
{file_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "sr-only",
|
||||
required: @required,
|
||||
phx_change: "file_selected",
|
||||
phx_target: @myself
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
</label>
|
||||
<span class="ml-3 text-sm text-gray-500" id={"file-name-#{@field}"}>
|
||||
<%= if @selected_file, do: @selected_file, else: "No file chosen" %>
|
||||
{if @selected_file, do: @selected_file, else: "No file chosen"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% _ -> %>
|
||||
<%= 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 %>
|
||||
<p class="mt-2 text-sm text-gray-500"><%= @description %></p>
|
||||
<p class="mt-2 text-sm text-gray-500">{@description}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -5,29 +5,31 @@ defmodule ClaperWeb.AdminLive.ModalComponent do
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
<div
|
||||
id={@id}
|
||||
class="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
role="dialog"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
phx-remove={hide_modal(@id)}
|
||||
style={if @show, do: "", else: "display: none;"}
|
||||
>
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
phx-click="hide"
|
||||
phx-target={@myself}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Trick browser into centering modal contents -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<!-- Trick browser into centering modal contents -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<!-- Modal panel -->
|
||||
<div class={[
|
||||
"inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle",
|
||||
@size_class
|
||||
@@ -42,34 +44,34 @@ defmodule ClaperWeb.AdminLive.ModalComponent do
|
||||
<i class={"fas #{@icon} #{@icon_text_class}"}></i>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class={[
|
||||
"mt-3 text-center sm:mt-0 sm:text-left",
|
||||
if(@icon, do: "sm:ml-4", else: "w-full")
|
||||
]}>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
|
||||
<%= @title %>
|
||||
{@title}
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<%= if @description do %>
|
||||
<p class="text-sm text-gray-500">
|
||||
<%= @description %>
|
||||
{@description}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @content do %>
|
||||
<div class="mt-4">
|
||||
<%= render_slot(@content) %>
|
||||
{render_slot(@content)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<%= if @confirm_action do %>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm"
|
||||
phx-target={@myself}
|
||||
@@ -78,23 +80,23 @@ defmodule ClaperWeb.AdminLive.ModalComponent do
|
||||
@confirm_class
|
||||
]}
|
||||
>
|
||||
<%= @confirm_action %>
|
||||
{@confirm_action}
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @cancel_action do %>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide"
|
||||
phx-target={@myself}
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
<%= @cancel_action %>
|
||||
{@cancel_action}
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
<%= render_slot(@custom_actions) %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -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 %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
|
||||
@@ -5,9 +5,23 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">OIDC Providers</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<.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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Provider
|
||||
</.link>
|
||||
@@ -22,8 +36,18 @@
|
||||
<form phx-change="search" class="flex w-full md:w-1/2">
|
||||
<div class="relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
@@ -36,35 +60,47 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Providers Table -->
|
||||
|
||||
<!-- Providers Table -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="name" class="group inline-flex">
|
||||
Name
|
||||
<%= sort_indicator(@current_sort, :name) %>
|
||||
Name {sort_indicator(@current_sort, :name)}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="issuer" class="group inline-flex">
|
||||
Issuer
|
||||
<%= sort_indicator(@current_sort, :issuer) %>
|
||||
Issuer {sort_indicator(@current_sort, :issuer)}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="active" class="group inline-flex">
|
||||
Status
|
||||
<%= sort_indicator(@current_sort, :active) %>
|
||||
Status {sort_indicator(@current_sort, :active)}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="inserted_at" class="group inline-flex">
|
||||
Created
|
||||
<%= sort_indicator(@current_sort, :inserted_at) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="inserted_at"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Created {sort_indicator(@current_sort, :inserted_at)}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
@@ -75,19 +111,25 @@
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if Enum.empty?(@providers) do %>
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No OIDC providers found</td>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No OIDC providers found
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for provider <- @providers do %>
|
||||
<tr id={"provider-#{provider.id}"}>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<%= provider.name %>
|
||||
{provider.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= provider.issuer %>
|
||||
{provider.issuer}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button phx-click="toggle_active" phx-value-id={provider.id} class="focus:outline-none">
|
||||
<button
|
||||
phx-click="toggle_active"
|
||||
phx-value-id={provider.id}
|
||||
class="focus:outline-none"
|
||||
>
|
||||
<%= if provider.active do %>
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
@@ -100,17 +142,29 @@
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<.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>
|
||||
<.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
|
||||
</.link>
|
||||
<a href="#" phx-click="delete" phx-value-id={provider.id} data-confirm="Are you sure you want to delete this provider?" class="text-red-600 hover:text-red-900">
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={provider.id}
|
||||
data-confirm="Are you sure you want to delete this provider?"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
@@ -125,17 +179,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% :show -> %>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">OIDC Provider Details</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
@@ -145,30 +204,38 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 mt-4">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900"><%= @provider.name %></h3>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">{@provider.name}</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">OIDC Provider</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @provider.name %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{@provider.name}</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Issuer</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @provider.issuer %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{@provider.issuer}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @provider.client_id %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{@provider.client_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Response Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @provider.response_type %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{@provider.response_type}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Scope</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @provider.scope %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{@provider.scope}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||
@@ -187,13 +254,13 @@
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -201,14 +268,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% :new -> %>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">New OIDC Provider</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -228,14 +297,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% :edit -> %>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Edit OIDC Provider</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -255,4 +326,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,4 +218,4 @@ defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,50 +12,53 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={@search_value || ""}
|
||||
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md"
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={@search_value || ""}
|
||||
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder={@search_placeholder || "Search..."}
|
||||
phx-debounce="300"
|
||||
phx-change="search_change"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<%= if @filters && length(@filters) > 0 do %>
|
||||
<div class="ml-3 flex space-x-2">
|
||||
<%= for filter <- @filters do %>
|
||||
<select
|
||||
<select
|
||||
name={filter.name}
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
phx-change="filter_change"
|
||||
phx-target={@myself}
|
||||
phx-value-filter={filter.name}
|
||||
>
|
||||
<option disabled={!@filter_values[filter.name]} selected={!@filter_values[filter.name]}>
|
||||
<%= filter.label %>
|
||||
<option
|
||||
disabled={!@filter_values[filter.name]}
|
||||
selected={!@filter_values[filter.name]}
|
||||
>
|
||||
{filter.label}
|
||||
</option>
|
||||
<%= for {label, value} <- filter.options do %>
|
||||
<option value={value} selected={@filter_values[filter.name] == value}>
|
||||
<%= label %>
|
||||
{label}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
|
||||
<%= if @show_clear and (@search_value || has_active_filters?(@filter_values)) do %>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear_all"
|
||||
phx-target={@myself}
|
||||
@@ -66,7 +69,7 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do
|
||||
<% end %>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="ml-4 mt-2 flex-shrink-0">
|
||||
<%= if @export_csv_enabled do %>
|
||||
<button
|
||||
@@ -75,23 +78,22 @@ defmodule ClaperWeb.AdminLive.SearchFilterComponent do
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-file-csv mr-2"></i>
|
||||
Export CSV
|
||||
<i class="fas fa-file-csv mr-2"></i> Export CSV
|
||||
</button>
|
||||
<% 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"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<%= @new_label || "New" %>
|
||||
{@new_label || "New"}
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
<%= render_slot(@custom_actions) %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<span class="sr-only">View</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @edit_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -30,7 +30,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<span class="sr-only">Edit</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @delete_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -38,13 +38,16 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
phx-target={@myself}
|
||||
class="text-red-600 hover:text-red-900 transition-colors duration-200"
|
||||
title="Delete"
|
||||
data-confirm={@delete_confirm_message || "Are you sure you want to delete this item? This action cannot be undone."}
|
||||
data-confirm={
|
||||
@delete_confirm_message ||
|
||||
"Are you sure you want to delete this item? This action cannot be undone."
|
||||
}
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span class="sr-only">Delete</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @duplicate_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -57,7 +60,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<span class="sr-only">Duplicate</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @archive_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,15 +68,18 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"transition-colors duration-200",
|
||||
if(@item_archived, do: "text-orange-600 hover:text-orange-900", else: "text-gray-600 hover:text-gray-900")
|
||||
if(@item_archived,
|
||||
do: "text-orange-600 hover:text-orange-900",
|
||||
else: "text-gray-600 hover:text-gray-900"
|
||||
)
|
||||
]}
|
||||
title={if @item_archived, do: "Unarchive", else: "Archive"}
|
||||
>
|
||||
<i class={if @item_archived, do: "fas fa-box-open", else: "fas fa-archive"}></i>
|
||||
<span class="sr-only"><%= if @item_archived, do: "Unarchive", else: "Archive" %></span>
|
||||
<span class="sr-only">{if @item_archived, do: "Unarchive", else: "Archive"}</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @toggle_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
@@ -81,15 +87,26 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"transition-colors duration-200",
|
||||
if(@item_active, do: "text-green-600 hover:text-green-900", else: "text-gray-600 hover:text-gray-900")
|
||||
if(@item_active,
|
||||
do: "text-green-600 hover:text-green-900",
|
||||
else: "text-gray-600 hover:text-gray-900"
|
||||
)
|
||||
]}
|
||||
title={if @item_active, do: @toggle_active_title || "Deactivate", else: @toggle_inactive_title || "Activate"}
|
||||
title={
|
||||
if @item_active,
|
||||
do: @toggle_active_title || "Deactivate",
|
||||
else: @toggle_inactive_title || "Activate"
|
||||
}
|
||||
>
|
||||
<i class={if @item_active, do: "fas fa-toggle-on", else: "fas fa-toggle-off"}></i>
|
||||
<span class="sr-only"><%= if @item_active, do: @toggle_active_title || "Deactivate", else: @toggle_inactive_title || "Activate" %></span>
|
||||
<span class="sr-only">
|
||||
{if @item_active,
|
||||
do: @toggle_active_title || "Deactivate",
|
||||
else: @toggle_inactive_title || "Activate"}
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @dropdown_actions && length(@dropdown_actions) > 0 do %>
|
||||
<div class="relative" phx-click-away="close_dropdown" phx-target={@myself}>
|
||||
<button
|
||||
@@ -102,7 +119,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
<span class="sr-only">More actions</span>
|
||||
</button>
|
||||
|
||||
|
||||
<%= if @dropdown_open do %>
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 border border-gray-200">
|
||||
<div class="py-1">
|
||||
@@ -125,7 +142,7 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<%= if action[:icon] do %>
|
||||
<i class={"#{action.icon} mr-2"}></i>
|
||||
<% end %>
|
||||
<%= action.label %>
|
||||
{action.label}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -133,9 +150,9 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
<%= render_slot(@custom_actions) %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -7,190 +7,202 @@ defmodule ClaperWeb.AdminLive.TableComponent do
|
||||
<div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<%= for {header, _index} <- Enum.with_index(@headers) do %>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
|
||||
if(@sortable && header.sortable, do: "cursor-pointer hover:bg-gray-100", else: "")
|
||||
]}
|
||||
phx-click={if @sortable && header.sortable, do: "sort", else: nil}
|
||||
phx-value-field={if @sortable && header.sortable, do: header.field, else: nil}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<%= 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 -> %>
|
||||
<i class="fas fa-sort-up ml-2 text-indigo-500"></i>
|
||||
<% %{field: field, direction: :desc} when field == header.field -> %>
|
||||
<i class="fas fa-sort-down ml-2 text-indigo-500"></i>
|
||||
<% _ -> %>
|
||||
<i class="fas fa-sort ml-2 text-gray-400"></i>
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<%= for {header, _index} <- Enum.with_index(@headers) do %>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
|
||||
if(@sortable && header.sortable, do: "cursor-pointer hover:bg-gray-100", else: "")
|
||||
]}
|
||||
phx-click={if @sortable && header.sortable, do: "sort", else: nil}
|
||||
phx-value-field={if @sortable && header.sortable, do: header.field, else: nil}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{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 -> %>
|
||||
<i class="fas fa-sort-up ml-2 text-indigo-500"></i>
|
||||
<% %{field: field, direction: :desc} when field == header.field -> %>
|
||||
<i class="fas fa-sort-down ml-2 text-indigo-500"></i>
|
||||
<% _ -> %>
|
||||
<i class="fas fa-sort ml-2 text-gray-400"></i>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if length(@rows) > 0 do %>
|
||||
<%= for {row, row_index} <- Enum.with_index(@rows) do %>
|
||||
<tr
|
||||
class={[
|
||||
"hover:bg-gray-50",
|
||||
if(@row_click_enabled, do: "cursor-pointer", else: "")
|
||||
]}
|
||||
phx-click={if @row_click_enabled, do: "row_clicked", else: nil}
|
||||
phx-value-row-index={row_index}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= case cell_content do %>
|
||||
<% {:safe, content} -> %>
|
||||
{raw(content)}
|
||||
<% content when is_binary(content) -> %>
|
||||
{content}
|
||||
<% content -> %>
|
||||
{to_string(content)}
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</div>
|
||||
</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if length(@rows) > 0 do %>
|
||||
<%= for {row, row_index} <- Enum.with_index(@rows) do %>
|
||||
<tr class={[
|
||||
"hover:bg-gray-50",
|
||||
if(@row_click_enabled, do: "cursor-pointer", else: "")
|
||||
]}
|
||||
phx-click={if @row_click_enabled, do: "row_clicked", else: nil}
|
||||
phx-value-row-index={row_index}
|
||||
phx-target={@myself}>
|
||||
<%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= case cell_content do %>
|
||||
<% {:safe, content} -> %>
|
||||
<%= raw(content) %>
|
||||
<% content when is_binary(content) -> %>
|
||||
<%= content %>
|
||||
<% content -> %>
|
||||
<%= to_string(content) %>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<tr>
|
||||
<td colspan={length(@headers)} class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<%= if @empty_icon do %>
|
||||
<i class={"#{@empty_icon} text-gray-300 text-4xl mb-4"}></i>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||
{@empty_title || "No items found"}
|
||||
</p>
|
||||
<p class="text-gray-500">
|
||||
{@empty_message || "There are no items to display."}
|
||||
</p>
|
||||
<%= if @empty_action do %>
|
||||
<div class="mt-4">
|
||||
{render_slot(@empty_action)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<tr>
|
||||
<td colspan={length(@headers)} class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<%= if @empty_icon do %>
|
||||
<i class={"#{@empty_icon} text-gray-300 text-4xl mb-4"}></i>
|
||||
<% end %>
|
||||
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||
<%= @empty_title || "No items found" %>
|
||||
</p>
|
||||
<p class="text-gray-500">
|
||||
<%= @empty_message || "There are no items to display." %>
|
||||
</p>
|
||||
<%= if @empty_action do %>
|
||||
<div class="mt-4">
|
||||
<%= render_slot(@empty_action) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%= if @pagination do %>
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Previous
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Next
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing <span class="font-medium"><%= (@pagination.page_number - 1) * @pagination.page_size + 1 %></span> to
|
||||
<span class="font-medium"><%= min(@pagination.page_number * @pagination.page_size, @pagination.total_entries) %></span> of
|
||||
<span class="font-medium"><%= @pagination.total_entries %></span> results
|
||||
</p>
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Previous
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Next
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">
|
||||
{(@pagination.page_number - 1) * @pagination.page_size + 1}
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= for page <- get_page_range(@pagination) do %>
|
||||
<%= if page == @pagination.page_number do %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-indigo-500 bg-indigo-50 text-sm font-medium text-indigo-600">
|
||||
<%= page %>
|
||||
</span>
|
||||
<% else %>
|
||||
to
|
||||
<span class="font-medium">
|
||||
{min(@pagination.page_number * @pagination.page_size, @pagination.total_entries)}
|
||||
</span>
|
||||
of <span class="font-medium">{@pagination.total_entries}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nav
|
||||
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={page}
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<%= page %>
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<%= for page <- get_page_range(@pagination) do %>
|
||||
<%= if page == @pagination.page_number do %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-indigo-500 bg-indigo-50 text-sm font-medium text-indigo-600">
|
||||
{page}
|
||||
</span>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={page}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -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(<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">Confirmed</span>)}
|
||||
{:safe,
|
||||
~s(<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">Confirmed</span>)}
|
||||
else
|
||||
{:safe, ~s(<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">Unconfirmed</span>)}
|
||||
{:safe,
|
||||
~s(<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">Unconfirmed</span>)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,17 +163,44 @@ defmodule ClaperWeb.AdminLive.UserLive do
|
||||
~H"""
|
||||
<%= if current_sort.field == field do %>
|
||||
<%= if current_sort.order == :asc do %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
|
||||
@@ -5,9 +5,23 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<.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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New User
|
||||
</.link>
|
||||
@@ -22,8 +36,18 @@
|
||||
<form phx-change="search" class="flex w-full md:w-1/2">
|
||||
<div class="relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
@@ -36,35 +60,57 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="email" class="group inline-flex">
|
||||
Email
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :email}) %>
|
||||
Email {sort_indicator(%{current_sort: @current_sort, field: :email})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button phx-click="sort" phx-value-field="role_id" class="group inline-flex">
|
||||
Role
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :role_id}) %>
|
||||
Role {sort_indicator(%{current_sort: @current_sort, field: :role_id})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="confirmed_at" class="group inline-flex">
|
||||
Status
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :confirmed_at}) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="confirmed_at"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Status {sort_indicator(%{
|
||||
current_sort: @current_sort,
|
||||
field: :confirmed_at
|
||||
})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<button phx-click="sort" phx-value-field="inserted_at" class="group inline-flex">
|
||||
Created
|
||||
<%= sort_indicator(%{current_sort: @current_sort, field: :inserted_at}) %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="inserted_at"
|
||||
class="group inline-flex"
|
||||
>
|
||||
Created {sort_indicator(%{
|
||||
current_sort: @current_sort,
|
||||
field: :inserted_at
|
||||
})}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
@@ -75,16 +121,18 @@
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if Enum.empty?(@users) do %>
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No users found</td>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for user <- @users do %>
|
||||
<tr id={"user-#{user.id}"}>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<%= user.email %>
|
||||
{user.email}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= if user.role, do: user.role.name, else: "No role" %>
|
||||
{if user.role, do: user.role.name, else: "No role"}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= if user.confirmed_at do %>
|
||||
@@ -98,13 +146,29 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<.link navigate={~p"/admin/users/#{user}"} class="text-indigo-600 hover:text-indigo-900">View</.link>
|
||||
<.link navigate={~p"/admin/users/#{user}/edit"} class="text-indigo-600 hover:text-indigo-900">Edit</.link>
|
||||
<a href="#" phx-click="delete" phx-value-id={user.id} data-confirm="Are you sure you want to delete this user?" class="text-red-600 hover:text-red-900">
|
||||
<.link
|
||||
navigate={~p"/admin/users/#{user}"}
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/users/#{user}/edit"}
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Edit
|
||||
</.link>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={user.id}
|
||||
data-confirm="Are you sure you want to delete this user?"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
@@ -125,10 +189,16 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">User Details</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
@@ -138,19 +208,19 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 mt-4">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900"><%= @user.email %></h3>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">{@user.email}</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">User Account</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Email</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"><%= @user.email %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{@user.email}</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Role</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= if @user.role, do: @user.role.name, else: "No role" %>
|
||||
{if @user.role, do: @user.role.name, else: "No role"}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
@@ -170,22 +240,22 @@
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M") %>
|
||||
{Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @user.confirmed_at do %>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Confirmed At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<%= Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M") %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Confirmed At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -198,7 +268,10 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">New User</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -224,7 +297,10 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Edit User</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.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>
|
||||
</div>
|
||||
@@ -244,4 +320,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -7,13 +7,7 @@ defmodule ClaperWeb.AdminLive.UserLive.FormComponent do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.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">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<.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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,98 +7,192 @@
|
||||
{csrf_meta_tag()}
|
||||
<.live_title suffix=" · Claper">{assigns[:page_title] || "Claper"}</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
||||
<link phx-track-static rel="stylesheet" href="/assets/admin.css" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png" />
|
||||
<link phx-track-static rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer phx-track-static type="text/javascript" src="/assets/app.js"></script>
|
||||
<script defer phx-track-static type="text/javascript" src="/assets/app.js">
|
||||
</script>
|
||||
</head>
|
||||
<body class="admin-background">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<div class="hidden md:flex md:flex-shrink-0">
|
||||
<aside class="w-60 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="text-2xl font-bold">Admin</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 py-6">
|
||||
<a href={~p"/admin"} class={"block px-6 py-3 #{if @conn.path_info == ["admin"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}>Dashboard</a>
|
||||
<a href={~p"/admin/events"} class={"block px-6 py-3 #{if @conn.path_info == ["admin", "events"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}>Events</a>
|
||||
<a href={~p"/admin/users"} class={"block px-6 py-3 #{if @conn.path_info == ["admin", "users"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}>Users</a>
|
||||
<%!-- <a href={~p"/admin/oidc_providers"} class={"block px-6 py-3 #{if @conn.path_info == ["admin", "oidc_providers"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}>OIDC Providers</a> --%>
|
||||
<div class="h-px bg-gray-200 mx-6 my-4"></div>
|
||||
<a href="#" class="block px-6 py-3 text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors">Documentation</a>
|
||||
</nav>
|
||||
|
||||
<a href={~p"/events"} class="flex items-center gap-2 px-6 py-4 border-t border-gray-200 text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to App
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<div class="text-2xl font-bold">Admin</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 py-6">
|
||||
<a
|
||||
href={~p"/admin"}
|
||||
class={"block px-6 py-3 #{if @conn.path_info == ["admin"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<a
|
||||
href={~p"/admin/events"}
|
||||
class={"block px-6 py-3 #{if @conn.path_info == ["admin", "events"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}
|
||||
>
|
||||
Events
|
||||
</a>
|
||||
<a
|
||||
href={~p"/admin/users"}
|
||||
class={"block px-6 py-3 #{if @conn.path_info == ["admin", "users"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}
|
||||
>
|
||||
Users
|
||||
</a>
|
||||
<%!-- <a href={~p"/admin/oidc_providers"} class={"block px-6 py-3 #{if @conn.path_info == ["admin", "oidc_providers"], do: "text-gray-900 bg-gray-100 border-l-4 border-gray-900 font-medium", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"}"}>OIDC Providers</a> --%>
|
||||
<div class="h-px bg-gray-200 mx-6 my-4"></div>
|
||||
<a
|
||||
href="#"
|
||||
class="block px-6 py-3 text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-l-4 border-transparent transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<a
|
||||
href={~p"/events"}
|
||||
class="flex items-center gap-2 px-6 py-4 border-t border-gray-200 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to App
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Mobile header -->
|
||||
<!-- Mobile header -->
|
||||
<div class="md:hidden fixed top-0 left-0 right-0 z-30 bg-white shadow-md">
|
||||
<div class="flex justify-between items-center p-4">
|
||||
<a href={~p"/admin"} class="text-xl font-bold flex items-center text-gray-900">
|
||||
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Admin Panel
|
||||
</a>
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href={~p"/events"} class="text-gray-600 hover:text-gray-900 transition-colors" title="Back to App">
|
||||
<a
|
||||
href={~p"/events"}
|
||||
class="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
title="Back to App"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button id="mobile-menu-button" class="focus:outline-none text-gray-600 hover:text-gray-900 p-1 transition-colors">
|
||||
<button
|
||||
id="mobile-menu-button"
|
||||
class="focus:outline-none text-gray-600 hover:text-gray-900 p-1 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu overlay -->
|
||||
<!-- Mobile menu overlay -->
|
||||
<div id="mobile-menu" class="hidden md:hidden fixed inset-0 z-40 bg-gray-800 bg-opacity-50">
|
||||
<!-- Mobile menu panel -->
|
||||
<div class="absolute right-0 top-0 bottom-0 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out">
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200">
|
||||
<span class="text-lg font-bold text-gray-900">Menu</span>
|
||||
<button id="mobile-menu-close" class="text-gray-600 focus:outline-none hover:text-gray-900 p-1 transition-colors">
|
||||
<button
|
||||
id="mobile-menu-close"
|
||||
class="text-gray-600 focus:outline-none hover:text-gray-900 p-1 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="overflow-y-auto h-full pb-20">
|
||||
<nav class="mt-2 px-4">
|
||||
<a href={~p"/admin"} class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}>
|
||||
<a
|
||||
href={~p"/admin"}
|
||||
class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href={~p"/admin/users"} class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin", "users"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}>
|
||||
<a
|
||||
href={~p"/admin/users"}
|
||||
class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin", "users"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Users</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href={~p"/admin/events"} class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin", "events"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}>
|
||||
<a
|
||||
href={~p"/admin/events"}
|
||||
class={"block px-4 py-3 text-base font-medium #{if @conn.path_info == ["admin", "events"], do: "bg-gray-100 text-gray-900", else: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"} rounded-lg mb-2 transition-colors"}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Events</span>
|
||||
</div>
|
||||
@@ -112,10 +206,18 @@
|
||||
</div>
|
||||
</a> --%>
|
||||
<div class="border-t border-gray-200 my-4"></div>
|
||||
<a href={~p"/events"} class="block px-4 py-3 text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<a
|
||||
href={~p"/events"}
|
||||
class="block px-4 py-3 text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
<span>Back to App</span>
|
||||
</div>
|
||||
@@ -125,76 +227,148 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile content padding -->
|
||||
<!-- Mobile content padding -->
|
||||
<div class="md:hidden h-16"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-y-auto bg-gray-50">
|
||||
<!-- Flash Messages -->
|
||||
<div id="flash-messages" class="fixed top-4 right-4 z-50 max-w-sm">
|
||||
<%= if get_flash(@conn, :info) do %>
|
||||
<div role="alert" class="mb-4 bg-blue-50 border border-blue-200 shadow-lg rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-4 bg-blue-50 border border-blue-200 shadow-lg rounded-lg p-4 flex items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-blue-800 flex-1"><%= get_flash(@conn, :info) %></p>
|
||||
<p class="text-blue-800 flex-1">{get_flash(@conn, :info)}</p>
|
||||
<button class="flash-close text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if get_flash(@conn, :success) do %>
|
||||
<div role="alert" class="mb-4 bg-green-50 border border-green-200 shadow-lg rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-4 bg-green-50 border border-green-200 shadow-lg rounded-lg p-4 flex items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-green-800 flex-1"><%= get_flash(@conn, :success) %></p>
|
||||
<p class="text-green-800 flex-1">{get_flash(@conn, :success)}</p>
|
||||
<button class="flash-close text-green-600 hover:text-green-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if get_flash(@conn, :warning) do %>
|
||||
<div role="alert" class="mb-4 bg-yellow-50 border border-yellow-200 shadow-lg rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-4 bg-yellow-50 border border-yellow-200 shadow-lg rounded-lg p-4 flex items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-yellow-800 flex-1"><%= get_flash(@conn, :warning) %></p>
|
||||
<p class="text-yellow-800 flex-1">{get_flash(@conn, :warning)}</p>
|
||||
<button class="flash-close text-yellow-600 hover:text-yellow-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if get_flash(@conn, :error) do %>
|
||||
<div role="alert" class="mb-4 bg-red-50 border border-red-200 shadow-lg rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-4 bg-red-50 border border-red-200 shadow-lg rounded-lg p-4 flex items-start gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-red-800 flex-1"><%= get_flash(@conn, :error) %></p>
|
||||
<p class="text-red-800 flex-1">{get_flash(@conn, :error)}</p>
|
||||
<button class="flash-close text-red-600 hover:text-red-800 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Inner Content -->
|
||||
<%= @inner_content %>
|
||||
<!-- Inner Content -->
|
||||
{@inner_content}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu and flash messages scripts -->
|
||||
<!-- Mobile menu and flash messages scripts -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile menu toggle
|
||||
@@ -288,4 +462,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
defmodule ClaperWeb.Admin.SharedView do
|
||||
use ClaperWeb, :view
|
||||
|
||||
|
||||
@doc """
|
||||
Renders shared components for the admin panel.
|
||||
"""
|
||||
|
||||
1
mix.exs
1
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
BIN
priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
BIN
priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
<div class="modal-content">Test Content</div>
|
||||
"""
|
||||
|
||||
|
||||
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"""
|
||||
<div class="modal-content">Test Content</div>
|
||||
"""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user