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:
ryan
2026-03-11 09:06:59 -04:00
parent 03c7e0c951
commit 53b2b3b366
4 changed files with 324 additions and 9 deletions

View File

@@ -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/<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})

View File

@@ -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();

View 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>
);
};

View File

@@ -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 */}