refactor: move admin management into session monitor, fix session_id enum format

- Remove standalone admins tab, replace with BotAdminsDialog in session monitor
- Add admin toggle button inline in chat header next to Active status
- Add BotAdminsDialog component with useBotAdmins hook
- Fix session_id written as LauncherTypes.PERSON_xxx instead of person_xxx
  (monitoring_helper.py x4, pipelinemgr.py x1 missing .value on launcher_type)
- Fix duplicate platform/person label in session list and chat header
- Migrate existing malformed session_id records in DB
This commit is contained in:
dadachann
2026-06-27 05:17:52 -04:00
parent 9ab346c678
commit 2383485b86
8 changed files with 545 additions and 411 deletions
@@ -32,7 +32,7 @@ class MonitoringHelper:
"""Record the start of query processing, returns message_id""" """Record the start of query processing, returns message_id"""
try: try:
# Check if session exists, if not, record session start # Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -137,7 +137,7 @@ class MonitoringHelper:
): ):
"""Record bot response message to monitoring""" """Record bot response message to monitoring"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -202,7 +202,7 @@ class MonitoringHelper:
) -> str: ) -> str:
"""Record query processing error, returns message_id""" """Record query processing error, returns message_id"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -268,7 +268,7 @@ class MonitoringHelper:
): ):
"""Record LLM call""" """Record LLM call"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call( await ap.monitoring_service.record_llm_call(
bot_id=bot_id, bot_id=bot_id,
+1 -1
View File
@@ -178,7 +178,7 @@ class RuntimePipeline:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '') message_id = query.variables.get('_monitoring_message_id', '')
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Update message status to error # Update message status to error
if message_id: if message_id:
+1 -23
View File
@@ -22,19 +22,11 @@ import {
import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import BotAdminsPanel from '@/app/home/bots/components/bot-admins/BotAdminsPanel';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
Settings,
FileText,
Users,
RefreshCw,
Trash2,
ShieldCheck,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -237,10 +229,6 @@ export default function BotDetailContent({ id }: { id: string }) {
</button> </button>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="admins" className="gap-1.5">
<ShieldCheck className="size-3.5" />
{t('bots.admins.title')}
</TabsTrigger>
</TabsList> </TabsList>
{/* Tab: Configuration */} {/* Tab: Configuration */}
@@ -303,16 +291,6 @@ export default function BotDetailContent({ id }: { id: string }) {
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4"> <TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} /> <BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent> </TabsContent>
{/* Tab: Admins */}
<TabsContent
value="admins"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl pb-8">
<BotAdminsPanel botId={id} />
</div>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, Plus, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
export interface BotAdmin {
id: number;
launcher_type: string;
launcher_id: string;
}
interface BotAdminsDialogProps {
botId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
admins: BotAdmin[];
onAdminsChange: () => void;
}
export default function BotAdminsDialog({
botId,
open,
onOpenChange,
admins,
onAdminsChange,
}: BotAdminsDialogProps) {
const { t } = useTranslation();
const [newType, setNewType] = useState('person');
const [newId, setNewId] = useState('');
const [adding, setAdding] = useState(false);
async function handleAdd() {
if (!newId.trim()) return;
setAdding(true);
try {
await httpClient.addBotAdmin(botId, newType, newId.trim());
toast.success(t('bots.admins.addSuccess'));
setNewId('');
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? ''));
} finally {
setAdding(false);
}
}
async function handleDelete(id: number) {
try {
await httpClient.deleteBotAdmin(botId, id);
toast.success(t('bots.admins.deleteSuccess'));
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(
t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''),
);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
{t('bots.admins.title')}
</DialogTitle>
<DialogDescription>{t('bots.admins.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Add row */}
<div className="flex gap-2 items-center">
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.admins.typePerson')}
</SelectItem>
<SelectItem value="group">
{t('bots.admins.typeGroup')}
</SelectItem>
</SelectContent>
</Select>
<Input
className="flex-1"
placeholder={t('bots.admins.placeholderId')}
value={newId}
onChange={(e) => setNewId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
<Button
size="sm"
onClick={handleAdd}
disabled={adding || !newId.trim()}
>
<Plus className="size-4 mr-1" />
{t('bots.admins.addAdmin')}
</Button>
</div>
{/* List */}
{admins.length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">
{t('bots.admins.noAdmins')}
</div>
) : (
<ScrollArea className="max-h-64">
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
{t('bots.admins.launcherType')}
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
{t('bots.admins.launcherId')}
</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr
key={admin.id}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
{admin.launcher_type === 'person'
? t('bots.admins.typePerson')
: t('bots.admins.typeGroup')}
</span>
</td>
<td className="px-3 py-2 font-mono">
{admin.launcher_id}
</td>
<td className="px-2 py-2">
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => handleDelete(admin.id)}
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Shared hook so the session monitor and the dialog stay in sync.
export function useBotAdmins(botId: string) {
const [admins, setAdmins] = useState<BotAdmin[]>([]);
const reload = useCallback(async () => {
try {
const res = await httpClient.getBotAdmins(botId);
setAdmins(res.admins ?? []);
} catch (error) {
console.error('Failed to load bot admins:', error);
}
}, [botId]);
useEffect(() => {
reload();
}, [reload]);
return { admins, reload };
}
@@ -1,153 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2, Plus } from 'lucide-react';
import { toast } from 'sonner';
interface BotAdmin {
id: number;
launcher_type: string;
launcher_id: string;
}
export default function BotAdminsPanel({ botId }: { botId: string }) {
const { t } = useTranslation();
const [admins, setAdmins] = useState<BotAdmin[]>([]);
const [loading, setLoading] = useState(false);
const [newType, setNewType] = useState('person');
const [newId, setNewId] = useState('');
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await httpClient.getBotAdmins(botId);
setAdmins(res.admins ?? []);
} finally {
setLoading(false);
}
}, [botId]);
useEffect(() => {
load();
}, [load]);
async function handleAdd() {
if (!newId.trim()) return;
setAdding(true);
try {
await httpClient.addBotAdmin(botId, newType, newId.trim());
toast.success(t('bots.admins.addSuccess'));
setNewId('');
await load();
} catch (e: any) {
toast.error(t('bots.admins.addError') + (e?.msg ?? e?.message ?? ''));
} finally {
setAdding(false);
}
}
async function handleDelete(id: number) {
try {
await httpClient.deleteBotAdmin(botId, id);
toast.success(t('bots.admins.deleteSuccess'));
setAdmins((prev) => prev.filter((a) => a.id !== id));
} catch (e: any) {
toast.error(t('bots.admins.deleteError') + (e?.msg ?? e?.message ?? ''));
}
}
return (
<div className="space-y-4">
<div className="flex gap-2 items-center">
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.admins.typePerson')}
</SelectItem>
<SelectItem value="group">{t('bots.admins.typeGroup')}</SelectItem>
</SelectContent>
</Select>
<Input
className="flex-1"
placeholder={t('bots.admins.placeholderId')}
value={newId}
onChange={(e) => setNewId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
<Button
size="sm"
onClick={handleAdd}
disabled={adding || !newId.trim()}
>
<Plus className="size-4 mr-1" />
{t('bots.admins.addAdmin')}
</Button>
</div>
{loading ? (
<div className="text-sm text-muted-foreground py-4 text-center">
{t('bots.sessionMonitor.loading')}
</div>
) : admins.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 text-center">
{t('bots.admins.noAdmins')}
</div>
) : (
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
{t('bots.admins.launcherType')}
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
{t('bots.admins.launcherId')}
</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr
key={admin.id}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
{admin.launcher_type === 'person'
? t('bots.admins.typePerson')
: t('bots.admins.typeGroup')}
</span>
</td>
<td className="px-3 py-2 font-mono">{admin.launcher_id}</td>
<td className="px-2 py-2">
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => handleDelete(admin.id)}
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -18,7 +18,14 @@ import {
Workflow, Workflow,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
ShieldCheck,
ShieldOff,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import BotAdminsDialog, {
useBotAdmins,
} from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import { copyToClipboard } from '@/app/utils/clipboard'; import { copyToClipboard } from '@/app/utils/clipboard';
import { import {
MessageChainComponent, MessageChainComponent,
@@ -94,15 +101,60 @@ const BotSessionMonitor = forwardRef<
Record<string, SessionFeedback> Record<string, SessionFeedback>
>({}); >({});
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const parseSessionType = (sessionId: string): string | null => { const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_'); const lower = sessionId.toLowerCase();
if (idx === -1) return null; if (lower.includes('person')) return 'person';
const type = sessionId.slice(0, idx); if (lower.includes('group')) return 'group';
if (type === 'person' || type === 'group') return type;
return null; return null;
}; };
const isSessionAdmin = (session: SessionInfo): boolean => {
const type = parseSessionType(session.session_id);
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
return admins.some(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
};
const toggleAdmin = async (session: SessionInfo) => {
const type = parseSessionType(session.session_id);
if (!type) return;
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
const key = session.session_id;
setTogglingAdmin(key);
try {
const existing = admins.find(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
if (existing) {
await httpClient.deleteBotAdmin(botId, existing.id);
toast.success(t('bots.admins.deleteSuccess'));
} else {
await httpClient.addBotAdmin(botId, type, lid);
toast.success(t('bots.admins.addSuccess'));
}
await reloadAdmins();
} catch {
toast.error(t('bots.admins.addError'));
} finally {
setTogglingAdmin(null);
}
};
const abbreviateId = (id: string): string => { const abbreviateId = (id: string): string => {
if (id.length <= 10) return id; if (id.length <= 10) return id;
return `${id.slice(0, 4)}..${id.slice(-4)}`; return `${id.slice(0, 4)}..${id.slice(-4)}`;
@@ -384,9 +436,28 @@ const BotSessionMonitor = forwardRef<
); );
return ( return (
<>
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden"> <div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */} {/* Left Panel: Session List */}
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0"> <div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
{/* Admin header */}
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
<button
type="button"
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
onClick={() => setAdminsDialogOpen(true)}
>
<ShieldCheck className="size-4" />
<span>
{t('bots.admins.configureAdmins')}
{admins.length > 0 && (
<span className="ml-1 tabular-nums text-xs text-muted-foreground">
({admins.length})
</span>
)}
</span>
</button>
</div>
{/* Session List */} {/* Session List */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? ( {loadingSessions && sessions.length === 0 ? (
@@ -401,12 +472,15 @@ const BotSessionMonitor = forwardRef<
<div className="p-1.5"> <div className="p-1.5">
{sessions.map((session) => { {sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id; const isSelected = selectedSessionId === session.session_id;
const sessionType = parseSessionType(session.session_id);
const sessionIsAdmin = isSessionAdmin(session);
return ( return (
<button <div
key={session.session_id} key={session.session_id}
type="button" role="button"
tabIndex={0}
className={cn( className={cn(
'w-full text-left px-2.5 py-2 rounded-md transition-colors', 'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50', isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)} )}
onClick={() => setSelectedSessionId(session.session_id)} onClick={() => setSelectedSessionId(session.session_id)}
@@ -422,16 +496,12 @@ const BotSessionMonitor = forwardRef<
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parseSessionType(session.session_id) && ( {sessionType && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]"> <span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{parseSessionType(session.session_id)} {sessionType}
</span>
)}
{session.platform && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{session.platform}
</span> </span>
)} )}
{session.user_id && ( {session.user_id && (
<span className="truncate text-[10px]"> <span className="truncate text-[10px]">
{abbreviateId(session.user_id)} {abbreviateId(session.user_id)}
@@ -443,7 +513,7 @@ const BotSessionMonitor = forwardRef<
</span> </span>
)} )}
</div> </div>
</button> </div>
); );
})} })}
</div> </div>
@@ -460,8 +530,8 @@ const BotSessionMonitor = forwardRef<
) : ( ) : (
<> <>
{/* Chat Header */} {/* Chat Header */}
<div className="px-4 py-2.5 border-b shrink-0"> <div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{selectedSession?.user_name || {selectedSession?.user_name ||
selectedSession?.user_id || selectedSession?.user_id ||
@@ -471,12 +541,6 @@ const BotSessionMonitor = forwardRef<
{parseSessionType(selectedSessionId) && ( {parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span> <span>{parseSessionType(selectedSessionId)}</span>
)} )}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && ( {selectedSession?.user_id && (
<> <>
<span>·</span> <span>·</span>
@@ -506,6 +570,34 @@ const BotSessionMonitor = forwardRef<
</span> </span>
</> </>
)} )}
{selectedSession && parseSessionType(selectedSessionId) && (
<>
<span>·</span>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 transition-colors',
isSessionAdmin(selectedSession)
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500',
)}
disabled={togglingAdmin === selectedSessionId}
title={
isSessionAdmin(selectedSession)
? t('bots.admins.removeAdminTitle')
: t('bots.admins.setAdminTitle')
}
onClick={() => toggleAdmin(selectedSession)}
>
{isSessionAdmin(selectedSession) ? (
<ShieldCheck className="size-3.5" />
) : (
<ShieldOff className="size-3.5" />
)}
<span>{t('bots.admins.adminBadge')}</span>
</button>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -554,7 +646,8 @@ const BotSessionMonitor = forwardRef<
isUser isUser
? 'bg-primary/10 rounded-br-sm' ? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm', : 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50', msg.status === 'error' &&
'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60', isDiscarded && 'opacity-60',
)} )}
> >
@@ -635,6 +728,15 @@ const BotSessionMonitor = forwardRef<
)} )}
</div> </div>
</div> </div>
<BotAdminsDialog
botId={botId}
open={adminsDialogOpen}
onOpenChange={setAdminsDialogOpen}
admins={admins}
onAdminsChange={reloadAdmins}
/>
</>
); );
}); });
+4
View File
@@ -459,6 +459,10 @@ const enUS = {
deleteSuccess: 'Admin removed', deleteSuccess: 'Admin removed',
deleteError: 'Failed to remove admin: ', deleteError: 'Failed to remove admin: ',
noAdmins: 'No admins configured', noAdmins: 'No admins configured',
setAdminTitle: 'Set as admin',
adminBadge: 'Admin',
configureAdmins: 'Manage Admins',
removeAdminTitle: 'Remove admin',
}, },
}, },
plugins: { plugins: {
+4
View File
@@ -440,6 +440,10 @@ const zhHans = {
deleteSuccess: '已移除', deleteSuccess: '已移除',
deleteError: '移除失败:', deleteError: '移除失败:',
noAdmins: '暂无管理员', noAdmins: '暂无管理员',
setAdminTitle: '设为管理员',
adminBadge: '管理员',
configureAdmins: '配置管理员',
removeAdminTitle: '移除管理员权限',
}, },
}, },
plugins: { plugins: {