feat: add lint

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-14 19:36:46 +08:00
parent f414264050
commit 451793f6ce
41 changed files with 3724 additions and 3017 deletions

View File

@@ -1,125 +1,142 @@
import { useState, useEffect } from 'react'
import { Hash, Users, Activity, LogIn, LogOut, ShieldCheck, Settings } from 'lucide-react'
import { useAuth } from './contexts/AuthContext'
import TopicsView from './views/TopicsView'
import UsersView from './views/UsersView'
import AdminView from './views/AdminView'
import {
Activity,
Hash,
LogIn,
LogOut,
Settings,
ShieldCheck,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "./contexts/AuthContext";
import AdminView from "./views/AdminView";
import TopicsView from "./views/TopicsView";
import UsersView from "./views/UsersView";
function App() {
const { user, loading, login, logout } = useAuth()
const [activeTab, setActiveTab] = useState('topics')
const [hasSetDefault, setHasSetDefault] = useState(false)
const { user, loading, login, logout } = useAuth();
const [activeTab, setActiveTab] = useState("topics");
const [hasSetDefault, setHasSetDefault] = useState(false);
useEffect(() => {
if (!loading && user && !hasSetDefault) {
setActiveTab(user.isAdmin ? 'admin' : 'topics')
setHasSetDefault(true)
}
}, [user, loading, hasSetDefault])
useEffect(() => {
if (!loading && user && !hasSetDefault) {
setActiveTab(user.isAdmin ? "admin" : "topics");
setHasSetDefault(true);
}
}, [user, loading, hasSetDefault]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
<Activity className="h-16 w-16 text-indigo-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Alert Message Center</h1>
<p className="text-gray-600 mb-6">Please sign in with Feishu to continue</p>
<button
onClick={login}
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
>
<LogIn className="mr-2 h-5 w-5" />
Sign in with Feishu
</button>
</div>
</div>
</div>
)
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
<Activity className="h-16 w-16 text-indigo-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Alert Message Center
</h1>
<p className="text-gray-600 mb-6">
Please sign in with Feishu to continue
</p>
<button
onClick={login}
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
>
<LogIn className="mr-2 h-5 w-5" />
Sign in with Feishu
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Activity className="h-8 w-8 text-indigo-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Alert Message Center</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{user.isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`${activeTab === 'admin'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Settings className="mr-2 h-4 w-4" />
Admin
</button>
)}
<button
onClick={() => setActiveTab('topics')}
className={`${activeTab === 'topics'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Hash className="mr-2 h-4 w-4" />
Topics
</button>
{user.isAdmin && (
<button
onClick={() => setActiveTab('users')}
className={`${activeTab === 'users'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Users className="mr-2 h-4 w-4" />
Users
</button>
)}
</div>
</div>
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Activity className="h-8 w-8 text-indigo-600" />
<span className="ml-2 text-xl font-bold text-gray-900">
Alert Message Center
</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
{user.isAdmin && (
<button
onClick={() => setActiveTab("admin")}
className={`${
activeTab === "admin"
? "border-indigo-500 text-gray-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Settings className="mr-2 h-4 w-4" />
Admin
</button>
)}
<button
onClick={() => setActiveTab("topics")}
className={`${
activeTab === "topics"
? "border-indigo-500 text-gray-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Hash className="mr-2 h-4 w-4" />
Topics
</button>
{user.isAdmin && (
<button
onClick={() => setActiveTab("users")}
className={`${
activeTab === "users"
? "border-indigo-500 text-gray-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
<Users className="mr-2 h-4 w-4" />
Users
</button>
)}
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700">{user.name}</span>
{user.isAdmin && (
<ShieldCheck className="h-5 w-5 text-indigo-600" />
)}
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100"
>
<LogOut className="mr-1 h-4 w-4" />
Logout
</button>
</div>
</div>
</div>
</nav>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700">{user.name}</span>
{user.isAdmin && (
<ShieldCheck className="h-5 w-5 text-indigo-600" />
)}
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100"
>
<LogOut className="mr-1 h-4 w-4" />
Logout
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{activeTab === 'topics' && <TopicsView />}
{activeTab === 'users' && user.isAdmin && <UsersView />}
{activeTab === 'admin' && user.isAdmin && <AdminView />}
</main>
</div>
)
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{activeTab === "topics" && <TopicsView />}
{activeTab === "users" && user.isAdmin && <UsersView />}
{activeTab === "admin" && user.isAdmin && <AdminView />}
</main>
</div>
);
}
export default App
export default App;

View File

@@ -1,196 +1,228 @@
import { useState, useEffect } from 'react';
import { Trash2, Plus, MessageCircle } from 'lucide-react';
import Modal from './Modal';
import { client } from '../lib/client';
import { MessageCircle, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { client } from "../lib/client";
import Modal from "./Modal";
interface GroupBinding {
id: string;
chatId: string;
name: string;
id: string;
chatId: string;
name: string;
}
interface KnownGroup {
chatId: string;
name: string;
lastActiveAt: string;
chatId: string;
name: string;
lastActiveAt: string;
}
interface GroupBindingsModalProps {
isOpen: boolean;
onClose: () => void;
topicId: string;
topicName: string;
isOpen: boolean;
onClose: () => void;
topicId: string;
topicName: string;
}
export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) {
// const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [selectedChatId, setSelectedChatId] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
export default function GroupBindingsModal({
isOpen,
onClose,
topicId,
topicName,
}: GroupBindingsModalProps) {
// const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [selectedChatId, setSelectedChatId] = useState("");
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
useEffect(() => {
if (isOpen && topicId) {
fetchBindings();
fetchKnownGroups();
setStatus(null);
setSelectedChatId('');
}
}, [isOpen, topicId]);
useEffect(() => {
if (isOpen && topicId) {
fetchBindings();
fetchKnownGroups();
setStatus(null);
setSelectedChatId("");
}
}, [isOpen, topicId]);
const fetchBindings = async () => {
try {
const res = await client.api.topics[':id'].groups.$get({
param: { id: topicId }
}, {
init: { credentials: 'include' }
});
const data = await res.json();
setBindings(data as any);
} catch (err) {
console.error(err);
}
};
const fetchBindings = async () => {
try {
const res = await client.api.topics[":id"].groups.$get(
{
param: { id: topicId },
},
{
init: { credentials: "include" },
},
);
const data = await res.json();
setBindings(data as any);
} catch (err) {
console.error(err);
}
};
const fetchKnownGroups = async () => {
try {
const res = await client.api.groups.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
// Only verify uniqueness if needed, but here we just list what server returns
setKnownGroups(data as any);
} catch (err) {
console.error(err);
}
};
const fetchKnownGroups = async () => {
try {
const res = await client.api.groups.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
// Only verify uniqueness if needed, but here we just list what server returns
setKnownGroups(data as any);
} catch (err) {
console.error(err);
}
};
const handleBind = async () => {
if (!selectedChatId) return;
setLoading(true);
setStatus(null);
const handleBind = async () => {
if (!selectedChatId) return;
setLoading(true);
setStatus(null);
const group = knownGroups.find(g => g.chatId === selectedChatId);
if (!group) return;
const group = knownGroups.find((g) => g.chatId === selectedChatId);
if (!group) return;
try {
const res = await client.api.topics[':id'].groups.$post({
param: { id: topicId },
json: {
chatId: group.chatId,
name: group.name,
}
}, {
init: { credentials: 'include' }
});
try {
const res = await client.api.topics[":id"].groups.$post(
{
param: { id: topicId },
json: {
chatId: group.chatId,
name: group.name,
},
},
{
init: { credentials: "include" },
},
);
if (res.ok) {
setStatus({ type: 'success', message: 'Group bound successfully!' });
fetchBindings();
setSelectedChatId('');
} else {
await res.json(); // Consume body
setStatus({ type: 'error', message: 'Failed to bind group' });
}
} catch (_) { // Ignore error
setStatus({ type: 'error', message: 'An error occurred' });
} finally {
setLoading(false);
}
};
if (res.ok) {
setStatus({ type: "success", message: "Group bound successfully!" });
fetchBindings();
setSelectedChatId("");
} else {
await res.json(); // Consume body
setStatus({ type: "error", message: "Failed to bind group" });
}
} catch (_) {
// Ignore error
setStatus({ type: "error", message: "An error occurred" });
} finally {
setLoading(false);
}
};
const handleUnbind = async (bindingId: string) => {
if (!confirm('Are you sure you want to remove this group binding?')) return;
const handleUnbind = async (bindingId: string) => {
if (!confirm("Are you sure you want to remove this group binding?")) return;
try {
const res = await client.api.topics[':id'].groups[':bindingId'].$delete({
param: { id: topicId, bindingId }
}, {
init: { credentials: 'include' }
});
try {
const res = await client.api.topics[":id"].groups[":bindingId"].$delete(
{
param: { id: topicId, bindingId },
},
{
init: { credentials: "include" },
},
);
if (res.ok) {
setBindings(prev => prev.filter(b => b.id !== bindingId));
}
} catch (err) {
console.error(err);
}
};
if (res.ok) {
setBindings((prev) => prev.filter((b) => b.id !== bindingId));
}
} catch (err) {
console.error(err);
}
};
// Filter out groups that are already bound
const availableGroups = knownGroups.filter(
kg => !bindings.some(b => b.chatId === kg.chatId)
);
// Filter out groups that are already bound
const availableGroups = knownGroups.filter(
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Manage Group Chats for ${topicName}`}
>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">Bound Groups</h4>
{bindings.length === 0 ? (
<p className="text-sm text-gray-500 italic">No groups bound to this topic yet.</p>
) : (
<ul className="divide-y divide-gray-200 border rounded-md">
{bindings.map(binding => (
<li key={binding.id} className="flex justify-between items-center p-3">
<div className="flex items-center">
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-700">{binding.name}</span>
</div>
<button
onClick={() => handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Remove binding"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Manage Group Chats for ${topicName}`}
>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">
Bound Groups
</h4>
{bindings.length === 0 ? (
<p className="text-sm text-gray-500 italic">
No groups bound to this topic yet.
</p>
) : (
<ul className="divide-y divide-gray-200 border rounded-md">
{bindings.map((binding) => (
<li
key={binding.id}
className="flex justify-between items-center p-3"
>
<div className="flex items-center">
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-700">
{binding.name}
</span>
</div>
<button
onClick={() => handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Remove binding"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">Add Group Binding</h4>
<p className="text-xs text-gray-500 mb-3">
Select a group where the Feishu Bot has been added. If your group is not listed, try removing and re-adding the bot to the group.
</p>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">
Add Group Binding
</h4>
<p className="text-xs text-gray-500 mb-3">
Select a group where the Feishu Bot has been added. If your group is
not listed, try removing and re-adding the bot to the group.
</p>
<div className="flex gap-2">
<select
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
value={selectedChatId}
onChange={(e) => setSelectedChatId(e.target.value)}
disabled={loading}
>
<option value="">Select a group...</option>
{availableGroups.map(group => (
<option key={group.chatId} value={group.chatId}>
{group.name}
</option>
))}
</select>
<button
onClick={handleBind}
disabled={!selectedChatId || loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4 mr-1" />
Add
</button>
</div>
{status && (
<p className={`mt-2 text-xs ${status.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{status.message}
</p>
)}
</div>
</div>
</Modal>
);
<div className="flex gap-2">
<select
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
value={selectedChatId}
onChange={(e) => setSelectedChatId(e.target.value)}
disabled={loading}
>
<option value="">Select a group...</option>
{availableGroups.map((group) => (
<option key={group.chatId} value={group.chatId}>
{group.name}
</option>
))}
</select>
<button
onClick={handleBind}
disabled={!selectedChatId || loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4 mr-1" />
Add
</button>
</div>
{status && (
<p
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
>
{status.message}
</p>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -1,44 +1,58 @@
import React from 'react';
import { X } from 'lucide-react';
import { X } from "lucide-react";
import type React from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
export default function Modal({
isOpen,
onClose,
title,
children,
}: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div className="absolute inset-0 bg-gray-500 opacity-75" onClick={onClose}></div>
</div>
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div
className="absolute inset-0 bg-gray-500 opacity-75"
onClick={onClose}
></div>
</div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-start">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<button
onClick={onClose}
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="mt-2">
{children}
</div>
</div>
</div>
</div>
</div>
);
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-start">
<h3
className="text-lg leading-6 font-medium text-gray-900"
id="modal-title"
>
{title}
</h3>
<button
onClick={onClose}
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
>
<X className="h-6 w-6" />
</button>
</div>
<div className="mt-2">{children}</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,85 +1,92 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { client } from '../lib/client';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { client } from "../lib/client";
interface User {
id: string;
name: string;
email: string | null;
isAdmin: boolean;
personalToken: string;
id: string;
name: string;
email: string | null;
isAdmin: boolean;
personalToken: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: () => void;
logout: () => void;
checkAuth: () => Promise<void>;
user: User | null;
loading: boolean;
login: () => void;
logout: () => void;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const checkAuth = useCallback(async () => {
try {
const res = await client.api.auth.me.$get(undefined, {
init: { credentials: 'include' }
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
console.error('Auth check failed:', error);
setUser(null);
} finally {
setLoading(false);
}
}, []);
const checkAuth = useCallback(async () => {
try {
const res = await client.api.auth.me.$get(undefined, {
init: { credentials: "include" },
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
console.error("Auth check failed:", error);
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
checkAuth();
}, [checkAuth]);
const login = useCallback(async () => {
try {
const res = await client.api.auth['login-url'].$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
window.location.href = data.loginUrl;
} catch (error) {
console.error('Login failed:', error);
}
}, []);
const login = useCallback(async () => {
try {
const res = await client.api.auth["login-url"].$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
window.location.href = data.loginUrl;
} catch (error) {
console.error("Login failed:", error);
}
}, []);
const logout = useCallback(async () => {
try {
await client.api.auth.logout.$post(undefined, {
init: { credentials: 'include' }
});
setUser(null);
} catch (error) {
console.error('Logout failed:', error);
}
}, []);
const logout = useCallback(async () => {
try {
await client.api.auth.logout.$post(undefined, {
init: { credentials: "include" },
});
setUser(null);
} catch (error) {
console.error("Logout failed:", error);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
);
return (
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -3,12 +3,18 @@
@tailwind utilities;
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,4 +1,4 @@
import { hc } from 'hono/client';
import type { AppType } from '../../../server/src/index';
import { hc } from "hono/client";
import type { AppType } from "../../../server/src/index";
export const client = hc<AppType>('/') as any;
export const client = hc<AppType>("/") as any;

View File

@@ -1,17 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import AuthCallback from './views/AuthCallback.tsx'
import { AuthProvider } from './contexts/AuthContext.tsx'
import './index.css'
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { AuthProvider } from "./contexts/AuthContext.tsx";
import AuthCallback from "./views/AuthCallback.tsx";
import "./index.css";
// Simple routing based on pathname
const pathname = window.location.pathname;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
{pathname === '/auth/callback' ? <AuthCallback /> : <App />}
</AuthProvider>
</React.StrictMode>,
)
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AuthProvider>
{pathname === "/auth/callback" ? <AuthCallback /> : <App />}
</AuthProvider>
</React.StrictMode>,
);

View File

@@ -1,236 +1,285 @@
import { useState, useEffect } from 'react';
import { client } from '../lib/client';
import SystemLoadView from './SystemLoadView';
import { useEffect, useState } from "react";
import { client } from "../lib/client";
import SystemLoadView from "./SystemLoadView";
export default function AdminView() {
const [activeTab, setActiveTab] = useState('load');
const [activeTab, setActiveTab] = useState("load");
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
</div>
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('load')}
className={`${activeTab === 'load'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
System Load
</button>
<button
onClick={() => setActiveTab('requests')}
className={`${activeTab === 'requests'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
Topic Requests
</button>
<button
onClick={() => setActiveTab('topics')}
className={`${activeTab === 'topics'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
All Topics
</button>
</nav>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("load")}
className={`${
activeTab === "load"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
System Load
</button>
<button
onClick={() => setActiveTab("requests")}
className={`${
activeTab === "requests"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
Topic Requests
</button>
<button
onClick={() => setActiveTab("topics")}
className={`${
activeTab === "topics"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
All Topics
</button>
</nav>
</div>
{activeTab === 'load' && <SystemLoadView />}
{activeTab === 'requests' && <TopicRequestsList />}
{activeTab === 'topics' && <TopicsManagement />}
</div>
</div>
);
{activeTab === "load" && <SystemLoadView />}
{activeTab === "requests" && <TopicRequestsList />}
{activeTab === "topics" && <TopicsManagement />}
</div>
</div>
);
}
function TopicsManagement() {
const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchAllTopics = async () => {
setLoading(true);
try {
const res = await client.api.topics.all.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setTopics(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const fetchAllTopics = async () => {
setLoading(true);
try {
const res = await client.api.topics.all.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
setTopics(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllTopics();
}, []);
useEffect(() => {
fetchAllTopics();
}, []);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) {
return;
}
const handleDelete = async (id: string, name: string) => {
if (
!confirm(
`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`,
)
) {
return;
}
try {
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
fetchAllTopics();
} catch (error) {
console.error(error);
}
};
try {
await client.api.topics[":id"].$delete(
{ param: { id } },
{ init: { credentials: "include" } },
);
fetchAllTopics();
} catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading topics...</div>;
if (loading) return <div>Loading topics...</div>;
return (
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribers</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved By</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{topics.map((topic) => (
<tr key={topic.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{topic.name}</div>
<div className="text-sm text-gray-500 font-mono">{topic.slug}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${topic.status === 'approved' ? 'bg-green-100 text-green-800' :
topic.status === 'rejected' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{topic.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.subscriptions?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.creator?.name || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.approver?.name || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDelete(topic.id, topic.name)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
return (
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Topic
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Subscribers
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created By
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Approved By
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{topics.map((topic) => (
<tr key={topic.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{topic.name}
</div>
<div className="text-sm text-gray-500 font-mono">
{topic.slug}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
topic.status === "approved"
? "bg-green-100 text-green-800"
: topic.status === "rejected"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{topic.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.subscriptions?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.creator?.name || "Unknown"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.approver?.name || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDelete(topic.id, topic.name)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TopicRequestsList() {
const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchRequests = async () => {
setLoading(true);
try {
const res = await client.api.topics.requests.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setRequests(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const fetchRequests = async () => {
setLoading(true);
try {
const res = await client.api.topics.requests.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
setRequests(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRequests();
}, []);
useEffect(() => {
fetchRequests();
}, []);
const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => {
try {
if (action === 'approve') {
await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } });
} else if (action === 'reject') {
await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } });
} else if (action === 'delete') {
if (!confirm(`Are you sure you want to delete request "${name}"?`)) return;
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
}
fetchRequests();
} catch (error) {
console.error(error);
}
};
const handleAction = async (
id: string,
action: "approve" | "reject" | "delete",
name?: string,
) => {
try {
if (action === "approve") {
await client.api.topics[":id"].approve.$post(
{ param: { id } },
{ init: { credentials: "include" } },
);
} else if (action === "reject") {
await client.api.topics[":id"].reject.$post(
{ param: { id } },
{ init: { credentials: "include" } },
);
} else if (action === "delete") {
if (!confirm(`Are you sure you want to delete request "${name}"?`))
return;
await client.api.topics[":id"].$delete(
{ param: { id } },
{ init: { credentials: "include" } },
);
}
fetchRequests();
} catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading requests...</div>;
if (loading) return <div>Loading requests...</div>;
if (requests.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No pending topic requests.
</div>
);
}
if (requests.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No pending topic requests.
</div>
);
}
return (
<div className="overflow-hidden">
<ul className="divide-y divide-gray-200">
{requests.map(req => (
<li key={req.id} className="py-4 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{req.name}</p>
<p className="text-sm text-gray-500">Slug: <span className="font-mono">{req.slug}</span></p>
<p className="text-sm text-gray-500">
Requested by: {req.creator?.name || 'Unknown'}
{req.creator?.email ? ` (${req.creator.email})` : ''}
</p>
{req.description && (
<p className="text-sm text-gray-500 mt-1 italic">"{req.description}"</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleAction(req.id, 'approve')}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
>
Approve
</button>
<button
onClick={() => handleAction(req.id, 'reject')}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
>
Reject
</button>
<button
onClick={() => handleAction(req.id, 'delete', req.name)}
className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
>
Delete
</button>
</div>
</li>
))}
</ul>
</div>
);
return (
<div className="overflow-hidden">
<ul className="divide-y divide-gray-200">
{requests.map((req) => (
<li key={req.id} className="py-4 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{req.name}</p>
<p className="text-sm text-gray-500">
Slug: <span className="font-mono">{req.slug}</span>
</p>
<p className="text-sm text-gray-500">
Requested by: {req.creator?.name || "Unknown"}
{req.creator?.email ? ` (${req.creator.email})` : ""}
</p>
{req.description && (
<p className="text-sm text-gray-500 mt-1 italic">
"{req.description}"
</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleAction(req.id, "approve")}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
>
Approve
</button>
<button
onClick={() => handleAction(req.id, "reject")}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
>
Reject
</button>
<button
onClick={() => handleAction(req.id, "delete", req.name)}
className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
>
Delete
</button>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,76 +1,83 @@
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
import { useEffect, useRef, useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { client } from "../lib/client";
export default function AuthCallback() {
const [error, setError] = useState<string | null>(null);
const { checkAuth } = useAuth();
const processed = useRef(false);
const [error, setError] = useState<string | null>(null);
const { checkAuth } = useAuth();
const processed = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (processed.current) return;
processed.current = true;
useEffect(() => {
const handleCallback = async () => {
if (processed.current) return;
processed.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (!code) {
setError('No authorization code received');
return;
}
if (!code) {
setError("No authorization code received");
return;
}
try {
const res = await client.api.auth.callback.$get({
query: {
code,
state: state || undefined
}
}, {
init: { credentials: 'include' }
});
try {
const res = await client.api.auth.callback.$get(
{
query: {
code,
state: state || undefined,
},
},
{
init: { credentials: "include" },
},
);
if (res.ok) {
await checkAuth();
// Redirect to home
window.location.href = '/';
} else {
const data = await res.json();
setError(data.error || 'Authentication failed');
}
} catch (err) {
setError('Authentication failed');
console.error(err);
}
};
if (res.ok) {
await checkAuth();
// Redirect to home
window.location.href = "/";
} else {
const data = await res.json();
setError(data.error || "Authentication failed");
}
} catch (err) {
setError("Authentication failed");
console.error(err);
}
};
handleCallback();
}, [checkAuth]);
handleCallback();
}, [checkAuth]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-red-600 mb-4">Authentication Error</h2>
<p className="text-gray-700">{error}</p>
<button
onClick={() => window.location.href = '/'}
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
>
Return to Home
</button>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Authentication Error
</h2>
<p className="text-gray-700">{error}</p>
<button
onClick={() => (window.location.href = "/")}
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
>
Return to Home
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Authenticating...</h2>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
</div>
</div>
);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Authenticating...
</h2>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
</div>
</div>
);
}

View File

@@ -1,291 +1,380 @@
import { useState, useEffect } from 'react';
import { client } from '../lib/client';
import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react';
import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { client } from "../lib/client";
interface Stats {
topics: {
topicSlug: string;
totalTasks: number;
totalRecipients: number;
totalSuccess: number;
}[];
recent: {
alertsReceived: number;
plannedMessages: number;
successCount: number;
failedCount: number;
successRate: number;
};
tasks: any[];
topics: {
topicSlug: string;
totalTasks: number;
totalRecipients: number;
totalSuccess: number;
}[];
recent: {
alertsReceived: number;
plannedMessages: number;
successCount: number;
failedCount: number;
successRate: number;
};
tasks: any[];
}
export default function SystemLoadView() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const fetchStats = async () => {
try {
const res = await client.api.stats.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
const fetchStats = async () => {
try {
const res = await client.api.stats.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
// Fetch recent tasks as well
const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, {
init: { credentials: 'include' }
});
const tasks = await tasksRes.json();
// Fetch recent tasks as well
const tasksRes = await client.api.alerts.tasks.$get(
{ query: { limit: "10" } },
{
init: { credentials: "include" },
},
);
const tasks = await tasksRes.json();
setStats({ ...data, tasks } as Stats);
setLastUpdated(new Date());
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
setStats({ ...data, tasks } as Stats);
setLastUpdated(new Date());
} catch (error) {
console.error("Failed to fetch stats:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
return () => clearInterval(interval);
}, []);
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
return () => clearInterval(interval);
}, []);
if (loading) return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
if (loading)
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
if (!stats) return <div className="text-center py-12 text-gray-500">Failed to load statistics.</div>;
if (!stats)
return (
<div className="text-center py-12 text-gray-500">
Failed to load statistics.
</div>
);
return (
<div className="space-y-8 animate-fade-in">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">Live Feedback</span>
</div>
<span className="text-xs text-gray-400">Last updated: {lastUpdated.toLocaleTimeString()}</span>
</div>
return (
<div className="space-y-8 animate-fade-in">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
Live Feedback
</span>
</div>
<span className="text-xs text-gray-400">
Last updated: {lastUpdated.toLocaleTimeString()}
</span>
</div>
{/* Top Row: General Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<MetricCard
title="Alerts Received"
value={stats.recent.alertsReceived}
icon={<Activity className="w-5 h-5 text-purple-500" />}
color="purple"
description="Total webhook hits"
/>
<MetricCard
title="Planned Deliveries"
value={stats.recent.plannedMessages}
icon={<Clock className="w-5 h-5 text-blue-500" />}
color="blue"
description="Total subscribers"
/>
<MetricCard
title="Success"
value={stats.recent.successCount}
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
color="green"
description="Successfully sent"
/>
<MetricCard
title="Failed"
value={stats.recent.failedCount}
icon={<XCircle className="w-5 h-5 text-red-500" />}
color="red"
description="API errors/failures"
/>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-500 mb-2">Success Rate</span>
<Gauge value={stats.recent.successRate} />
</div>
</div>
{/* Top Row: General Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<MetricCard
title="Alerts Received"
value={stats.recent.alertsReceived}
icon={<Activity className="w-5 h-5 text-purple-500" />}
color="purple"
description="Total webhook hits"
/>
<MetricCard
title="Planned Deliveries"
value={stats.recent.plannedMessages}
icon={<Clock className="w-5 h-5 text-blue-500" />}
color="blue"
description="Total subscribers"
/>
<MetricCard
title="Success"
value={stats.recent.successCount}
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
color="green"
description="Successfully sent"
/>
<MetricCard
title="Failed"
value={stats.recent.failedCount}
icon={<XCircle className="w-5 h-5 text-red-500" />}
color="red"
description="API errors/failures"
/>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-500 mb-2">
Success Rate
</span>
<Gauge value={stats.recent.successRate} />
</div>
</div>
{/* Middle Row: Topic Message Counts */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Historical Topic Metrics</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Alerts (Tasks)</th>
<th className="px-6 py-4 text-left tracking-wider">Planned (Recipients)</th>
<th className="px-6 py-4 text-left tracking-wider">Distributed (Success)</th>
<th className="px-6 py-4 text-left tracking-wider">Health Rate</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.topics.map((topic) => {
const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100;
return (
<tr key={topic.topicSlug} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className={`font-mono text-xs font-bold ${topic.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}>
{topic.topicSlug || '[Private DM]'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">{topic.totalTasks}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalRecipients}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalSuccess}</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-20 bg-gray-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-1000 ${rate > 90 ? 'bg-green-500' : rate > 70 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${rate}%` }}
></div>
</div>
<span className="text-[11px] font-bold text-gray-700">{rate.toFixed(1)}%</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${rate === 100 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rate === 100 ? 'Healthy' : 'Errors'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Middle Row: Topic Message Counts */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">
Historical Topic Metrics
</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">
Alerts (Tasks)
</th>
<th className="px-6 py-4 text-left tracking-wider">
Planned (Recipients)
</th>
<th className="px-6 py-4 text-left tracking-wider">
Distributed (Success)
</th>
<th className="px-6 py-4 text-left tracking-wider">
Health Rate
</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.topics.map((topic) => {
const rate =
topic.totalRecipients > 0
? (topic.totalSuccess / topic.totalRecipients) * 100
: 100;
return (
<tr
key={topic.topicSlug}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`font-mono text-xs font-bold ${topic.topicSlug ? "text-indigo-600 bg-indigo-50" : "text-gray-600 bg-gray-100"} px-2 py-1 rounded-md`}
>
{topic.topicSlug || "[Private DM]"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">
{topic.totalTasks}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{topic.totalRecipients}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{topic.totalSuccess}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-20 bg-gray-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-1000 ${rate > 90 ? "bg-green-500" : rate > 70 ? "bg-yellow-500" : "bg-red-500"}`}
style={{ width: `${rate}%` }}
></div>
</div>
<span className="text-[11px] font-bold text-gray-700">
{rate.toFixed(1)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
rate === 100
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{rate === 100 ? "Healthy" : "Errors"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Bottom Row: Recent Alerts with Sender Info */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Recent Alerts (Audit Log)</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Time</th>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Sender</th>
<th className="px-6 py-4 text-left tracking-wider">Recipients</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.tasks.map((task: any) => (
<tr key={task.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`font-mono text-xs font-bold ${task.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}>
{task.topicSlug || '[Private DM]'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{task.sender?.name || 'Unknown'}</span>
<span className="text-[10px] text-gray-400">{task.sender?.email || 'N/A'}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{task.successCount} / {task.recipientCount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${task.status === 'completed' ? 'bg-green-100 text-green-700' : task.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
}`}>
{task.status}
</span>
</td>
</tr>
))}
{stats.tasks.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500 italic text-sm">
No alerts sent yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
{/* Bottom Row: Recent Alerts with Sender Info */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">
Recent Alerts (Audit Log)
</h3>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<tr>
<th className="px-6 py-4 text-left tracking-wider">Time</th>
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Sender</th>
<th className="px-6 py-4 text-left tracking-wider">
Recipients
</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.tasks.map((task: any) => (
<tr
key={task.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`font-mono text-xs font-bold ${task.topicSlug ? "text-indigo-600 bg-indigo-50" : "text-gray-600 bg-gray-100"} px-2 py-1 rounded-md`}
>
{task.topicSlug || "[Private DM]"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{task.sender?.name || "Unknown"}
</span>
<span className="text-[10px] text-gray-400">
{task.sender?.email || "N/A"}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{task.successCount} / {task.recipientCount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
task.status === "completed"
? "bg-green-100 text-green-700"
: task.status === "failed"
? "bg-red-100 text-red-700"
: "bg-blue-100 text-blue-700"
}`}
>
{task.status}
</span>
</td>
</tr>
))}
{stats.tasks.length === 0 && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500 italic text-sm"
>
No alerts sent yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function MetricCard({ title, value, icon, color, description }: { title: string, value: number, icon: React.ReactNode, color: string, description?: string }) {
return (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex flex-col transition-all hover:shadow-md hover:-translate-y-1">
<div className="flex items-start justify-between mb-4">
<div className={`p-2.5 rounded-xl bg-${color}-50`}>
{icon}
</div>
<div className="text-right">
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">{title}</p>
<h3 className="text-2xl font-black text-gray-900 leading-none mt-1">{value.toLocaleString()}</h3>
</div>
</div>
{description && <p className="text-[10px] text-gray-500 font-medium italic">/ {description}</p>}
</div>
);
function MetricCard({
title,
value,
icon,
color,
description,
}: {
title: string;
value: number;
icon: React.ReactNode;
color: string;
description?: string;
}) {
return (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex flex-col transition-all hover:shadow-md hover:-translate-y-1">
<div className="flex items-start justify-between mb-4">
<div className={`p-2.5 rounded-xl bg-${color}-50`}>{icon}</div>
<div className="text-right">
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
{title}
</p>
<h3 className="text-2xl font-black text-gray-900 leading-none mt-1">
{value.toLocaleString()}
</h3>
</div>
</div>
{description && (
<p className="text-[10px] text-gray-500 font-medium italic">
/ {description}
</p>
)}
</div>
);
}
function Gauge({ value }: { value: number }) {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
const radius = 40;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
// Determine color based on value
const getColor = (v: number) => {
if (v >= 95) return '#10b981'; // green-500
if (v >= 80) return '#f59e0b'; // yellow-500
return '#ef4444'; // red-500
};
// Determine color based on value
const getColor = (v: number) => {
if (v >= 95) return "#10b981"; // green-500
if (v >= 80) return "#f59e0b"; // yellow-500
return "#ef4444"; // red-500
};
return (
<div className="relative flex items-center justify-center">
<svg className="w-32 h-32 transform -rotate-90">
<circle
className="text-gray-100"
strokeWidth="8"
stroke="currentColor"
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
<circle
className="transition-all duration-1000 ease-out"
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
stroke={getColor(value)}
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center -mt-1">
<span className="text-2xl font-bold text-gray-900">{value.toFixed(1)}%</span>
</div>
</div>
);
return (
<div className="relative flex items-center justify-center">
<svg className="w-32 h-32 transform -rotate-90">
<circle
className="text-gray-100"
strokeWidth="8"
stroke="currentColor"
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
<circle
className="transition-all duration-1000 ease-out"
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
stroke={getColor(value)}
fill="transparent"
r={radius}
cx="64"
cy="64"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center -mt-1">
<span className="text-2xl font-bold text-gray-900">
{value.toFixed(1)}%
</span>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +1,218 @@
import { useState, useEffect } from 'react';
import { Trash2, Plus } from 'lucide-react';
import Modal from '../components/Modal';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "../components/Modal";
import { useAuth } from "../contexts/AuthContext";
import { client } from "../lib/client";
interface User {
id: string;
name: string;
feishuUserId?: string;
email?: string;
personalToken?: string;
id: string;
name: string;
feishuUserId?: string;
email?: string;
personalToken?: string;
}
export default function UsersView() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Partial<User>>({
name: '',
feishuUserId: '',
email: '',
});
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Partial<User>>({
name: "",
feishuUserId: "",
email: "",
});
const fetchUsers = async () => {
setLoading(true);
try {
const res = await client.api.users.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
setUsers(data as unknown as User[]);
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
}
};
const fetchUsers = async () => {
setLoading(true);
try {
const res = await client.api.users.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
setUsers(data as unknown as User[]);
setLoading(false);
} catch (err) {
console.error(err);
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
fetchUsers();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await client.api.users.$post({
json: formData as any
}, {
init: { credentials: 'include' }
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await client.api.users.$post(
{
json: formData as any,
},
{
init: { credentials: "include" },
},
);
if (res.ok) {
setIsModalOpen(false);
setFormData({ name: '', feishuUserId: '', email: '' });
fetchUsers();
}
} catch (error) {
console.error('Error creating user:', error);
}
};
if (res.ok) {
setIsModalOpen(false);
setFormData({ name: "", feishuUserId: "", email: "" });
fetchUsers();
}
} catch (error) {
console.error("Error creating user:", error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await client.api.users[':id'].$delete({
param: { id }
}, {
init: { credentials: 'include' }
});
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this user?")) return;
try {
await client.api.users[":id"].$delete(
{
param: { id },
},
{
init: { credentials: "include" },
},
);
fetchUsers();
} catch (error) {
console.error("Error deleting user:", error);
}
};
if (loading) return <div className="p-4">Loading...</div>;
if (loading) return <div className="p-4">Loading...</div>;
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Users</h2>
{currentUser?.isAdmin && (
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Add User
</button>
)}
</div>
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Users</h2>
{currentUser?.isAdmin && (
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Add User
</button>
)}
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{users.map((user) => (
<li key={user.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-indigo-600 truncate">{user.name}</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<p className="flex items-center text-sm text-gray-500">
Feishu ID: <span className="font-mono ml-1 bg-gray-100 px-1 rounded">{user.feishuUserId || 'N/A'}</span>
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Email: {user.email || 'N/A'}
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Personal Token: <span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">{user.personalToken || 'N/A'}</span>
</p>
</div>
</div>
</div>
{currentUser?.isAdmin && (
<div className="ml-4 flex items-center space-x-2">
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900 p-2"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
</li>
))}
{users.length === 0 && (
<li className="px-4 py-8 text-center text-gray-500">
No users found. Create one to get started.
</li>
)}
</ul>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{users.map((user) => (
<li key={user.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-indigo-600 truncate">
{user.name}
</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<p className="flex items-center text-sm text-gray-500">
Feishu ID:{" "}
<span className="font-mono ml-1 bg-gray-100 px-1 rounded">
{user.feishuUserId || "N/A"}
</span>
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Email: {user.email || "N/A"}
</p>
<p className="flex items-center text-sm text-gray-500 mt-1">
Personal Token:{" "}
<span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">
{user.personalToken || "N/A"}
</span>
</p>
</div>
</div>
</div>
{currentUser?.isAdmin && (
<div className="ml-4 flex items-center space-x-2">
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900 p-2"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
</li>
))}
{users.length === 0 && (
<li className="px-4 py-8 text-center text-gray-500">
No users found. Create one to get started.
</li>
)}
</ul>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Add New User"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Feishu User ID</label>
<input
type="text"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.feishuUserId}
onChange={e => setFormData({ ...formData, feishuUserId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="flex justify-end pt-4">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700"
>
Create User
</button>
</div>
</form>
</Modal>
</div>
);
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Add New User"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Feishu User ID
</label>
<input
type="text"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.feishuUserId}
onChange={(e) =>
setFormData({ ...formData, feishuUserId: e.target.value })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
/>
</div>
<div className="flex justify-end pt-4">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700"
>
Create User
</button>
</div>
</form>
</Modal>
</div>
);
}