mirror of
https://github.com/ClaperCo/Claper.git
synced 2025-12-14 19:07:52 +01:00
Add admin panel and user roles (#189)
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
112
CLAUDE.md
Normal 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
63
assets/css/admin.css
Normal 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);
|
||||
@@ -2,7 +2,7 @@
|
||||
@import 'animate.css/animate.min.css';
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css';
|
||||
@import './theme.css' layer(theme);
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
@@ -302,6 +302,20 @@
|
||||
url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 100 300 400 500 700 900;
|
||||
src: url('/fonts/Montserrat/Montserrat-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: italic;
|
||||
font-weight: 100 300 400 500 700 900;
|
||||
src: url('/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
.bg-gradient-animate {
|
||||
background: linear-gradient(-45deg, var(--color-secondary-500), var(--color-secondary-600), var(--color-primary-400), var(--color-primary-400));
|
||||
background-size: 400% 400%;
|
||||
|
||||
152
assets/css/modern.css
Normal file
152
assets/css/modern.css
Normal file
@@ -0,0 +1,152 @@
|
||||
@theme {
|
||||
/* Colors */
|
||||
/* Main colors */
|
||||
--color-*: initial;
|
||||
--color-sky-blue: #81EBFE;
|
||||
--color-teal: #14BFDB;
|
||||
--color-azure: #29ACED;
|
||||
--color-mauve: #8611ED;
|
||||
--color-purple: #B80FEF;
|
||||
--color-navy: #140553;
|
||||
|
||||
/* Light colors */
|
||||
--color-cold-pink: #F3DEFA;
|
||||
--color-warm-pink: #FFD2F6;
|
||||
--color-yeallaw: #FFEAC2;
|
||||
--color-pistacio: #D4FFF4;
|
||||
--color-sky-tern: #C8EDFF;
|
||||
--color-tealy: #81EBFE;
|
||||
--color-earl-night: #0A7BB4;
|
||||
--color-dark-night: #184992;
|
||||
|
||||
/* Gradient */
|
||||
--gradient-primary: 123deg, var(--color-sky-blue) -36%, var(--color-teal) -12%, var(--color-azure) 21%, var(--color-mauve) 81%, var(--color-purple) 130%;
|
||||
--gradient-secondary: 123deg, rgba(129, 235, 254, 0.9) -36%, rgba(20, 191, 219, 0.9) -12%, rgba(41, 172, 237, 0.9) 21%, rgba(134, 17, 237, 0.9) 81%, rgba(184, 15, 239, 0.9) 130%;
|
||||
|
||||
/* Grayscale */
|
||||
--color-white: #FFFFFF;
|
||||
--color-black: #191919;
|
||||
--color-gray-20: #CCCCCC;
|
||||
--color-gray-40: #9F9F9F;
|
||||
--color-gray-60: #737373;
|
||||
--color-gray-80: #464646;
|
||||
--color-gray-100: #191919;
|
||||
--color-gray-120: #000000;
|
||||
|
||||
--color-platinum: #F0F0F0;
|
||||
--color-platinum-20: #FFFFFF;
|
||||
--color-platinum-40: #FCFCFC;
|
||||
--color-platinum-60: #F8F8F8;
|
||||
--color-platinum-80: #F4F4F4;
|
||||
--color-platinum-100: #F0F0F0;
|
||||
--color-platinum-120: #EEEEEE;
|
||||
|
||||
/* Typography */
|
||||
--font-display: "Montserrat", sans-serif;
|
||||
|
||||
/* Font Sizes - Desktop */
|
||||
--text-h1: 80px;
|
||||
--leading-h1: 120%;
|
||||
--font-weight-h1: 900;
|
||||
|
||||
--text-h2: 40px;
|
||||
--leading-h2: 120%;
|
||||
--font-weight-h2: 700;
|
||||
|
||||
--text-h3: 32px;
|
||||
--leading-h3: 120%;
|
||||
--font-weight-h3: 600;
|
||||
|
||||
--text-h4: 24px;
|
||||
--leading-h4: 120%;
|
||||
--font-weight-h4: 500;
|
||||
|
||||
--text-h5: 18px;
|
||||
--leading-h5: 120%;
|
||||
--font-weight-h5: 600;
|
||||
|
||||
--text-h6: 16px;
|
||||
--leading-h6: 120%;
|
||||
--font-weight-h6: 500;
|
||||
|
||||
--text-subheading: 20px;
|
||||
--leading-subheading: 150%;
|
||||
--font-weight-subheading: 300;
|
||||
|
||||
--text-body-bold: 16px;
|
||||
--leading-body-bold: 150%;
|
||||
--font-weight-body-bold: 700;
|
||||
|
||||
--text-body: 16px;
|
||||
--leading-body: 150%;
|
||||
--font-weight-body: 400;
|
||||
|
||||
--text-small-body-bold: 14px;
|
||||
--leading-small-body-bold: 150%;
|
||||
--font-weight-small-body-bold: 600;
|
||||
|
||||
--text-small-body: 14px;
|
||||
--leading-small-body: 150%;
|
||||
--font-weight-small-body: 400;
|
||||
|
||||
--text-caption: 14px;
|
||||
--leading-caption: 150%;
|
||||
--font-weight-caption: 300;
|
||||
|
||||
/* Font Sizes - Mobile */
|
||||
--text-mobile-h1: 32px;
|
||||
--leading-mobile-h1: 120%;
|
||||
--font-weight-mobile-h1: 900;
|
||||
|
||||
--text-mobile-h2: 28px;
|
||||
--leading-mobile-h2: 120%;
|
||||
--font-weight-mobile-h2: 700;
|
||||
|
||||
--text-mobile-h3: 22px;
|
||||
--leading-mobile-h3: 120%;
|
||||
--font-weight-mobile-h3: 600;
|
||||
|
||||
--text-mobile-h4: 18px;
|
||||
--leading-mobile-h4: 120%;
|
||||
--font-weight-mobile-h4: 500;
|
||||
|
||||
--text-mobile-h5: 16px;
|
||||
--leading-mobile-h5: 120%;
|
||||
--font-weight-mobile-h5: 600;
|
||||
|
||||
--text-mobile-h6: 14px;
|
||||
--leading-mobile-h6: 120%;
|
||||
--font-weight-mobile-h6: 500;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-0: 0px;
|
||||
--spacing-4: 4px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-28: 28px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-36: 36px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-44: 44px;
|
||||
--spacing-48: 48px;
|
||||
--spacing-52: 52px;
|
||||
--spacing-56: 56px;
|
||||
--spacing-60: 60px;
|
||||
--spacing-64: 64px;
|
||||
--spacing-68: 68px;
|
||||
--spacing-72: 72px;
|
||||
--spacing-76: 76px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-128: 128px;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-3xl: 0px 902px 253px 0px rgba(65, 69, 124, 0.00), 0px 577px 231px 0px rgba(65, 69, 124, 0.01), 0px 325px 195px 0px rgba(65, 69, 124, 0.05), 0px 144px 144px 0px rgba(65, 69, 124, 0.09), 0px 36px 79px 0px rgba(65, 69, 124, 0.10)
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Theme variables based on tailwind.config.js */
|
||||
|
||||
@theme {
|
||||
/* Primary Colors (water-blue) */
|
||||
--color-primary-50: #E3F2FD;
|
||||
@@ -75,6 +75,7 @@
|
||||
/* Font Family */
|
||||
--font-family-sans: 'Roboto', sans-serif;
|
||||
--font-family-serif: 'Merriweather', serif;
|
||||
--font-display: 'Montserrat', sans-serif;
|
||||
|
||||
/* Box Shadows */
|
||||
--shadow-base: 0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06);
|
||||
|
||||
207
assets/js/admin-charts.js
Normal file
207
assets/js/admin-charts.js
Normal 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();
|
||||
@@ -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
197
assets/js/hooks.js
Normal 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;
|
||||
66
assets/package-lock.json
generated
66
assets/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -44,6 +44,13 @@ config :tailwind,
|
||||
--output=priv/static/assets/app.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
],
|
||||
admin: [
|
||||
args: ~w(
|
||||
--input=assets/css/admin.css
|
||||
--output=priv/static/assets/admin.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
]
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
|
||||
@@ -14,6 +14,7 @@ config :claper, ClaperWeb.Endpoint,
|
||||
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
|
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||
tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]},
|
||||
sass: {
|
||||
DartSass,
|
||||
:install_and_run,
|
||||
|
||||
14
dev.sh
14
dev.sh
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
27
lib/claper/accounts/guardian.ex
Normal file
27
lib/claper/accounts/guardian.ex
Normal 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
119
lib/claper/accounts/oidc.ex
Normal 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
|
||||
57
lib/claper/accounts/oidc/provider.ex
Normal file
57
lib/claper/accounts/oidc/provider.ex
Normal 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
|
||||
31
lib/claper/accounts/role.ex
Normal file
31
lib/claper/accounts/role.ex
Normal 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
|
||||
@@ -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
635
lib/claper/admin.ex
Normal 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
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
169
lib/claper_web/helpers/csv_exporter.ex
Normal file
169
lib/claper_web/helpers/csv_exporter.ex
Normal 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
|
||||
109
lib/claper_web/live/admin_live/dashboard_live.ex
Normal file
109
lib/claper_web/live/admin_live/dashboard_live.ex
Normal 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
|
||||
212
lib/claper_web/live/admin_live/dashboard_live.html.heex
Normal file
212
lib/claper_web/live/admin_live/dashboard_live.html.heex
Normal 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>
|
||||
175
lib/claper_web/live/admin_live/event_live.ex
Normal file
175
lib/claper_web/live/admin_live/event_live.ex
Normal 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
|
||||
367
lib/claper_web/live/admin_live/event_live.html.heex
Normal file
367
lib/claper_web/live/admin_live/event_live.html.heex
Normal 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 %>
|
||||
160
lib/claper_web/live/admin_live/event_live/form_component.ex
Normal file
160
lib/claper_web/live/admin_live/event_live/form_component.ex
Normal 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
|
||||
184
lib/claper_web/live/admin_live/form_field_component.ex
Normal file
184
lib/claper_web/live/admin_live/form_field_component.ex
Normal 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
|
||||
196
lib/claper_web/live/admin_live/modal_component.ex
Normal file
196
lib/claper_web/live/admin_live/modal_component.ex
Normal 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">
|
||||
​
|
||||
</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
|
||||
155
lib/claper_web/live/admin_live/oidc_provider_live.ex
Normal file
155
lib/claper_web/live/admin_live/oidc_provider_live.ex
Normal 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
|
||||
358
lib/claper_web/live/admin_live/oidc_provider_live.html.heex
Normal file
358
lib/claper_web/live/admin_live/oidc_provider_live.html.heex
Normal 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 %>
|
||||
@@ -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
|
||||
175
lib/claper_web/live/admin_live/search_filter_component.ex
Normal file
175
lib/claper_web/live/admin_live/search_filter_component.ex
Normal 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
|
||||
170
lib/claper_web/live/admin_live/searchable_select_component.ex
Normal file
170
lib/claper_web/live/admin_live/searchable_select_component.ex
Normal 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
|
||||
242
lib/claper_web/live/admin_live/table_actions_component.ex
Normal file
242
lib/claper_web/live/admin_live/table_actions_component.ex
Normal 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
|
||||
282
lib/claper_web/live/admin_live/table_component.ex
Normal file
282
lib/claper_web/live/admin_live/table_component.ex
Normal 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
|
||||
192
lib/claper_web/live/admin_live/user_live.ex
Normal file
192
lib/claper_web/live/admin_live/user_live.ex
Normal 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
|
||||
348
lib/claper_web/live/admin_live/user_live.html.heex
Normal file
348
lib/claper_web/live/admin_live/user_live.html.heex
Normal 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 %>
|
||||
196
lib/claper_web/live/admin_live/user_live/form_component.ex
Normal file
196
lib/claper_web/live/admin_live/user_live/form_component.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
33
lib/claper_web/plugs/admin_required_plug.ex
Normal file
33
lib/claper_web/plugs/admin_required_plug.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
316
lib/claper_web/templates/layout/admin.html.heex
Normal file
316
lib/claper_web/templates/layout/admin.html.heex
Normal 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>
|
||||
122
lib/claper_web/validators/admin_form_validator.ex
Normal file
122
lib/claper_web/validators/admin_form_validator.ex
Normal 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
|
||||
3
lib/claper_web/views/admin/shared_view.ex
Normal file
3
lib/claper_web/views/admin/shared_view.ex
Normal file
@@ -0,0 +1,3 @@
|
||||
defmodule ClaperWeb.Admin.SharedView do
|
||||
use ClaperWeb, :view
|
||||
end
|
||||
@@ -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])]
|
||||
|
||||
1
mix.exs
1
mix.exs
@@ -136,6 +136,7 @@ defmodule Claper.MixProject do
|
||||
"assets.deploy": [
|
||||
"cmd --cd assets npm install",
|
||||
"tailwind default --minify",
|
||||
"tailwind admin --minify",
|
||||
"esbuild default --minify",
|
||||
"sass default --no-source-map --style=compressed",
|
||||
"phx.digest"
|
||||
|
||||
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
14
priv/repo/migrations/20250711135508_create_roles.exs
Normal file
14
priv/repo/migrations/20250711135508_create_roles.exs
Normal 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
|
||||
11
priv/repo/migrations/20250711135909_add_role_id_to_users.exs
Normal file
11
priv/repo/migrations/20250711135909_add_role_id_to_users.exs
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
BIN
priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
BIN
priv/static/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
Binary file not shown.
133
test/claper/accounts/role_test.exs
Normal file
133
test/claper/accounts/role_test.exs
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
219
test/claper_web/helpers/csv_exporter_test.exs
Normal file
219
test/claper_web/helpers/csv_exporter_test.exs
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
36
with_env.sh
Executable 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[@]}"
|
||||
Reference in New Issue
Block a user