This commit is contained in:
Alex Lion
2025-07-26 16:31:39 +02:00
parent a687742994
commit e0720a55ab
54 changed files with 2318 additions and 986 deletions

2
assets/css/admin.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import './modern.css' layer(theme);

View File

@@ -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
View 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)
}

View File

@@ -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);

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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 %>

View File

@@ -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

View File

@@ -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

View File

@@ -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">&#8203;</span>
<!-- Trick browser into centering modal contents -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</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

View File

@@ -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 %>
"""

View File

@@ -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 %>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %>
"""

View File

@@ -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 %>

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -1,6 +1,6 @@
defmodule ClaperWeb.Admin.SharedView do
use ClaperWeb, :view
@doc """
Renders shared components for the admin panel.
"""

View File

@@ -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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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&#39;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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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"