[SPRINT-1] feat: Complete RBAC, Audit and Notifications frontend modules
## ST-1.1 RBAC Frontend (5 SP) - Add RoleDetailPage for create/edit roles - Add PermissionsPage to list all permissions - Add RoleCard, RoleForm, PermissionsMatrix components - Add routes: /rbac/roles/new, /rbac/roles/:id, /rbac/permissions - Add sidebar navigation item ## ST-1.2 Audit Complete (3 SP) - Enhance AuditFilters with user filter and search - Add Recharts graphs to AuditStatsCard (activity trend, action distribution) - Add period selector (7/14/30 days) - Add most active users section ## ST-1.3 Notifications Complete (3 SP) - Add TemplatesPage for CRUD notification templates - Add TemplateCard, TemplateForm, TemplatePreview components - Add ChannelConfig component for channel toggles - Extend notifications API with template endpoints - Extend useNotifications hook with template queries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9019261f8e
commit
193b26f6f1
@ -1,13 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { AuditAction, QueryAuditLogsParams } from '@/services/api';
|
||||
import { AuditAction, QueryAuditLogsParams, User } from '@/services/api';
|
||||
import { getAuditActionLabel } from '@/hooks/useAudit';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { Filter, X, Search } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditFiltersProps {
|
||||
filters: QueryAuditLogsParams;
|
||||
onFiltersChange: (filters: QueryAuditLogsParams) => void;
|
||||
entityTypes?: string[];
|
||||
users?: Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>[];
|
||||
onSearchChange?: (search: string) => void;
|
||||
searchValue?: string;
|
||||
}
|
||||
|
||||
const AUDIT_ACTIONS: AuditAction[] = [
|
||||
@ -21,8 +24,16 @@ const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'import',
|
||||
];
|
||||
|
||||
export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: AuditFiltersProps) {
|
||||
export function AuditFilters({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
entityTypes = [],
|
||||
users = [],
|
||||
onSearchChange,
|
||||
searchValue = '',
|
||||
}: AuditFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localSearch, setLocalSearch] = useState(searchValue);
|
||||
|
||||
const activeFiltersCount = [
|
||||
filters.action,
|
||||
@ -32,6 +43,21 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
||||
filters.to_date,
|
||||
].filter(Boolean).length;
|
||||
|
||||
const getUserDisplayName = (user: Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>) => {
|
||||
const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim();
|
||||
return fullName || user.email;
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearchChange?.(localSearch);
|
||||
};
|
||||
|
||||
const handleSearchClear = () => {
|
||||
setLocalSearch('');
|
||||
onSearchChange?.('');
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof QueryAuditLogsParams, value: string | undefined) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
@ -46,41 +72,66 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filter toggle button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isOpen || activeFiltersCount > 0
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear all
|
||||
</button>
|
||||
{/* Search and Filter toggle */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Search input */}
|
||||
{onSearchChange && (
|
||||
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
placeholder="Search logs by description, entity ID..."
|
||||
className="w-full pl-10 pr-10 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||
/>
|
||||
{localSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearchClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isOpen || activeFiltersCount > 0
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{isOpen && (
|
||||
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Action filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
@ -119,6 +170,27 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* User filter */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
User
|
||||
</label>
|
||||
<select
|
||||
value={filters.user_id || ''}
|
||||
onChange={(e) => handleChange('user_id', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">All users</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{getUserDisplayName(user)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* From date filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
@ -163,6 +235,12 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
||||
onRemove={() => handleChange('entity_type', undefined)}
|
||||
/>
|
||||
)}
|
||||
{filters.user_id && (
|
||||
<FilterBadge
|
||||
label={`User: ${users.find((u) => u.id === filters.user_id)?.email || filters.user_id}`}
|
||||
onRemove={() => handleChange('user_id', undefined)}
|
||||
/>
|
||||
)}
|
||||
{filters.from_date && (
|
||||
<FilterBadge
|
||||
label={`From: ${filters.from_date}`}
|
||||
|
||||
@ -1,14 +1,37 @@
|
||||
import { AuditStats } from '@/services/api';
|
||||
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
|
||||
import { Activity, TrendingUp, Users, FileText } from 'lucide-react';
|
||||
import { TrendingUp, Users, FileText, Calendar } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
PieChart,
|
||||
Pie,
|
||||
} from 'recharts';
|
||||
|
||||
interface AuditStatsCardProps {
|
||||
stats: AuditStats;
|
||||
isLoading?: boolean;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: '#22c55e',
|
||||
update: '#3b82f6',
|
||||
delete: '#ef4444',
|
||||
read: '#6b7280',
|
||||
login: '#a855f7',
|
||||
logout: '#f97316',
|
||||
export: '#06b6d4',
|
||||
import: '#6366f1',
|
||||
};
|
||||
|
||||
export function AuditStatsCard({ stats, isLoading, days = 7 }: AuditStatsCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
@ -32,6 +55,19 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4);
|
||||
|
||||
// Prepare chart data for actions pie chart
|
||||
const actionChartData = Object.entries(stats.by_action).map(([action, count]) => ({
|
||||
name: getAuditActionLabel(action as any),
|
||||
value: count,
|
||||
color: ACTION_COLORS[action] || '#6b7280',
|
||||
}));
|
||||
|
||||
// Prepare chart data for daily trend
|
||||
const dailyChartData = stats.by_day?.map((day) => ({
|
||||
date: new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
count: day.count,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@ -39,8 +75,8 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
||||
Audit Overview
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
||||
<Activity className="w-4 h-4" />
|
||||
Last 7 days
|
||||
<Calendar className="w-4 h-4" />
|
||||
Last {days} days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -131,29 +167,144 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily trend */}
|
||||
{stats.by_day && stats.by_day.length > 0 && (
|
||||
{/* Daily Activity Chart */}
|
||||
{dailyChartData.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Daily Activity
|
||||
Daily Activity Trend
|
||||
</h4>
|
||||
<div className="flex items-end justify-between h-16 gap-1">
|
||||
{stats.by_day.map((day) => {
|
||||
const maxCount = Math.max(...stats.by_day.map((d) => d.count));
|
||||
const height = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
title={`${day.date}: ${day.count} logs`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={dailyChartData} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: '#9ca3af' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#9ca3af' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(31, 41, 55, 0.95)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelStyle={{ color: '#9ca3af' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorCount)"
|
||||
name="Logs"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Distribution */}
|
||||
{actionChartData.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Actions Distribution
|
||||
</h4>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-32 h-32">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={actionChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={45}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{actionChartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(31, 41, 55, 0.95)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-secondary-400">
|
||||
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'narrow' })}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
{actionChartData.slice(0, 6).map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-secondary-600 dark:text-secondary-400 truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-secondary-900 dark:text-secondary-100 font-medium">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Users */}
|
||||
{stats.top_users && stats.top_users.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Most Active Users
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{stats.top_users.slice(0, 5).map((user, index) => {
|
||||
const maxCount = stats.top_users[0]?.count || 1;
|
||||
const percentage = (user.count / maxCount) * 100;
|
||||
const displayName = user.user?.first_name
|
||||
? `${user.user.first_name} ${user.user?.email?.split('@')[0] || ''}`
|
||||
: user.user?.email || user.user_id.slice(0, 8);
|
||||
return (
|
||||
<div key={user.user_id} className="flex items-center gap-3">
|
||||
<span className="text-xs text-secondary-400 w-4">{index + 1}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500">
|
||||
{user.count.toLocaleString()} logs
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
186
src/components/notifications/ChannelConfig.tsx
Normal file
186
src/components/notifications/ChannelConfig.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { Mail, Smartphone, Bell, MessageSquare, MessageCircle, Settings, CheckCircle2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ChannelConfigProps {
|
||||
config: {
|
||||
email_enabled: boolean;
|
||||
push_enabled: boolean;
|
||||
in_app_enabled: boolean;
|
||||
sms_enabled: boolean;
|
||||
whatsapp_enabled: boolean;
|
||||
};
|
||||
onUpdate: (key: string, value: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Mail;
|
||||
color: string;
|
||||
configurable: boolean;
|
||||
}
|
||||
|
||||
const CHANNELS: Channel[] = [
|
||||
{
|
||||
key: 'email_enabled',
|
||||
label: 'Email',
|
||||
description: 'Send notifications via email using configured provider (SendGrid, SES, SMTP)',
|
||||
icon: Mail,
|
||||
color: 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
configurable: true,
|
||||
},
|
||||
{
|
||||
key: 'push_enabled',
|
||||
label: 'Push Notifications',
|
||||
description: 'Send browser push notifications to users with enabled permissions',
|
||||
icon: Smartphone,
|
||||
color: 'bg-purple-100 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
configurable: true,
|
||||
},
|
||||
{
|
||||
key: 'in_app_enabled',
|
||||
label: 'In-App Notifications',
|
||||
description: 'Show notifications inside the application interface',
|
||||
icon: Bell,
|
||||
color: 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400',
|
||||
configurable: true,
|
||||
},
|
||||
{
|
||||
key: 'sms_enabled',
|
||||
label: 'SMS',
|
||||
description: 'Send text messages to users (requires SMS provider configuration)',
|
||||
icon: MessageSquare,
|
||||
color: 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400',
|
||||
configurable: true,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp_enabled',
|
||||
label: 'WhatsApp',
|
||||
description: 'Send WhatsApp messages via Business API (requires configuration)',
|
||||
icon: MessageCircle,
|
||||
color: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400',
|
||||
configurable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function ChannelConfig({ config, onUpdate, isLoading }: ChannelConfigProps) {
|
||||
const [pendingKey, setPendingKey] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = (key: string) => {
|
||||
setPendingKey(key);
|
||||
onUpdate(key, !(config as Record<string, boolean>)[key]);
|
||||
setTimeout(() => setPendingKey(null), 500);
|
||||
};
|
||||
|
||||
const enabledCount = CHANNELS.filter(
|
||||
(ch) => (config as Record<string, boolean>)[ch.key]
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-blue-100 p-2 dark:bg-blue-900/20">
|
||||
<Settings className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Channel Configuration
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{enabledCount} of {CHANNELS.length} channels enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{CHANNELS.map((ch) => {
|
||||
const Icon = ch.icon;
|
||||
const isEnabled = (config as Record<string, boolean>)[ch.key];
|
||||
return (
|
||||
<div
|
||||
key={ch.key}
|
||||
className={`rounded-full p-1.5 ${
|
||||
isEnabled ? ch.color : 'bg-gray-200 text-gray-400 dark:bg-gray-700'
|
||||
}`}
|
||||
title={ch.label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel List */}
|
||||
<div className="space-y-3">
|
||||
{CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
const isEnabled = (config as Record<string, boolean>)[channel.key];
|
||||
const isPending = pendingKey === channel.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.key}
|
||||
className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
|
||||
isEnabled
|
||||
? 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`rounded-lg p-2.5 ${channel.color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{channel.label}
|
||||
</p>
|
||||
{isEnabled && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{channel.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{channel.configurable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle(channel.key)}
|
||||
disabled={isLoading || isPending}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isEnabled
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400">
|
||||
<strong>Note:</strong> Some channels require additional configuration in your provider
|
||||
settings. Make sure you have configured the appropriate API keys and credentials before
|
||||
enabling.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/components/notifications/TemplateCard.tsx
Normal file
155
src/components/notifications/TemplateCard.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { Mail, Smartphone, Bell, MessageSquare, MoreVertical, Edit2, Trash2, Eye, Power } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { NotificationTemplate } from '@/hooks/useNotifications';
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: NotificationTemplate;
|
||||
onEdit: (template: NotificationTemplate) => void;
|
||||
onDelete: (template: NotificationTemplate) => void;
|
||||
onPreview: (template: NotificationTemplate) => void;
|
||||
onToggleActive: (template: NotificationTemplate) => void;
|
||||
}
|
||||
|
||||
const channelIcons: Record<string, typeof Mail> = {
|
||||
email: Mail,
|
||||
push: Smartphone,
|
||||
in_app: Bell,
|
||||
sms: MessageSquare,
|
||||
};
|
||||
|
||||
const channelColors: Record<string, string> = {
|
||||
email: 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
push: 'bg-purple-100 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
in_app: 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400',
|
||||
sms: 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400',
|
||||
};
|
||||
|
||||
const channelLabels: Record<string, string> = {
|
||||
email: 'Email',
|
||||
push: 'Push',
|
||||
in_app: 'In-App',
|
||||
sms: 'SMS',
|
||||
};
|
||||
|
||||
export function TemplateCard({
|
||||
template,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPreview,
|
||||
onToggleActive,
|
||||
}: TemplateCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const ChannelIcon = channelIcons[template.channel] || Bell;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${channelColors[template.channel]}`}>
|
||||
<ChannelIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{template.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{template.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<MoreVertical className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 z-10 mt-1 w-40 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => {
|
||||
onPreview(template);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(template);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleActive(template);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{template.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(template);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${channelColors[template.channel]}`}>
|
||||
{channelLabels[template.channel]}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{template.category}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
template.is_active
|
||||
? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{template.variables && template.variables.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Variables: {template.variables.map((v) => `{{${v.name}}}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
442
src/components/notifications/TemplateForm.tsx
Normal file
442
src/components/notifications/TemplateForm.tsx
Normal file
@ -0,0 +1,442 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, AlertCircle } from 'lucide-react';
|
||||
import type { NotificationTemplate } from '@/hooks/useNotifications';
|
||||
import type { CreateTemplateRequest, NotificationChannel } from '@/services/api';
|
||||
|
||||
interface TemplateFormProps {
|
||||
template?: NotificationTemplate;
|
||||
onSubmit: (data: CreateTemplateRequest) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface Variable {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const CHANNELS: { value: NotificationChannel; label: string }[] = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'push', label: 'Push Notification' },
|
||||
{ value: 'in_app', label: 'In-App' },
|
||||
{ value: 'sms', label: 'SMS' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
'auth',
|
||||
'billing',
|
||||
'system',
|
||||
'marketing',
|
||||
'alerts',
|
||||
'reminders',
|
||||
'updates',
|
||||
'other',
|
||||
];
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ name: 'user_name', description: 'User full name' },
|
||||
{ name: 'user_email', description: 'User email address' },
|
||||
{ name: 'user_first_name', description: 'User first name' },
|
||||
{ name: 'tenant_name', description: 'Organization/Tenant name' },
|
||||
{ name: 'app_name', description: 'Application name' },
|
||||
{ name: 'app_url', description: 'Application URL' },
|
||||
{ name: 'action_url', description: 'Action URL for CTA' },
|
||||
{ name: 'date', description: 'Current date' },
|
||||
{ name: 'time', description: 'Current time' },
|
||||
];
|
||||
|
||||
export function TemplateForm({ template, onSubmit, onCancel, isLoading }: TemplateFormProps) {
|
||||
const [code, setCode] = useState(template?.code || '');
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
const [description, setDescription] = useState(template?.description || '');
|
||||
const [category, setCategory] = useState(template?.category || 'system');
|
||||
const [channel, setChannel] = useState<NotificationChannel>(template?.channel || 'email');
|
||||
const [subject, setSubject] = useState(template?.subject || '');
|
||||
const [body, setBody] = useState(template?.body || '');
|
||||
const [bodyHtml, setBodyHtml] = useState(template?.body_html || '');
|
||||
const [variables, setVariables] = useState<Variable[]>(
|
||||
template?.variables?.map((v) => ({
|
||||
name: v.name,
|
||||
description: v.description || '',
|
||||
required: v.required || false,
|
||||
})) || []
|
||||
);
|
||||
const [isActive, setIsActive] = useState(template?.is_active ?? true);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isEditing = !!template;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing && name && !code) {
|
||||
setCode(
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
);
|
||||
}
|
||||
}, [name, isEditing, code]);
|
||||
|
||||
const addVariable = () => {
|
||||
setVariables([...variables, { name: '', description: '', required: false }]);
|
||||
};
|
||||
|
||||
const removeVariable = (index: number) => {
|
||||
setVariables(variables.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateVariable = (index: number, field: keyof Variable, value: string | boolean) => {
|
||||
const updated = [...variables];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setVariables(updated);
|
||||
};
|
||||
|
||||
const insertVariable = (varName: string, targetField: 'subject' | 'body') => {
|
||||
const placeholder = `{{${varName}}}`;
|
||||
if (targetField === 'subject') {
|
||||
setSubject((prev) => prev + placeholder);
|
||||
} else {
|
||||
setBody((prev) => prev + placeholder);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!code.trim()) {
|
||||
newErrors.code = 'Code is required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(code)) {
|
||||
newErrors.code = 'Code must contain only lowercase letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!body.trim()) {
|
||||
newErrors.body = 'Body is required';
|
||||
}
|
||||
|
||||
if (channel === 'email' && !subject.trim()) {
|
||||
newErrors.subject = 'Subject is required for email templates';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
const data: CreateTemplateRequest = {
|
||||
code,
|
||||
name,
|
||||
category,
|
||||
channel,
|
||||
body,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
if (description.trim()) data.description = description;
|
||||
if (subject.trim()) data.subject = subject;
|
||||
if (bodyHtml.trim()) data.body_html = bodyHtml;
|
||||
if (variables.length > 0) {
|
||||
data.variables = variables
|
||||
.filter((v) => v.name.trim())
|
||||
.map((v) => ({
|
||||
name: v.name,
|
||||
description: v.description || undefined,
|
||||
required: v.required,
|
||||
}));
|
||||
}
|
||||
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
disabled={isEditing}
|
||||
placeholder="e.g., welcome_email"
|
||||
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm ${
|
||||
errors.code
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||
} ${isEditing ? 'bg-gray-100 dark:bg-gray-600' : ''}`}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Welcome Email"
|
||||
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm ${
|
||||
errors.name
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Describe when this template is used..."
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value as NotificationChannel)}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
{CHANNELS.map((ch) => (
|
||||
<option key={ch.value} value={ch.value}>
|
||||
{ch.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject (for email) */}
|
||||
{channel === 'email' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Subject
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.slice(0, 4).map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
onClick={() => insertVariable(v.name, 'subject')}
|
||||
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
title={v.description}
|
||||
>
|
||||
{`{{${v.name}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Email subject line..."
|
||||
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm ${
|
||||
errors.subject
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.subject}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Body (Plain Text)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
onClick={() => insertVariable(v.name, 'body')}
|
||||
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
title={v.description}
|
||||
>
|
||||
{`{{${v.name}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Template body content..."
|
||||
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm font-mono ${
|
||||
errors.body
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
{errors.body && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HTML Body (for email) */}
|
||||
{channel === 'email' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Body (HTML) - Optional
|
||||
</label>
|
||||
<textarea
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="<html>...</html>"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
If provided, this will be used for email clients that support HTML.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variables */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Custom Variables
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariable}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
{variables.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{variables.map((variable, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={variable.name}
|
||||
onChange={(e) => updateVariable(index, 'name', e.target.value)}
|
||||
placeholder="Variable name"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={variable.description}
|
||||
onChange={(e) => updateVariable(index, 'description', e.target.value)}
|
||||
placeholder="Description"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={variable.required}
|
||||
onChange={(e) => updateVariable(index, 'required', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVariable(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Standard variables like user_name, tenant_name are always available.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none dark:bg-gray-700" />
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{isActive ? 'Template is active' : 'Template is inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : isEditing ? 'Update Template' : 'Create Template'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
314
src/components/notifications/TemplatePreview.tsx
Normal file
314
src/components/notifications/TemplatePreview.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { X, Mail, Smartphone, Bell, MessageSquare, Code, Eye } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { NotificationTemplate } from '@/hooks/useNotifications';
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
template: NotificationTemplate;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
user_name: 'John Doe',
|
||||
user_email: 'john.doe@example.com',
|
||||
user_first_name: 'John',
|
||||
tenant_name: 'Acme Inc',
|
||||
app_name: 'Template SaaS',
|
||||
app_url: 'https://app.template-saas.com',
|
||||
action_url: 'https://app.template-saas.com/action',
|
||||
date: new Date().toLocaleDateString(),
|
||||
time: new Date().toLocaleTimeString(),
|
||||
};
|
||||
|
||||
const channelIcons: Record<string, typeof Mail> = {
|
||||
email: Mail,
|
||||
push: Smartphone,
|
||||
in_app: Bell,
|
||||
sms: MessageSquare,
|
||||
};
|
||||
|
||||
const channelLabels: Record<string, string> = {
|
||||
email: 'Email',
|
||||
push: 'Push Notification',
|
||||
in_app: 'In-App Notification',
|
||||
sms: 'SMS',
|
||||
};
|
||||
|
||||
function replaceVariables(text: string, variables: Record<string, string>): string {
|
||||
let result = text;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function TemplatePreview({ template, onClose }: TemplatePreviewProps) {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'source'>('preview');
|
||||
const [customValues, setCustomValues] = useState<Record<string, string>>({});
|
||||
|
||||
const allVariables = useMemo(() => {
|
||||
const vars = { ...SAMPLE_VALUES, ...customValues };
|
||||
if (template.variables) {
|
||||
template.variables.forEach((v) => {
|
||||
if (!vars[v.name]) {
|
||||
vars[v.name] = `[${v.name}]`;
|
||||
}
|
||||
});
|
||||
}
|
||||
return vars;
|
||||
}, [template.variables, customValues]);
|
||||
|
||||
const renderedSubject = useMemo(() => {
|
||||
return template.subject ? replaceVariables(template.subject, allVariables) : '';
|
||||
}, [template.subject, allVariables]);
|
||||
|
||||
const renderedBody = useMemo(() => {
|
||||
return replaceVariables(template.body, allVariables);
|
||||
}, [template.body, allVariables]);
|
||||
|
||||
const ChannelIcon = channelIcons[template.channel] || Bell;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-3xl overflow-hidden rounded-lg bg-white dark:bg-gray-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-gray-100 p-2 dark:bg-gray-700">
|
||||
<ChannelIcon className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{template.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{channelLabels[template.channel]} Template
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 text-sm ${
|
||||
viewMode === 'preview'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('source')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 text-sm ${
|
||||
viewMode === 'source'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
||||
{viewMode === 'preview' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Email Preview */}
|
||||
{template.channel === 'email' && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-16 text-gray-500 dark:text-gray-400">From:</span>
|
||||
<span className="text-gray-900 dark:text-white">noreply@template-saas.com</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-16 text-gray-500 dark:text-gray-400">To:</span>
|
||||
<span className="text-gray-900 dark:text-white">{allVariables.user_email}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-16 text-gray-500 dark:text-gray-400">Subject:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{renderedSubject}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{template.body_html ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: replaceVariables(template.body_html, allVariables),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">
|
||||
{renderedBody}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push/In-App Preview */}
|
||||
{(template.channel === 'push' || template.channel === 'in_app') && (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/20">
|
||||
<Bell className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{template.subject || 'Template SaaS'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{renderedBody}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-400">Just now</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMS Preview */}
|
||||
{template.channel === 'sms' && (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="rounded-2xl bg-gray-100 p-4 dark:bg-gray-900">
|
||||
<div className="rounded-2xl bg-green-500 px-4 py-2 text-white">
|
||||
<p className="text-sm">{renderedBody}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-xs text-gray-400">
|
||||
Delivered
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variables Section */}
|
||||
{template.variables && template.variables.length > 0 && (
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Custom Variables
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{template.variables.map((v) => (
|
||||
<div key={v.name}>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{`{{${v.name}}}`}
|
||||
{v.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customValues[v.name] || ''}
|
||||
onChange={(e) =>
|
||||
setCustomValues({ ...customValues, [v.name]: e.target.value })
|
||||
}
|
||||
placeholder={v.description || v.name}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Source View */}
|
||||
{template.subject && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Subject
|
||||
</label>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-100 p-3 text-sm dark:bg-gray-900">
|
||||
{template.subject}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Body (Plain Text)
|
||||
</label>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-100 p-3 text-sm dark:bg-gray-900">
|
||||
{template.body}
|
||||
</pre>
|
||||
</div>
|
||||
{template.body_html && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Body (HTML)
|
||||
</label>
|
||||
<pre className="max-h-48 overflow-auto rounded-lg bg-gray-100 p-3 text-xs dark:bg-gray-900">
|
||||
{template.body_html}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{template.variables && template.variables.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Variables
|
||||
</label>
|
||||
<div className="rounded-lg bg-gray-100 p-3 dark:bg-gray-900">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400">
|
||||
<th className="pb-2">Name</th>
|
||||
<th className="pb-2">Description</th>
|
||||
<th className="pb-2">Required</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{template.variables.map((v) => (
|
||||
<tr key={v.name} className="text-gray-700 dark:text-gray-300">
|
||||
<td className="py-1 font-mono">{`{{${v.name}}}`}</td>
|
||||
<td className="py-1">{v.description || '-'}</td>
|
||||
<td className="py-1">{v.required ? 'Yes' : 'No'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-200 px-6 py-4 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Code: </span>
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
|
||||
{template.code}
|
||||
</code>
|
||||
<span className="mx-2">|</span>
|
||||
<span>Category: {template.category}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span className={template.is_active ? 'text-green-600' : 'text-gray-500'}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,3 +3,7 @@ export { NotificationDrawer } from './NotificationDrawer';
|
||||
export { NotificationItem } from './NotificationItem';
|
||||
export { PushPermissionBanner } from './PushPermissionBanner';
|
||||
export { DevicesManager } from './DevicesManager';
|
||||
export { TemplateCard } from './TemplateCard';
|
||||
export { TemplateForm } from './TemplateForm';
|
||||
export { TemplatePreview } from './TemplatePreview';
|
||||
export { ChannelConfig } from './ChannelConfig';
|
||||
|
||||
192
src/components/rbac/PermissionsMatrix.tsx
Normal file
192
src/components/rbac/PermissionsMatrix.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Check, Minus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { Permission } from '@/services/api';
|
||||
import { getPermissionCategoryLabel, getPermissionActionLabel } from '@/hooks/useRbac';
|
||||
|
||||
interface PermissionsMatrixProps {
|
||||
permissions: Permission[];
|
||||
selectedPermissions: string[];
|
||||
onTogglePermission: (permissionSlug: string) => void;
|
||||
onToggleCategory: (category: string, permissionSlugs: string[]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PermissionsMatrix({
|
||||
permissions,
|
||||
selectedPermissions,
|
||||
onTogglePermission,
|
||||
onToggleCategory,
|
||||
readOnly = false,
|
||||
}: PermissionsMatrixProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
const permissionsByCategory = useMemo(() => {
|
||||
const grouped: Record<string, Permission[]> = {};
|
||||
permissions.forEach((perm) => {
|
||||
if (!grouped[perm.category]) {
|
||||
grouped[perm.category] = [];
|
||||
}
|
||||
grouped[perm.category].push(perm);
|
||||
});
|
||||
return grouped;
|
||||
}, [permissions]);
|
||||
|
||||
const categories = useMemo(() => Object.keys(permissionsByCategory).sort(), [permissionsByCategory]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryStatus = (category: string): 'all' | 'some' | 'none' => {
|
||||
const categoryPerms = permissionsByCategory[category];
|
||||
const selectedCount = categoryPerms.filter((p) => selectedPermissions.includes(p.slug)).length;
|
||||
if (selectedCount === 0) return 'none';
|
||||
if (selectedCount === categoryPerms.length) return 'all';
|
||||
return 'some';
|
||||
};
|
||||
|
||||
const handleCategoryCheckboxClick = (category: string) => {
|
||||
if (readOnly) return;
|
||||
const categoryPerms = permissionsByCategory[category];
|
||||
const slugs = categoryPerms.map((p) => p.slug);
|
||||
onToggleCategory(category, slugs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{categories.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
const categoryPerms = permissionsByCategory[category];
|
||||
const status = getCategoryStatus(category);
|
||||
const selectedCount = categoryPerms.filter((p) =>
|
||||
selectedPermissions.includes(p.slug)
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 text-gray-400"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCategoryCheckboxClick(category);
|
||||
}}
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border ${
|
||||
status === 'all'
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: status === 'some'
|
||||
? 'border-blue-600 bg-blue-100 text-blue-600 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'
|
||||
}`}
|
||||
aria-label={`Toggle all ${category} permissions`}
|
||||
>
|
||||
{status === 'all' && <Check className="h-3.5 w-3.5" />}
|
||||
{status === 'some' && <Minus className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{getPermissionCategoryLabel(category)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedCount} / {categoryPerms.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permissions List */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categoryPerms.map((perm) => {
|
||||
const isSelected = selectedPermissions.includes(perm.slug);
|
||||
const [, action] = perm.slug.split(':');
|
||||
|
||||
return (
|
||||
<label
|
||||
key={perm.id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
|
||||
readOnly
|
||||
? ''
|
||||
: 'hover:bg-white dark:hover:bg-gray-700'
|
||||
} ${isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => !readOnly && onTogglePermission(perm.slug)}
|
||||
disabled={readOnly}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed dark:border-gray-600"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{perm.name}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
action === 'delete'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
: action === 'write'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{getPermissionActionLabel(action)}
|
||||
</span>
|
||||
</div>
|
||||
{perm.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{perm.description}
|
||||
</p>
|
||||
)}
|
||||
<code className="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
||||
{perm.slug}
|
||||
</code>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No permissions available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/rbac/RoleCard.tsx
Normal file
121
src/components/rbac/RoleCard.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { Shield, Lock, Users, Edit, Trash2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Role } from '@/services/api';
|
||||
|
||||
interface RoleCardProps {
|
||||
role: Role;
|
||||
onDelete?: (id: string) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export function RoleCard({ role, onDelete, isDeleting }: RoleCardProps) {
|
||||
const handleDelete = () => {
|
||||
if (role.is_system) {
|
||||
return;
|
||||
}
|
||||
if (window.confirm(`Are you sure you want to delete the role "${role.name}"?`)) {
|
||||
onDelete?.(role.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
role.is_system
|
||||
? 'bg-purple-100 dark:bg-purple-900/20'
|
||||
: 'bg-blue-100 dark:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<Shield
|
||||
className={`h-5 w-5 ${
|
||||
role.is_system
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{role.name}</h3>
|
||||
{role.is_system && (
|
||||
<span>
|
||||
<Lock className="h-4 w-4 text-gray-400" />
|
||||
</span>
|
||||
)}
|
||||
{role.is_default && (
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{role.description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={`/dashboard/rbac/roles/${role.id}`}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
title="View details"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Link>
|
||||
{!role.is_system && (
|
||||
<>
|
||||
<Link
|
||||
to={`/dashboard/rbac/roles/${role.id}/edit`}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
title="Edit role"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title="Delete role"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{role.permissions?.length || 0}
|
||||
</span>
|
||||
<span>permissions</span>
|
||||
</div>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<div>
|
||||
<span className="font-mono text-xs">{role.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{role.permissions && role.permissions.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{role.permissions.slice(0, 5).map((perm) => (
|
||||
<span
|
||||
key={perm.id}
|
||||
className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{perm.slug}
|
||||
</span>
|
||||
))}
|
||||
{role.permissions.length > 5 && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
+{role.permissions.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/rbac/RoleForm.tsx
Normal file
208
src/components/rbac/RoleForm.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import type { Role, Permission, CreateRoleRequest, UpdateRoleRequest } from '@/services/api';
|
||||
import { PermissionsMatrix } from './PermissionsMatrix';
|
||||
|
||||
interface RoleFormProps {
|
||||
role?: Role | null;
|
||||
permissions: Permission[];
|
||||
onSubmit: (data: CreateRoleRequest | UpdateRoleRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function RoleForm({ role, permissions, onSubmit, isLoading }: RoleFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!role;
|
||||
|
||||
const [name, setName] = useState(role?.name || '');
|
||||
const [slug, setSlug] = useState(role?.slug || '');
|
||||
const [description, setDescription] = useState(role?.description || '');
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(
|
||||
role?.permissions?.map((p) => p.slug) || []
|
||||
);
|
||||
const [autoSlug, setAutoSlug] = useState(!isEditing);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSlug && !isEditing) {
|
||||
const generatedSlug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
setSlug(generatedSlug);
|
||||
}
|
||||
}, [name, autoSlug, isEditing]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data: CreateRoleRequest | UpdateRoleRequest = isEditing
|
||||
? {
|
||||
name,
|
||||
description: description || undefined,
|
||||
permissions: selectedPermissions,
|
||||
}
|
||||
: {
|
||||
name,
|
||||
slug: slug || undefined,
|
||||
description: description || undefined,
|
||||
permissions: selectedPermissions,
|
||||
};
|
||||
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionSlug: string) => {
|
||||
setSelectedPermissions((prev) =>
|
||||
prev.includes(permissionSlug)
|
||||
? prev.filter((p) => p !== permissionSlug)
|
||||
: [...prev, permissionSlug]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCategoryToggle = (category: string, permissionSlugs: string[]) => {
|
||||
void category; // Parameter required by PermissionsMatrix interface
|
||||
const allSelected = permissionSlugs.every((p) => selectedPermissions.includes(p));
|
||||
if (allSelected) {
|
||||
setSelectedPermissions((prev) => prev.filter((p) => !permissionSlugs.includes(p)));
|
||||
} else {
|
||||
setSelectedPermissions((prev) => [...new Set([...prev, ...permissionSlugs])]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allSlugs = permissions.map((p) => p.slug);
|
||||
setSelectedPermissions(allSlugs);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedPermissions([]);
|
||||
};
|
||||
|
||||
const isValid = name.trim() && (isEditing || slug.trim());
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Role Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Sales Manager"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Slug {!isEditing && '*'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setAutoSlug(false);
|
||||
}}
|
||||
placeholder="e.g., sales-manager"
|
||||
disabled={isEditing}
|
||||
className={clsx(
|
||||
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
isEditing && 'cursor-not-allowed bg-gray-100 dark:bg-gray-800'
|
||||
)}
|
||||
required={!isEditing}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">
|
||||
{autoSlug ? 'Auto' : 'Manual'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<p className="mt-1 text-xs text-gray-500">Slug cannot be changed after creation</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this role is for..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Permissions</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedPermissions.length} of {permissions.length} selected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAll}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionsMatrix
|
||||
permissions={permissions}
|
||||
selectedPermissions={selectedPermissions}
|
||||
onTogglePermission={handlePermissionToggle}
|
||||
onToggleCategory={handleCategoryToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
||||
className="rounded-lg px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-2 font-medium',
|
||||
isValid && !isLoading
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'cursor-not-allowed bg-gray-200 text-gray-400'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Saving...' : isEditing ? 'Update Role' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
3
src/components/rbac/index.ts
Normal file
3
src/components/rbac/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { RoleCard } from './RoleCard';
|
||||
export { RoleForm } from './RoleForm';
|
||||
export { PermissionsMatrix } from './PermissionsMatrix';
|
||||
@ -64,3 +64,26 @@ export {
|
||||
useSchemePerformance,
|
||||
} from './useCommissions';
|
||||
export * from './useRbac';
|
||||
// Notification hooks - prefer useNotifications.ts over useData.ts
|
||||
export {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkAsRead,
|
||||
useMarkAllAsRead,
|
||||
useNotificationPreferences,
|
||||
useUpdatePreferences,
|
||||
useNotificationTemplates,
|
||||
useNotificationTemplate,
|
||||
useCreateTemplate,
|
||||
useUpdateTemplate,
|
||||
useDeleteTemplate,
|
||||
useToggleTemplateActive,
|
||||
useSendFromTemplate,
|
||||
getNotificationTypeColor,
|
||||
getNotificationChannelLabel,
|
||||
formatNotificationTime,
|
||||
type Notification,
|
||||
type NotificationPreferences,
|
||||
type PaginatedNotifications,
|
||||
type NotificationTemplate,
|
||||
} from './useNotifications';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationsApi } from '@/services/api';
|
||||
import { notificationsApi, NotificationTemplate, CreateTemplateRequest, UpdateTemplateRequest } from '@/services/api';
|
||||
|
||||
// Query keys
|
||||
const notificationKeys = {
|
||||
@ -8,6 +8,8 @@ const notificationKeys = {
|
||||
[...notificationKeys.all, 'list', params] as const,
|
||||
unreadCount: () => [...notificationKeys.all, 'unread-count'] as const,
|
||||
preferences: () => [...notificationKeys.all, 'preferences'] as const,
|
||||
templates: () => [...notificationKeys.all, 'templates'] as const,
|
||||
template: (code: string) => [...notificationKeys.all, 'template', code] as const,
|
||||
};
|
||||
|
||||
// Types
|
||||
@ -139,3 +141,80 @@ export function formatNotificationTime(dateString: string): string {
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ==================== Template Queries ====================
|
||||
|
||||
export function useNotificationTemplates() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.templates(),
|
||||
queryFn: () => notificationsApi.listTemplates(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationTemplate(code: string) {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.template(code),
|
||||
queryFn: () => notificationsApi.getTemplate(code),
|
||||
enabled: !!code,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Template Mutations ====================
|
||||
|
||||
export function useCreateTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTemplateRequest) => notificationsApi.createTemplate(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ code, data }: { code: string; data: UpdateTemplateRequest }) =>
|
||||
notificationsApi.updateTemplate(code, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.template(variables.code) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (code: string) => notificationsApi.deleteTemplate(code),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateActive() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ code, is_active }: { code: string; is_active: boolean }) =>
|
||||
notificationsApi.updateTemplate(code, { is_active }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.template(variables.code) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendFromTemplate() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { template_code: string; user_id: string; variables?: Record<string, string> }) =>
|
||||
notificationsApi.sendFromTemplate(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export template type
|
||||
export type { NotificationTemplate };
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
MessageSquare,
|
||||
Target,
|
||||
Network,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
@ -34,6 +35,7 @@ const navigation = [
|
||||
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
|
||||
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
|
||||
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },
|
||||
{ name: 'Roles & Permissions', href: '/dashboard/rbac/roles', icon: Key },
|
||||
{ name: 'Audit Logs', href: '/dashboard/audit', icon: ClipboardList },
|
||||
{ name: 'Users', href: '/dashboard/users', icon: Users },
|
||||
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { QueryAuditLogsParams } from '@/services/api';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { QueryAuditLogsParams, User } from '@/services/api';
|
||||
import {
|
||||
useAuditLogs,
|
||||
useAuditStats,
|
||||
@ -19,13 +19,17 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'logs' | 'activity';
|
||||
type StatsPeriod = 7 | 14 | 30;
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [statsPeriod, setStatsPeriod] = useState<StatsPeriod>(7);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filters, setFilters] = useState<QueryAuditLogsParams>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
@ -33,7 +37,7 @@ export function AuditLogsPage() {
|
||||
|
||||
// Queries
|
||||
const { data: auditLogsData, isLoading: logsLoading } = useAuditLogs(filters);
|
||||
const { data: auditStats, isLoading: statsLoading } = useAuditStats(7);
|
||||
const { data: auditStats, isLoading: statsLoading } = useAuditStats(statsPeriod);
|
||||
const { data: activitiesData, isLoading: activitiesLoading } = useActivityLogs({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
@ -46,10 +50,33 @@ export function AuditLogsPage() {
|
||||
return Object.keys(auditStats.by_entity_type);
|
||||
}, [auditStats]);
|
||||
|
||||
// Extract unique users from audit logs for user filter
|
||||
const uniqueUsers = useMemo(() => {
|
||||
if (!auditLogsData?.items) return [];
|
||||
const userMap = new Map<string, Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>>();
|
||||
auditLogsData.items.forEach((log) => {
|
||||
if (log.user && log.user.id && !userMap.has(log.user.id)) {
|
||||
userMap.set(log.user.id, {
|
||||
id: log.user.id,
|
||||
email: log.user.email,
|
||||
first_name: log.user.first_name,
|
||||
last_name: log.user.last_name,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(userMap.values());
|
||||
}, [auditLogsData]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters((prev) => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
setSearchQuery(search);
|
||||
// The search would be handled server-side if backend supports it
|
||||
// For now we could filter client-side or integrate with backend search endpoint
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@ -65,8 +92,28 @@ export function AuditLogsPage() {
|
||||
<ExportButton reportType="audit" />
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} />
|
||||
{/* Stats Period Selector + Stats Card */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Calendar className="w-4 h-4 text-secondary-400" />
|
||||
<span className="text-sm text-secondary-500">Period:</span>
|
||||
{([7, 14, 30] as StatsPeriod[]).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
onClick={() => setStatsPeriod(period)}
|
||||
className={clsx(
|
||||
'px-3 py-1 text-sm rounded-lg transition-colors',
|
||||
statsPeriod === period
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-400 dark:hover:bg-secondary-600'
|
||||
)}
|
||||
>
|
||||
{period}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} days={statsPeriod} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
@ -114,6 +161,9 @@ export function AuditLogsPage() {
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
entityTypes={entityTypes}
|
||||
users={uniqueUsers}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={searchQuery}
|
||||
/>
|
||||
|
||||
{/* Logs list */}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Bell, CheckCheck, ExternalLink, Settings, Mail, Smartphone, MessageSquare } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Bell, CheckCheck, ExternalLink, Settings, Mail, Smartphone, MessageSquare, FileText } from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
@ -169,23 +170,32 @@ export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Stay updated with your latest notifications
|
||||
</p>
|
||||
</div>
|
||||
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/dashboard/notifications/templates"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
<FileText className="h-4 w-4" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
308
src/pages/dashboard/notifications/TemplatesPage.tsx
Normal file
308
src/pages/dashboard/notifications/TemplatesPage.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, ArrowLeft, FileText, AlertTriangle, Search, Filter } from 'lucide-react';
|
||||
import {
|
||||
useNotificationTemplates,
|
||||
useCreateTemplate,
|
||||
useUpdateTemplate,
|
||||
useDeleteTemplate,
|
||||
useToggleTemplateActive,
|
||||
type NotificationTemplate,
|
||||
} from '@/hooks/useNotifications';
|
||||
import type { CreateTemplateRequest } from '@/services/api';
|
||||
import { TemplateCard } from '@/components/notifications/TemplateCard';
|
||||
import { TemplateForm } from '@/components/notifications/TemplateForm';
|
||||
import { TemplatePreview } from '@/components/notifications/TemplatePreview';
|
||||
|
||||
type ViewMode = 'list' | 'create' | 'edit';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<NotificationTemplate | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterChannel, setFilterChannel] = useState<string>('all');
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all');
|
||||
|
||||
// Queries
|
||||
const { data: templates = [], isLoading } = useNotificationTemplates();
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateTemplate();
|
||||
const updateMutation = useUpdateTemplate();
|
||||
const deleteMutation = useDeleteTemplate();
|
||||
const toggleMutation = useToggleTemplateActive();
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(t.description && t.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesChannel = filterChannel === 'all' || t.channel === filterChannel;
|
||||
const matchesCategory = filterCategory === 'all' || t.category === filterCategory;
|
||||
return matchesSearch && matchesChannel && matchesCategory;
|
||||
});
|
||||
|
||||
// Get unique categories from templates
|
||||
const categories = [...new Set(templates.map((t) => t.category))];
|
||||
|
||||
const handleCreate = async (data: CreateTemplateRequest) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
setViewMode('list');
|
||||
} catch (error) {
|
||||
console.error('Failed to create template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: CreateTemplateRequest) => {
|
||||
if (!selectedTemplate) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ code: selectedTemplate.code, data });
|
||||
setViewMode('list');
|
||||
setSelectedTemplate(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.code);
|
||||
setDeleteConfirm(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (template: NotificationTemplate) => {
|
||||
try {
|
||||
await toggleMutation.mutateAsync({
|
||||
code: template.code,
|
||||
is_active: !template.is_active,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// List View
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Notification Templates
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Manage notification templates for different channels
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search templates..."
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="appearance-none rounded-lg border border-gray-300 py-2 pl-10 pr-8 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
<option value="all">All Channels</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="in_app">In-App</option>
|
||||
<option value="sms">SMS</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-white py-12 dark:border-gray-700 dark:bg-gray-800">
|
||||
<FileText className="h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{templates.length === 0 ? 'No templates yet' : 'No matching templates'}
|
||||
</h3>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{templates.length === 0
|
||||
? 'Create your first notification template to get started.'
|
||||
: 'Try adjusting your search or filters.'}
|
||||
</p>
|
||||
{templates.length === 0 && (
|
||||
<button
|
||||
onClick={() => setViewMode('create')}
|
||||
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onEdit={(t) => {
|
||||
setSelectedTemplate(t);
|
||||
setViewMode('edit');
|
||||
}}
|
||||
onDelete={setDeleteConfirm}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleActive={handleToggleActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-full bg-red-100 p-2 dark:bg-red-900/20">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Delete Template
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to delete "{deleteConfirm.name}"? This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewTemplate && (
|
||||
<TemplatePreview
|
||||
template={previewTemplate}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create View
|
||||
if (viewMode === 'create') {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Create Template
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<TemplateForm
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setViewMode('list')}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit View
|
||||
if (viewMode === 'edit' && selectedTemplate) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Template
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
<TemplateForm
|
||||
template={selectedTemplate}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => {
|
||||
setViewMode('list');
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export { default as NotificationsPage } from './NotificationsPage';
|
||||
export { default as TemplatesPage } from './TemplatesPage';
|
||||
|
||||
212
src/pages/dashboard/rbac/PermissionsPage.tsx
Normal file
212
src/pages/dashboard/rbac/PermissionsPage.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search, Key, Shield, Filter } from 'lucide-react';
|
||||
import { usePermissions, getPermissionCategoryLabel, getPermissionActionLabel } from '@/hooks/useRbac';
|
||||
|
||||
export default function PermissionsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
|
||||
const { data: permissions, isLoading, error } = usePermissions();
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!permissions) return [];
|
||||
const cats = new Set(permissions.map((p) => p.category));
|
||||
return Array.from(cats).sort();
|
||||
}, [permissions]);
|
||||
|
||||
const filteredPermissions = useMemo(() => {
|
||||
if (!permissions) return [];
|
||||
return permissions.filter((perm) => {
|
||||
const matchesSearch =
|
||||
searchTerm === '' ||
|
||||
perm.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
perm.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(perm.description?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||
|
||||
const matchesCategory =
|
||||
selectedCategory === 'all' || perm.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [permissions, searchTerm, selectedCategory]);
|
||||
|
||||
const permissionsByCategory = useMemo(() => {
|
||||
const grouped: Record<string, typeof filteredPermissions> = {};
|
||||
filteredPermissions.forEach((perm) => {
|
||||
if (!grouped[perm.category]) {
|
||||
grouped[perm.category] = [];
|
||||
}
|
||||
grouped[perm.category].push(perm);
|
||||
});
|
||||
return grouped;
|
||||
}, [filteredPermissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-red-600">Error loading permissions</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Permissions</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
View all available permissions in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/20">
|
||||
<Key className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{permissions?.length || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total Permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/20">
|
||||
<Shield className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{categories.length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Categories</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-100 p-2 dark:bg-green-900/20">
|
||||
<Filter className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{filteredPermissions.length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Filtered Results</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search permissions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{getPermissionCategoryLabel(cat)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Permissions List */}
|
||||
<div className="space-y-6">
|
||||
{Object.entries(permissionsByCategory)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([category, perms]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{getPermissionCategoryLabel(category)}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{perms.length} permissions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{perms.map((perm) => {
|
||||
const [, action] = perm.slug.split(':');
|
||||
return (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="flex items-start justify-between px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{perm.name}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
action === 'delete'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
: action === 'write'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
: action === 'assign'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{getPermissionActionLabel(action)}
|
||||
</span>
|
||||
</div>
|
||||
{perm.description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{perm.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<code className="ml-4 flex-shrink-0 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{perm.slug}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPermissions.length === 0 && (
|
||||
<div className="py-10 text-center">
|
||||
<Key className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p className="mt-4 text-gray-500 dark:text-gray-400">
|
||||
No permissions found matching your criteria
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/pages/dashboard/rbac/RoleDetailPage.tsx
Normal file
100
src/pages/dashboard/rbac/RoleDetailPage.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useRole, useCreateRole, useUpdateRole, usePermissions } from '@/hooks/useRbac';
|
||||
import { RoleForm } from '@/components/rbac';
|
||||
import type { CreateRoleRequest, UpdateRoleRequest } from '@/services/api';
|
||||
|
||||
export default function RoleDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isNew = id === 'new';
|
||||
const isEditMode = searchParams.get('mode') === 'edit' || !id || isNew;
|
||||
|
||||
const { data: role, isLoading: roleLoading, error: roleError } = useRole(isNew ? '' : id || '');
|
||||
const { data: permissions, isLoading: permissionsLoading } = usePermissions();
|
||||
const createMutation = useCreateRole();
|
||||
const updateMutation = useUpdateRole();
|
||||
|
||||
const isLoading = roleLoading || permissionsLoading;
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleSubmit = async (data: CreateRoleRequest | UpdateRoleRequest) => {
|
||||
if (isNew) {
|
||||
await createMutation.mutateAsync(data as CreateRoleRequest);
|
||||
navigate('/dashboard/rbac/roles');
|
||||
} else if (id) {
|
||||
await updateMutation.mutateAsync({ id, data: data as UpdateRoleRequest });
|
||||
navigate('/dashboard/rbac/roles');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNew && roleError) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-red-600">Error loading role</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
||||
className="mt-4 text-blue-600 hover:underline"
|
||||
>
|
||||
Back to roles
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNew && role?.is_system && isEditMode) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-amber-600">System roles cannot be edited</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
||||
className="mt-4 text-blue-600 hover:underline"
|
||||
>
|
||||
Back to roles
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/rbac/roles')}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{isNew ? 'Create New Role' : `Edit Role: ${role?.name}`}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{isNew
|
||||
? 'Create a new role with specific permissions'
|
||||
: 'Modify role settings and permissions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<RoleForm
|
||||
role={isNew ? null : role}
|
||||
permissions={permissions || []}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1 +1,3 @@
|
||||
export { default as RolesPage } from './RolesPage';
|
||||
export { default as RoleDetailPage } from './RoleDetailPage';
|
||||
export { default as PermissionsPage } from './PermissionsPage';
|
||||
|
||||
@ -65,9 +65,12 @@ const MLMMyEarningsPage = lazy(() => import('@/pages/dashboard/mlm').then(m => (
|
||||
|
||||
// Lazy loaded pages - RBAC
|
||||
const RolesPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RolesPage })));
|
||||
const RoleDetailPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RoleDetailPage })));
|
||||
const PermissionsPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.PermissionsPage })));
|
||||
|
||||
// Lazy loaded pages - Notifications
|
||||
const NotificationsPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.NotificationsPage })));
|
||||
const NotificationTemplatesPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.TemplatesPage })));
|
||||
|
||||
// Lazy loaded pages - Admin
|
||||
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
|
||||
@ -206,9 +209,14 @@ export function AppRouter() {
|
||||
|
||||
{/* RBAC routes */}
|
||||
<Route path="rbac/roles" element={<SuspensePage><RolesPage /></SuspensePage>} />
|
||||
<Route path="rbac/roles/new" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
|
||||
<Route path="rbac/roles/:id" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
|
||||
<Route path="rbac/roles/:id/edit" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
|
||||
<Route path="rbac/permissions" element={<SuspensePage><PermissionsPage /></SuspensePage>} />
|
||||
|
||||
{/* Notifications routes */}
|
||||
<Route path="notifications" element={<SuspensePage><NotificationsPage /></SuspensePage>} />
|
||||
<Route path="notifications/templates" element={<SuspensePage><NotificationTemplatesPage /></SuspensePage>} />
|
||||
</Route>
|
||||
|
||||
{/* Superadmin routes */}
|
||||
|
||||
@ -273,6 +273,60 @@ export const stripeApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Notification Types
|
||||
export type NotificationChannel = 'in_app' | 'email' | 'push' | 'sms';
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
channel: NotificationChannel;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
body_html: string | null;
|
||||
variables: { name: string; description?: string; required?: boolean }[] | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
channel: NotificationChannel;
|
||||
subject?: string;
|
||||
body: string;
|
||||
body_html?: string;
|
||||
variables?: { name: string; description?: string; required?: boolean }[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
channel?: NotificationChannel;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
body_html?: string;
|
||||
variables?: { name: string; description?: string; required?: boolean }[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelConfig {
|
||||
email_enabled: boolean;
|
||||
push_enabled: boolean;
|
||||
in_app_enabled: boolean;
|
||||
sms_enabled: boolean;
|
||||
whatsapp_enabled: boolean;
|
||||
email_provider?: string;
|
||||
push_provider?: string;
|
||||
}
|
||||
|
||||
// Notifications API
|
||||
export const notificationsApi = {
|
||||
list: async (params?: { page?: number; limit?: number }) => {
|
||||
@ -295,6 +349,10 @@ export const notificationsApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
await api.delete(`/notifications/${id}`);
|
||||
},
|
||||
|
||||
getPreferences: async () => {
|
||||
const response = await api.get('/notifications/preferences');
|
||||
return response.data;
|
||||
@ -304,6 +362,54 @@ export const notificationsApi = {
|
||||
const response = await api.patch('/notifications/preferences', preferences);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Templates
|
||||
listTemplates: async (): Promise<NotificationTemplate[]> => {
|
||||
const response = await api.get<NotificationTemplate[]>('/notifications/templates');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTemplate: async (code: string): Promise<NotificationTemplate> => {
|
||||
const response = await api.get<NotificationTemplate>(`/notifications/templates/${code}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTemplate: async (data: CreateTemplateRequest): Promise<NotificationTemplate> => {
|
||||
const response = await api.post<NotificationTemplate>('/notifications/templates', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTemplate: async (code: string, data: UpdateTemplateRequest): Promise<NotificationTemplate> => {
|
||||
const response = await api.patch<NotificationTemplate>(`/notifications/templates/${code}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTemplate: async (code: string): Promise<void> => {
|
||||
await api.delete(`/notifications/templates/${code}`);
|
||||
},
|
||||
|
||||
// Admin - Send notifications
|
||||
sendNotification: async (data: {
|
||||
user_id: string;
|
||||
type: string;
|
||||
channel: NotificationChannel;
|
||||
title: string;
|
||||
body: string;
|
||||
action_url?: string;
|
||||
action_label?: string;
|
||||
}) => {
|
||||
const response = await api.post('/notifications', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendFromTemplate: async (data: {
|
||||
template_code: string;
|
||||
user_id: string;
|
||||
variables?: Record<string, string>;
|
||||
}) => {
|
||||
const response = await api.post('/notifications/template', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Health API
|
||||
|
||||
Loading…
Reference in New Issue
Block a user