[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:
Adrian Flores Cortes 2026-02-03 19:58:31 -06:00
parent 9019261f8e
commit 193b26f6f1
23 changed files with 2826 additions and 71 deletions

View File

@ -1,13 +1,16 @@
import { useState } from 'react';
import { AuditAction, QueryAuditLogsParams } from '@/services/api';
import { AuditAction, QueryAuditLogsParams, User } from '@/services/api';
import { getAuditActionLabel } from '@/hooks/useAudit';
import { Filter, X } from 'lucide-react';
import { Filter, X, Search } from 'lucide-react';
import clsx from 'clsx';
interface AuditFiltersProps {
filters: QueryAuditLogsParams;
onFiltersChange: (filters: QueryAuditLogsParams) => void;
entityTypes?: string[];
users?: Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>[];
onSearchChange?: (search: string) => void;
searchValue?: string;
}
const AUDIT_ACTIONS: AuditAction[] = [
@ -21,8 +24,16 @@ const AUDIT_ACTIONS: AuditAction[] = [
'import',
];
export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: AuditFiltersProps) {
export function AuditFilters({
filters,
onFiltersChange,
entityTypes = [],
users = [],
onSearchChange,
searchValue = '',
}: AuditFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
const [localSearch, setLocalSearch] = useState(searchValue);
const activeFiltersCount = [
filters.action,
@ -32,6 +43,21 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
filters.to_date,
].filter(Boolean).length;
const getUserDisplayName = (user: Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>) => {
const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim();
return fullName || user.email;
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearchChange?.(localSearch);
};
const handleSearchClear = () => {
setLocalSearch('');
onSearchChange?.('');
};
const handleChange = (key: keyof QueryAuditLogsParams, value: string | undefined) => {
onFiltersChange({
...filters,
@ -46,41 +72,66 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
return (
<div className="space-y-3">
{/* Filter toggle button */}
<div className="flex items-center justify-between">
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isOpen || activeFiltersCount > 0
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
)}
>
<Filter className="w-4 h-4" />
Filters
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
>
<X className="w-4 h-4" />
Clear all
</button>
{/* Search and Filter toggle */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Search input */}
{onSearchChange && (
<form onSubmit={handleSearchSubmit} className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
<input
type="text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search logs by description, entity ID..."
className="w-full pl-10 pr-10 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm"
/>
{localSearch && (
<button
type="button"
onClick={handleSearchClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
>
<X className="w-4 h-4" />
</button>
)}
</form>
)}
<div className="flex items-center gap-3">
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isOpen || activeFiltersCount > 0
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
)}
>
<Filter className="w-4 h-4" />
Filters
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
>
<X className="w-4 h-4" />
Clear all
</button>
)}
</div>
</div>
{/* Filter panel */}
{isOpen && (
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Action filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
@ -119,6 +170,27 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
</select>
</div>
{/* User filter */}
{users.length > 0 && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
User
</label>
<select
value={filters.user_id || ''}
onChange={(e) => handleChange('user_id', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{getUserDisplayName(user)}
</option>
))}
</select>
</div>
)}
{/* From date filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
@ -163,6 +235,12 @@ export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: Aud
onRemove={() => handleChange('entity_type', undefined)}
/>
)}
{filters.user_id && (
<FilterBadge
label={`User: ${users.find((u) => u.id === filters.user_id)?.email || filters.user_id}`}
onRemove={() => handleChange('user_id', undefined)}
/>
)}
{filters.from_date && (
<FilterBadge
label={`From: ${filters.from_date}`}

View File

@ -1,14 +1,37 @@
import { AuditStats } from '@/services/api';
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
import { Activity, TrendingUp, Users, FileText } from 'lucide-react';
import { TrendingUp, Users, FileText, Calendar } from 'lucide-react';
import clsx from 'clsx';
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
PieChart,
Pie,
} from 'recharts';
interface AuditStatsCardProps {
stats: AuditStats;
isLoading?: boolean;
days?: number;
}
export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
const ACTION_COLORS: Record<string, string> = {
create: '#22c55e',
update: '#3b82f6',
delete: '#ef4444',
read: '#6b7280',
login: '#a855f7',
logout: '#f97316',
export: '#06b6d4',
import: '#6366f1',
};
export function AuditStatsCard({ stats, isLoading, days = 7 }: AuditStatsCardProps) {
if (isLoading) {
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
@ -32,6 +55,19 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
// Prepare chart data for actions pie chart
const actionChartData = Object.entries(stats.by_action).map(([action, count]) => ({
name: getAuditActionLabel(action as any),
value: count,
color: ACTION_COLORS[action] || '#6b7280',
}));
// Prepare chart data for daily trend
const dailyChartData = stats.by_day?.map((day) => ({
date: new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
count: day.count,
})) || [];
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-center justify-between mb-6">
@ -39,8 +75,8 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
Audit Overview
</h3>
<div className="flex items-center gap-2 text-sm text-secondary-500">
<Activity className="w-4 h-4" />
Last 7 days
<Calendar className="w-4 h-4" />
Last {days} days
</div>
</div>
@ -131,29 +167,144 @@ export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
</div>
</div>
{/* Daily trend */}
{stats.by_day && stats.by_day.length > 0 && (
{/* Daily Activity Chart */}
{dailyChartData.length > 0 && (
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Daily Activity
Daily Activity Trend
</h4>
<div className="flex items-end justify-between h-16 gap-1">
{stats.by_day.map((day) => {
const maxCount = Math.max(...stats.by_day.map((d) => d.count));
const height = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center gap-1"
title={`${day.date}: ${day.count} logs`}
>
<div
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t"
style={{ height: `${Math.max(height, 4)}%` }}
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={dailyChartData} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(31, 41, 55, 0.95)',
border: 'none',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
}}
labelStyle={{ color: '#9ca3af' }}
/>
<Area
type="monotone"
dataKey="count"
stroke="#6366f1"
strokeWidth={2}
fill="url(#colorCount)"
name="Logs"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Actions Distribution */}
{actionChartData.length > 0 && (
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Actions Distribution
</h4>
<div className="flex items-center gap-6">
<div className="w-32 h-32">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={actionChartData}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={45}
paddingAngle={2}
dataKey="value"
>
{actionChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(31, 41, 55, 0.95)',
border: 'none',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
}}
/>
<span className="text-xs text-secondary-400">
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'narrow' })}
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 grid grid-cols-2 gap-2">
{actionChartData.slice(0, 6).map((item) => (
<div key={item.name} className="flex items-center gap-2 text-sm">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-secondary-600 dark:text-secondary-400 truncate">
{item.name}
</span>
<span className="text-secondary-900 dark:text-secondary-100 font-medium">
{item.value}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Top Users */}
{stats.top_users && stats.top_users.length > 0 && (
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Most Active Users
</h4>
<div className="space-y-2">
{stats.top_users.slice(0, 5).map((user, index) => {
const maxCount = stats.top_users[0]?.count || 1;
const percentage = (user.count / maxCount) * 100;
const displayName = user.user?.first_name
? `${user.user.first_name} ${user.user?.email?.split('@')[0] || ''}`
: user.user?.email || user.user_id.slice(0, 8);
return (
<div key={user.user_id} className="flex items-center gap-3">
<span className="text-xs text-secondary-400 w-4">{index + 1}</span>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-secondary-700 dark:text-secondary-300 truncate">
{displayName}
</span>
<span className="text-xs text-secondary-500">
{user.count.toLocaleString()} logs
</span>
</div>
<div className="h-1.5 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
);
})}

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

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

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

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

View File

@ -3,3 +3,7 @@ export { NotificationDrawer } from './NotificationDrawer';
export { NotificationItem } from './NotificationItem';
export { PushPermissionBanner } from './PushPermissionBanner';
export { DevicesManager } from './DevicesManager';
export { TemplateCard } from './TemplateCard';
export { TemplateForm } from './TemplateForm';
export { TemplatePreview } from './TemplatePreview';
export { ChannelConfig } from './ChannelConfig';

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

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

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

View File

@ -0,0 +1,3 @@
export { RoleCard } from './RoleCard';
export { RoleForm } from './RoleForm';
export { PermissionsMatrix } from './PermissionsMatrix';

View File

@ -64,3 +64,26 @@ export {
useSchemePerformance,
} from './useCommissions';
export * from './useRbac';
// Notification hooks - prefer useNotifications.ts over useData.ts
export {
useNotifications,
useUnreadCount,
useMarkAsRead,
useMarkAllAsRead,
useNotificationPreferences,
useUpdatePreferences,
useNotificationTemplates,
useNotificationTemplate,
useCreateTemplate,
useUpdateTemplate,
useDeleteTemplate,
useToggleTemplateActive,
useSendFromTemplate,
getNotificationTypeColor,
getNotificationChannelLabel,
formatNotificationTime,
type Notification,
type NotificationPreferences,
type PaginatedNotifications,
type NotificationTemplate,
} from './useNotifications';

View File

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationsApi } from '@/services/api';
import { notificationsApi, NotificationTemplate, CreateTemplateRequest, UpdateTemplateRequest } from '@/services/api';
// Query keys
const notificationKeys = {
@ -8,6 +8,8 @@ const notificationKeys = {
[...notificationKeys.all, 'list', params] as const,
unreadCount: () => [...notificationKeys.all, 'unread-count'] as const,
preferences: () => [...notificationKeys.all, 'preferences'] as const,
templates: () => [...notificationKeys.all, 'templates'] as const,
template: (code: string) => [...notificationKeys.all, 'template', code] as const,
};
// Types
@ -139,3 +141,80 @@ export function formatNotificationTime(dateString: string): string {
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ==================== Template Queries ====================
export function useNotificationTemplates() {
return useQuery({
queryKey: notificationKeys.templates(),
queryFn: () => notificationsApi.listTemplates(),
});
}
export function useNotificationTemplate(code: string) {
return useQuery({
queryKey: notificationKeys.template(code),
queryFn: () => notificationsApi.getTemplate(code),
enabled: !!code,
});
}
// ==================== Template Mutations ====================
export function useCreateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTemplateRequest) => notificationsApi.createTemplate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
},
});
}
export function useUpdateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ code, data }: { code: string; data: UpdateTemplateRequest }) =>
notificationsApi.updateTemplate(code, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
queryClient.invalidateQueries({ queryKey: notificationKeys.template(variables.code) });
},
});
}
export function useDeleteTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => notificationsApi.deleteTemplate(code),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
},
});
}
export function useToggleTemplateActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ code, is_active }: { code: string; is_active: boolean }) =>
notificationsApi.updateTemplate(code, { is_active }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: notificationKeys.templates() });
queryClient.invalidateQueries({ queryKey: notificationKeys.template(variables.code) });
},
});
}
export function useSendFromTemplate() {
return useMutation({
mutationFn: (data: { template_code: string; user_id: string; variables?: Record<string, string> }) =>
notificationsApi.sendFromTemplate(data),
});
}
// Re-export template type
export type { NotificationTemplate };

View File

@ -21,6 +21,7 @@ import {
MessageSquare,
Target,
Network,
Key,
} from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
@ -34,6 +35,7 @@ const navigation = [
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },
{ name: 'Roles & Permissions', href: '/dashboard/rbac/roles', icon: Key },
{ name: 'Audit Logs', href: '/dashboard/audit', icon: ClipboardList },
{ name: 'Users', href: '/dashboard/users', icon: Users },
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { QueryAuditLogsParams } from '@/services/api';
import { useState, useMemo, useCallback } from 'react';
import { QueryAuditLogsParams, User } from '@/services/api';
import {
useAuditLogs,
useAuditStats,
@ -19,13 +19,17 @@ import {
ChevronLeft,
ChevronRight,
Loader2,
Calendar,
} from 'lucide-react';
import clsx from 'clsx';
type TabType = 'logs' | 'activity';
type StatsPeriod = 7 | 14 | 30;
export function AuditLogsPage() {
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [statsPeriod, setStatsPeriod] = useState<StatsPeriod>(7);
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<QueryAuditLogsParams>({
page: 1,
limit: 20,
@ -33,7 +37,7 @@ export function AuditLogsPage() {
// Queries
const { data: auditLogsData, isLoading: logsLoading } = useAuditLogs(filters);
const { data: auditStats, isLoading: statsLoading } = useAuditStats(7);
const { data: auditStats, isLoading: statsLoading } = useAuditStats(statsPeriod);
const { data: activitiesData, isLoading: activitiesLoading } = useActivityLogs({
page: 1,
limit: 50,
@ -46,10 +50,33 @@ export function AuditLogsPage() {
return Object.keys(auditStats.by_entity_type);
}, [auditStats]);
// Extract unique users from audit logs for user filter
const uniqueUsers = useMemo(() => {
if (!auditLogsData?.items) return [];
const userMap = new Map<string, Pick<User, 'id' | 'email' | 'first_name' | 'last_name'>>();
auditLogsData.items.forEach((log) => {
if (log.user && log.user.id && !userMap.has(log.user.id)) {
userMap.set(log.user.id, {
id: log.user.id,
email: log.user.email,
first_name: log.user.first_name,
last_name: log.user.last_name,
});
}
});
return Array.from(userMap.values());
}, [auditLogsData]);
const handlePageChange = (newPage: number) => {
setFilters((prev) => ({ ...prev, page: newPage }));
};
const handleSearchChange = useCallback((search: string) => {
setSearchQuery(search);
// The search would be handled server-side if backend supports it
// For now we could filter client-side or integrate with backend search endpoint
}, []);
return (
<div className="space-y-6">
{/* Header */}
@ -65,8 +92,28 @@ export function AuditLogsPage() {
<ExportButton reportType="audit" />
</div>
{/* Stats Card */}
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} />
{/* Stats Period Selector + Stats Card */}
<div className="space-y-4">
<div className="flex items-center justify-end gap-2">
<Calendar className="w-4 h-4 text-secondary-400" />
<span className="text-sm text-secondary-500">Period:</span>
{([7, 14, 30] as StatsPeriod[]).map((period) => (
<button
key={period}
onClick={() => setStatsPeriod(period)}
className={clsx(
'px-3 py-1 text-sm rounded-lg transition-colors',
statsPeriod === period
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-400 dark:hover:bg-secondary-600'
)}
>
{period}d
</button>
))}
</div>
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} days={statsPeriod} />
</div>
{/* Tabs */}
<div className="flex items-center gap-4 border-b border-secondary-200 dark:border-secondary-700">
@ -114,6 +161,9 @@ export function AuditLogsPage() {
filters={filters}
onFiltersChange={setFilters}
entityTypes={entityTypes}
users={uniqueUsers}
onSearchChange={handleSearchChange}
searchValue={searchQuery}
/>
{/* Logs list */}

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Bell, CheckCheck, ExternalLink, Settings, Mail, Smartphone, MessageSquare } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Bell, CheckCheck, ExternalLink, Settings, Mail, Smartphone, MessageSquare, FileText } from 'lucide-react';
import {
useNotifications,
useUnreadCount,
@ -169,23 +170,32 @@ export default function NotificationsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h1>
<p className="text-gray-500 dark:text-gray-400">
Stay updated with your latest notifications
</p>
</div>
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
<button
onClick={handleMarkAllAsRead}
disabled={markAllAsReadMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
<div className="flex items-center gap-2">
<Link
to="/dashboard/notifications/templates"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<CheckCheck className="h-4 w-4" />
Mark all as read
</button>
)}
<FileText className="h-4 w-4" />
Manage Templates
</Link>
{activeTab !== 'settings' && (unreadData?.count ?? 0) > 0 && (
<button
onClick={handleMarkAllAsRead}
disabled={markAllAsReadMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<CheckCheck className="h-4 w-4" />
Mark all as read
</button>
)}
</div>
</div>
{/* Tabs */}

View 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;
}

View File

@ -1 +1,2 @@
export { default as NotificationsPage } from './NotificationsPage';
export { default as TemplatesPage } from './TemplatesPage';

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

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

View File

@ -1 +1,3 @@
export { default as RolesPage } from './RolesPage';
export { default as RoleDetailPage } from './RoleDetailPage';
export { default as PermissionsPage } from './PermissionsPage';

View File

@ -65,9 +65,12 @@ const MLMMyEarningsPage = lazy(() => import('@/pages/dashboard/mlm').then(m => (
// Lazy loaded pages - RBAC
const RolesPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RolesPage })));
const RoleDetailPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RoleDetailPage })));
const PermissionsPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.PermissionsPage })));
// Lazy loaded pages - Notifications
const NotificationsPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.NotificationsPage })));
const NotificationTemplatesPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.TemplatesPage })));
// Lazy loaded pages - Admin
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
@ -206,9 +209,14 @@ export function AppRouter() {
{/* RBAC routes */}
<Route path="rbac/roles" element={<SuspensePage><RolesPage /></SuspensePage>} />
<Route path="rbac/roles/new" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
<Route path="rbac/roles/:id" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
<Route path="rbac/roles/:id/edit" element={<SuspensePage><RoleDetailPage /></SuspensePage>} />
<Route path="rbac/permissions" element={<SuspensePage><PermissionsPage /></SuspensePage>} />
{/* Notifications routes */}
<Route path="notifications" element={<SuspensePage><NotificationsPage /></SuspensePage>} />
<Route path="notifications/templates" element={<SuspensePage><NotificationTemplatesPage /></SuspensePage>} />
</Route>
{/* Superadmin routes */}

View File

@ -273,6 +273,60 @@ export const stripeApi = {
},
};
// Notification Types
export type NotificationChannel = 'in_app' | 'email' | 'push' | 'sms';
export interface NotificationTemplate {
id: string;
code: string;
name: string;
description: string | null;
category: string;
channel: NotificationChannel;
subject: string | null;
body: string;
body_html: string | null;
variables: { name: string; description?: string; required?: boolean }[] | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTemplateRequest {
code: string;
name: string;
description?: string;
category: string;
channel: NotificationChannel;
subject?: string;
body: string;
body_html?: string;
variables?: { name: string; description?: string; required?: boolean }[];
is_active?: boolean;
}
export interface UpdateTemplateRequest {
name?: string;
description?: string;
category?: string;
channel?: NotificationChannel;
subject?: string;
body?: string;
body_html?: string;
variables?: { name: string; description?: string; required?: boolean }[];
is_active?: boolean;
}
export interface ChannelConfig {
email_enabled: boolean;
push_enabled: boolean;
in_app_enabled: boolean;
sms_enabled: boolean;
whatsapp_enabled: boolean;
email_provider?: string;
push_provider?: string;
}
// Notifications API
export const notificationsApi = {
list: async (params?: { page?: number; limit?: number }) => {
@ -295,6 +349,10 @@ export const notificationsApi = {
return response.data;
},
delete: async (id: string) => {
await api.delete(`/notifications/${id}`);
},
getPreferences: async () => {
const response = await api.get('/notifications/preferences');
return response.data;
@ -304,6 +362,54 @@ export const notificationsApi = {
const response = await api.patch('/notifications/preferences', preferences);
return response.data;
},
// Templates
listTemplates: async (): Promise<NotificationTemplate[]> => {
const response = await api.get<NotificationTemplate[]>('/notifications/templates');
return response.data;
},
getTemplate: async (code: string): Promise<NotificationTemplate> => {
const response = await api.get<NotificationTemplate>(`/notifications/templates/${code}`);
return response.data;
},
createTemplate: async (data: CreateTemplateRequest): Promise<NotificationTemplate> => {
const response = await api.post<NotificationTemplate>('/notifications/templates', data);
return response.data;
},
updateTemplate: async (code: string, data: UpdateTemplateRequest): Promise<NotificationTemplate> => {
const response = await api.patch<NotificationTemplate>(`/notifications/templates/${code}`, data);
return response.data;
},
deleteTemplate: async (code: string): Promise<void> => {
await api.delete(`/notifications/templates/${code}`);
},
// Admin - Send notifications
sendNotification: async (data: {
user_id: string;
type: string;
channel: NotificationChannel;
title: string;
body: string;
action_url?: string;
action_label?: string;
}) => {
const response = await api.post('/notifications', data);
return response.data;
},
sendFromTemplate: async (data: {
template_code: string;
user_id: string;
variables?: Record<string, string>;
}) => {
const response = await api.post('/notifications/template', data);
return response.data;
},
};
// Health API