Add admin panel and fix simba mode response display
- Add /me, /admin/users, and WhatsApp link/unlink endpoints - Add AdminPanel component with user management UI - Add userService methods for admin API calls - Fix simba mode so cat responses appear in the message list - Fetch userinfo endpoint for groups on OIDC callback (Authelia compat) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,48 @@ class UserService {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> {
|
||||
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`);
|
||||
if (!response.ok) throw new Error("Failed to fetch user profile");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async adminListUsers(): Promise<AdminUserRecord[]> {
|
||||
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`);
|
||||
if (!response.ok) throw new Error("Failed to list users");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> {
|
||||
const response = await this.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
|
||||
{ method: "PUT", body: JSON.stringify({ whatsapp_number: number }) },
|
||||
);
|
||||
if (response.status === 409) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error ?? "WhatsApp number already in use");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to set WhatsApp number");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async adminUnlinkWhatsapp(userId: string): Promise<void> {
|
||||
const response = await this.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to unlink WhatsApp number");
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
whatsapp_number: string | null;
|
||||
auth_provider: string;
|
||||
}
|
||||
|
||||
export { UserService };
|
||||
export const userService = new UserService();
|
||||
|
||||
180
raggr-frontend/src/components/AdminPanel.tsx
Normal file
180
raggr-frontend/src/components/AdminPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { userService, type AdminUserRecord } from "../api/userService";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const AdminPanel = ({ onClose }: Props) => {
|
||||
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [rowError, setRowError] = useState<Record<string, string>>({});
|
||||
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
userService
|
||||
.adminListUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const startEdit = (user: AdminUserRecord) => {
|
||||
setEditingId(user.id);
|
||||
setEditValue(user.whatsapp_number ?? "");
|
||||
setRowError((prev) => ({ ...prev, [user.id]: "" }));
|
||||
setRowSuccess((prev) => ({ ...prev, [user.id]: "" }));
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const saveWhatsapp = async (userId: string) => {
|
||||
setRowError((prev) => ({ ...prev, [userId]: "" }));
|
||||
try {
|
||||
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((prev) => ({ ...prev, [userId]: "Saved" }));
|
||||
setEditingId(null);
|
||||
setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((prev) => ({
|
||||
...prev,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to save",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkWhatsapp = async (userId: string) => {
|
||||
setRowError((prev) => ({ ...prev, [userId]: "" }));
|
||||
try {
|
||||
await userService.adminUnlinkWhatsapp(userId);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
||||
);
|
||||
setRowSuccess((prev) => ({ ...prev, [userId]: "Unlinked" }));
|
||||
setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((prev) => ({
|
||||
...prev,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to unlink",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="bg-warm-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light">
|
||||
<h2 className="text-base font-semibold text-charcoal">Admin: WhatsApp Numbers</h2>
|
||||
<button
|
||||
className="text-warm-gray hover:text-charcoal text-xl leading-none cursor-pointer"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="px-6 py-10 text-center text-warm-gray text-sm">Loading…</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-sand-light text-left text-warm-gray">
|
||||
<th className="px-6 py-3 font-medium">Username</th>
|
||||
<th className="px-6 py-3 font-medium">Email</th>
|
||||
<th className="px-6 py-3 font-medium">WhatsApp</th>
|
||||
<th className="px-6 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b border-sand-light/50 hover:bg-cream/40 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3 text-charcoal font-medium">{user.username}</td>
|
||||
<td className="px-6 py-3 text-warm-gray">{user.email}</td>
|
||||
<td className="px-6 py-3">
|
||||
{editingId === user.id ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
className="border border-sand-light rounded-lg px-2 py-1 text-sm text-charcoal bg-cream focus:outline-none focus:ring-1 focus:ring-amber-soft w-52"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder="whatsapp:+15551234567"
|
||||
autoFocus
|
||||
/>
|
||||
{rowError[user.id] && (
|
||||
<span className="text-xs text-red-500">{rowError[user.id]}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={user.whatsapp_number ? "text-charcoal" : "text-warm-gray/50 italic"}>
|
||||
{user.whatsapp_number ?? "—"}
|
||||
</span>
|
||||
{rowSuccess[user.id] && (
|
||||
<span className="text-xs text-green-600">{rowSuccess[user.id]}</span>
|
||||
)}
|
||||
{rowError[user.id] && (
|
||||
<span className="text-xs text-red-500">{rowError[user.id]}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{editingId === user.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-amber-soft/80 text-charcoal hover:bg-amber-soft transition-colors cursor-pointer"
|
||||
onClick={() => saveWhatsapp(user.id)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{user.whatsapp_number && (
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-red-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
||||
onClick={() => unlinkWhatsapp(user.id)}
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { userService } from "../api/userService";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ToolBubble } from "./ToolBubble";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
type Message = {
|
||||
@@ -60,6 +62,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
useState<Conversation | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
@@ -129,6 +133,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,15 +176,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
if (simbaMode) {
|
||||
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
||||
const randomElement = simbaAnswers[randomIndex];
|
||||
setAnswer(randomElement);
|
||||
setQuestionsAnswers(
|
||||
questionsAnswers.concat([
|
||||
{
|
||||
question: query,
|
||||
answer: randomElement,
|
||||
},
|
||||
]),
|
||||
);
|
||||
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -282,6 +279,14 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 pb-4 pt-2 border-t border-white/10">
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="w-full py-2.5 px-3 text-sm text-cream/60 hover:text-cream hover:bg-white/5 rounded-lg transition-all duration-200 cursor-pointer"
|
||||
onClick={() => setShowAdminPanel(true)}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full py-2.5 px-3 text-sm text-cream/60 hover:text-cream hover:bg-white/5
|
||||
rounded-lg transition-all duration-200 cursor-pointer"
|
||||
@@ -303,6 +308,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
|
||||
{/* Main chat area */}
|
||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
|
||||
Reference in New Issue
Block a user