Add admin panel and user roles (#189)

This commit is contained in:
Alexandre Lion
2025-11-20 10:44:06 +01:00
committed by GitHub
parent 16bcce1a60
commit 5853bc34d8
80 changed files with 13473 additions and 603 deletions

7
.gitignore vendored
View File

@@ -30,16 +30,21 @@ claper-*.tar
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# Env files except sample
.env*
!.env.sample
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
priv/static/images/.DS_Store
priv/static/.DS_Store
.env
priv/static/fonts/.DS_Store
test/e2e/node_modules
.DS_Store
priv/static/.well-known/apple-developer-merchantid-domain-association
priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt
.elixir_ls
tailwind.config.js

View File

@@ -2,9 +2,12 @@
### Features
- Add Admin Panel to manage users and presentations
- Add user roles: user, admin
- Add `LANGUAGES` setting to configure available languages in the app
- Add Latvian language support (@possible-im)
- Add Hungarian language support (@bpisch)
- Add hideable presenter attendee count (#183 #155)
- Add Hungarian translation (#161)
- Add Latvian translation (#163)
### Fixes and improvements
@@ -15,7 +18,10 @@
- Fix event name validation to be required
- Docker image is now using Ubuntu instead of Alpine for better dependencies support
- Fix scrollbar not showing in event manager when no presentation file (#164) (@aryel780)
- Fix settings scroll for small screen (#168) (@ChayanDass)
- Fix settings scroll for small screen (#168)
- Fix duplicate key quiz when duplicate (#182)
- Fix email change confirmation (#172)
- Fix italian translation (#179)
### v.2.3.2

112
CLAUDE.md Normal file
View File

@@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Setup and Dependencies
```bash
# Install dependencies
mix deps.get
mix setup # Runs deps.get + ecto.setup
# Setup database
mix ecto.setup # Creates DB, runs migrations, seeds
mix ecto.reset # Drops DB and runs ecto.setup
# Install frontend dependencies
cd assets && npm install && cd ..
```
### Running the Application
```bash
# Start Phoenix server
mix phx.server
# Or inside IEx
iex -S mix phx.server
```
### Testing
```bash
# Run tests
mix test
# Run specific test file
mix test test/path/to/test_file.exs
# Run test with specific line number
mix test test/path/to/test_file.exs:42
```
### Code Quality
```bash
# Format code
mix format
# Run Credo for code analysis
mix credo
```
### Building Assets
```bash
# For production deployment
mix assets.deploy
```
## High-Level Architecture
Claper is an interactive presentation platform built with Phoenix Framework and Elixir. It enables real-time audience interaction during presentations through polls, forms, messages, and quizzes.
### Core Components
1. **Phoenix LiveView Architecture**
- Real-time updates without JavaScript through WebSocket connections
- LiveView modules in `lib/claper_web/live/` handle interactive UI
- Presence tracking for real-time user counts
2. **Main Domain Contexts** (in `lib/claper/`)
- `Accounts` - User management, authentication, OIDC integration
- `Events` - Core presentation/event management
- `Posts` - Audience messages and reactions
- `Polls` - Interactive polls with real-time voting
- `Forms` - Custom forms for audience feedback
- `Quizzes` - Quiz functionality with LTI support
- `Presentations` - Slide management and state tracking
- `Embeds` - External content embedding
3. **Authentication & Authorization**
- Multiple auth methods: email/password, OIDC
- Role-based access control with admin panel
- LTI 1.3 support for educational platforms
4. **Real-time Features**
- Phoenix PubSub for broadcasting updates
- Phoenix Presence for tracking online users
- LiveView for reactive UI without custom JavaScript
5. **Background Jobs**
- Oban for background job processing
- Email sending
6. **Frontend Stack**
- Tailwind CSS for styling (with DaisyUI components)
- Alpine.js for minimal JavaScript interactions
- esbuild for JavaScript bundling
- Separate admin and user interfaces
7. **Key LiveView Modules**
- `EventLive.Show` - Attendee view
- `EventLive.Presenter` - Presenter control view
- `EventLive.Manage` - Event management interface
- `AdminLive.*` - Admin panel components
8. **Database Structure**
- PostgreSQL with Ecto
- Key models: User, Event, Post, Poll, Quiz, PresentationState
- Soft deletes for users
- UUID-based public identifiers
9. **LTI Integration**
- LTI 1.3 support for quizzes, publish score to LMS
- LTI launch handling in `LtiController`

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

@@ -0,0 +1,63 @@
@import "tailwindcss";
@plugin "daisyui";
@utility btn {
@apply rounded-full !font-display text-small-body;
}
@utility input {
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
}
@utility select {
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
}
@utility label {
@apply ml-2;
}
@utility fieldset-legend {
@apply ml-3;
}
@plugin "daisyui/theme" {
name: "claper";
default: true; /* set as default */
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
color-scheme: light; /* color of browser-provided UI */
--color-primary: #140753;
--color-primary-content: #ffffff;
--color-secondary: #f4f4f4;
--color-secondary-content: #140553;
--color-accent: #8611ed;
--color-accent-content: #ffffff;
--color-neutral: #000000;
--color-neutral-content: #ffffff;
--color-info: #79bfe2;
--color-info-content: #0e3649;
--color-success: #3cb957;
--color-success-content: #143e1d;
--color-warning: #ffb62e;
--color-warning-content: #523500;
--color-error: #e7000b;
--color-error-content: #fff;
/* border radius */
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
/* base sizes */
--size-selector: 0.25rem;
--size-field: 0.25rem;
/* border size */
--border: 1px;
/* effects */
--depth: 1;
--noise: 0;
}
@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);

207
assets/js/admin-charts.js Normal file
View File

@@ -0,0 +1,207 @@
import {
Chart,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
LineController,
Filler
} from 'chart.js';
import 'chartjs-adapter-moment';
// Register Chart.js components
Chart.register(
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
LineController,
Filler
);
export class AdminCharts {
constructor() {
this.charts = {};
this.defaultOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
family: 'Inter, system-ui, sans-serif'
},
color: 'rgba(255, 255, 255, 0.9)'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.9)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 8,
displayColors: false,
padding: 12,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.7)',
font: {
size: 11
}
}
},
y: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)',
drawBorder: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.7)',
font: {
size: 11
},
callback: function(value) {
return Number.isInteger(value) ? value : '';
}
}
}
},
elements: {
line: {
tension: 0.4,
borderWidth: 3,
fill: true
},
point: {
radius: 0,
hoverRadius: 6,
hoverBorderWidth: 2,
hoverBorderColor: 'rgba(255, 255, 255, 0.9)'
}
},
animation: {
duration: 800,
easing: 'easeInOutQuart'
}
};
}
createUsersChart(canvasId, data) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chartData = {
labels: data.labels,
datasets: [{
label: 'Users',
data: data.values,
borderColor: 'rgba(102, 126, 234, 1)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
pointBackgroundColor: 'rgba(102, 126, 234, 1)',
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
pointHoverBackgroundColor: 'rgba(102, 126, 234, 1)',
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
}]
};
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
}
this.charts[canvasId] = new Chart(ctx, {
type: 'line',
data: chartData,
options: this.defaultOptions
});
return this.charts[canvasId];
}
createEventsChart(canvasId, data) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chartData = {
labels: data.labels,
datasets: [{
label: 'Events',
data: data.values,
borderColor: 'rgba(16, 185, 129, 1)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
pointBackgroundColor: 'rgba(16, 185, 129, 1)',
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
pointHoverBackgroundColor: 'rgba(16, 185, 129, 1)',
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
}]
};
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
}
this.charts[canvasId] = new Chart(ctx, {
type: 'line',
data: chartData,
options: this.defaultOptions
});
return this.charts[canvasId];
}
updateChart(canvasId, data) {
const chart = this.charts[canvasId];
if (!chart) return;
chart.data.labels = data.labels;
chart.data.datasets[0].data = data.values;
chart.update('active');
}
destroyChart(canvasId) {
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
}
destroyAllCharts() {
Object.keys(this.charts).forEach(canvasId => {
this.destroyChart(canvasId);
});
}
}
// Create global instance
window.AdminCharts = new AdminCharts();

View File

@@ -25,7 +25,9 @@ import QRCodeStyling from "qr-code-styling";
import { Presenter } from "./presenter";
import { Manager } from "./manager";
import Split from "split-grid";
import CustomHooks from "./hooks";
import { TourGuideClient } from "@sjmc11/tourguidejs/src/Tour";
import "./admin-charts.js";
window.moment = moment;
// Get supported locales from backend configuration or fallback to default list
@@ -304,6 +306,19 @@ Hooks.EmptyNickname = {
},
};
Hooks.SearchableSelect = {
mounted() {
this.handleEvent("update_hidden_field", (payload) => {
if (payload.id === this.el.id) {
this.el.value = payload.value;
// Trigger a change event to update the form
const event = new Event('input', { bubbles: true });
this.el.dispatchEvent(event);
}
});
}
};
Hooks.PostForm = {
onPress(e, submitBtn, TA) {
if (e.key == "Enter" && !e.shiftKey) {
@@ -616,6 +631,60 @@ Hooks.Dropdown = {
},
};
Hooks.AdminChart = {
mounted() {
const chartType = this.el.dataset.chartType;
const canvasId = this.el.querySelector('canvas').id;
const data = JSON.parse(this.el.dataset.chartData);
if (chartType === 'users') {
window.AdminCharts.createUsersChart(canvasId, data);
} else if (chartType === 'events') {
window.AdminCharts.createEventsChart(canvasId, data);
}
this.handleEvent("update_chart", (newData) => {
window.AdminCharts.updateChart(canvasId, newData);
});
},
updated() {
const chartType = this.el.dataset.chartType;
const canvasId = this.el.querySelector('canvas').id;
const data = JSON.parse(this.el.dataset.chartData);
if (chartType === 'users') {
window.AdminCharts.createUsersChart(canvasId, data);
} else if (chartType === 'events') {
window.AdminCharts.createEventsChart(canvasId, data);
}
},
destroyed() {
const canvasId = this.el.querySelector('canvas').id;
window.AdminCharts.destroyChart(canvasId);
}
};
Hooks.CSVDownloader = {
mounted() {
this.handleEvent("download_csv", ({ filename, content }) => {
const blob = new Blob([content], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
});
}
};
// Merge our custom hooks with the existing hooks
Object.assign(Hooks, CustomHooks);
let Uploaders = {};
Uploaders.S3 = function (entries, onViewError) {

197
assets/js/hooks.js Normal file
View File

@@ -0,0 +1,197 @@
// LiveView hooks for client-side functionality
const Hooks = {
// Hook for handling CSV downloads from LiveView
CSVDownloader: {
mounted() {
this.handleEvent("download_csv", ({ filename, content }) => {
// Create a Blob with the CSV content
const blob = new Blob([content], { type: "text/csv" });
// Create a temporary URL for the Blob
const url = window.URL.createObjectURL(blob);
// Create a temporary link element
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
// Append the link to the document body
document.body.appendChild(link);
// Trigger the download
link.click();
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
});
}
},
// Hook for User Growth Chart
UserGrowthChart: {
mounted() {
// Import Chart.js dynamically
import("chart.js/auto").then(({ default: Chart }) => {
const ctx = this.el.getContext("2d");
const labels = JSON.parse(this.el.dataset.labels);
const values = JSON.parse(this.el.dataset.values);
this.chart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "New Users",
data: values,
borderColor: "#111827",
backgroundColor: "rgba(17, 24, 39, 0.05)",
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 0,
pointBackgroundColor: "transparent",
pointBorderColor: "transparent"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
titleColor: "#fff",
bodyColor: "#fff",
borderColor: "#111827",
borderWidth: 1,
cornerRadius: 4,
displayColors: false,
padding: 8,
titleFont: {
size: 12
},
bodyFont: {
size: 14,
weight: 'bold'
},
callbacks: {
label: function(context) {
return context.parsed.y + ' users';
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: false
}
}
}
});
});
},
destroyed() {
if (this.chart) {
this.chart.destroy();
}
}
},
// Hook for Event Creation Chart
EventCreationChart: {
mounted() {
// Import Chart.js dynamically
import("chart.js/auto").then(({ default: Chart }) => {
const ctx = this.el.getContext("2d");
const labels = JSON.parse(this.el.dataset.labels);
const values = JSON.parse(this.el.dataset.values);
this.chart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "New Events",
data: values,
borderColor: "#111827",
backgroundColor: "rgba(17, 24, 39, 0.05)",
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 0,
pointBackgroundColor: "transparent",
pointBorderColor: "transparent"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
titleColor: "#fff",
bodyColor: "#fff",
borderColor: "#111827",
borderWidth: 1,
cornerRadius: 4,
displayColors: false,
padding: 8,
titleFont: {
size: 12
},
bodyFont: {
size: 14,
weight: 'bold'
},
callbacks: {
label: function(context) {
return context.parsed.y + ' events';
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: false
}
}
}
});
});
},
destroyed() {
if (this.chart) {
this.chart.destroy();
}
}
}
};
export default Hooks;

View File

@@ -9,6 +9,8 @@
"@tailwindcss/container-queries": "^0.1.1",
"air-datepicker": "^3.5.0",
"animate.css": "^4.1.1",
"chart.js": "^4.4.0",
"chartjs-adapter-moment": "^1.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"phoenix": "file:../deps/phoenix",
@@ -22,6 +24,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"alpinejs": "^3.13.8",
"daisyui": "^5.0.47",
"esbuild": "^0.25.5",
"postcss": "^8.4.38",
"tailwindcss": "^4.1.11"
@@ -560,6 +563,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@sjmc11/tourguidejs": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@sjmc11/tourguidejs/-/tourguidejs-0.0.16.tgz",
@@ -921,6 +930,28 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-moment": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz",
"integrity": "sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=3.0.0",
"moment": "^2.10.2"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -954,6 +985,16 @@
"node": ">=18"
}
},
"node_modules/daisyui": {
"version": "5.0.47",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.47.tgz",
"integrity": "sha512-RuYjjVKpodDoOYAHIvG6qC3BeRxhlyj4JCO+6aV0VzK+i3RWD7cmICh0m5+Xfr5938mV0Mk7FUOQ00msz8H8dw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1881,6 +1922,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"@sjmc11/tourguidejs": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@sjmc11/tourguidejs/-/tourguidejs-0.0.16.tgz",
@@ -2094,6 +2140,20 @@
"fill-range": "^7.1.1"
}
},
"chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"chartjs-adapter-moment": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz",
"integrity": "sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==",
"requires": {}
},
"chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2115,6 +2175,12 @@
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true
},
"daisyui": {
"version": "5.0.47",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.47.tgz",
"integrity": "sha512-RuYjjVKpodDoOYAHIvG6qC3BeRxhlyj4JCO+6aV0VzK+i3RWD7cmICh0m5+Xfr5938mV0Mk7FUOQ00msz8H8dw==",
"dev": true
},
"detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",

View File

@@ -5,6 +5,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"alpinejs": "^3.13.8",
"daisyui": "^5.0.47",
"esbuild": "^0.25.5",
"postcss": "^8.4.38",
"tailwindcss": "^4.1.11"
@@ -14,6 +15,8 @@
"@tailwindcss/container-queries": "^0.1.1",
"air-datepicker": "^3.5.0",
"animate.css": "^4.1.1",
"chart.js": "^4.4.0",
"chartjs-adapter-moment": "^1.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"phoenix": "file:../deps/phoenix",

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,

14
dev.sh
View File

@@ -1,14 +0,0 @@
set -a
source .env
set +a
args=("$@")
if [ "${args[0]}" == "start" ]; then
mix phx.server
elif [ "${args[0]}" == "iex" ]; then
iex -S mix
else
mix "$@"
fi

View File

@@ -7,7 +7,7 @@ defmodule Claper.Accounts do
alias Claper.Accounts
alias Claper.Repo
alias Claper.Accounts.{User, UserToken, UserNotifier}
alias Claper.Accounts.{User, UserToken, UserNotifier, Role}
@doc """
Creates a user.
@@ -20,6 +20,9 @@ defmodule Claper.Accounts do
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs) do
# Get user role if not explicitly set
attrs = maybe_set_default_role(attrs)
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert(returning: [:uuid])
@@ -55,18 +58,39 @@ defmodule Claper.Accounts do
def get_user_by_email_or_create(email) when is_binary(email) do
case get_user_by_email(email) do
nil ->
create_user(%{
attrs = %{
email: email,
confirmed_at: DateTime.utc_now(),
is_randomized_password: true,
password: :crypto.strong_rand_bytes(32)
})
}
# Set default role if not explicitly set
attrs = maybe_set_default_role(attrs)
create_user(attrs)
user ->
{:ok, user}
end
end
@doc """
Lists all users that are not deleted.
## Examples
iex> list_users()
[%User{}, ...]
"""
def list_users(preload \\ []) do
User
|> where([u], is_nil(u.deleted_at))
|> Repo.all()
|> Repo.preload(preload)
end
@doc """
Gets a user by email and password.
@@ -147,6 +171,9 @@ defmodule Claper.Accounts do
"""
def register_user(attrs) do
# Get user role if not explicitly set
attrs = maybe_set_default_role(attrs)
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert(returning: [:uuid])
@@ -165,6 +192,37 @@ defmodule Claper.Accounts do
User.registration_changeset(user, attrs, hash_password: false)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes for admin operations.
## Examples
iex> change_user(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user(%User{} = user, attrs \\ %{}) do
User.admin_changeset(user, attrs, hash_password: false)
end
@doc """
Updates a user for admin operations.
## Examples
iex> update_user(user, %{field: new_value})
{:ok, %User{}}
iex> update_user(user, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_user(%User{} = user, attrs) do
user
|> User.admin_changeset(attrs)
|> Repo.update()
end
## Settings
@doc """
@@ -262,6 +320,23 @@ defmodule Claper.Accounts do
end
end
# Alternative version with different signature - keeping for compatibility
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(curr_pw)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
@doc """
Sets the user password.
## Examples
@@ -343,34 +418,6 @@ defmodule Claper.Accounts do
User.password_changeset(user, attrs)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(curr_pw)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Session
@doc """
@@ -602,4 +649,271 @@ defmodule Claper.Accounts do
{:error, changeset} -> {:error, changeset}
end
end
## Role Management
@doc """
Returns the list of roles.
## Examples
iex> list_roles()
[%Role{}, ...]
"""
def list_roles do
Repo.all(Role)
end
@doc """
Gets a single role.
Raises `Ecto.NoResultsError` if the Role does not exist.
## Examples
iex> get_role!(123)
%Role{}
iex> get_role!(456)
** (Ecto.NoResultsError)
"""
def get_role!(id), do: Repo.get!(Role, id)
@doc """
Gets a role by name.
Returns nil if the Role does not exist.
## Examples
iex> get_role_by_name("admin")
%Role{}
iex> get_role_by_name("nonexistent")
nil
"""
def get_role_by_name(name) when is_binary(name) do
Repo.get_by(Role, name: name)
end
@doc """
Creates a role.
## Examples
iex> create_role(%{field: value})
{:ok, %Role{}}
iex> create_role(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_role(attrs \\ %{}) do
%Role{}
|> Role.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a role.
## Examples
iex> update_role(role, %{field: new_value})
{:ok, %Role{}}
iex> update_role(role, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_role(%Role{} = role, attrs) do
role
|> Role.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a role.
## Examples
iex> delete_role(role)
{:ok, %Role{}}
iex> delete_role(role)
{:error, %Ecto.Changeset{}}
"""
def delete_role(%Role{} = role) do
Repo.delete(role)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking role changes.
## Examples
iex> change_role(role)
%Ecto.Changeset{data: %Role{}}
"""
def change_role(%Role{} = role, attrs \\ %{}) do
Role.changeset(role, attrs)
end
@doc """
Assigns a role to a user by role name or role struct.
## Examples
iex> assign_role(user, "admin")
{:ok, %User{}}
iex> assign_role(user, %Role{id: 1})
{:ok, %User{}}
iex> assign_role(user, "unknown")
{:error, :role_not_found}
"""
def assign_role(%User{} = user, %Role{} = role) do
user
|> Ecto.Changeset.change(%{role_id: role.id})
|> Repo.update()
end
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}
role ->
user
|> Ecto.Changeset.change(%{role_id: role.id})
|> Repo.update()
end
end
@doc """
Gets the role of a user.
## Examples
iex> get_user_role(user)
%Role{}
iex> get_user_role(user_without_role)
nil
"""
def get_user_role(%User{} = user) do
user = user |> Repo.preload(:role)
user.role
end
@doc """
Lists users by role name.
## Examples
iex> list_users_by_role("admin")
[%User{}, ...]
iex> list_users_by_role("unknown")
[]
"""
def list_users_by_role(role_name) when is_binary(role_name) do
case get_role_by_name(role_name) do
nil ->
[]
role ->
User
|> where([u], u.role_id == ^role.id and is_nil(u.deleted_at))
|> Repo.all()
end
end
@doc """
Checks if a user has a specific role.
## Examples
iex> user_has_role?(user, "admin")
true
iex> user_has_role?(user, "unknown")
false
"""
def user_has_role?(%User{} = user, role_name) when is_binary(role_name) do
case get_user_role(user) do
nil -> false
role -> role.name == role_name
end
end
@doc """
Promotes a user to admin role.
## Examples
iex> promote_to_admin(user)
{:ok, %User{}}
iex> promote_to_admin(already_admin_user)
{:ok, %User{}}
"""
def promote_to_admin(%User{} = user) do
assign_role(user, "admin")
end
@doc """
Demotes a user from admin role to regular user role.
## Examples
iex> demote_from_admin(admin_user)
{:ok, %User{}}
iex> demote_from_admin(already_user_user)
{:ok, %User{}}
"""
def demote_from_admin(%User{} = user) do
assign_role(user, "user")
end
# Private helper to set default role if not already set
defp maybe_set_default_role(attrs) do
# Only set default role if role_id is not explicitly provided
case Map.get(attrs, :role_id) || Map.get(attrs, "role_id") do
nil -> set_default_user_role(attrs)
_ -> attrs
end
end
defp set_default_user_role(attrs) do
case get_role_by_name("user") do
nil -> attrs
user_role -> put_role_id(attrs, user_role.id)
end
end
defp put_role_id(attrs, role_id) do
key = determine_role_key(attrs)
Map.put(attrs, key, role_id)
end
defp determine_role_key(attrs) do
if has_string_keys?(attrs) do
"role_id"
else
:role_id
end
end
defp has_string_keys?(attrs) do
is_map(attrs) and map_size(attrs) > 0 and
Enum.all?(Map.keys(attrs), &is_binary/1)
end
end

View File

@@ -0,0 +1,27 @@
defmodule Claper.Accounts.Guardian do
@moduledoc """
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
## Returns
- Updated conn with user signed in
"""
def sign_in(conn, user) do
# For tests, we'll just put the user in the conn assigns
Elixir.Plug.Conn.assign(conn, :current_user, user)
end
end
end

119
lib/claper/accounts/oidc.ex Normal file
View File

@@ -0,0 +1,119 @@
defmodule Claper.Accounts.Oidc do
@moduledoc """
The OIDC context for authentication and provider management.
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.Oidc.Provider
@doc """
Returns the list of oidc_providers.
## Examples
iex> list_providers()
[%Provider{}, ...]
"""
def list_providers do
Repo.all(Provider)
end
@doc """
Gets a single provider.
Raises `Ecto.NoResultsError` if the Provider does not exist.
## Examples
iex> get_provider!(123)
%Provider{}
iex> get_provider!(456)
** (Ecto.NoResultsError)
"""
def get_provider!(id), do: Repo.get!(Provider, id)
@doc """
Creates a provider.
## Examples
iex> create_provider(%{field: value})
{:ok, %Provider{}}
iex> create_provider(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_provider(attrs \\ %{}) do
%Provider{}
|> Provider.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a provider.
## Examples
iex> update_provider(provider, %{field: new_value})
{:ok, %Provider{}}
iex> update_provider(provider, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_provider(%Provider{} = provider, attrs) do
provider
|> Provider.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a provider.
## Examples
iex> delete_provider(provider)
{:ok, %Provider{}}
iex> delete_provider(provider)
{:error, %Ecto.Changeset{}}
"""
def delete_provider(%Provider{} = provider) do
Repo.delete(provider)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking provider changes.
## Examples
iex> change_provider(provider)
%Ecto.Changeset{data: %Provider{}}
"""
def change_provider(%Provider{} = provider, attrs \\ %{}) do
Provider.changeset(provider, attrs)
end
@doc """
Search providers by name or issuer.
## Examples
iex> search_providers("%example%")
[%Provider{}, ...]
"""
def search_providers(search_term) do
from(p in Provider,
where: ilike(p.name, ^search_term) or ilike(p.issuer, ^search_term)
)
|> Repo.all()
end
end

View File

@@ -0,0 +1,57 @@
defmodule Claper.Accounts.Oidc.Provider do
use Ecto.Schema
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()
}
schema "oidc_providers" do
field :name, :string
field :issuer, :string
field :client_id, :string
field :client_secret, :string
field :redirect_uri, :string
field :scope, :string, default: "openid email profile"
field :active, :boolean, default: true
field :response_type, :string, default: "code"
field :response_mode, :string, default: "query"
timestamps()
end
@doc """
A changeset for creating or updating an OIDC provider.
"""
def changeset(provider, attrs) do
provider
|> 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://"
)
|> unique_constraint(:name)
end
end

View File

@@ -0,0 +1,31 @@
defmodule Claper.Accounts.Role do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
name: String.t(),
permissions: map(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "roles" do
field :name, :string
field :permissions, :map, default: %{}
has_many :users, Claper.Accounts.User
timestamps()
end
@doc """
Changeset for creating or updating a role.
"""
def changeset(role, attrs) do
role
|> cast(attrs, [:name, :permissions])
|> validate_required([:name])
|> unique_constraint(:name)
end
end

View File

@@ -13,6 +13,8 @@ defmodule Claper.Accounts.User do
confirmed_at: NaiveDateTime.t() | nil,
locale: String.t() | nil,
events: [Claper.Events.Event.t()] | nil,
role: Claper.Accounts.Role.t() | nil,
role_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t(),
deleted_at: NaiveDateTime.t() | nil
@@ -30,6 +32,7 @@ defmodule Claper.Accounts.User do
has_many :events, Claper.Events.Event
has_one :lti_user, Lti13.Users.User
belongs_to :role, Claper.Accounts.Role
has_many :quiz_responses, Claper.Quizzes.QuizResponse
timestamps()
@@ -37,9 +40,10 @@ defmodule Claper.Accounts.User do
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password])
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password, :role_id])
|> validate_email()
|> validate_password(opts)
|> foreign_key_constraint(:role_id)
end
def preferences_changeset(user, attrs) do
@@ -47,6 +51,17 @@ defmodule Claper.Accounts.User do
|> cast(attrs, [:locale])
end
@doc """
A changeset for admin operations on users.
"""
def admin_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :confirmed_at, :password, :role_id])
|> validate_email()
|> validate_admin_password(opts)
|> foreign_key_constraint(:role_id)
end
@doc """
A changeset for marking a user as deleted.
"""
@@ -71,6 +86,19 @@ defmodule Claper.Accounts.User do
|> maybe_hash_password(opts)
end
defp validate_admin_password(changeset, opts) do
password = get_change(changeset, :password)
# Only validate password if it's provided
if password && password != "" do
changeset
|> validate_length(:password, min: 6, max: 72)
|> maybe_hash_password(opts)
else
changeset
end
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)

635
lib/claper/admin.ex Normal file
View File

@@ -0,0 +1,635 @@
defmodule Claper.Admin do
@moduledoc """
The Admin context.
Provides functions for admin dashboard statistics and paginated lists of resources.
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.User
alias Claper.Events.Event
alias Claper.Accounts.Oidc.Provider
@doc """
Gets dashboard statistics.
Returns a map with counts of users, events, and upcoming events.
## Examples
iex> get_dashboard_stats()
%{users_count: 10, events_count: 20, upcoming_events: 5}
"""
def get_dashboard_stats do
users_count =
User
|> where([u], is_nil(u.deleted_at))
|> Repo.aggregate(:count, :id)
events_count =
Event
|> Repo.aggregate(:count, :id)
_now = NaiveDateTime.utc_now()
upcoming_events =
Event
|> where([e], is_nil(e.expired_at))
|> Repo.aggregate(:count, :id)
%{
users_count: users_count,
events_count: events_count,
upcoming_events: upcoming_events
}
end
@doc """
Gets users over time for analytics charts.
Returns user registration data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_users_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [2, 5, 1, 3, ...]
}
"""
def get_users_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual user counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM users
WHERE deleted_at IS NULL
AND inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
user_counts =
result.rows
|> Enum.map(fn [period, count] ->
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# 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)
%{
labels: labels,
values: values
}
end
@doc """
Gets events over time for analytics charts.
Returns event creation data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_events_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [1, 3, 0, 2, ...]
}
"""
def get_events_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual event counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM events
WHERE inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
event_counts =
result.rows
|> Enum.map(fn [period, count] ->
# Normalize the timestamp by removing microseconds
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# 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)
%{
labels: labels,
values: values
}
end
@doc """
Gets growth metrics for dashboard statistics.
Returns percentage growth for users and events compared to previous period.
"""
def get_growth_metrics do
now = NaiveDateTime.utc_now()
thirty_days_ago = NaiveDateTime.add(now, -(30 * 24 * 60 * 60), :second)
sixty_days_ago = NaiveDateTime.add(now, -(60 * 24 * 60 * 60), :second)
# Current period (last 30 days)
current_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^thirty_days_ago and u.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
current_events =
Event
|> where([e], e.inserted_at >= ^thirty_days_ago and e.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
# Previous period (30-60 days ago)
previous_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^sixty_days_ago and u.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
previous_events =
Event
|> where([e], e.inserted_at >= ^sixty_days_ago and e.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
%{
users_growth: calculate_growth_percentage(current_users, previous_users),
events_growth: calculate_growth_percentage(current_events, previous_events)
}
end
@doc """
Gets recent activity stats for dashboard.
Returns counts of recent activities.
"""
def get_activity_stats do
now = NaiveDateTime.utc_now()
twenty_four_hours_ago = NaiveDateTime.add(now, -(24 * 60 * 60), :second)
seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second)
%{
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
|> where([e], e.inserted_at >= ^twenty_four_hours_ago)
|> Repo.aggregate(:count, :id),
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
|> where([e], e.inserted_at >= ^seven_days_ago)
|> Repo.aggregate(:count, :id)
}
end
# Private helper functions
defp generate_date_range(start_date, end_date, period) do
start_date
|> NaiveDateTime.to_date()
|> 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()
end
end
defp period_sql(period) do
case period do
:day -> "day"
:week -> "week"
:month -> "month"
end
end
defp format_date_label(date, period) do
case period do
:day -> Date.to_string(date)
:week -> "Week of #{Date.to_string(date)}"
:month -> "#{Date.to_string(date) |> String.slice(0..6)}"
end
end
defp truncate_date(date, period) do
naive_date = NaiveDateTime.new!(date, ~T[00:00:00])
case period do
: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)
:month ->
date
|> Date.beginning_of_month()
|> NaiveDateTime.new!(~T[00:00:00])
|> NaiveDateTime.truncate(:second)
end
end
defp calculate_growth_percentage(current, previous) do
cond do
previous == 0 and current > 0 ->
100.0
previous == 0 and current == 0 ->
0.0
previous > 0 ->
:erlang.float_to_binary(((current - previous) / previous * 100) |> Float.round(1),
decimals: 1
)
true ->
0.0
end
end
@doc """
Returns a paginated list of users.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of users per page (default: 20)
* `:search` - Search term for filtering users by email
* `:role` - Filter users by role name
## Examples
iex> list_users_paginated(%{page: 1, per_page: 10})
%{entries: [%User{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_users_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> join(:inner, [u], r in assoc(u, :role), on: r.name == ^role)
else
query
end
query = query |> order_by([u], desc: u.inserted_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of events.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of events per page (default: 20)
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_events_paginated(%{page: 1, per_page: 10})
%{entries: [%Event{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_events_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
status = Map.get(params, "status", "")
start_date = Map.get(params, "start_date", nil)
end_date = Map.get(params, "end_date", nil)
creator_id = Map.get(params, "creator_id", nil)
query =
Event
|> preload(:user)
query =
if search != "" do
query |> where([e], ilike(e.name, ^"%#{search}%"))
else
query
end
query =
case status do
"upcoming" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
"past" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
_ ->
query
end
query =
if start_date do
query |> where([e], e.started_at >= ^start_date)
else
query
end
query =
if end_date do
query |> where([e], e.started_at <= ^end_date)
else
query
end
query =
if creator_id do
query |> where([e], e.user_id == ^creator_id)
else
query
end
query = query |> order_by([e], desc: e.started_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of OIDC providers.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of providers per page (default: 20)
* `:search` - Search term for filtering providers by name
## Examples
iex> list_oidc_providers_paginated(%{page: 1, per_page: 10})
%{entries: [%Provider{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_oidc_providers_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a complete list of OIDC providers for export purposes.
Unlike the paginated version, this returns all providers matching the search criteria.
## Options
* `:search` - Search term for filtering providers by name
## Examples
iex> list_all_oidc_providers(%{search: "Google"})
[%Provider{}, ...]
"""
def list_all_oidc_providers(params \\ %{}) do
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.all(query)
end
@doc """
Returns a complete list of events for export purposes.
Unlike the paginated version, this returns all events matching the search criteria.
## Options
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_all_events(%{search: "Conference"})
[%Event{}, ...]
"""
def list_all_events(params \\ %{}) do
Event
|> join(:left, [e], u in assoc(e, :user))
|> preload([e, u], user: u)
|> apply_event_search_filter(Map.get(params, "search", ""))
|> apply_event_status_filter(Map.get(params, "status", ""))
|> apply_event_start_date_filter(Map.get(params, "start_date", nil))
|> apply_event_end_date_filter(Map.get(params, "end_date", nil))
|> apply_event_creator_filter(Map.get(params, "creator_id", nil))
|> order_by([e], desc: e.started_at)
|> Repo.all()
|> Enum.map(fn event -> Map.put(event, :user_email, event.user.email) end)
end
defp apply_event_search_filter(query, ""), do: query
defp apply_event_search_filter(query, search) do
search_term = "%#{search}%"
query
|> where(
[e, u],
ilike(e.name, ^search_term) or ilike(e.code, ^search_term) or
ilike(u.email, ^search_term)
)
end
defp apply_event_status_filter(query, "upcoming") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
end
defp apply_event_status_filter(query, "past") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
end
defp apply_event_status_filter(query, _), do: query
defp apply_event_start_date_filter(query, nil), do: query
defp apply_event_start_date_filter(query, start_date) do
query |> where([e], e.started_at >= ^start_date)
end
defp apply_event_end_date_filter(query, nil), do: query
defp apply_event_end_date_filter(query, end_date) do
query |> where([e], e.started_at <= ^end_date)
end
defp apply_event_creator_filter(query, nil), do: query
defp apply_event_creator_filter(query, creator_id) do
query |> where([e], e.user_id == ^creator_id)
end
@doc """
Returns a complete list of users for export purposes.
Unlike the paginated version, this returns all users matching the search criteria.
## Options
* `:search` - Search term for filtering users by email or name
* `:role` - Filter users by role ID
## Examples
iex> list_all_users(%{search: "admin"})
[%User{}, ...]
"""
def list_all_users(params \\ %{}) do
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%") or ilike(u.name, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> where([u], u.role_id == ^role)
else
query
end
query = query |> order_by([u], u.email)
# Add a virtual field for role_name to make it accessible in CSV export
Repo.all(query)
|> Enum.map(fn user ->
role_name = if user.role, do: user.role.name, else: "none"
user
|> Map.put(:role_name, role_name)
end)
end
end

View File

@@ -61,7 +61,7 @@ defmodule Claper.Embeds.Embed do
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/youtu\.be/,
|> validate_format(:content, ~r/(youtu\.be)|(youtube\.com)/,
message: gettext("Please enter a valid %{provider} link", provider: "YouTube")
)

View File

@@ -8,7 +8,9 @@ defmodule Claper.Events do
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.User
alias Claper.Events.{Event, ActivityLeader}
alias Claper.Presentations
@default_page_size 5
@@ -22,7 +24,7 @@ defmodule Claper.Events do
"""
def list_events(user_id, preload \\ []) do
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.inserted_at])
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.id])
|> Repo.all()
|> Repo.preload(preload)
end
@@ -43,7 +45,7 @@ defmodule Claper.Events do
query =
from(e in Event,
where: e.user_id == ^user_id,
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
@@ -61,7 +63,7 @@ defmodule Claper.Events do
def list_not_expired_events(user_id, preload \\ []) do
from(e in Event,
where: e.user_id == ^user_id and is_nil(e.expired_at),
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
|> Repo.all()
|> Repo.preload(preload)
@@ -83,7 +85,7 @@ defmodule Claper.Events do
query =
from(e in Event,
where: e.user_id == ^user_id and is_nil(e.expired_at),
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
@@ -145,7 +147,7 @@ defmodule Claper.Events do
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email,
order_by: [desc: e.expired_at],
order_by: [desc: e.expired_at, desc: e.id],
select: e
)
|> Repo.all()
@@ -172,7 +174,7 @@ defmodule Claper.Events do
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email,
order_by: [desc: e.expired_at],
order_by: [desc: e.expired_at, desc: e.id],
select: e
)
@@ -193,8 +195,7 @@ defmodule Claper.Events do
def count_expired_events(user_id) do
from(e in Event,
where: e.user_id == ^user_id and not is_nil(e.expired_at),
order_by: [desc: e.expired_at]
where: e.user_id == ^user_id and not is_nil(e.expired_at)
)
|> Repo.aggregate(:count, :id)
end
@@ -207,28 +208,42 @@ defmodule Claper.Events do
from(e in Event,
where:
e.user_id == ^user_id and e.inserted_at <= ^DateTime.utc_now() and
e.inserted_at >= ^last_month,
order_by: [desc: e.id]
e.inserted_at >= ^last_month
)
|> Repo.aggregate(:count, :id)
end
@doc """
Gets a single event.
Gets a single event by serial ID or UUID.
Raises `Ecto.NoResultsError` if the Event does not exist.
## Examples
iex> get_event!(123)
%Event{}
iex> get_event!("123e4567-e89b-12d3-a456-426614174000")
%Event{}
iex> get_event!(456)
** (Ecto.NoResultsError)
iex> get_event!("123e4567-e89b-12d3-a456-4266141740111")
** (Ecto.NoResultsError)
"""
def get_event!(id, preload \\ []),
do: Repo.get_by!(Event, uuid: id) |> Repo.preload(preload)
def get_event!(id_or_uuid, preload \\ [])
def get_event!(
<<_::bytes-8, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-12>> =
uuid,
preload
),
do: Repo.get_by!(Event, uuid: uuid) |> Repo.preload(preload)
def get_event!(id, preload),
do: Repo.get!(Event, id) |> Repo.preload(preload)
@doc """
Gets a single managed event.
@@ -244,17 +259,18 @@ defmodule Claper.Events do
** (Ecto.NoResultsError)
"""
def get_managed_event!(current_user, id, preload \\ []) do
event = Repo.get_by!(Event, uuid: id)
is_leader =
Claper.Events.leaded_by?(current_user.email, event) || event.user_id == current_user.id
if is_leader do
event |> Repo.preload(preload)
else
raise Ecto.NoResultsError
end
def get_managed_event!(user, uuid, preload \\ []) do
from(
a in ActivityLeader,
join: e in Event,
on: e.id == a.event_id,
join: u in User,
on: e.user_id == u.id,
where: e.uuid == ^uuid and (u.id == ^user.id or a.email == ^user.email),
select: e
)
|> Repo.one!()
|> Repo.preload(preload)
end
@doc """
@@ -304,43 +320,22 @@ defmodule Claper.Events do
|> Repo.preload(preload)
end
@doc """
Get a single event with the same code excluding a specific event.
## Examples
iex> get_different_event_with_code("Hello", 123)
%Event{}
"""
def get_different_event_with_code(nil, _event_id), do: nil
def get_different_event_with_code(code, event_id) do
now = DateTime.utc_now()
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|> Repo.one()
end
@doc """
Check if a user is a facilitator of a specific event.
## Examples
iex> leaded_by?("email@example.com", 123)
iex> led_by?("email@example.com", 123)
true
"""
def leaded_by?(email, event) do
def led_by?(email, event) do
from(a in ActivityLeader,
join: u in Claper.Accounts.User,
on: u.email == a.email,
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email and e.id == ^event.id,
order_by: [desc: e.expired_at]
where: a.email == ^email and e.id == ^event.id
)
|> Repo.exists?()
end
@@ -384,6 +379,15 @@ defmodule Claper.Events do
end
end
defp get_different_event_with_code(nil, _event_id), do: nil
defp get_different_event_with_code(code, event_id) do
now = DateTime.utc_now()
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|> Repo.one()
end
@doc """
Updates an event.
@@ -517,7 +521,10 @@ defmodule Claper.Events do
end
@doc """
Duplicate an event
Duplicates an event.
Raises `Ecto.NoResultsError` for invalid `user_id`-`event_uuid` combinations
and returns an error tuple if any part of the transaction fails.
## Examples
@@ -527,159 +534,201 @@ defmodule Claper.Events do
iex> duplicate(user_id, event_uuid)
{:error, %Ecto.Changeset{}}
iex> duplicate(another_user_id, event_uuid)
** (Ecto.NoResultsError)
"""
def duplicate_event(user_id, event_uuid) do
case Ecto.Multi.new()
|> Ecto.Multi.run(:original_event, fn _repo, _changes ->
{:ok,
get_user_event!(user_id, event_uuid,
presentation_file: [
polls: [:poll_opts],
forms: [],
embeds: [],
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts],
presentation_state: []
]
)}
end)
|> Ecto.Multi.run(:new_event, fn _repo, %{original_event: original_event} ->
new_code =
for _ <- 1..5,
into: "",
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
original =
get_user_event!(user_id, event_uuid,
presentation_file: [
presentation_state: [],
polls: [:poll_opts],
forms: [],
embeds: [],
quizzes: [quiz_questions: [:quiz_question_opts]]
]
)
attrs =
Map.from_struct(original_event)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|> Map.put(:leaders, [])
|> Map.put(:code, "#{new_code}")
|> Map.put(:name, "#{original_event.name} (Copy)")
multi =
Ecto.Multi.new()
|> Ecto.Multi.run(:event, fn _repo, changes -> duplicate_event_attrs(original, changes) end)
|> Ecto.Multi.run(:presentation_file, fn _repo, changes ->
duplicate_presentation_file(original, changes)
end)
|> Ecto.Multi.run(:presentation_state, fn _repo, changes ->
duplicate_presentation_state(original, changes)
end)
|> Ecto.Multi.run(:polls, fn _repo, changes -> duplicate_polls(original, changes) end)
|> Ecto.Multi.run(:forms, fn _repo, changes -> duplicate_forms(original, changes) end)
|> Ecto.Multi.run(:embeds, fn _repo, changes -> duplicate_embeds(original, changes) end)
|> Ecto.Multi.run(:quizzes, fn _repo, changes -> duplicate_quizzes(original, changes) end)
create_event(attrs)
end)
|> Ecto.Multi.run(:new_presentation_file, fn _repo,
%{
original_event: original_event,
new_event: new_event
} ->
attrs =
Map.from_struct(original_event.presentation_file)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|> Map.put(:event_id, new_event.id)
Claper.Presentations.create_presentation_file(attrs)
end)
|> Ecto.Multi.run(:new_presentation_state, fn _repo,
%{
original_event: original_event,
new_presentation_file:
new_presentation_file
} ->
attrs =
Map.from_struct(original_event.presentation_file.presentation_state)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(:position, 0)
|> Map.put(:banned, [])
Claper.Presentations.create_presentation_state(attrs)
end)
|> Ecto.Multi.run(:polls, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.polls, fn poll ->
poll_attrs =
Map.from_struct(poll)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:poll_opts,
Enum.map(poll.poll_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
end)
)
{:ok, new_poll} = Claper.Polls.create_poll(poll_attrs)
new_poll
end)}
end)
|> Ecto.Multi.run(:forms, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.forms, fn form ->
form_attrs =
Map.from_struct(form)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:fields,
Enum.map(form.fields, &Map.from_struct(&1))
)
{:ok, new_form} = Claper.Forms.create_form(form_attrs)
new_form
end)}
end)
|> Ecto.Multi.run(:embeds, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.embeds, fn embed ->
embed_attrs =
Map.from_struct(embed)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
{:ok, new_embed} = Claper.Embeds.create_embed(embed_attrs)
new_embed
end)}
end)
|> Ecto.Multi.run(:quizzes, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.quizzes, fn quiz ->
quiz_attrs =
Map.from_struct(quiz)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:quiz_questions,
Enum.map(quiz.quiz_questions, fn question ->
Map.from_struct(question)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(
:quiz_question_opts,
Enum.map(question.quiz_question_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:response_count, 0)
end)
)
end)
)
{:ok, new_quiz} = Claper.Quizzes.create_quiz(quiz_attrs)
new_quiz
end)}
end)
|> Repo.transaction() do
{:ok, %{new_event: new_event}} -> {:ok, new_event}
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}
case Repo.transaction(multi) do
{:ok, %{event: event}} -> {:ok, event}
{:error, _operation, value, _changes} -> {:error, value}
end
end
defp duplicate_event_attrs(original, _changes) do
code =
for _ <- 1..5,
into: "",
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
attrs =
Map.from_struct(original)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|> Map.put(:leaders, [])
|> Map.put(:code, "#{code}")
|> Map.put(:name, "#{original.name} (Copy)")
create_event(attrs)
end
defp duplicate_presentation_file(original, changes) do
case get_in(original.presentation_file) do
%Presentations.PresentationFile{} = presentation_file ->
attrs =
Map.from_struct(presentation_file)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|> Map.put(:event_id, changes.event.id)
Claper.Presentations.create_presentation_file(attrs)
_ ->
{:ok, nil}
end
end
defp duplicate_presentation_state(original, changes) do
case get_in(original.presentation_file.presentation_state) do
%Presentations.PresentationState{} = presentation_state ->
attrs =
Map.from_struct(presentation_state)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(:position, 0)
|> Map.put(:banned, [])
Claper.Presentations.create_presentation_state(attrs)
_ ->
{:ok, nil}
end
end
defp duplicate_polls(original, changes) do
case get_in(original.presentation_file.polls) do
polls when is_list(polls) ->
polls =
for poll <- polls do
attrs =
Map.from_struct(poll)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(
:poll_opts,
Enum.map(poll.poll_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at, :vote_count])
end)
)
{:ok, poll} = Claper.Polls.create_poll(attrs)
poll
end
{:ok, polls}
_ ->
{:ok, nil}
end
end
defp duplicate_forms(original, changes) do
case get_in(original.presentation_file.forms) do
forms when is_list(forms) ->
forms =
for form <- forms do
attrs =
Map.from_struct(form)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(
:fields,
Enum.map(form.fields, &Map.from_struct(&1))
)
{:ok, form} = Claper.Forms.create_form(attrs)
form
end
{:ok, forms}
_ ->
{:ok, nil}
end
end
defp duplicate_embeds(original, changes) do
case get_in(original.presentation_file.embeds) do
embeds when is_list(embeds) ->
embeds =
for embed <- embeds do
attrs =
Map.from_struct(embed)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
{:ok, embed} = Claper.Embeds.create_embed(attrs)
embed
end
{:ok, embeds}
_ ->
{:ok, nil}
end
end
defp duplicate_quizzes(original, changes) do
case get_in(original.presentation_file.quizzes) do
quizzes when is_list(quizzes) ->
quizzes =
for quiz <- quizzes do
attrs =
Map.from_struct(quiz)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(:quiz_questions, Enum.map(quiz.quiz_questions, &map_quiz_question/1))
{:ok, quiz} = Claper.Quizzes.create_quiz(attrs)
quiz
end
{:ok, quizzes}
_ ->
{:ok, nil}
end
end
defp map_quiz_question(question) do
Map.from_struct(question)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(
:quiz_question_opts,
Enum.map(question.quiz_question_opts, &map_quiz_question_opt/1)
)
end
defp map_quiz_question_opt(opt) do
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:response_count, 0)
end
@doc """
Deletes a event.

View File

@@ -45,11 +45,12 @@ defmodule Claper.Events.Event do
:code,
:started_at,
:expired_at,
:audience_peak
:audience_peak,
:user_id
])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:code, :name])
|> validate_required([:name, :code, :started_at])
end
def create_changeset(event, attrs) do
@@ -57,7 +58,7 @@ defmodule Claper.Events.Event do
|> cast(attrs, [:name, :code, :user_id, :started_at, :expired_at])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:name, :code, :started_at])
|> validate_required([:name, :code, :started_at, :user_id])
|> validate_length(:code, min: 5, max: 10)
|> validate_length(:name, min: 5, max: 50)
|> downcase_code
@@ -74,10 +75,10 @@ defmodule Claper.Events.Event do
def update_changeset(event, attrs) do
event
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak])
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak, :user_id])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:code, :started_at])
|> validate_required([:name, :code, :started_at, :user_id])
|> validate_length(:code, min: 5, max: 10)
|> validate_length(:name, min: 5, max: 50)
|> downcase_code

View File

@@ -2,6 +2,22 @@ defmodule Claper.Quizzes.Quiz do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
title: String.t(),
position: integer() | nil,
enabled: boolean(),
show_results: boolean(),
allow_anonymous: boolean(),
lti_line_item_url: String.t() | nil,
lti_resource: Lti13.Resources.Resource.t() | nil,
quiz_responses: [Claper.Quizzes.QuizResponse.t()] | nil,
quiz_questions: [Claper.Quizzes.QuizQuestion.t()] | nil,
presentation_file: Claper.Presentations.PresentationFile.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quizzes" do
field :title, :string
field :position, :integer, default: 0

View File

@@ -4,6 +4,16 @@ defmodule Claper.Quizzes.QuizQuestion do
use Gettext, backend: ClaperWeb.Gettext
@type t :: %__MODULE__{
id: integer(),
content: String.t(),
type: String.t(),
quiz: Claper.Quizzes.Quiz.t() | nil,
quiz_question_opts: [Claper.Quizzes.QuizQuestionOpt.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_questions" do
field :content, :string
field :type, :string, default: "qcm"

View File

@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizQuestionOpt do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
content: String.t(),
is_correct: boolean(),
response_count: integer(),
percentage: float() | nil,
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_question_opts" do
field :content, :string
field :is_correct, :boolean, default: false

View File

@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizResponse do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
attendee_identifier: String.t() | nil,
quiz: Claper.Quizzes.Quiz.t() | nil,
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
quiz_question_opt: Claper.Quizzes.QuizQuestionOpt.t() | nil,
user: Claper.Accounts.User.t() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_responses" do
field :attendee_identifier, :string

View File

@@ -21,7 +21,8 @@ defmodule Claper.Release do
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn _repo ->
Code.eval_file("priv/repo/seeds.exs")
seed_script = Application.app_dir(@app, "priv/repo/seeds.exs")
Code.eval_file(seed_script)
end)
end
end

View File

@@ -197,7 +197,7 @@ defmodule ClaperWeb.StatController do
# Private functions
defp authorize_event_access(user, event) do
if Events.leaded_by?(user.email, event) || event.user_id == user.id do
if Events.led_by?(user.email, event) || event.user_id == user.id do
:ok
else
:unauthorized

View File

@@ -0,0 +1,169 @@
defmodule ClaperWeb.Helpers.CSVExporter do
@moduledoc """
Helper module for exporting data to CSV format.
This module provides functions to convert collections of data
into CSV format for download in the admin panel.
"""
@doc """
Converts a list of records to CSV format.
## Parameters
- records: List of records/maps to convert
- headers: List of column headers
- fields: List of fields to include in the CSV
## Returns
- CSV formatted string
"""
def to_csv(records, headers, fields) do
records
|> build_rows(fields)
|> add_headers(headers)
|> CSV.encode()
|> Enum.to_list()
|> Enum.join("")
end
@doc """
Generates a timestamped filename for CSV exports.
## Parameters
- prefix: Prefix for the filename (e.g., "users", "events")
## Returns
- String filename with timestamp
"""
def generate_filename(prefix) do
date = DateTime.utc_now() |> Calendar.strftime("%Y%m%d_%H%M%S")
"#{prefix}_export_#{date}.csv"
end
# Private helper functions
defp build_rows(records, fields) do
Enum.map(records, fn record ->
Enum.map(fields, fn field ->
format_field_value(Map.get(record, field))
end)
end)
end
defp add_headers(rows, headers) do
[headers | rows]
end
defp format_field_value(value) when is_boolean(value) do
if value, do: "Yes", else: "No"
end
defp format_field_value(%DateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
end
defp format_field_value(%NaiveDateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
end
defp format_field_value(nil), do: ""
defp format_field_value(value), do: to_string(value)
@doc """
Exports a list of users to CSV format.
## Parameters
- users: List of User structs to export
## Returns
- CSV formatted string
"""
def export_users_to_csv(users) do
headers = ["Email", "Name", "Role", "Created At"]
# Transform users to include role name
users_with_role =
Enum.map(users, fn user ->
role_name = if user.role, do: user.role.name, else: ""
%{
email: user.email,
# Users don't have a name field currently
name: "",
role: role_name,
inserted_at: user.inserted_at
}
end)
fields = [:email, :name, :role, :inserted_at]
to_csv(users_with_role, headers, fields)
end
@doc """
Exports a list of events to CSV format.
## Parameters
- events: List of Event structs to export
## Returns
- CSV formatted string
"""
def export_events_to_csv(events) do
headers = [
"Name",
"Description",
"Start Date",
"End Date",
"Status"
]
# Transform events to include description and status
events_transformed =
Enum.map(events, fn event ->
status =
cond do
event.expired_at &&
NaiveDateTime.compare(event.expired_at, NaiveDateTime.utc_now()) == :lt ->
"completed"
event.started_at &&
NaiveDateTime.compare(event.started_at, NaiveDateTime.utc_now()) == :gt ->
"scheduled"
true ->
"active"
end
%{
name: event.name,
# Events don't have a description field currently
description: "",
start_date: event.started_at,
end_date: event.expired_at,
status: status
}
end)
fields = [:name, :description, :start_date, :end_date, :status]
to_csv(events_transformed, headers, fields)
end
@doc """
Exports a list of OIDC providers to CSV format.
## Parameters
- providers: List of Provider structs to export
## Returns
- CSV formatted string
"""
def export_oidc_providers_to_csv(providers) do
headers = ["Name", "Issuer", "Client ID", "Active"]
fields = [:name, :issuer, :client_id, :active]
to_csv(providers, headers, fields)
end
end

View File

@@ -0,0 +1,109 @@
defmodule ClaperWeb.AdminLive.DashboardLive do
use ClaperWeb, :live_view
import Ecto.Query, warn: false
alias Claper.Admin
alias Claper.Events.Event
alias Claper.Repo
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
# Set up periodic updates every 30 seconds
:timer.send_interval(30_000, self(), :update_charts)
end
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
socket =
socket
|> assign(:page_title, gettext("Dashboard"))
|> assign(:selected_period, :day)
|> assign(:days_back, 30)
|> load_dashboard_data()
{:ok, socket}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@impl true
def handle_event("change_period", %{"period" => period}, socket) do
period_atom = String.to_atom(period)
days_back =
case period_atom do
:day -> 30
# 12 weeks
:week -> 84
# 12 months
:month -> 365
_ -> 30
end
socket =
socket
|> assign(:selected_period, period_atom)
|> assign(:days_back, days_back)
|> load_chart_data()
{:noreply, socket}
end
@impl true
def handle_event("refresh_data", _params, socket) do
socket = load_dashboard_data(socket)
{:noreply, socket}
end
@impl true
def handle_info(:update_charts, socket) do
socket = load_chart_data(socket)
{:noreply, socket}
end
defp load_dashboard_data(socket) do
stats = Admin.get_dashboard_stats()
growth_metrics = Admin.get_growth_metrics()
activity_stats = Admin.get_activity_stats()
# Get recent events for the dashboard
recent_events =
Event
|> order_by([e], desc: e.started_at)
|> limit(5)
|> preload(:user)
|> Repo.all()
# Transform stats to match template expectations
transformed_stats = %{
total_users: stats.users_count,
total_events: stats.events_count,
active_events: stats.upcoming_events
}
socket
|> assign(:stats, transformed_stats)
|> assign(:growth_metrics, growth_metrics)
|> assign(:activity_stats, activity_stats)
|> assign(:recent_events, recent_events)
|> load_chart_data()
end
defp load_chart_data(socket) do
period = socket.assigns.selected_period
days_back = socket.assigns.days_back
users_chart_data = Admin.get_users_over_time(period, days_back)
events_chart_data = Admin.get_events_over_time(period, days_back)
socket
|> assign(:users_chart_data, users_chart_data)
|> assign(:events_chart_data, events_chart_data)
end
end

View File

@@ -0,0 +1,212 @@
<!-- Header -->
<div class="mb-24">
<h1 class="text-3xl font-bold">{gettext("Dashboard")}</h1>
</div>
<!-- Dashboard Content -->
<div>
<!-- Stats Cards -->
<div class="stats w-full stats-horizontal shadow mb-12">
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Total Users")}</div>
<div class="stat-value">{@stats.total_users}</div>
<div class="stat-desc">
<span class={
if @growth_metrics.users_growth > 0 do
"text-success"
else
"text-error"
end
}>
<%= if @growth_metrics.users_growth >= 0 do %>
↗︎
<% else %>
↘︎
<% end %>
{@growth_metrics.users_growth}%
</span>
{gettext("vs last month")}
</div>
</div>
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Total Events")}</div>
<div class="stat-value">{@stats.total_events}</div>
<div class="stat-desc">
<span class={
if @growth_metrics.events_growth > 0 do
"text-success"
else
"text-error"
end
}>
<%= if @growth_metrics.events_growth >= 0 do %>
↗︎ +
<% else %>
↘︎
<% end %>
{@growth_metrics.events_growth}%
</span>
{gettext("vs last month")}
</div>
</div>
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Active Events")}</div>
<div class="stat-value">{@stats.active_events}</div>
<div class="stat-desc">{gettext("Currently running")}</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{gettext("User Growth")}</h2>
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
<div class="h-72 relative mt-4">
<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>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{gettext("Event Creation")}</h2>
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
<div class="h-72 relative mt-4">
<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>
</div>
<!-- Recent Events Table -->
<div class="mt-12">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">{gettext("Recent Events")}</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Code")}</th>
<th>{gettext("Owner")}</th>
<th>{gettext("Start Date")}</th>
<th>{gettext("Audience Peak")}</th>
<th>{gettext("Status")}</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for event <- @recent_events do %>
<% now = NaiveDateTime.utc_now() %>
<% {status_text, status_class} =
cond do
is_nil(event.expired_at) == false ->
{gettext("Completed"), "badge-ghost"}
NaiveDateTime.compare(event.started_at, now) == :gt ->
{gettext("Scheduled"), "badge-info"}
true ->
{gettext("Active"), "badge-success"}
end %>
<tr>
<td>
<div class="font-bold">{event.name}</div>
</td>
<td>
<code class="text-sm uppercase">{event.code}</code>
</td>
<td>{event.user.email}</td>
<td>{Calendar.strftime(event.started_at, "%b %d, %Y")}</td>
<td>
<div class="badge badge-outline">{event.audience_peak}</div>
</td>
<td>
<div class={"badge #{status_class}"}>
{status_text}
</div>
</td>
<td>
<div class="flex gap-2">
<.link
navigate={~p"/admin/events/#{event}"}
class="btn btn-link btn-xs"
title={gettext("View event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/events/#{event}/edit"}
class="btn btn-link btn-xs text-primary"
title={gettext("Edit event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
</div>
</td>
</tr>
<% end %>
<%= if Enum.empty?(@recent_events) do %>
<tr>
<td colspan="7" class="text-center">
<div class="py-8 text-base-content/60">
{gettext("No events found")}
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,175 @@
defmodule ClaperWeb.AdminLive.EventLive do
use ClaperWeb, :live_view
alias Claper.Admin
alias Claper.Events.Event
alias ClaperWeb.Helpers.CSVExporter
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("Events"))
|> assign(:events, list_events())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Events")
|> assign(:event, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New event"))
|> assign(:event, %Event{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit event"))
|> assign(:event, Claper.Events.get_event!(id, [:user]))
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Event details"))
|> assign(:event, Claper.Events.get_event!(id, [:user]))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
event = Claper.Events.get_event!(id)
{:ok, _} = Claper.Events.delete_event(event)
{:noreply,
socket
|> put_flash(:info, gettext("Event deleted successfully"))
|> assign(:events, list_events())}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
events = search_events(search)
{:noreply, socket |> assign(:search, search) |> assign(:events, events)}
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
events = sort_events(socket.assigns.events, field, direction)
current_sort = %{field: field, order: direction}
{:noreply,
socket
|> assign(:events, events)
|> assign(:current_sort, current_sort)}
end
@impl true
def handle_info({:export_csv_requested, _params}, socket) do
filename = CSVExporter.generate_filename("events")
csv_content = CSVExporter.export_events_to_csv(socket.assigns.events)
{:noreply,
socket
|> put_flash(:info, gettext("Events exported successfully"))
|> push_event("download_csv", %{filename: filename, content: csv_content})}
end
@impl true
def handle_info({:table_action, action, event, _event_id}, socket) do
case action do
:view ->
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}")}
:edit ->
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}/edit")}
:delete ->
{:ok, _} = Claper.Events.delete_event(event)
{:noreply,
socket
|> put_flash(:info, gettext("Event deleted successfully"))
|> assign(:events, list_events())}
end
end
defp list_events do
Admin.list_all_events()
end
defp search_events(search) when search == "", do: list_events()
defp search_events(search) do
Admin.list_all_events(%{"search" => search})
end
defp sort_events(events, field, order) do
Enum.sort_by(events, &Map.get(&1, field), order)
end
def sort_indicator(assigns) 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>
<% 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>
<% 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>
<% end %>
"""
end
end

View File

@@ -0,0 +1,367 @@
<%= case @live_action do %>
<% :index -> %>
<div phx-hook="CSVDownloader" id="event-list">
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Events")}</h1>
<div class="flex space-x-3">
<%!-- <.link navigate={~p"/admin/events/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New event")}
</.link> --%>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search events...")}
/>
</label>
</form>
</div>
<!-- events Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="name"
class="btn btn-ghost btn-sm"
>
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="code"
class="btn btn-ghost btn-sm"
>
{gettext("Code")} {sort_indicator(assigns |> Map.put(:field, :code))}
</button>
</th>
<th class="text-left">
{gettext("Owner")}
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="started_at"
class="btn btn-ghost btn-sm"
>
{gettext("Started At")} {sort_indicator(
assigns
|> Map.put(:field, :started_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="expired_at"
class="btn btn-ghost btn-sm"
>
{gettext("Expired At")} {sort_indicator(
assigns
|> Map.put(:field, :expired_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="audience_peak"
class="btn btn-ghost btn-sm"
>
{gettext("Audience Peak")} {sort_indicator(
assigns
|> Map.put(:field, :audience_peak)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@events) do %>
<tr>
<td colspan="7" class="text-center">
{gettext("No events found")}
</td>
</tr>
<% else %>
<%= for event <- @events do %>
<tr id={"event-#{event.id}"}>
<td class="font-medium">
{event.name}
</td>
<td class="uppercase">
{event.code}
</td>
<td>
{if event.user, do: event.user.email, else: gettext("No owner")}
</td>
<td>
{if event.started_at,
do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"),
else: gettext("Not started")}
</td>
<td>
{if event.expired_at,
do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"),
else: gettext("Not expired")}
</td>
<td>
<div class="badge badge-outline">
{event.audience_peak || 0}
</div>
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/events/#{event}"}
class="btn btn-link btn-sm"
title={gettext("View event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/events/#{event}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={event.id}
data-confirm={
gettext("Are you sure you want to delete this event?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Event details")}</h1>
<div class="flex gap-3">
<.link navigate={~p"/admin/events"} class="btn btn-outline btn-sm">
{gettext("Back to events")}
</.link>
<.link navigate={~p"/admin/events/#{@event}/edit"} class="btn btn-primary btn-sm">
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@event.name}</h2>
<p class="text-base-content/70 uppercase">#{@event.code}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Name")}</dt>
<dd class="col-span-2">{@event.name}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Owner")}</dt>
<dd class="col-span-2">
{if @event.user, do: @event.user.email, else: gettext("No owner")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Started At")}</dt>
<dd class="col-span-2">
{if @event.started_at,
do: Calendar.strftime(@event.started_at, "%Y-%m-%d %H:%M"),
else: gettext("Not started")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Expired At")}</dt>
<dd class="col-span-2">
{if @event.expired_at,
do: Calendar.strftime(@event.expired_at, "%Y-%m-%d %H:%M"),
else: gettext("Not expired")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Audience Peak")}</dt>
<dd class="col-span-2">
{@event.audience_peak || 0} {gettext("attendees")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New event")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.EventLive.FormComponent}
id="event-form"
title={gettext("New event")}
action={:new}
event={@event}
navigate={~p"/admin/events"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit event")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.EventLive.FormComponent}
id={"event-form-#{@event.id}"}
title={gettext("Edit event")}
action={:edit}
event={@event}
navigate={~p"/admin/events"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,160 @@
defmodule ClaperWeb.AdminLive.EventLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Events
alias Claper.Accounts
@impl true
def render(assigns) do
~H"""
<div>
<.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}
id="event-name"
form={@form}
field={:name}
type="text"
label={gettext("Name")}
placeholder={gettext("Enter event name")}
required={true}
width_class="sm:col-span-6"
description={gettext("A unique name for this event")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-code"
form={@form}
field={:code}
type="text"
label={gettext("Code")}
placeholder={gettext("Enter event code")}
required={true}
width_class="sm:col-span-3"
field_class="uppercase"
description={gettext("A unique code for participants to join this event")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-started-at"
form={@form}
field={:started_at}
type="datetime"
label={gettext("Started At")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-expired-at"
form={@form}
field={:expired_at}
type="datetime"
label={gettext("Expired At")}
required={false}
width_class="sm:col-span-3"
description={gettext("When this event expires (optional)")}
/>
<.live_component
module={ClaperWeb.AdminLive.SearchableSelectComponent}
id="event-user-id"
form={@form}
field={:user_id}
label={gettext("Assigned User")}
options={@user_options}
placeholder={gettext("Search for a user...")}
required={true}
width_class="sm:col-span-6"
description={gettext("The user who owns this event (required)")}
/>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create Event"), else: gettext("Update Event")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{event: event} = assigns, socket) do
changeset = Events.change_event(event)
user_options =
Accounts.list_users()
|> Enum.map(&{"#{&1.email}", &1.id})
{:ok,
socket
|> assign(assigns)
|> assign(:user_options, user_options)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"event" => event_params}, socket) do
changeset =
socket.assigns.event
|> Events.change_event(event_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"event" => event_params}, socket) do
save_event(socket, socket.assigns.action, event_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_event(socket, :edit, event_params) do
case Events.update_event(socket.assigns.event, event_params) do
{:ok, event} ->
notify_parent({:saved, event})
{:noreply,
socket
|> put_flash(:info, gettext("Event updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_event(socket, :new, event_params) do
case Events.create_event(event_params) do
{:ok, event} ->
notify_parent({:saved, event})
{:noreply,
socket
|> put_flash(:info, gettext("Event created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -0,0 +1,184 @@
defmodule ClaperWeb.AdminLive.FormFieldComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
<div class="form-control w-full">
<label class="label">
<span class="label-text">{@label}</span>
</label>
<%= case @type do %>
<% "text" -> %>
{text_input(
@form,
@field,
[
class: "input w-full " <> @field_class,
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% "email" -> %>
{email_input(
@form,
@field,
[
class: "input w-full",
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% "password" -> %>
<div class="relative">
{password_input(
@form,
@field,
[
class: "input w-full 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-base-content/50 hover:text-base-content"
phx-click={toggle_password_visibility("password-field-#{@field}")}
>
<i class="fas fa-eye"></i>
</button>
</div>
<% "textarea" -> %>
{textarea(
@form,
@field,
[
class: "input w-full",
placeholder: @placeholder,
required: @required,
rows: @rows
] ++ @extra_attrs
)}
<% "select" -> %>
{select(
@form,
@field,
@select_options,
[
class: "select w-full",
prompt: @prompt || gettext("Select an option"),
required: @required
] ++ @extra_attrs
)}
<% "checkbox" -> %>
<div class="form-control">
<label class="label cursor-pointer justify-start">
{checkbox(
@form,
@field,
[
class: "checkbox checkbox-primary",
checked:
Phoenix.HTML.Form.input_value(@form, @field) == true ||
Phoenix.HTML.Form.input_value(@form, @field) == "true"
] ++ @extra_attrs
)}
<span class="label-text ml-2">{@checkbox_label || @label}</span>
</label>
</div>
<% "date" -> %>
{date_input(
@form,
@field,
[
class: "input input-bordered w-full",
required: @required
] ++ @extra_attrs
)}
<% "datetime" -> %>
{datetime_local_input(
@form,
@field,
[
class: "input input-bordered w-full",
required: @required
] ++ @extra_attrs
)}
<% "file" -> %>
<div class="flex items-center gap-3">
<label class="btn btn-outline btn-sm">
<span>{gettext("Choose file")}</span>
{file_input(
@form,
@field,
[
class: "sr-only",
required: @required,
phx_change: "file_selected",
phx_target: @myself
] ++ @extra_attrs
)}
</label>
<span class="text-sm text-base-content/70" id={"file-name-#{@field}"}>
{if @selected_file, do: @selected_file, else: gettext("No file chosen")}
</span>
</div>
<% _ -> %>
{text_input(
@form,
@field,
[
class: "input input-bordered w-full",
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% end %>
<label class="label">
{error_tag(@form, @field)}
<%= if @description do %>
<span class="label-text-alt">{@description}</span>
<% end %>
</label>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, selected_file: nil)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:placeholder, fn -> "" end)
|> assign_new(:required, fn -> false end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:width_class, fn -> nil end)
|> assign_new(:field_class, fn -> "" end)
|> assign_new(:checkbox_label, fn -> nil end)
|> assign_new(:prompt, fn -> nil end)
|> assign_new(:select_options, fn -> [] end)
|> assign_new(:rows, fn -> 3 end)
|> assign_new(:extra_attrs, fn -> [] end)
{:ok, socket}
end
@impl true
def handle_event("file_selected", %{"_target" => [_field_name]}, socket) do
{:noreply, assign(socket, selected_file: gettext("File selected"))}
end
defp toggle_password_visibility(field_id) do
%Phoenix.LiveView.JS{}
|> Phoenix.LiveView.JS.dispatch("toggle-password", to: "##{field_id}")
end
end

View File

@@ -0,0 +1,196 @@
defmodule ClaperWeb.AdminLive.ModalComponent do
use ClaperWeb, :live_component
alias Phoenix.LiveView.JS
@impl true
def render(assigns) do
~H"""
<div
id={@id}
class="fixed z-50 inset-0 overflow-y-auto"
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"
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>
<!-- 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
]}>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<%= if @icon do %>
<div class={[
"mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full sm:mx-0 sm:h-10 sm:w-10",
@icon_bg_class
]}>
<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}
</h3>
<div class="mt-2">
<%= if @description do %>
<p class="text-sm text-gray-500">
{@description}
</p>
<% end %>
<%= if @content do %>
<div class="mt-4">
{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
type="button"
phx-click="confirm"
phx-target={@myself}
class={[
"w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm",
@confirm_class
]}
>
{@confirm_action}
</button>
<% end %>
<%= if @cancel_action do %>
<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}
</button>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, show: false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show, fn -> false end)
|> assign_new(:icon, fn -> nil end)
|> assign_new(:icon_bg_class, fn -> "bg-red-100" end)
|> assign_new(:icon_text_class, fn -> "text-red-600" end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:content, fn -> [] end)
|> assign_new(:confirm_action, fn -> nil end)
|> assign_new(:confirm_class, fn -> "bg-red-600 hover:bg-red-700 focus:ring-red-500" end)
|> assign_new(:cancel_action, fn -> "Cancel" end)
|> assign_new(:custom_actions, fn -> [] end)
|> assign_new(:size_class, fn -> "sm:max-w-lg sm:w-full" end)
{:ok, socket}
end
@impl true
def handle_event("hide", _params, socket) do
send(self(), {:modal_cancelled, socket.assigns.id})
{:noreply, assign(socket, show: false)}
end
def handle_event("confirm", _params, socket) do
send(self(), {:modal_confirmed, socket.assigns.id})
{:noreply, assign(socket, show: false)}
end
# Public API for controlling the modal
def show_modal(js \\ %JS{}, modal_id) do
js
|> JS.show(to: "##{modal_id}")
|> JS.add_class("animate-fade-in", to: "##{modal_id}")
end
def hide_modal(js \\ %JS{}, modal_id) do
js
|> JS.add_class("animate-fade-out", to: "##{modal_id}")
|> JS.hide(to: "##{modal_id}", transition: "animate-fade-out", time: 200)
end
# Preset configurations for common modal types
def delete_modal_config(title, description) do
%{
icon: "fa-exclamation-triangle",
icon_bg_class: "bg-red-100",
icon_text_class: "text-red-600",
title: title,
description: description,
confirm_action: "Delete",
confirm_class: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
cancel_action: "Cancel"
}
end
def warning_modal_config(title, description) do
%{
icon: "fa-exclamation-triangle",
icon_bg_class: "bg-yellow-100",
icon_text_class: "text-yellow-600",
title: title,
description: description,
confirm_action: "Continue",
confirm_class: "bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500",
cancel_action: "Cancel"
}
end
def info_modal_config(title, description) do
%{
icon: "fa-info-circle",
icon_bg_class: "bg-blue-100",
icon_text_class: "text-blue-600",
title: title,
description: description,
confirm_action: "OK",
confirm_class: "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500",
cancel_action: nil
}
end
end

View File

@@ -0,0 +1,155 @@
defmodule ClaperWeb.AdminLive.OidcProviderLive do
use ClaperWeb, :live_view
alias Claper.Accounts.Oidc
alias Claper.Accounts.Oidc.Provider
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("OIDC Providers"))
|> assign(:providers, list_providers())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "OIDC Providers")
|> assign(:provider, nil)
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Provider details"))
|> assign(:provider, Oidc.get_provider!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New provider"))
|> assign(:provider, %Provider{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit provider"))
|> assign(:provider, Oidc.get_provider!(id))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
provider = Oidc.get_provider!(id)
{:ok, _} = Oidc.delete_provider(provider)
{:noreply,
socket
|> put_flash(:info, gettext("Provider deleted successfully"))
|> assign(:providers, list_providers())}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
providers = search_providers(search)
{:noreply, socket |> assign(:search, search) |> assign(:providers, providers)}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
%{current_sort: %{field: current_field, order: current_order}} = socket.assigns
{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})}
end
@impl true
def handle_info(
{ClaperWeb.AdminLive.OidcProviderLive.FormComponent, {:saved, _provider}},
socket
) do
{:noreply, assign(socket, :providers, list_providers())}
end
defp list_providers do
Oidc.list_providers()
end
defp search_providers(search) when search == "", do: list_providers()
defp search_providers(search) do
search_term = "%#{search}%"
Oidc.search_providers(search_term)
end
defp sort_providers(providers, field, order) do
Enum.sort_by(providers, &Map.get(&1, field), order)
end
def sort_indicator(assigns) 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>
<% 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>
<% 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>
<% end %>
"""
end
end

View File

@@ -0,0 +1,358 @@
<%= case @live_action do %>
<% :index -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("OIDC Providers")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/oidc_providers/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New provider")}
</.link>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search providers...")}
/>
</label>
</form>
</div>
<!-- Providers Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="name"
class="btn btn-ghost btn-sm"
>
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="issuer"
class="btn btn-ghost btn-sm"
>
{gettext("Issuer")} {sort_indicator(assigns |> Map.put(:field, :issuer))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="active"
class="btn btn-ghost btn-sm"
>
{gettext("Status")} {sort_indicator(assigns |> Map.put(:field, :active))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="inserted_at"
class="btn btn-ghost btn-sm"
>
{gettext("Created")} {sort_indicator(
assigns
|> Map.put(:field, :inserted_at)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@providers) do %>
<tr>
<td colspan="5" class="text-center">
{gettext("No providers found")}
</td>
</tr>
<% else %>
<%= for provider <- @providers do %>
<tr id={"provider-#{provider.id}"}>
<td class="font-medium">
{provider.name}
</td>
<td>
{provider.issuer}
</td>
<td>
<%= if provider.active do %>
<span class="badge badge-success">
{gettext("Active")}
</span>
<% else %>
<span class="badge badge-ghost">
{gettext("Inactive")}
</span>
<% end %>
</td>
<td>
{Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/oidc_providers/#{provider}"}
class="btn btn-link btn-sm"
title={gettext("View provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/oidc_providers/#{provider}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={provider.id}
data-confirm={
gettext("Are you sure you want to delete this provider?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("OIDC Provider details")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/oidc_providers"} class="btn btn-outline btn-sm">
{gettext("Back to providers")}
</.link>
<.link
navigate={~p"/admin/oidc_providers/#{@provider}/edit"}
class="btn btn-primary btn-sm"
>
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@provider.name}</h2>
<p class="text-base-content/70">{gettext("OIDC Provider")}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Name")}</dt>
<dd class="col-span-2">{@provider.name}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Issuer")}</dt>
<dd class="col-span-2">
{@provider.issuer}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Client ID")}</dt>
<dd class="col-span-2">
{@provider.client_id}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Response Type")}</dt>
<dd class="col-span-2">
{@provider.response_type}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Scope")}</dt>
<dd class="col-span-2">
{@provider.scope}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Status")}</dt>
<dd class="col-span-2">
<%= if @provider.active do %>
<span class="badge badge-success">
{gettext("Active")}
</span>
<% else %>
<span class="badge badge-ghost">
{gettext("Inactive")}
</span>
<% end %>
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New provider")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
id="provider-form"
title={gettext("New provider")}
action={:new}
provider={@provider}
navigate={~p"/admin/oidc_providers"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit OIDC Provider")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
id={"provider-form-#{@provider.id}"}
title={gettext("Edit provider")}
action={:edit}
provider={@provider}
navigate={~p"/admin/oidc_providers"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,222 @@
defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Accounts.Oidc
@impl true
def render(assigns) do
~H"""
<div>
<.form
for={@form}
id="provider-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="grid grid-cols-6 gap-6">
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="name-field"
form={@form}
field={:name}
type="text"
label={gettext("Name")}
placeholder={gettext("Enter provider name")}
required={true}
width_class="sm:col-span-6"
description={gettext("A unique name to identify this OIDC provider")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="issuer-field"
form={@form}
field={:issuer}
type="text"
label={gettext("Issuer URL")}
placeholder={gettext("https://example.com")}
required={true}
width_class="sm:col-span-6"
description={gettext("The OIDC issuer URL (must start with http:// or https://)")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="client_id-field"
form={@form}
field={:client_id}
type="text"
label={gettext("Client ID")}
placeholder={gettext("Enter client ID")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="client_secret-field"
form={@form}
field={:client_secret}
type="text"
label={gettext("Client Secret")}
placeholder={gettext("Enter client secret")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="redirect_uri-field"
form={@form}
field={:redirect_uri}
type="text"
label={gettext("Redirect URI")}
placeholder={gettext("https://yourapp.com/auth/callback")}
required={true}
width_class="sm:col-span-6"
description={
gettext("The callback URL for your application (must start with http:// or https://)")
}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="scope-field"
form={@form}
field={:scope}
type="text"
label={gettext("Scope")}
placeholder={gettext("openid email profile")}
width_class="sm:col-span-3"
description={gettext("OIDC scopes to request (defaults to 'openid email profile')")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="response_type-field"
form={@form}
field={:response_type}
type="select"
label={gettext("Response Type")}
select_options={[
{gettext("Authorization Code"), "code"},
{gettext("Implicit"), "token"},
{gettext("Hybrid"), "code token"}
]}
width_class="sm:col-span-3"
description={gettext("OAuth 2.0 response type (defaults to 'code')")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="response_mode-field"
form={@form}
field={:response_mode}
type="select"
label={gettext("Response Mode")}
select_options={[
{gettext("Query"), "query"},
{gettext("Fragment"), "fragment"},
{gettext("Form Post"), "form_post"}
]}
width_class="sm:col-span-3"
description={
gettext("How the authorization response should be returned (defaults to 'query')")
}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="active-field"
form={@form}
field={:active}
type="checkbox"
label={gettext("Active")}
checkbox_label={gettext("Enable this OIDC provider")}
width_class="sm:col-span-3"
description={
gettext("Whether this provider is currently active and available for authentication")
}
/>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create Provider"), else: gettext("Update Provider")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{provider: provider} = assigns, socket) do
changeset = Oidc.change_provider(provider)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"provider" => provider_params}, socket) do
changeset =
socket.assigns.provider
|> Oidc.change_provider(provider_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"provider" => provider_params}, socket) do
save_provider(socket, socket.assigns.action, provider_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_provider(socket, :edit, provider_params) do
case Oidc.update_provider(socket.assigns.provider, provider_params) do
{:ok, provider} ->
notify_parent({:saved, provider})
{:noreply,
socket
|> put_flash(:info, gettext("Provider updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_provider(socket, :new, provider_params) do
case Oidc.create_provider(provider_params) do
{:ok, provider} ->
notify_parent({:saved, provider})
{:noreply,
socket
|> put_flash(:info, gettext("Provider created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -0,0 +1,175 @@
defmodule ClaperWeb.AdminLive.SearchFilterComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class="bg-white px-4 py-5 border-b border-gray-200 sm:px-6">
<div class="-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap">
<div class="ml-4 mt-2">
<form phx-submit="search" phx-target={@myself} class="flex items-center">
<div class="relative rounded-md shadow-sm">
<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"
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
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>
<%= for {label, value} <- filter.options do %>
<option value={value} selected={@filter_values[filter.name] == value}>
{label}
</option>
<% end %>
</select>
<% end %>
</div>
<% end %>
<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
type="button"
phx-click="clear_all"
phx-target={@myself}
class="ml-2 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"
>
Clear
</button>
<% end %>
</form>
</div>
<div class="ml-4 mt-2 flex-shrink-0">
<%= if @export_csv_enabled do %>
<button
type="button"
phx-click="export_csv"
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
</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"}
</.link>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, search_value: "", filter_values: %{})}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:search_placeholder, fn -> "Search..." end)
|> assign_new(:filters, fn -> [] end)
|> assign_new(:filter_values, fn -> %{} end)
|> assign_new(:search_value, fn -> "" end)
|> assign_new(:show_clear, fn -> true end)
|> assign_new(:export_csv_enabled, fn -> false end)
|> assign_new(:new_path, fn -> nil end)
|> assign_new(:new_label, fn -> "New" end)
|> assign_new(:custom_actions, fn -> [] end)
{:ok, socket}
end
@impl true
def handle_event("search", %{"search" => search_value}, socket) do
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}}
)
{: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}}
)
{:noreply, assign(socket, filter_values: filter_values)}
end
def handle_event("clear_all", _params, socket) do
send(self(), {:search_filter_changed, %{search: "", filters: %{}}})
{:noreply, assign(socket, search_value: "", filter_values: %{})}
end
def handle_event("export_csv", _params, socket) do
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

View File

@@ -0,0 +1,170 @@
defmodule ClaperWeb.AdminLive.SearchableSelectComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
<div class="form-control w-full">
<label class="label">
<span class="label-text">{@label}</span>
</label>
<div class="dropdown dropdown-bottom w-full">
<input
type="text"
value={@search_term}
placeholder={@placeholder}
class="input input-bordered w-full"
phx-keyup="search"
phx-focus="open_dropdown"
phx-target={@myself}
phx-debounce="300"
autocomplete="off"
tabindex="0"
/>
<input
type="hidden"
name={"#{@form.name}[#{@field}]"}
value={@selected_value || ""}
id={"#{@form.name}_#{@field}"}
/>
<%= if @show_dropdown and length(@filtered_options) > 0 do %>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-full p-2 shadow"
phx-click-away="close_dropdown"
phx-target={@myself}
>
<%= for {label, value} <- @filtered_options do %>
<li class="w-full">
<a
class={if to_string(value) == to_string(@selected_value), do: "active", else: ""}
phx-click="select_option"
phx-value-value={value}
phx-value-label={label}
phx-target={@myself}
>
{label}
</a>
</li>
<% end %>
</ul>
<% end %>
</div>
<label class="label">
{error_tag(@form, @field)}
<%= if @description do %>
<span class="label-text-alt">{@description}</span>
<% end %>
</label>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:show_dropdown, false)
|> assign(:search_term, "")
|> assign(:filtered_options, [])
|> assign(:selected_value, nil)
|> assign(:display_value, "")}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:placeholder, fn -> gettext("Select...") end)
|> assign_new(:required, fn -> false end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:width_class, fn -> nil end)
|> assign_new(:options, fn -> [] end)
|> update_filtered_options()
|> update_display_value()
{:ok, socket}
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
socket =
socket
|> assign(:search_term, search_term)
|> assign(:show_dropdown, true)
|> update_filtered_options()
{:noreply, socket}
end
def handle_event("select_option", %{"value" => value, "label" => label}, socket) do
socket =
socket
|> assign(:selected_value, value)
|> assign(:display_value, label)
|> assign(:show_dropdown, false)
|> assign(:search_term, label)
{:noreply, socket}
end
def handle_event("open_dropdown", _params, socket) do
socket =
socket
|> assign(:show_dropdown, true)
|> update_filtered_options()
{:noreply, socket}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :show_dropdown, false)}
end
defp update_filtered_options(socket) do
search_term = String.downcase(socket.assigns[:search_term] || "")
filtered =
if search_term == "" do
socket.assigns.options
else
Enum.filter(socket.assigns.options, fn {label, _value} ->
String.contains?(String.downcase(label), search_term)
end)
end
assign(socket, :filtered_options, filtered)
end
defp update_display_value(socket) do
current_value = get_field_value(socket.assigns.form, socket.assigns.field)
display_value = find_display_value(current_value, socket.assigns.options)
socket
|> assign(:selected_value, current_value)
|> assign(:display_value, display_value)
|> assign(:search_term, display_value)
end
defp find_display_value(nil, _options), do: ""
defp find_display_value(current_value, options) do
case Enum.find(options, fn {_label, value} ->
to_string(value) == to_string(current_value)
end) do
{label, _value} -> label
nil -> ""
end
end
defp get_field_value(form, field) do
Map.get(form.data, field) || Map.get(form.params, to_string(field))
end
end

View File

@@ -0,0 +1,242 @@
defmodule ClaperWeb.AdminLive.TableActionsComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class="flex items-center space-x-2">
<%= if @view_enabled do %>
<button
type="button"
phx-click="view_item"
phx-target={@myself}
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
title="View"
>
<i class="fas fa-eye"></i>
<span class="sr-only">View</span>
</button>
<% end %>
<%= if @edit_enabled do %>
<button
type="button"
phx-click="edit_item"
phx-target={@myself}
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
title="Edit"
>
<i class="fas fa-edit"></i>
<span class="sr-only">Edit</span>
</button>
<% end %>
<%= if @delete_enabled do %>
<button
type="button"
phx-click="delete_item"
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."
}
>
<i class="fas fa-trash-alt"></i>
<span class="sr-only">Delete</span>
</button>
<% end %>
<%= if @duplicate_enabled do %>
<button
type="button"
phx-click="duplicate_item"
phx-target={@myself}
class="text-green-600 hover:text-green-900 transition-colors duration-200"
title="Duplicate"
>
<i class="fas fa-copy"></i>
<span class="sr-only">Duplicate</span>
</button>
<% end %>
<%= if @archive_enabled do %>
<button
type="button"
phx-click="archive_item"
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"
)
]}
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>
</button>
<% end %>
<%= if @toggle_enabled do %>
<button
type="button"
phx-click="toggle_item"
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"
)
]}
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>
</button>
<% end %>
<%= if @dropdown_actions && length(@dropdown_actions) > 0 do %>
<div class="relative" phx-click-away="close_dropdown" phx-target={@myself}>
<button
type="button"
phx-click="toggle_dropdown"
phx-target={@myself}
class="text-gray-600 hover:text-gray-900 transition-colors duration-200"
title="More actions"
>
<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">
<%= for action <- @dropdown_actions do %>
<button
type="button"
phx-click="dropdown_action"
phx-value-action={action.key}
phx-target={@myself}
class={[
"block w-full text-left px-4 py-2 text-sm transition-colors duration-200",
case action.type do
"danger" -> "text-red-700 hover:bg-red-50"
"warning" -> "text-orange-700 hover:bg-orange-50"
_ -> "text-gray-700 hover:bg-gray-50"
end
]}
data-confirm={action[:confirm]}
>
<%= if action[:icon] do %>
<i class={"#{action.icon} mr-2"}></i>
<% end %>
{action.label}
</button>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, dropdown_open: false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:view_enabled, fn -> false end)
|> assign_new(:edit_enabled, fn -> true end)
|> assign_new(:delete_enabled, fn -> true end)
|> assign_new(:duplicate_enabled, fn -> false end)
|> assign_new(:archive_enabled, fn -> false end)
|> assign_new(:toggle_enabled, fn -> false end)
|> assign_new(:item_archived, fn -> false end)
|> assign_new(:item_active, fn -> true end)
|> assign_new(:delete_confirm_message, fn -> nil end)
|> assign_new(:toggle_active_title, fn -> nil end)
|> assign_new(:toggle_inactive_title, fn -> nil end)
|> assign_new(:dropdown_actions, fn -> [] end)
|> assign_new(:custom_actions, fn -> [] end)
|> assign_new(:dropdown_open, fn -> false end)
{:ok, socket}
end
@impl true
def handle_event("view_item", _params, socket) do
send(self(), {:table_action, :view, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("edit_item", _params, socket) do
send(self(), {:table_action, :edit, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("delete_item", _params, socket) do
send(self(), {:table_action, :delete, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("duplicate_item", _params, socket) do
send(self(), {:table_action, :duplicate, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("archive_item", _params, socket) do
action = if socket.assigns.item_archived, do: :unarchive, else: :archive
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("toggle_item", _params, socket) do
action = if socket.assigns.item_active, do: :deactivate, else: :activate
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, dropdown_open: !socket.assigns.dropdown_open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, dropdown_open: false)}
end
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}
)
end
{:noreply, assign(socket, dropdown_open: false)}
end
end

View File

@@ -0,0 +1,282 @@
defmodule ClaperWeb.AdminLive.TableComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<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>
<% 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 %>
</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>
</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>
<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>
</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 %>
<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>
<% end %>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, sort_config: %{field: nil, direction: :asc})}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:sortable, fn -> false end)
|> assign_new(:sort_config, fn -> %{field: nil, direction: :asc} end)
|> assign_new(:row_click_enabled, fn -> false end)
|> assign_new(:empty_title, fn -> nil end)
|> assign_new(:empty_message, fn -> nil end)
|> assign_new(:empty_icon, fn -> nil end)
|> assign_new(:empty_action, fn -> [] end)
|> assign_new(:pagination, fn -> nil end)
|> assign_new(:row_func, fn -> nil end)
{:ok, socket}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
current_sort = socket.assigns.sort_config
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
def handle_event("paginate", %{"page" => page}, socket) do
page_number = String.to_integer(page)
send(self(), {:table_page_changed, page_number})
{:noreply, socket}
end
def handle_event("row_clicked", %{"row-index" => row_index}, socket) do
index = String.to_integer(row_index)
row = Enum.at(socket.assigns.rows, index)
send(self(), {:table_row_clicked, row, index})
{:noreply, socket}
end
defp get_row_cells(row, headers, nil) do
# Default behavior: assume row is a list/tuple matching header count
case row do
row when is_list(row) -> row
row when is_tuple(row) -> Tuple.to_list(row)
_ -> List.duplicate("", length(headers))
end
end
defp get_row_cells(row, _headers, row_func) when is_function(row_func) do
row_func.(row)
end
defp get_page_range(pagination) do
start_page = max(1, pagination.page_number - 2)
end_page = min(pagination.total_pages, pagination.page_number + 2)
start_page..end_page
end
end

View File

@@ -0,0 +1,192 @@
defmodule ClaperWeb.AdminLive.UserLive do
use ClaperWeb, :live_view
alias Claper.Admin
alias Claper.Accounts
alias Claper.Accounts.User
alias ClaperWeb.Helpers.CSVExporter
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("Users"))
|> assign(:users, list_users())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Users")
|> assign(:user, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New user"))
|> assign(:user, %User{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit user"))
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("User details"))
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
{:ok, _} = Accounts.delete_user(user)
{:noreply,
socket
|> put_flash(:info, gettext("User deleted successfully"))
|> assign(:users, list_users())}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_sort = socket.assigns.current_sort
new_direction =
if current_sort.field == field_atom do
if current_sort.order == :asc, do: :desc, else: :asc
else
:asc
end
users = sort_users(socket.assigns.users, field_atom, new_direction)
current_sort = %{field: field_atom, order: new_direction}
{:noreply,
socket
|> assign(:users, users)
|> assign(:current_sort, current_sort)}
end
@impl true
def handle_info({:search_filter_changed, %{search: search, filters: _filters}}, socket) do
users = search_users(search)
{:noreply, socket |> assign(:search, search) |> assign(:users, users)}
end
@impl true
def handle_info({:export_csv_requested, _params}, socket) do
filename = CSVExporter.generate_filename("users")
csv_content = CSVExporter.export_users_to_csv(socket.assigns.users)
{:noreply,
socket
|> put_flash(:info, gettext("Users exported successfully"))
|> push_event("download_csv", %{filename: filename, content: csv_content})}
end
@impl true
def handle_info({:table_sort_changed, sort_config}, socket) do
%{field: field, direction: direction} = sort_config
users = sort_users(socket.assigns.users, field, direction)
current_sort = %{field: field, order: direction}
{:noreply,
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 ->
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}")}
:edit ->
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}/edit")}
:delete ->
{:ok, _} = Accounts.delete_user(user)
{:noreply,
socket
|> put_flash(:info, gettext("User deleted successfully"))
|> assign(:users, list_users())}
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
defp sort_users(users, field, order) do
Enum.sort_by(users, &Map.get(&1, field), order)
end
def sort_indicator(assigns) 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>
<% 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>
<% 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>
<% end %>
"""
end
end

View File

@@ -0,0 +1,348 @@
<%= case @live_action do %>
<% :index -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Users")}</h1>
<div class="flex space-x-3">
<%!-- <.link navigate={~p"/admin/users/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New user")}
</.link> --%>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search users...")}
/>
</label>
</form>
</div>
<!-- Users Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="email"
class="btn btn-ghost btn-sm"
>
{gettext("Email")} {sort_indicator(assigns |> Map.put(:field, :email))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="role_id"
class="btn btn-ghost btn-sm"
>
{gettext("Role")} {sort_indicator(assigns |> Map.put(:field, :role_id))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="confirmed_at"
class="btn btn-ghost btn-sm"
>
{gettext("Status")} {sort_indicator(
assigns
|> Map.put(:field, :confirmed_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="inserted_at"
class="btn btn-ghost btn-sm"
>
{gettext("Created")} {sort_indicator(
assigns
|> Map.put(:field, :inserted_at)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@users) do %>
<tr>
<td colspan="5" class="text-center">
{gettext("No users found")}
</td>
</tr>
<% else %>
<%= for user <- @users do %>
<tr id={"user-#{user.id}"}>
<td class="font-medium">
{user.email}
</td>
<td>
{if user.role, do: user.role.name, else: gettext("No role")}
</td>
<td>
<%= if user.confirmed_at do %>
<span class="badge badge-success">
{gettext("Confirmed")}
</span>
<% else %>
<span class="badge badge-warning">
{gettext("Unconfirmed")}
</span>
<% end %>
</td>
<td>
{Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/users/#{user}"}
class="btn btn-link btn-sm"
title={gettext("View user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/users/#{user}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={user.id}
data-confirm={
gettext("Are you sure you want to delete this user?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("User details")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/users"} class="btn btn-outline btn-sm">
{gettext("Back to users")}
</.link>
<.link navigate={~p"/admin/users/#{@user}/edit"} class="btn btn-primary btn-sm">
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@user.email}</h2>
<p class="text-base-content/70">{gettext("User Account")}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Email")}</dt>
<dd class="col-span-2">{@user.email}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Role")}</dt>
<dd class="col-span-2">
{if @user.role, do: @user.role.name, else: gettext("No role")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Status")}</dt>
<dd class="col-span-2">
<%= if @user.confirmed_at do %>
<span class="badge badge-success">
{gettext("Confirmed")}
</span>
<% else %>
<span class="badge badge-warning">
{gettext("Unconfirmed")}
</span>
<% end %>
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<%= if @user.confirmed_at do %>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Confirmed At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<% end %>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New user")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.UserLive.FormComponent}
id="user-form"
title={gettext("New user")}
action={:new}
user={@user}
navigate={~p"/admin/users"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit user")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.UserLive.FormComponent}
id={"user-form-#{@user.id}"}
title={gettext("Edit user")}
action={:edit}
user={@user}
navigate={~p"/admin/users"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,196 @@
defmodule ClaperWeb.AdminLive.UserLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Accounts
@impl true
def render(assigns) do
~H"""
<div>
<.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}
id="user-email"
form={@form}
field={:email}
type="email"
label={gettext("Email")}
placeholder={gettext("Enter user email")}
required={true}
width_class="sm:col-span-6"
description={gettext("User's email address (must be unique)")}
/>
<%= if @action == :new do %>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="user-password"
form={@form}
field={:password}
type="password"
label={gettext("Password")}
placeholder={gettext("Enter password")}
required={true}
width_class="sm:col-span-3"
description={gettext("Initial password for the user")}
/>
<% end %>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="user-role-id"
form={@form}
field={:role_id}
type="select"
label={gettext("Role")}
select_options={@role_options}
required={true}
width_class="sm:col-span-3"
description={gettext("User's access level")}
/>
<div class="sm:col-span-6">
<div class="form-control">
<label class="label cursor-pointer justify-start">
<input
type="checkbox"
name="user[confirmed]"
value="true"
checked={@confirmed_checked}
class="checkbox checkbox-primary"
/>
<span class="label-text ml-2">{gettext("Account is confirmed and active")}</span>
</label>
</div>
</div>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create User"), else: gettext("Update User")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{user: user} = assigns, socket) do
# For edit action, ensure we have the current role_id in the changeset
# attrs =
# case assigns.action do
# :edit -> %{"role_id" => user.role_id}
# :new -> %{}
# end
changeset = Accounts.change_user(user)
role_options =
Accounts.list_roles()
|> Enum.map(&{String.capitalize(&1.name), &1.id})
# Determine if confirmed checkbox should be checked
confirmed_checked =
case assigns.action do
:edit -> !is_nil(user.confirmed_at)
:new -> false
end
{:ok,
socket
|> assign(assigns)
|> assign(:role_options, role_options)
|> assign(:confirmed_checked, confirmed_checked)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
# Update the confirmed_checked state based on form params
confirmed_checked = Map.get(user_params, "confirmed") == "true"
# Convert confirmed checkbox to confirmed_at datetime
user_params = maybe_convert_confirmed_field(user_params)
changeset =
socket.assigns.user
|> Accounts.change_user(user_params)
|> Map.put(:action, :validate)
{:noreply,
socket
|> assign(:confirmed_checked, confirmed_checked)
|> assign_form(changeset)}
end
def handle_event("save", %{"user" => user_params}, socket) do
# Convert confirmed checkbox to confirmed_at datetime
user_params = maybe_convert_confirmed_field(user_params)
save_user(socket, socket.assigns.action, user_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_user(socket, :edit, user_params) do
case Accounts.update_user(socket.assigns.user, user_params) do
{:ok, user} ->
notify_parent({:saved, user})
{:noreply,
socket
|> put_flash(:info, gettext("User updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_user(socket, :new, user_params) do
case Accounts.create_user(user_params) do
{:ok, user} ->
notify_parent({:saved, user})
{:noreply,
socket
|> put_flash(:info, gettext("User created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: :user)
assign(socket, :form, form)
end
defp maybe_convert_confirmed_field(user_params) do
case Map.get(user_params, "confirmed") do
"true" ->
user_params
|> Map.delete("confirmed")
|> Map.put("confirmed_at", NaiveDateTime.utc_now())
"false" ->
user_params
|> Map.delete("confirmed")
|> Map.put("confirmed_at", nil)
_ ->
user_params
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -74,7 +74,7 @@ defmodule ClaperWeb.EventLive.Manage do
end
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
end
defp leader?(_socket, _event), do: false

View File

@@ -70,7 +70,7 @@ defmodule ClaperWeb.EventLive.Presenter do
end
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
end
defp leader?(_socket, _event), do: false

View File

@@ -109,7 +109,7 @@ defmodule ClaperWeb.EventLive.Show do
defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event)
when is_map(current_user) do
is_leader =
current_user.id == event.user_id || Claper.Events.leaded_by?(current_user.email, event)
current_user.id == event.user_id || Claper.Events.led_by?(current_user.email, event)
socket |> assign(:is_leader, is_leader)
end

View File

@@ -0,0 +1,33 @@
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
def call(conn, _opts) do
user = conn.assigns[:current_user]
if user && Accounts.user_has_role?(user, "admin") do
conn
else
conn
|> put_flash(:error, "You must be an admin to access this page.")
|> redirect(to: ~p"/events")
|> halt()
end
end
end

View File

@@ -14,6 +14,10 @@ defmodule ClaperWeb.Router do
plug(ClaperWeb.Plugs.Locale)
end
pipeline :admin_required do
plug(ClaperWeb.Plugs.AdminRequiredPlug)
end
pipeline :lti do
plug(:accepts, ["html", "json"])
plug(:put_root_layout, html: {ClaperWeb.LayoutView, :root})
@@ -165,4 +169,28 @@ 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
end
end
end

View File

@@ -5,6 +5,13 @@
>
{gettext("My account")}
</a>
<a
:if={Claper.Accounts.user_has_role?(@user, "admin")}
href={~p"/admin"}
class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"
>
{gettext("Admin")}
</a>
<a
href="https://docs.claper.co"
class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"

View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{csrf_meta_tag()}
<.live_title suffix=" · Claper Admin">
{assigns[:page_title] || "Admin Dashboard"}
</.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" />
<script defer phx-track-static type="text/javascript" src="/assets/app.js">
</script>
</head>
<body>
<div class="drawer lg:drawer-open">
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar for mobile -->
<div class="navbar lg:hidden bg-base-100 shadow-lg">
<div class="flex-none">
<label for="drawer-toggle" class="btn btn-square btn-ghost drawer-button">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-5 h-5 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
>
</path>
</svg>
</label>
</div>
<div class="flex-1">
<a href={~p"/admin"} class="btn btn-ghost normal-case text-xl">
<img src="/images/logo.svg" class="h-24 mr-2" /> {gettext("Admin")}
</a>
</div>
</div>
<!-- Page content -->
<main class="flex-1 overflow-y-auto bg-base-200 p-4 lg:p-8">
<!-- Toast container for flash messages -->
<div class="toast toast-top toast-end" id="flash-messages">
<%= if Phoenix.Flash.get(@flash, :info) do %>
<div class="alert alert-info shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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"
>
</path>
</svg>
<span>{Phoenix.Flash.get(@flash, :info)}</span>
<button class="btn btn-ghost btn-xs" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" 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"
/>
</svg>
</button>
</div>
<% end %>
<%= if Phoenix.Flash.get(@flash, :success) do %>
<div class="alert alert-success shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
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>
<span>{Phoenix.Flash.get(@flash, :success)}</span>
<button class="btn btn-ghost btn-xs" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" 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"
/>
</svg>
</button>
</div>
<% end %>
<%= if Phoenix.Flash.get(@flash, :warning) do %>
<div class="alert alert-warning shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
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>
<span>{Phoenix.Flash.get(@flash, :warning)}</span>
<button class="btn btn-ghost btn-xs" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" 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"
/>
</svg>
</button>
</div>
<% end %>
<%= if Phoenix.Flash.get(@flash, :error) do %>
<div class="alert alert-error shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
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>
<span>{Phoenix.Flash.get(@flash, :error)}</span>
<button class="btn btn-ghost btn-xs" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" 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"
/>
</svg>
</button>
</div>
<% end %>
</div>
<div class="p-8 md:p-16">
{@inner_content}
</div>
</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<label for="drawer-toggle" class="drawer-overlay"></label>
<aside class="bg-base-100 min-h-full px-12">
<div class="flex items-center p-5 gap-x-3">
<img src="/images/logo.svg" class="h-24" />
<h1 class="text-xl font-bold !font-display">{gettext("Admin")}</h1>
</div>
<ul class="menu">
<li>
<.link
patch={~p"/admin"}
class={"#{if @conn.path_info == ["admin"], do: "active", else: ""}"}
>
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
{gettext("Dashboard")}
</.link>
</li>
<li>
<.link
patch={~p"/admin/events"}
class={"#{if @conn.path_info == ["admin", "events"], do: "active", else: ""}"}
>
<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="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>
{gettext("Events")}
</.link>
</li>
<li>
<.link
patch={~p"/admin/users"}
class={"#{if @conn.path_info == ["admin", "users"], do: "active", else: ""}"}
>
<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="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>
{gettext("Users")}
</.link>
</li>
<%!-- <li>
<.link
patch={~p"/admin/oidc_providers"}
class={"#{if @conn.path_info == ["admin", "oidc_providers"], do: "active", else: ""}"}
>
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
{gettext("OIDC Providers")}
</.link>
</li> --%>
<div class="divider"></div>
<li>
<a href="https://docs.claper.co" target="_blank" rel="noopener noreferrer">
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
{gettext("Documentation")}
<svg class="w-3 h-3 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</li>
<div class="mt-auto pt-4">
<li>
<a href={~p"/events"} class="btn btn-outline btn-sm">
<svg class="w-3 h-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"
/>
</svg>
{gettext("Back to app")}
</a>
</li>
</div>
</ul>
</aside>
</div>
</div>
<!-- Auto-dismiss flash messages script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-dismiss flash messages after 5 seconds
const flashMessages = document.querySelectorAll('#flash-messages .alert');
if (flashMessages.length > 0) {
setTimeout(function() {
flashMessages.forEach(function(message) {
if (document.body.contains(message)) {
message.style.transition = 'opacity 0.5s ease';
message.style.opacity = '0';
setTimeout(function() {
message.remove();
}, 500);
}
});
}, 5000);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,122 @@
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 valid_url?(params["issuer"]) do
errors
else
[{:issuer, "Issuer must be a valid URL"} | 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]
else
if valid_email?(params["email"]) do
errors
else
[{:email, "Email is not valid"} | errors]
end
end
if Enum.empty?(errors) do
{:ok, params}
else
{:error, errors}
end
end
# Private helper functions
defp valid_url?(nil), do: false
defp valid_url?(url) do
uri = URI.parse(url)
uri.scheme != nil && uri.host != nil && uri.host =~ "."
end
defp valid_email?(nil), do: false
defp valid_email?(email) do
Regex.match?(~r/^[^\s]+@[^\s]+\.[^\s]+$/, email)
end
end

View File

@@ -0,0 +1,3 @@
defmodule ClaperWeb.Admin.SharedView do
use ClaperWeb, :view
end

View File

@@ -24,6 +24,17 @@ defmodule ClaperWeb.LayoutView do
end
end
def get_section_path(conn) do
section = Enum.at(conn.path_info, 1)
case section do
"users" -> ~p"/admin/users"
"events" -> ~p"/admin/events"
"oidc_providers" -> ~p"/admin/oidc_providers"
_ -> ~p"/admin"
end
end
def active_link(%Plug.Conn{} = conn, text, opts) do
class =
[opts[:class], active_class(conn, opts[:to])]

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"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
defmodule Claper.Repo.Migrations.CreateRoles do
use Ecto.Migration
def change do
create table(:roles) do
add :name, :string, null: false
add :permissions, :map, default: %{}
timestamps()
end
create unique_index(:roles, [:name])
end
end

View File

@@ -0,0 +1,11 @@
defmodule Claper.Repo.Migrations.AddRoleIdToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :role_id, references(:roles, on_delete: :nilify_all), null: true
end
create index(:users, [:role_id])
end
end

View File

@@ -0,0 +1,22 @@
defmodule Claper.Repo.Migrations.CreateOidcProviders do
use Ecto.Migration
def change do
create table(:oidc_providers) do
add :name, :string, null: false
add :issuer, :string, null: false
add :client_id, :string, null: false
add :client_secret, :string, null: false
add :redirect_uri, :string, null: false
add :response_type, :string, default: "code"
add :response_mode, :string
add :scope, :string, default: "openid email profile"
add :active, :boolean, default: true
timestamps()
end
create index(:oidc_providers, [:name])
create unique_index(:oidc_providers, [:issuer])
end
end

View File

@@ -10,6 +10,26 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
# Create roles if they don't exist
alias Claper.Accounts.Role
alias Claper.Repo
# Create admin role if it doesn't exist
if !Repo.get_by(Role, name: "admin") do
%Role{name: "admin", permissions: %{"all" => true}}
|> Repo.insert!()
IO.puts("Created admin role")
end
# Create user role if it doesn't exist
if !Repo.get_by(Role, name: "user") do
%Role{name: "user", permissions: %{}}
|> Repo.insert!()
IO.puts("Created user role")
end
# create a default active lti_1p3 jwk
if !Claper.Repo.get_by(Lti13.Jwks.Jwk, id: 1) do
%{private_key: private_key} = Lti13.Jwks.Utils.KeyGenerator.generate_key_pair()
@@ -22,3 +42,29 @@ if !Claper.Repo.get_by(Lti13.Jwks.Jwk, id: 1) do
active: true
})
end
# Create default admin user if no users exist
alias Claper.Accounts
alias Claper.Accounts.User
if Repo.aggregate(User, :count, :id) == 0 do
admin_role = Repo.get_by(Role, name: "admin")
if admin_role do
{:ok, admin_user} =
Accounts.register_user(%{
email: "admin@claper.co",
password: "claper",
confirmed_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
})
Accounts.assign_role(admin_user, admin_role)
IO.puts("Created default admin user:")
IO.puts(" Email: admin@claper.co")
IO.puts(" Password: claper")
IO.puts(" IMPORTANT: Please change this password after first login!")
else
IO.puts("Warning: Admin role not found, skipping default admin user creation")
end
end

View File

@@ -0,0 +1,133 @@
defmodule Claper.Accounts.RoleTest do
use Claper.DataCase
alias Claper.Accounts
alias Claper.Accounts.{User, Role}
alias Claper.Repo
describe "roles" do
setup do
# Ensure admin and user roles exist
{:ok, _admin_role} = Accounts.create_role(%{name: "admin"})
{:ok, _user_role} = Accounts.create_role(%{name: "user"})
:ok
end
test "list_roles/0 returns all roles" do
roles = Accounts.list_roles()
assert length(roles) == 2
assert Enum.any?(roles, fn r -> r.name == "admin" end)
assert Enum.any?(roles, fn r -> r.name == "user" end)
end
test "get_role!/1 returns the role with given id" do
role = Repo.get_by(Role, name: "admin")
assert Accounts.get_role!(role.id).name == "admin"
end
test "get_role_by_name/1 returns the role with given name" do
assert Accounts.get_role_by_name("admin").name == "admin"
assert Accounts.get_role_by_name("user").name == "user"
assert Accounts.get_role_by_name("nonexistent") == nil
end
test "create_role/1 with valid data creates a role" do
assert {:ok, %Role{} = role} = Accounts.create_role(%{name: "moderator"})
assert role.name == "moderator"
end
test "create_role/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Accounts.create_role(%{name: nil})
end
end
describe "user role management" do
setup 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"
end
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
# Create another user with a different role
{: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
# 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")
end
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
test "demote_from_admin/1 demotes a user from admin", %{user: user} 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")
end
end
end

View File

@@ -75,15 +75,17 @@ defmodule Claper.EmbedsTest do
"https://youtu.be/dQw4w9WgXcQ?si=g1A6ZegIXzcrisSw"
end
test "create_embed/1 with invalid data creates a youtube embed" do
test "create_embed/1 with invalid youtube URL returns error" do
presentation_file = presentation_file_fixture()
attrs = %{
title: "some title",
content: "https://youtube.com/dQw4w9WgXcQ?si=g1A6ZegIXzcrisSw",
# Wrong provider URL for youtube
content: "https://vimeo.com/123456",
provider: "youtube",
presentation_file_id: presentation_file.id,
position: 0
position: 0,
attendee_visibility: false
}
assert {:error, %Ecto.Changeset{}} = Embeds.create_embed(attrs)

View File

@@ -2,6 +2,7 @@ defmodule Claper.EventsTest do
use Claper.DataCase
alias Claper.Events
alias Claper.Events.{Event, ActivityLeader}
import Claper.{
EventsFixtures,
@@ -12,70 +13,417 @@ defmodule Claper.EventsTest do
EmbedsFixtures
}
describe "events" do
alias Claper.Events.Event
setup_all do
sandbox_owner_pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: true)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(sandbox_owner_pid) end)
@invalid_attrs %{name: nil, code: nil}
alice = user_fixture(%{email: "alice@example.com"})
bob = user_fixture(%{email: "bob@example.com"})
carol = user_fixture(%{email: "carol@example.com"})
test "list_events/1 returns all events of a user" do
event = event_fixture()
assert Events.list_events(event.user_id) == [event]
now = NaiveDateTime.utc_now()
alice_active_events =
for _ <- 1..12 do
event_fixture(%{user: alice})
end
alice_expired_events = []
bob_active_events = []
bob_expired_events =
for i <- 1..12 do
event_fixture(%{user: bob, expired_at: NaiveDateTime.add(now, i - 1, :hour)})
end
carol_active_events =
for _ <- 1..6 do
event =
event_fixture(%{user: carol})
activity_leader_fixture(%{event: event, user: alice})
event
end
carol_expired_events =
for i <- 1..6 do
event =
event_fixture(%{user: carol, expired_at: NaiveDateTime.add(now, i - 1, :hour)})
activity_leader_fixture(%{event: event, user: bob})
event
end
[
sandbox_owner_pid: sandbox_owner_pid,
alice: alice,
alice_active_events: alice_active_events,
alice_expired_events: alice_expired_events,
alice_events: alice_active_events ++ alice_expired_events,
bob: bob,
bob_active_events: bob_active_events,
bob_expired_events: bob_expired_events,
bob_events: bob_active_events ++ bob_expired_events,
carol: carol,
carol_active_events: carol_active_events,
carol_expired_events: carol_expired_events,
carol_events: carol_active_events ++ carol_expired_events
]
end
describe "listing events" do
test "list_events/2 lists all events of a user but not others", context do
assert Events.list_events(context.alice.id) == list(context.alice_events)
assert Events.list_events(context.bob.id) == list(context.bob_events)
assert Events.list_events(context.carol.id) == list(context.carol_events)
end
test "list_events/1 doesn't returns events of other users" do
event = event_fixture()
test "paginate_events/3 paginates all events of a user but not others", context do
assert Events.paginate_events(context.alice.id) ==
paginate(context.alice_events)
event2 = event_fixture()
params = %{"page" => 2}
assert Events.list_events(event.user_id) == [event]
assert Events.list_events(event.user_id) != [event2]
assert Events.paginate_events(context.alice.id, params) ==
paginate(context.alice_events, params)
assert Events.paginate_events(context.bob.id) ==
paginate(context.bob_events)
params = %{"page" => 2, "page_size" => 12}
assert Events.paginate_events(context.bob.id, params) ==
paginate(context.bob_events, params)
end
test "get_event!/2 returns the event with given id" do
test "list_not_expired_events/2 lists all active events for a user but not others",
context do
assert Events.list_not_expired_events(context.alice.id) ==
list(context.alice_active_events)
assert Events.list_not_expired_events(context.bob.id) ==
list(context.bob_active_events)
assert Events.list_not_expired_events(context.carol.id) ==
list(context.carol_active_events)
end
test "paginate_not_expired_events/3 paginates all active events for a user but not others",
context do
assert Events.paginate_not_expired_events(context.alice.id) ==
paginate(context.alice_active_events)
assert Events.paginate_not_expired_events(context.bob.id) ==
paginate(context.bob_active_events)
params = %{"page" => 2, "page_size" => 10}
assert Events.paginate_not_expired_events(context.carol.id, params) ==
paginate(context.carol_active_events, params)
end
test "list_expired_events/2 lists all expired events for a user but not others", context do
assert Events.list_expired_events(context.alice.id) ==
Enum.reverse(context.alice_expired_events)
assert Events.list_expired_events(context.bob.id) ==
Enum.reverse(context.bob_expired_events)
assert Events.list_expired_events(context.carol.id) ==
Enum.reverse(context.carol_expired_events)
end
test "paginate_expired_events/3 lists all expired events for a user but not others",
context do
assert Events.paginate_expired_events(context.alice.id) ==
paginate(context.alice_expired_events)
assert Events.paginate_expired_events(context.bob.id) ==
paginate(context.bob_expired_events)
params = %{"page" => 2, "page_size" => 10}
assert Events.paginate_expired_events(context.bob.id, params) ==
paginate(context.bob_expired_events, params)
assert Events.paginate_expired_events(context.carol.id) ==
paginate(context.carol_expired_events)
end
test "list_managed_events_by/2 lists all managed events by user but not others", context do
assert Events.list_managed_events_by(context.alice.email) ==
list(context.carol_active_events)
assert Events.list_managed_events_by(context.bob.email) ==
list(context.carol_expired_events)
assert Events.list_managed_events_by(context.carol.email) == []
end
test "paginate_managed_events_by/3 paginates all managed events by user but not others",
context do
assert Events.paginate_managed_events_by(context.alice.email) ==
paginate(context.carol_active_events)
assert Events.paginate_managed_events_by(context.bob.email) ==
paginate(context.carol_expired_events)
assert Events.paginate_managed_events_by(context.carol.email) == {[], 0, 0}
end
end
describe "counting events" do
test "count_managed_events_by/1 counts all managed events by user", context do
assert Events.count_managed_events_by(context.alice.email) ==
Enum.count(context.carol_active_events)
assert Events.count_managed_events_by(context.bob.email) ==
Enum.count(context.carol_expired_events)
assert Events.count_managed_events_by(context.carol.email) == 0
end
test "count_expired_events/1 counts all expired events for user", context do
assert Events.count_expired_events(context.alice.id) ==
Enum.count(context.alice_expired_events)
assert Events.count_expired_events(context.bob.id) == Enum.count(context.bob_expired_events)
assert Events.count_expired_events(context.carol.id) ==
Enum.count(context.carol_expired_events)
end
test "count_events_month/1 counts all events for user created in the last 30 days",
context do
assert Events.count_events_month(context.alice.id) ==
Enum.count(context.alice_active_events) + Enum.count(context.alice_expired_events)
assert Events.count_events_month(context.bob.id) ==
Enum.count(context.bob_active_events) + Enum.count(context.bob_expired_events)
assert Events.count_events_month(context.carol.id) ==
Enum.count(context.carol_active_events) + Enum.count(context.carol_expired_events)
end
end
describe "getting events" do
test "get_event!/2 gets event by serial ID and UUID" do
event = event_fixture()
assert Events.get_event!(event.id) == event
assert Events.get_event!(to_string(event.id)) == event
assert Events.get_event!(event.uuid) == event
end
test "get_user_event!/3 with invalid user raises exception" do
event = event_fixture()
event2 = event_fixture()
test "get_managed_event!/3 gets event managed by owner and leader, raises if neither",
context do
event = Enum.at(context.carol_active_events, 0)
assert Events.get_managed_event!(context.alice, event.uuid) == event
assert Events.get_managed_event!(context.carol, event.uuid) == event
assert_raise Ecto.NoResultsError, fn ->
Events.get_user_event!(event.user_id, event2.uuid) == event
Events.get_managed_event!(context.bob, event.uuid)
end
end
test "get_user_event!/3 gets event by owner, raises if not", context do
event = Enum.at(context.alice_active_events, 0)
assert Events.get_user_event!(context.alice.id, event.uuid) == event
assert_raise Ecto.NoResultsError, fn ->
Events.get_user_event!(context.bob.id, event.uuid)
end
end
test "get_event_with_code!/2 gets non-expired event by code, raises if not found", context do
active_event = Enum.at(context.carol_active_events, 0)
expired_event = Enum.at(context.carol_expired_events, 0)
assert Events.get_event_with_code!(active_event.code) == active_event
assert_raise Ecto.NoResultsError, fn ->
Events.get_event_with_code!(expired_event.code)
end
assert_raise Ecto.NoResultsError, fn ->
Events.get_event_with_code!("ABC123")
end
end
test "get_event_with_code/2 gets non-expired event by code, returns nil if not found",
context do
active_event = Enum.at(context.carol_active_events, 0)
expired_event = Enum.at(context.carol_expired_events, 0)
assert Events.get_event_with_code(active_event.code) == active_event
assert Events.get_event_with_code(expired_event.code) == nil
assert Events.get_event_with_code("ABC123") == nil
end
end
describe "writing events" do
test "create_event/1 with valid data creates a event" do
user = user_fixture()
valid_attrs = %{
attrs = %{
name: "some name",
code: "12345",
user_id: user.id,
started_at: NaiveDateTime.utc_now(),
expired_at: NaiveDateTime.add(NaiveDateTime.utc_now(), 7200, :second)
started_at:
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.truncate(:second),
expired_at:
NaiveDateTime.add(NaiveDateTime.utc_now(), 2, :hour)
|> NaiveDateTime.truncate(:second)
}
assert {:ok, %Event{} = event} = Events.create_event(valid_attrs)
assert event.name == "some name"
end
assert {:ok, %Event{} = event} = Events.create_event(attrs)
test "create_event/1 with too short code returns error changeset" do
user = user_fixture()
valid_attrs = %{
name: "some name",
code: "code",
user_id: user.id,
started_at: NaiveDateTime.utc_now(),
expired_at: NaiveDateTime.add(NaiveDateTime.utc_now(), 7200, :second)
}
assert {:error, %Ecto.Changeset{}} = Events.create_event(valid_attrs)
assert event.name == attrs.name
assert event.code == attrs.code
assert event.user_id == attrs.user_id
assert event.started_at == attrs.started_at
assert attrs.expired_at == event.expired_at
end
test "create_event/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Events.create_event(@invalid_attrs)
user = user_fixture()
attrs = %{
name: "some name",
code: "12345",
user_id: user.id,
started_at:
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.truncate(:second),
expired_at:
NaiveDateTime.add(NaiveDateTime.utc_now(), 2, :hour)
|> NaiveDateTime.truncate(:second)
}
too_short_code = "tiny"
assert {:error, %Ecto.Changeset{}} = Events.create_event(Map.delete(attrs, :name))
assert {:error, %Ecto.Changeset{}} = Events.create_event(Map.delete(attrs, :code))
assert {:error, %Ecto.Changeset{}} = Events.create_event(Map.delete(attrs, :user_id))
assert {:error, %Ecto.Changeset{}} = Events.create_event(Map.delete(attrs, :started_at))
assert {:error, %Ecto.Changeset{}} =
Events.create_event(Map.merge(attrs, %{code: too_short_code}))
end
test "duplicate_event/2 duplicates an event without presentation association" do
original = event_fixture()
{:ok, duplicate} = Events.duplicate_event(original.user_id, original.uuid)
assert duplicate.name == "#{original.name} (Copy)"
assert duplicate.id != original.id
assert duplicate.code != original.code
end
test "duplicate_event/2 duplicates an event with presentation associations" do
original = event_fixture()
presentation_file = presentation_file_fixture(%{event: original})
presentation_state = presentation_state_fixture(%{presentation_file: presentation_file})
poll =
poll_fixture(%{
presentation_file_id: presentation_file.id,
poll_opts: [
%{content: "some option 1", vote_count: 1},
%{content: "some option 2", vote_count: 2}
]
})
poll_fixture(%{presentation_file_id: presentation_file.id})
form = form_fixture(%{presentation_file_id: presentation_file.id})
form_fixture(%{presentation_file_id: presentation_file.id})
embed = embed_fixture(%{presentation_file_id: presentation_file.id})
embed_fixture(%{presentation_file_id: presentation_file.id})
duplicate =
Events.duplicate_event(original.user_id, original.uuid)
|> then(fn {:ok, duplicate} ->
duplicate
|> Repo.preload(
presentation_file: [:embeds, :forms, :presentation_state, polls: [:poll_opts]]
)
end)
# Event
assert duplicate.id != original.id
assert duplicate.uuid != original.uuid
assert duplicate.code != original.code
assert duplicate.name == "#{original.name} (Copy)"
assert duplicate.user_id == original.user_id
# Presentation file
assert duplicate.presentation_file.id != presentation_file.id
assert duplicate.presentation_file.hash == presentation_file.hash
assert duplicate.presentation_file.length == presentation_file.length
assert duplicate.presentation_file.event_id != presentation_file.event_id
assert duplicate.presentation_file.event_id == duplicate.id
# Presentation state
duplicate_state = duplicate.presentation_file.presentation_state
assert duplicate_state.id != presentation_state.id
assert duplicate_state.presentation_file_id != presentation_state.presentation_file_id
assert duplicate_state.presentation_file_id == duplicate.presentation_file.id
# Polls
[duplicate_poll, _] = duplicate.presentation_file.polls
assert duplicate_poll.id != poll.id
assert duplicate_poll.presentation_file_id != poll.presentation_file_id
assert duplicate_poll.presentation_file_id == duplicate.presentation_file.id
assert duplicate_poll.title == poll.title
assert duplicate_poll.position == poll.position
# Poll options
[o1, o2] = poll.poll_opts
[do1, do2] = duplicate_poll.poll_opts
assert do1.id != o1.id
assert do1.poll_id != o1.poll_id
assert do1.poll_id == duplicate_poll.id
assert do1.content == o1.content
assert do1.vote_count == 0
assert do2.id != o2.id
assert do2.poll_id != o2.poll_id
assert do2.poll_id == duplicate_poll.id
assert do2.content == o2.content
assert do2.vote_count == 0
# Forms
[duplicate_form, _] = duplicate.presentation_file.forms
assert duplicate_form.id != form.id
assert duplicate_form.presentation_file_id != form.presentation_file_id
assert duplicate_form.presentation_file_id == duplicate.presentation_file.id
assert duplicate_form.enabled == form.enabled
assert duplicate_form.fields == form.fields
assert duplicate_form.position == form.position
assert duplicate_form.title == form.title
# Embeds
[duplicate_embed, _] = duplicate.presentation_file.embeds
assert duplicate_embed.id != embed.id
assert duplicate_embed.presentation_file_id != embed.presentation_file_id
assert duplicate_embed.presentation_file_id == duplicate.presentation_file.id
assert(duplicate_embed.attendee_visibility == embed.attendee_visibility)
assert duplicate_embed.content == embed.content
assert duplicate_embed.enabled == embed.enabled
assert duplicate_embed.position == embed.position
assert duplicate_embed.provider == embed.provider
assert duplicate_embed.title == embed.title
end
test "duplicate_event/2 raises when an invalid user-event is supplied", context do
original = Enum.at(context.alice_active_events, 0)
assert_raise Ecto.NoResultsError, fn ->
Events.duplicate_event(context.bob.id, original.uuid)
end
end
test "update_event/2 with valid data updates the event" do
@@ -88,10 +436,102 @@ defmodule Claper.EventsTest do
test "update_event/2 with invalid data returns error changeset" do
event = event_fixture()
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, @invalid_attrs)
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, %{name: nil})
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, %{code: nil})
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, %{code: "tiny"})
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, %{user_id: nil})
assert {:error, %Ecto.Changeset{}} = Events.update_event(event, %{started_at: nil})
assert event == Events.get_event!(event.uuid)
end
test "change_event/1 returns a event changeset" do
event = event_fixture()
assert %Ecto.Changeset{} = Events.change_event(event)
end
test "terminate_event/1 terminates an event and broadcasts it" do
event = event_fixture()
assert event.expired_at == nil
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event.uuid}")
{:ok, event} = Events.terminate_event(event)
assert NaiveDateTime.diff(NaiveDateTime.utc_now(), event.expired_at) |> abs() < 1
assert_received {:event_terminated, uuid}
assert uuid == event.uuid
end
test "delete_event/1 deletes the event" do
event = event_fixture()
assert {:ok, %Event{}} = Events.delete_event(event)
assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.uuid) end
end
end
describe "leading events" do
test "led_by?/2", context do
assert Events.led_by?(context.alice.email, Enum.at(context.carol_active_events, 0)) == true
assert Events.led_by?(context.bob.email, Enum.at(context.carol_active_events, 0)) == false
end
test "create_activity_leader/1 with valid data creates an activity leader" do
attrs = %{
email: "dan@example.com"
}
{:ok, leader = %ActivityLeader{}} = Events.create_activity_leader(attrs)
assert leader.email == attrs.email
event = event_fixture()
attrs = %{
email: "dan@example.com",
event_id: event.id
}
{:ok, leader = %ActivityLeader{}} = Events.create_activity_leader(attrs)
assert leader.email == attrs.email
assert leader.event_id == attrs.event_id
end
test "create_activity_leader/1 with invalid data returns error changeset" do
{:error, %Ecto.Changeset{}} = Events.create_activity_leader(%{})
end
test "create_activity_leader/1 disallows event owner as leader" do
user = user_fixture()
event = event_fixture(%{user: user})
attrs = %{
email: user.email,
event_id: event.id,
user_email: user.email
}
{:error, %Ecto.Changeset{}} = Events.create_activity_leader(attrs)
end
test "get_activity_leader!/1 gets activity leader by ID" do
leader = activity_leader_fixture()
assert Events.get_activity_leader!(leader.id) == leader
end
test "get_activity_leaders_for_event/1 gets activity leaders for event by ID", context do
event = Enum.at(context.carol_active_events, 0)
[leader] = Events.get_activity_leaders_for_event(event.id)
assert leader.user_id == context.alice.id
end
test "change_activity_leader/2 returns an activity leader changeset" do
leader = activity_leader_fixture()
assert %Ecto.Changeset{} = Events.change_activity_leader(leader)
end
end
describe "importing events" do
test "import/3 transfer all interactions from an event to another" do
user = user_fixture()
from_event = event_fixture(%{user: user, name: "from event"})
@@ -121,48 +561,20 @@ defmodule Claper.EventsTest do
Events.import(user.id, from_event.uuid, to_event.uuid)
end
end
end
test "duplicate_event/2 duplicates an event" do
user = user_fixture()
original_event = event_fixture(%{user: user, name: "Original Event"})
presentation_file = presentation_file_fixture(%{event: original_event})
presentation_state_fixture(%{presentation_file: presentation_file})
poll_fixture(%{presentation_file_id: presentation_file.id})
form_fixture(%{presentation_file_id: presentation_file.id})
embed_fixture(%{presentation_file_id: presentation_file.id})
defp list(events), do: Enum.reverse(events)
assert {:ok, duplicated_event} = Events.duplicate_event(user.id, original_event.uuid)
defp paginate(events, params \\ %{}) do
page = Map.get(params, "page", 1)
page_size = Map.get(params, "page_size", 5)
start_index = (page - 1) * page_size
event_count = length(events)
assert duplicated_event.id != original_event.id
assert duplicated_event.user_id == original_event.user_id
assert duplicated_event.name == "Original Event (Copy)"
assert duplicated_event.code != original_event.code
assert duplicated_event.uuid != original_event.uuid
# Check if the presentation file was duplicated
duplicated_presentation_file =
Claper.Events.get_event!(duplicated_event.uuid, [:presentation_file]).presentation_file
assert duplicated_presentation_file.id != presentation_file.id
assert duplicated_presentation_file.hash == presentation_file.hash
assert duplicated_presentation_file.length == presentation_file.length
# Check if polls, forms, and embeds were duplicated
assert length(Claper.Polls.list_polls(duplicated_presentation_file.id)) == 1
assert length(Claper.Forms.list_forms(duplicated_presentation_file.id)) == 1
assert length(Claper.Embeds.list_embeds(duplicated_presentation_file.id)) == 1
end
test "delete_event/1 deletes the event" do
event = event_fixture()
assert {:ok, %Event{}} = Events.delete_event(event)
assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.uuid) end
end
test "change_event/1 returns a event changeset" do
event = event_fixture()
assert %Ecto.Changeset{} = Events.change_event(event)
end
{
events |> Enum.reverse() |> Enum.slice(start_index, page_size),
event_count,
ceil(event_count / page_size)
}
end
end

View File

@@ -0,0 +1,219 @@
defmodule ClaperWeb.Helpers.CSVExporterTest do
use Claper.DataCase
alias ClaperWeb.Helpers.CSVExporter
alias Claper.Accounts.User
alias Claper.Events.Event
alias Claper.Accounts.Oidc.Provider
alias Claper.Accounts.Role
alias Claper.Repo
describe "export_users_to_csv/1" do
setup 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
})
%{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"
assert Enum.at(lines, 2) =~ "admin@example.com"
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"
assert header =~ "Name"
assert header =~ "Role"
end
end
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]
})
%{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"
assert header =~ "Description"
assert header =~ "Start Date"
assert header =~ "End Date"
assert header =~ "Status"
# Check data rows contain event names
assert Enum.at(lines, 1) =~ "Event One"
assert Enum.at(lines, 2) =~ "Event Two"
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"
assert header =~ "Description"
assert header =~ "Start Date"
end
end
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]
})
%{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) =~ "Yes"
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) =~ "No"
# 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"
assert header =~ "Issuer"
assert header =~ "Client ID"
end
end
end

View File

@@ -28,9 +28,13 @@ defmodule Claper.DataCase do
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
setup context do
# Don't check out a connection if a setup_all did so already
if context[:sandbox_owner_pid] == nil do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Claper.Repo, shared: not context[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
:ok
end

View File

@@ -9,7 +9,27 @@ defmodule Claper.EventsFixtures do
require Claper.UtilFixture
@doc """
Generate a event.
Generate an activity leader.
"""
def activity_leader_fixture(attrs \\ %{}, preload \\ []) do
assoc = %{
event: attrs[:event] || event_fixture(),
user: attrs[:user] || user_fixture()
}
{:ok, activity_leader} =
attrs
|> Enum.into(%{
email: assoc.user.email,
event_id: assoc.event.id
})
|> Claper.Events.create_activity_leader()
Claper.UtilFixture.merge_preload(activity_leader, preload, assoc)
end
@doc """
Generate an event.
"""
def event_fixture(attrs \\ %{}, preload \\ []) do
assoc = %{user: attrs[:user] || user_fixture()}

36
with_env.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -a
# Default environment file
env_file=".env"
# Parse command-line arguments
while [[ "$#" -gt 0 ]]; do
case "$1" in
--env)
env_file="$2"
shift 2 # Skip the next argument as it's the value for --env
;;
*)
break # Exit the loop if an unrecognized option is found
;;
esac
done
source "$env_file"
set +a
# Remaining arguments are treated as the command to run
args=("$@")
# Check if no command is provided
if [ ${#args[@]} -eq 0 ]; then
echo "Usage: $0 [--env path/to/env] <command>"
echo "Example: $0 mix phx.server"
echo "Example: $0 --env path/to/env mix phx.server"
exit 1
fi
# Execute the command
"${args[@]}"