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:
@@ -7,6 +7,7 @@ from quart_jwt_extended import (
|
|||||||
)
|
)
|
||||||
from .models import User
|
from .models import User
|
||||||
from .oidc_service import OIDCUserService
|
from .oidc_service import OIDCUserService
|
||||||
|
from .decorators import admin_required
|
||||||
from config.oidc_config import oidc_config
|
from config.oidc_config import oidc_config
|
||||||
import secrets
|
import secrets
|
||||||
import httpx
|
import httpx
|
||||||
@@ -131,6 +132,21 @@ async def oidc_callback():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
|
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
|
||||||
|
|
||||||
|
# Fetch userinfo to get groups (older Authelia versions only include groups there)
|
||||||
|
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||||
|
if userinfo_endpoint:
|
||||||
|
access_token_str = tokens.get("access_token")
|
||||||
|
if access_token_str:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
userinfo_response = await client.get(
|
||||||
|
userinfo_endpoint,
|
||||||
|
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||||
|
)
|
||||||
|
if userinfo_response.status_code == 200:
|
||||||
|
userinfo = userinfo_response.json()
|
||||||
|
if "groups" in userinfo and "groups" not in claims:
|
||||||
|
claims["groups"] = userinfo["groups"]
|
||||||
|
|
||||||
# Get or create user from OIDC claims
|
# Get or create user from OIDC claims
|
||||||
user = await OIDCUserService.get_or_create_user_from_oidc(claims)
|
user = await OIDCUserService.get_or_create_user_from_oidc(claims)
|
||||||
|
|
||||||
@@ -186,3 +202,73 @@ async def login():
|
|||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
user={"id": str(user.id), "username": user.username},
|
user={"id": str(user.id), "username": user.username},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/me", methods=["GET"])
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def me():
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
return jsonify({
|
||||||
|
"id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"is_admin": user.is_admin(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/admin/users", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
async def list_users():
|
||||||
|
users = await User.all().order_by("username")
|
||||||
|
return jsonify([
|
||||||
|
{
|
||||||
|
"id": str(u.id),
|
||||||
|
"username": u.username,
|
||||||
|
"email": u.email,
|
||||||
|
"whatsapp_number": u.whatsapp_number,
|
||||||
|
"auth_provider": u.auth_provider,
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
async def set_whatsapp(user_id):
|
||||||
|
data = await request.get_json()
|
||||||
|
number = (data or {}).get("whatsapp_number", "").strip()
|
||||||
|
if not number:
|
||||||
|
return jsonify({"error": "whatsapp_number is required"}), 400
|
||||||
|
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
conflict = await User.filter(whatsapp_number=number).exclude(id=user_id).first()
|
||||||
|
if conflict:
|
||||||
|
return jsonify({"error": "That WhatsApp number is already linked to another account"}), 409
|
||||||
|
|
||||||
|
user.whatsapp_number = number
|
||||||
|
await user.save()
|
||||||
|
return jsonify({
|
||||||
|
"id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"whatsapp_number": user.whatsapp_number,
|
||||||
|
"auth_provider": user.auth_provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
async def unlink_whatsapp(user_id):
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
user.whatsapp_number = None
|
||||||
|
await user.save()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -134,6 +134,48 @@ class UserService {
|
|||||||
|
|
||||||
return response;
|
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();
|
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 { useEffect, useState, useRef } from "react";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { conversationService } from "../api/conversationService";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
import { QuestionBubble } from "./QuestionBubble";
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
import { AnswerBubble } from "./AnswerBubble";
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
import { ToolBubble } from "./ToolBubble";
|
import { ToolBubble } from "./ToolBubble";
|
||||||
import { MessageInput } from "./MessageInput";
|
import { MessageInput } from "./MessageInput";
|
||||||
import { ConversationList } from "./ConversationList";
|
import { ConversationList } from "./ConversationList";
|
||||||
|
import { AdminPanel } from "./AdminPanel";
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
@@ -60,6 +62,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
useState<Conversation | null>(null);
|
useState<Conversation | null>(null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||||||
const [isLoading, setIsLoading] = 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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isMountedRef = useRef<boolean>(true);
|
const isMountedRef = useRef<boolean>(true);
|
||||||
@@ -129,6 +133,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
|
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,15 +176,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
if (simbaMode) {
|
if (simbaMode) {
|
||||||
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
||||||
const randomElement = simbaAnswers[randomIndex];
|
const randomElement = simbaAnswers[randomIndex];
|
||||||
setAnswer(randomElement);
|
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
|
||||||
setQuestionsAnswers(
|
|
||||||
questionsAnswers.concat([
|
|
||||||
{
|
|
||||||
question: query,
|
|
||||||
answer: randomElement,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,6 +279,14 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="px-3 pb-4 pt-2 border-t border-white/10">
|
<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
|
<button
|
||||||
className="w-full py-2.5 px-3 text-sm text-cream/60 hover:text-cream hover:bg-white/5
|
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"
|
rounded-lg transition-all duration-200 cursor-pointer"
|
||||||
@@ -303,6 +308,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||||
|
|
||||||
{/* Main chat area */}
|
{/* Main chat area */}
|
||||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
|
|||||||
Reference in New Issue
Block a user