mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
feat: add group binding management
Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
users,
|
||||
} from "./db/schema";
|
||||
import { type AuthSession, requireAdmin, requireAuth } from "./middleware";
|
||||
import { notifyAdminsOfNewTopic } from "./lib/admin-notifier";
|
||||
|
||||
const api = new Hono<{ Variables: { session: AuthSession } }>();
|
||||
|
||||
@@ -30,6 +31,7 @@ const userSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
feishuUserId: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
isTrusted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// --- Topics ---
|
||||
@@ -73,6 +75,17 @@ api.get("/topics/requests", requireAdmin, async (c) => {
|
||||
return c.json(requests);
|
||||
});
|
||||
|
||||
api.get("/topics/groups/requests", requireAdmin, async (c) => {
|
||||
const requests = await db.query.topicGroupChats.findMany({
|
||||
where: eq(topicGroupChats.status, "pending"),
|
||||
with: {
|
||||
topic: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
return c.json(requests);
|
||||
});
|
||||
|
||||
api.get("/topics/all", requireAdmin, async (c) => {
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
with: {
|
||||
@@ -124,7 +137,7 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const session = c.get("session");
|
||||
|
||||
const status = session.isAdmin ? "approved" : "pending";
|
||||
const status = session.isAdmin || session.isTrusted ? "approved" : "pending";
|
||||
|
||||
const result = await db
|
||||
.insert(topics)
|
||||
@@ -132,9 +145,18 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
|
||||
...body,
|
||||
status,
|
||||
createdBy: session.id,
|
||||
approvedBy: session.isAdmin ? session.id : null,
|
||||
approvedBy: session.isAdmin || session.isTrusted ? session.id : null,
|
||||
})
|
||||
.returning();
|
||||
if (status === "pending") {
|
||||
await notifyAdminsOfNewTopic({
|
||||
id: result[0].id,
|
||||
name: result[0].name,
|
||||
slug: result[0].slug,
|
||||
createdBy: session.id,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
@@ -278,6 +300,21 @@ api.post(
|
||||
const body = c.req.valid("json");
|
||||
const session = c.get("session");
|
||||
|
||||
// Check topic ownership or admin
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: eq(topics.id, topicId),
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
return c.json({ error: "Topic not found" }, 404);
|
||||
}
|
||||
|
||||
if (topic.createdBy !== session.id && !session.isAdmin) {
|
||||
return c.json({ error: "Only topic owner or admin can bind groups" }, 403);
|
||||
}
|
||||
|
||||
const status = session.isAdmin || session.isTrusted ? "approved" : "pending";
|
||||
|
||||
const result = await db
|
||||
.insert(topicGroupChats)
|
||||
.values({
|
||||
@@ -285,9 +322,23 @@ api.post(
|
||||
chatId: body.chatId,
|
||||
name: body.name,
|
||||
createdBy: session.id,
|
||||
status,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (status === "pending") {
|
||||
// Notify admins about the new group binding request
|
||||
await notifyAdminsOfNewTopic({
|
||||
id: topic.id,
|
||||
name: topic.name,
|
||||
slug: topic.slug,
|
||||
createdBy: session.id,
|
||||
// Metadata passed to notifier for better context
|
||||
isGroupBinding: true,
|
||||
groupName: body.name,
|
||||
} as any);
|
||||
}
|
||||
|
||||
return c.json(result[0]);
|
||||
},
|
||||
);
|
||||
@@ -295,6 +346,23 @@ api.post(
|
||||
// Unbind a group
|
||||
api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
|
||||
const { id: topicId, bindingId } = c.req.param();
|
||||
const session = c.get("session");
|
||||
|
||||
// Check topic ownership or admin
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: eq(topics.id, topicId),
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
return c.json({ error: "Topic not found" }, 404);
|
||||
}
|
||||
|
||||
if (topic.createdBy !== session.id && !session.isAdmin) {
|
||||
return c.json(
|
||||
{ error: "Only topic owner or admin can unbind groups" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(topicGroupChats)
|
||||
@@ -308,6 +376,38 @@ api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Approve a group binding
|
||||
api.post("/topics/:id/groups/:bindingId/approve", requireAdmin, async (c) => {
|
||||
const { id: topicId, bindingId } = c.req.param();
|
||||
const result = await db
|
||||
.update(topicGroupChats)
|
||||
.set({ status: "approved" })
|
||||
.where(
|
||||
and(
|
||||
eq(topicGroupChats.id, bindingId),
|
||||
eq(topicGroupChats.topicId, topicId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// Reject a group binding
|
||||
api.post("/topics/:id/groups/:bindingId/reject", requireAdmin, async (c) => {
|
||||
const { id: topicId, bindingId } = c.req.param();
|
||||
const result = await db
|
||||
.update(topicGroupChats)
|
||||
.set({ status: "rejected" })
|
||||
.where(
|
||||
and(
|
||||
eq(topicGroupChats.id, bindingId),
|
||||
eq(topicGroupChats.topicId, topicId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// --- Alert Tasks ---
|
||||
|
||||
api.get("/alerts/tasks", requireAdmin, async (c) => {
|
||||
|
||||
@@ -78,13 +78,13 @@ auth.get("/callback", async (c) => {
|
||||
.returning();
|
||||
user = result[0];
|
||||
} else {
|
||||
// Update user info (in case name or admin status changed)
|
||||
// Update user info (don't overwrite admin/trusted status from feishu logic unless it's a new admin)
|
||||
const result = await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: userData.name,
|
||||
email: userData.email || user.email,
|
||||
isAdmin,
|
||||
isAdmin: user.isAdmin || isAdmin, // Keep admin if already admin or in ADMIN_EMAILS
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning();
|
||||
@@ -100,6 +100,7 @@ auth.get("/callback", async (c) => {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isTrusted: user.isTrusted,
|
||||
personalToken: user.personalToken,
|
||||
}),
|
||||
{
|
||||
@@ -117,6 +118,7 @@ auth.get("/callback", async (c) => {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isTrusted: user.isTrusted,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,6 +35,9 @@ export const topicGroupChats = pgTable("topic_group_chats", {
|
||||
.references(() => topics.id, { onDelete: "cascade" }),
|
||||
chatId: text("chat_id").notNull(), // 飞书群 chat_id
|
||||
name: text("name").notNull(), // 群名称快照
|
||||
status: text("status", { enum: ["pending", "approved", "rejected"] })
|
||||
.default("approved")
|
||||
.notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
createdBy: text("created_by").references(() => users.id),
|
||||
});
|
||||
@@ -83,6 +86,7 @@ export const users = pgTable("users", {
|
||||
feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
|
||||
email: text("email").unique(),
|
||||
isAdmin: boolean("is_admin").default(false),
|
||||
isTrusted: boolean("is_trusted").default(false),
|
||||
personalToken: text("personal_token")
|
||||
.notNull()
|
||||
.unique()
|
||||
|
||||
85
apps/server/src/lib/admin-notifier.ts
Normal file
85
apps/server/src/lib/admin-notifier.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { db } from "../db";
|
||||
import { users } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { feishuClient } from "../feishu";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export async function notifyAdminsOfNewTopic(topic: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
createdBy: string | null;
|
||||
isGroupBinding?: boolean;
|
||||
groupName?: string;
|
||||
}) {
|
||||
try {
|
||||
// 1. Get all admins
|
||||
const admins = await db.query.users.findMany({
|
||||
where: eq(users.isAdmin, true),
|
||||
});
|
||||
|
||||
if (admins.length === 0) {
|
||||
logger.warn("No admins found to notify");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Get creator name
|
||||
let creatorName = "Unknown";
|
||||
if (topic.createdBy) {
|
||||
const creator = await db.query.users.findFirst({
|
||||
where: eq(users.id, topic.createdBy),
|
||||
});
|
||||
if (creator) creatorName = creator.name;
|
||||
}
|
||||
|
||||
// 3. Prepare message content
|
||||
const title = topic.isGroupBinding
|
||||
? "🔗 新的群聊绑定申请"
|
||||
: "🆕 新的 Topic 申请";
|
||||
const detailContent = topic.isGroupBinding
|
||||
? `**Topic:** ${topic.name}\n**群聊:** ${topic.groupName}\n**创建者:** ${creatorName}`
|
||||
: `**名称:** ${topic.name}\n**Slug:** ${topic.slug}\n**创建者:** ${creatorName}`;
|
||||
|
||||
const content = {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
template: topic.isGroupBinding ? "blue" : "orange",
|
||||
title: { content: title, tag: "plain_text" },
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "div",
|
||||
text: {
|
||||
content: detailContent,
|
||||
tag: "lark_md",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "button",
|
||||
text: { content: "前往审批", tag: "plain_text" },
|
||||
type: "primary",
|
||||
url: `${process.env.FRONTEND_URL || "http://localhost:5173"}/admin/topics`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 4. Send notification to each admin
|
||||
for (const admin of admins) {
|
||||
if (admin.feishuUserId) {
|
||||
await feishuClient.sendMessage(
|
||||
admin.feishuUserId,
|
||||
"open_id",
|
||||
"interactive",
|
||||
content,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, topicId: topic.id }, "Failed to notify admins");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface AuthSession {
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isTrusted: boolean;
|
||||
}
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
|
||||
@@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
})
|
||||
.filter((u): u is NonNullable<typeof u> => u !== null);
|
||||
|
||||
const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({
|
||||
type: "group",
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
feishuId: g.chatId,
|
||||
idType: "chat_id" as FeishuReceiveIdType,
|
||||
}));
|
||||
const groupRecipients: Recipient[] = topic.groupChats
|
||||
.filter((g) => g.status === "approved")
|
||||
.map((g) => ({
|
||||
type: "group",
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
feishuId: g.chatId,
|
||||
idType: "chat_id" as FeishuReceiveIdType,
|
||||
}));
|
||||
|
||||
const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ interface GroupBinding {
|
||||
id: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
}
|
||||
|
||||
interface KnownGroup {
|
||||
@@ -103,7 +104,14 @@ export default function GroupBindingsModal({
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
setStatus({ type: "success", message: "Group bound successfully!" });
|
||||
const data = (await res.json()) as GroupBinding;
|
||||
setStatus({
|
||||
type: "success",
|
||||
message:
|
||||
data.status === "approved"
|
||||
? "Group bound successfully!"
|
||||
: "Request submitted! Waiting for approval.",
|
||||
});
|
||||
fetchBindings();
|
||||
setSelectedChatId("");
|
||||
} else {
|
||||
@@ -166,11 +174,21 @@ export default function GroupBindingsModal({
|
||||
key={binding.id}
|
||||
className="flex justify-between items-center p-3"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center flex-1">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-sm text-gray-700 mr-2">
|
||||
{binding.name}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium ${binding.status === "approved"
|
||||
? "bg-green-100 text-green-800"
|
||||
: binding.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{binding.status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -30,38 +30,45 @@ export default function AdminView() {
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="border-b border-gray-200 mb-6 overflow-x-auto">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("load")}
|
||||
className={`${
|
||||
activeTab === "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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
System Load
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("requests")}
|
||||
className={`${
|
||||
activeTab === "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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Topic Requests
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("topics")}
|
||||
className={`${
|
||||
activeTab === "topics"
|
||||
onClick={() => setActiveTab("group-requests")}
|
||||
className={`${activeTab === "group-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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Group Bindings
|
||||
</button>
|
||||
<button
|
||||
type="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>
|
||||
@@ -70,12 +77,126 @@ export default function AdminView() {
|
||||
|
||||
{activeTab === "load" && <SystemLoadView />}
|
||||
{activeTab === "requests" && <TopicRequestsList />}
|
||||
{activeTab === "group-requests" && <GroupRequestsList />}
|
||||
{activeTab === "topics" && <TopicsManagement />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupRequest {
|
||||
id: string;
|
||||
topicId: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
topic?: Topic;
|
||||
creator?: TopicUser;
|
||||
}
|
||||
|
||||
function GroupRequestsList() {
|
||||
const [requests, setRequests] = useState<GroupRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// @ts-ignore - groups requests might not be in the generated client yet
|
||||
const res = await client.api.topics.groups.requests.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setRequests(data as unknown as GroupRequest[]);
|
||||
} else {
|
||||
setRequests([]);
|
||||
}
|
||||
} else {
|
||||
setRequests([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setRequests([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [fetchRequests]);
|
||||
|
||||
const handleAction = async (
|
||||
req: GroupRequest,
|
||||
action: "approve" | "reject",
|
||||
) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await client.api.topics[":id"].groups[":bindingId"][action].$post(
|
||||
{ param: { id: req.topicId, bindingId: req.id } },
|
||||
{ init: { credentials: "include" } },
|
||||
);
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading group requests...</div>;
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No pending group binding 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">
|
||||
Group: <span className="text-indigo-600">{req.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Topic: <span className="font-semibold">{req.topic?.name}</span> (
|
||||
{req.topic?.slug})
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Requested by: {req.creator?.name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
ID: {req.chatId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(req, "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
|
||||
type="button"
|
||||
onClick={() => handleAction(req, "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>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopicsManagement() {
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -170,13 +291,12 @@ function TopicsManagement() {
|
||||
</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"
|
||||
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>
|
||||
|
||||
@@ -10,6 +10,8 @@ interface User {
|
||||
feishuUserId?: string;
|
||||
email?: string;
|
||||
personalToken?: string;
|
||||
isTrusted?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export default function UsersView() {
|
||||
@@ -85,6 +87,28 @@ export default function UsersView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTrusted = async (user: User) => {
|
||||
try {
|
||||
await client.api.users[":id"].$put(
|
||||
{
|
||||
param: { id: user.id },
|
||||
json: {
|
||||
name: user.name,
|
||||
feishuUserId: user.feishuUserId,
|
||||
email: user.email,
|
||||
isTrusted: !user.isTrusted,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("Error toggling trusted status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-4">Loading...</div>;
|
||||
|
||||
return (
|
||||
@@ -110,9 +134,16 @@ export default function UsersView() {
|
||||
<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="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
{user.isAdmin && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
@@ -134,11 +165,25 @@ export default function UsersView() {
|
||||
</div>
|
||||
</div>
|
||||
{currentUser?.isAdmin && (
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<div className="ml-4 flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={user.isTrusted || false}
|
||||
onChange={() => handleToggleTrusted(user)}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
|
||||
<span className="ms-3 text-sm font-medium text-gray-500">
|
||||
Trusted
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900 p-2"
|
||||
className="text-red-400 hover:text-red-600 p-2 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user