From 53b2b3b36672bb931c2528fd2fd363747f9b645c Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 11 Mar 2026 09:06:59 -0400 Subject: [PATCH] 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 --- blueprints/users/__init__.py | 86 +++++++++ raggr-frontend/src/api/userService.ts | 42 +++++ raggr-frontend/src/components/AdminPanel.tsx | 180 +++++++++++++++++++ raggr-frontend/src/components/ChatScreen.tsx | 25 ++- 4 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 raggr-frontend/src/components/AdminPanel.tsx diff --git a/blueprints/users/__init__.py b/blueprints/users/__init__.py index 5412c50..62a23a6 100644 --- a/blueprints/users/__init__.py +++ b/blueprints/users/__init__.py @@ -7,6 +7,7 @@ from quart_jwt_extended import ( ) from .models import User from .oidc_service import OIDCUserService +from .decorators import admin_required from config.oidc_config import oidc_config import secrets import httpx @@ -131,6 +132,21 @@ async def oidc_callback(): except Exception as e: 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 user = await OIDCUserService.get_or_create_user_from_oidc(claims) @@ -186,3 +202,73 @@ async def login(): refresh_token=refresh_token, 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//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//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}) diff --git a/raggr-frontend/src/api/userService.ts b/raggr-frontend/src/api/userService.ts index 7254960..a325fbe 100644 --- a/raggr-frontend/src/api/userService.ts +++ b/raggr-frontend/src/api/userService.ts @@ -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 { + 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 { + 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 { + 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(); diff --git a/raggr-frontend/src/components/AdminPanel.tsx b/raggr-frontend/src/components/AdminPanel.tsx new file mode 100644 index 0000000..e4f12ea --- /dev/null +++ b/raggr-frontend/src/components/AdminPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const [rowError, setRowError] = useState>({}); + const [rowSuccess, setRowSuccess] = useState>({}); + + 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 ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+

Admin: WhatsApp Numbers

+ +
+ + {/* Body */} +
+ {loading ? ( +
Loading…
+ ) : ( + + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
UsernameEmailWhatsAppActions
{user.username}{user.email} + {editingId === user.id ? ( +
+ setEditValue(e.target.value)} + placeholder="whatsapp:+15551234567" + autoFocus + /> + {rowError[user.id] && ( + {rowError[user.id]} + )} +
+ ) : ( +
+ + {user.whatsapp_number ?? "—"} + + {rowSuccess[user.id] && ( + {rowSuccess[user.id]} + )} + {rowError[user.id] && ( + {rowError[user.id]} + )} +
+ )} +
+ {editingId === user.id ? ( +
+ + +
+ ) : ( +
+ + {user.whatsapp_number && ( + + )} +
+ )} +
+ )} +
+
+
+ ); +}; diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 3129b57..131bdbe 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -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(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const [showAdminPanel, setShowAdminPanel] = useState(false); const messagesEndRef = useRef(null); const isMountedRef = useRef(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 */}
+ {isAdmin && ( + + )}