[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 { useState } from 'react';
|
||||||
import { AuditAction, QueryAuditLogsParams } from '@/services/api';
|
import { AuditAction, QueryAuditLogsParams, User } from '@/services/api';
|
||||||
import { getAuditActionLabel } from '@/hooks/useAudit';
|
import { getAuditActionLabel } from '@/hooks/useAudit';
|
||||||
import { Filter, X } from 'lucide-react';
|
import { Filter, X, Search } from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
interface AuditFiltersProps {
|
interface AuditFiltersProps {
|
||||||
filters: QueryAuditLogsParams;
|
filters: QueryAuditLogsParams;
|
||||||
onFiltersChange: (filters: QueryAuditLogsParams) => void;
|
onFiltersChange: (filters: QueryAuditLogsParams) => void;
|
||||||
entityTypes?: string[];
|
entityTypes?: string[];
|
||||||
|
users?: Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>[];
|
||||||
|
onSearchChange?: (search: string) => void;
|
||||||
|
searchValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIT_ACTIONS: AuditAction[] = [
|
const AUDIT_ACTIONS: AuditAction[] = [
|
||||||
@ -21,8 +24,16 @@ const AUDIT_ACTIONS: AuditAction[] = [
|
|||||||
'import',
|
'import',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: AuditFiltersProps) {
|
export function AuditFilters({
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
entityTypes = [],
|
||||||
|
users = [],
|
||||||
|
onSearchChange,
|
||||||
|
searchValue = '',
|
||||||
|
}: AuditFiltersProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [localSearch, setLocalSearch] = useState(searchValue);
|
||||||
|
|
||||||
const activeFiltersCount = [
|
const activeFiltersCount = [
|
||||||
filters.action,
|
filters.action,
|
||||||
@ -32,6 +43,21 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
|||||||
filters.to_date,
|
filters.to_date,
|
||||||
].filter(Boolean).length;
|
].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) => {
|
const handleChange = (key: keyof QueryAuditLogsParams, value: string | undefined) => {
|
||||||
onFiltersChange({
|
onFiltersChange({
|
||||||
...filters,
|
...filters,
|
||||||
@ -46,8 +72,32 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Filter toggle button */}
|
{/* Search and Filter toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -76,11 +126,12 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filter panel */}
|
{/* Filter panel */}
|
||||||
{isOpen && (
|
{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="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 */}
|
{/* Action filter */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* From date filter */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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)}
|
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 && (
|
{filters.from_date && (
|
||||||
<FilterBadge
|
<FilterBadge
|
||||||
label={`From: ${filters.from_date}`}
|
label={`From: ${filters.from_date}`}
|
||||||
|
|||||||
@ -1,14 +1,37 @@
|
|||||||
import { AuditStats } from '@/services/api';
|
import { AuditStats } from '@/services/api';
|
||||||
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
|
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 clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
interface AuditStatsCardProps {
|
interface AuditStatsCardProps {
|
||||||
stats: AuditStats;
|
stats: AuditStats;
|
||||||
isLoading?: boolean;
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
<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])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 4);
|
.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 (
|
return (
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
<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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@ -39,8 +75,8 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
|||||||
Audit Overview
|
Audit Overview
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
||||||
<Activity className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
Last 7 days
|
Last {days} days
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -131,29 +167,144 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily trend */}
|
{/* Daily Activity Chart */}
|
||||||
{stats.by_day && stats.by_day.length > 0 && (
|
{dailyChartData.length > 0 && (
|
||||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
<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">
|
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
|
||||||
Daily Activity
|
Daily Activity Trend
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-end justify-between h-16 gap-1">
|
<div className="h-48">
|
||||||
{stats.by_day.map((day) => {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
const maxCount = Math.max(...stats.by_day.map((d) => d.count));
|
<AreaChart data={dailyChartData} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||||
const height = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
|
<defs>
|
||||||
return (
|
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||||
<div
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||||
key={day.date}
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
className="flex-1 flex flex-col items-center gap-1"
|
</linearGradient>
|
||||||
title={`${day.date}: ${day.count} logs`}
|
</defs>
|
||||||
>
|
<XAxis
|
||||||
<div
|
dataKey="date"
|
||||||
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t"
|
tick={{ fontSize: 11, fill: '#9ca3af' }}
|
||||||
style={{ height: `${Math.max(height, 4)}%` }}
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-secondary-400">
|
<YAxis
|
||||||
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'narrow' })}
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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>
|
</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 { NotificationItem } from './NotificationItem';
|
||||||
export { PushPermissionBanner } from './PushPermissionBanner';
|
export { PushPermissionBanner } from './PushPermissionBanner';
|
||||||
export { DevicesManager } from './DevicesManager';
|
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,
|
useSchemePerformance,
|
||||||
} from './useCommissions';
|
} from './useCommissions';
|
||||||
export * from './useRbac';
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { notificationsApi } from '@/services/api';
|
import { notificationsApi, NotificationTemplate, CreateTemplateRequest, UpdateTemplateRequest } from '@/services/api';
|
||||||
|
|
||||||
// Query keys
|
// Query keys
|
||||||
const notificationKeys = {
|
const notificationKeys = {
|
||||||
@ -8,6 +8,8 @@ const notificationKeys = {
|
|||||||
[...notificationKeys.all, 'list', params] as const,
|
[...notificationKeys.all, 'list', params] as const,
|
||||||
unreadCount: () => [...notificationKeys.all, 'unread-count'] as const,
|
unreadCount: () => [...notificationKeys.all, 'unread-count'] as const,
|
||||||
preferences: () => [...notificationKeys.all, 'preferences'] as const,
|
preferences: () => [...notificationKeys.all, 'preferences'] as const,
|
||||||
|
templates: () => [...notificationKeys.all, 'templates'] as const,
|
||||||
|
template: (code: string) => [...notificationKeys.all, 'template', code] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@ -139,3 +141,80 @@ export function formatNotificationTime(dateString: string): string {
|
|||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
return date.toLocaleDateString();
|
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,
|
MessageSquare,
|
||||||
Target,
|
Target,
|
||||||
Network,
|
Network,
|
||||||
|
Key,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@ -34,6 +35,7 @@ const navigation = [
|
|||||||
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
|
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
|
||||||
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
|
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
|
||||||
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },
|
{ 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: 'Audit Logs', href: '/dashboard/audit', icon: ClipboardList },
|
||||||
{ name: 'Users', href: '/dashboard/users', icon: Users },
|
{ name: 'Users', href: '/dashboard/users', icon: Users },
|
||||||
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { QueryAuditLogsParams } from '@/services/api';
|
import { QueryAuditLogsParams, User } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
useAuditLogs,
|
useAuditLogs,
|
||||||
useAuditStats,
|
useAuditStats,
|
||||||
@ -19,13 +19,17 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type TabType = 'logs' | 'activity';
|
type TabType = 'logs' | 'activity';
|
||||||
|
type StatsPeriod = 7 | 14 | 30;
|
||||||
|
|
||||||
export function AuditLogsPage() {
|
export function AuditLogsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||||
|
const [statsPeriod, setStatsPeriod] = useState<StatsPeriod>(7);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filters, setFilters] = useState<QueryAuditLogsParams>({
|
const [filters, setFilters] = useState<QueryAuditLogsParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@ -33,7 +37,7 @@ export function AuditLogsPage() {
|
|||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: auditLogsData, isLoading: logsLoading } = useAuditLogs(filters);
|
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({
|
const { data: activitiesData, isLoading: activitiesLoading } = useActivityLogs({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@ -46,10 +50,33 @@ export function AuditLogsPage() {
|
|||||||
return Object.keys(auditStats.by_entity_type);
|
return Object.keys(auditStats.by_entity_type);
|
||||||
}, [auditStats]);
|
}, [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) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setFilters((prev) => ({ ...prev, page: newPage }));
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -65,8 +92,28 @@ export function AuditLogsPage() {
|
|||||||
<ExportButton reportType="audit" />
|
<ExportButton reportType="audit" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Period Selector + Stats Card */}
|
||||||
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} />
|
<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 */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-4 border-b border-secondary-200 dark:border-secondary-700">
|
<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}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
entityTypes={entityTypes}
|
entityTypes={entityTypes}
|
||||||
|
users={uniqueUsers}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchValue={searchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Logs list */}
|
{/* Logs list */}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
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 {
|
import {
|
||||||
useNotifications,
|
useNotifications,
|
||||||
useUnreadCount,
|
useUnreadCount,
|
||||||
@ -169,13 +170,21 @@ export default function NotificationsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h1>
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Stay updated with your latest notifications
|
Stay updated with your latest notifications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Manage Templates
|
||||||
|
</Link>
|
||||||
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
|
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={handleMarkAllAsRead}
|
||||||
@ -187,6 +196,7 @@ export default function NotificationsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
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 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 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
|
// Lazy loaded pages - RBAC
|
||||||
const RolesPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RolesPage })));
|
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
|
// Lazy loaded pages - Notifications
|
||||||
const NotificationsPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.NotificationsPage })));
|
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
|
// Lazy loaded pages - Admin
|
||||||
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
|
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
|
||||||
@ -206,9 +209,14 @@ export function AppRouter() {
|
|||||||
|
|
||||||
{/* RBAC routes */}
|
{/* RBAC routes */}
|
||||||
<Route path="rbac/roles" element={<SuspensePage><RolesPage /></SuspensePage>} />
|
<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 */}
|
{/* Notifications routes */}
|
||||||
<Route path="notifications" element={<SuspensePage><NotificationsPage /></SuspensePage>} />
|
<Route path="notifications" element={<SuspensePage><NotificationsPage /></SuspensePage>} />
|
||||||
|
<Route path="notifications/templates" element={<SuspensePage><NotificationTemplatesPage /></SuspensePage>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Superadmin routes */}
|
{/* 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
|
// Notifications API
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
list: async (params?: { page?: number; limit?: number }) => {
|
list: async (params?: { page?: number; limit?: number }) => {
|
||||||
@ -295,6 +349,10 @@ export const notificationsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
await api.delete(`/notifications/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
getPreferences: async () => {
|
getPreferences: async () => {
|
||||||
const response = await api.get('/notifications/preferences');
|
const response = await api.get('/notifications/preferences');
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -304,6 +362,54 @@ export const notificationsApi = {
|
|||||||
const response = await api.patch('/notifications/preferences', preferences);
|
const response = await api.patch('/notifications/preferences', preferences);
|
||||||
return response.data;
|
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
|
// Health API
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user