feat: scaffold matrix_admin_panel v0.1.0 (issue 0163)
Wails + React + Mantine v7 admin panel for Matrix/Synapse. Replaces the removed synapse-admin container. MAS OIDC PKCE login (loopback :8766) + Synapse Admin API (users/rooms/sessions). - MAS client: XSFD2SWA394DXRVJFTREAMY6J6 (public PKCE, no auth method). - Backend: AdminService (Go) with Login/SetAdminToken/ListUsers/ DeactivateUser/ResetUserPassword/ListRooms/DeleteRoom/GetUserDevices. - Vendored helpers in internal/infra/ from registry: mas_oidc_loopback_go_infra, keyring_token_store_go_infra, synapse_admin_client_go_infra. - Frontend: AppShell + sidebar tabs (Users/Rooms/Sessions). Sessions placeholder pending MAS admin API. - Build verified: Linux + Windows.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>matrix_admin_panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "matrix_admin_panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.13.0",
|
||||
"@mantine/hooks": "^7.13.0",
|
||||
"@mantine/notifications": "^7.13.0",
|
||||
"@tabler/icons-react": "^3.19.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
935cdc8db32c31326419659d899b94e2
|
||||
Generated
+1445
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconShieldCheck } from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { SetAdminToken } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function AdminTokenModal({ opened, onClose, onSaved }: Props) {
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await SetAdminToken(token);
|
||||
notifications.show({
|
||||
title: "Admin token saved",
|
||||
message: "Admin API validated successfully.",
|
||||
color: "green",
|
||||
});
|
||||
setToken("");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-red-5)" />
|
||||
<Text fw={600}>Synapse admin token</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
withCloseButton={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
MAS aun no expone scope admin para la Synapse Admin API. Pega aqui el{" "}
|
||||
<Code>access_token</Code> de un usuario con <Code>admin: true</Code>{" "}
|
||||
(obtenible via Synapse legacy login o el <Code>.env</Code> del VPS).
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="access_token"
|
||||
placeholder="syt_..."
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="violet"
|
||||
onClick={handleSave}
|
||||
loading={busy}
|
||||
disabled={!token.trim()}
|
||||
>
|
||||
Validate and save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import LoginScreen from "./LoginScreen";
|
||||
import HomeScreen from "./HomeScreen";
|
||||
import AdminTokenModal from "./AdminTokenModal";
|
||||
import { GetSession } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
const LAST_USER_KEY = "matrix_admin_panel.last_user_id";
|
||||
|
||||
interface Session {
|
||||
user_id: string;
|
||||
homeserver_url: string;
|
||||
has_oidc_token: boolean;
|
||||
has_admin_token: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tokenModalOpen, setTokenModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const last = localStorage.getItem(LAST_USER_KEY);
|
||||
if (!last) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
GetSession(last)
|
||||
.then((s) => {
|
||||
const sess = s as Session | null;
|
||||
if (sess && sess.has_oidc_token) {
|
||||
setUserID(sess.user_id);
|
||||
setSession(sess);
|
||||
if (!sess.has_admin_token) {
|
||||
setTokenModalOpen(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function refreshSession(uid: string) {
|
||||
const s = (await GetSession(uid)) as Session | null;
|
||||
if (s) setSession(s);
|
||||
}
|
||||
|
||||
const handleLogin = async (uid: string) => {
|
||||
localStorage.setItem(LAST_USER_KEY, uid);
|
||||
setUserID(uid);
|
||||
await refreshSession(uid);
|
||||
// After OIDC login, ALWAYS prompt for admin token unless already saved.
|
||||
const s = (await GetSession(uid)) as Session | null;
|
||||
if (s && !s.has_admin_token) setTokenModalOpen(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem(LAST_USER_KEY);
|
||||
setUserID(null);
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
const handleTokenSaved = async () => {
|
||||
setTokenModalOpen(false);
|
||||
if (userID) await refreshSession(userID);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box pos="relative" mih="100vh">
|
||||
<LoadingOverlay visible={loading} />
|
||||
{userID ? (
|
||||
<>
|
||||
<HomeScreen
|
||||
userID={userID}
|
||||
session={session}
|
||||
onLogout={handleLogout}
|
||||
onRequestAdminToken={() => setTokenModalOpen(true)}
|
||||
/>
|
||||
<AdminTokenModal
|
||||
opened={tokenModalOpen}
|
||||
onClose={() => setTokenModalOpen(false)}
|
||||
onSaved={handleTokenSaved}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<LoginScreen onLogin={handleLogin} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
NavLink,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconLogout,
|
||||
IconUsers,
|
||||
IconBuildingCommunity,
|
||||
IconDeviceMobile,
|
||||
IconShieldCheck,
|
||||
IconShieldOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { Logout } from "../wailsjs/go/main/AdminService";
|
||||
import UsersTab from "./UsersTab";
|
||||
import RoomsTab from "./RoomsTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
|
||||
interface Session {
|
||||
user_id: string;
|
||||
homeserver_url: string;
|
||||
has_oidc_token: boolean;
|
||||
has_admin_token: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
type Tab = "users" | "rooms" | "sessions";
|
||||
|
||||
interface Props {
|
||||
userID: string;
|
||||
session: Session | null;
|
||||
onLogout: () => void;
|
||||
onRequestAdminToken: () => void;
|
||||
}
|
||||
|
||||
export default function HomeScreen({
|
||||
userID,
|
||||
session,
|
||||
onLogout,
|
||||
onRequestAdminToken,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<Tab>("users");
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await Logout(userID);
|
||||
} finally {
|
||||
onLogout();
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdmin = !!session?.has_admin_token;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{ width: 220, breakpoint: "sm" }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={22} color="var(--mantine-color-red-5)" />
|
||||
<Text fw={600}>matrix_admin_panel</Text>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
v0.1.0
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Tooltip label={userID}>
|
||||
<Text size="sm" c="dimmed" style={{ maxWidth: 260 }} truncate>
|
||||
{userID}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{hasAdmin ? (
|
||||
<Badge color="green" variant="light" leftSection={<IconShieldCheck size={12} />}>
|
||||
Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconShieldOff size={14} />}
|
||||
onClick={onRequestAdminToken}
|
||||
>
|
||||
Set admin token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="xs">
|
||||
<NavLink
|
||||
label="Users"
|
||||
leftSection={<IconUsers size={18} />}
|
||||
active={tab === "users"}
|
||||
onClick={() => setTab("users")}
|
||||
/>
|
||||
<NavLink
|
||||
label="Rooms"
|
||||
leftSection={<IconBuildingCommunity size={18} />}
|
||||
active={tab === "rooms"}
|
||||
onClick={() => setTab("rooms")}
|
||||
/>
|
||||
<NavLink
|
||||
label="Sessions"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
active={tab === "sessions"}
|
||||
onClick={() => setTab("sessions")}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Box>
|
||||
{tab === "users" && <UsersTab hasAdminToken={hasAdmin} />}
|
||||
{tab === "rooms" && <RoomsTab hasAdminToken={hasAdmin} />}
|
||||
{tab === "sessions" && <SessionsTab />}
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { Login } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
export default function LoginScreen({ onLogin }: { onLogin: (uid: string) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const uid = await Login();
|
||||
onLogin(uid);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh" p="lg">
|
||||
<Card shadow="md" padding="xl" radius="lg" withBorder maw={480} w="100%">
|
||||
<Stack gap="lg" align="center">
|
||||
<IconShieldCheck size={48} color="var(--mantine-color-red-5)" />
|
||||
<Stack gap={4} align="center">
|
||||
<Title order={2}>matrix_admin_panel</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Synapse admin (Wails + React + Mantine)
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="sm" ta="center" c="dimmed" maw={360}>
|
||||
Inicia sesion en <Code>matrix-af2f3d.organic-machine.com</Code> via
|
||||
Matrix Authentication Service. Tras login, se pedira un{" "}
|
||||
<Code>admin_token</Code> Synapse adicional.
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
w="100%"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
color="violet"
|
||||
loading={busy}
|
||||
onClick={handleClick}
|
||||
fullWidth
|
||||
>
|
||||
Sign in with MAS
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
v0.1.0 (issue 0163)
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Menu,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDots,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { ListRooms, DeleteRoom } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface AdminRoom {
|
||||
room_id: string;
|
||||
name: string;
|
||||
canonical_alias: string;
|
||||
joined_members: number;
|
||||
joined_local: number;
|
||||
version: string;
|
||||
encrypted: boolean;
|
||||
federatable: boolean;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
rooms: AdminRoom[];
|
||||
total_count: number;
|
||||
next_token: number;
|
||||
}
|
||||
|
||||
export default function RoomsTab({ hasAdminToken }: { hasAdminToken: boolean }) {
|
||||
const [data, setData] = useState<ListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [from, setFrom] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
// Delete modal
|
||||
const [deleteRoom, setDeleteRoom] = useState<AdminRoom | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [purge, setPurge] = useState(true);
|
||||
const [block, setBlock] = useState(false);
|
||||
|
||||
async function load() {
|
||||
if (!hasAdminToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = (await ListRooms(from, limit, search)) as unknown as ListResult;
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAdminToken) load();
|
||||
|
||||
}, [hasAdminToken, from]);
|
||||
|
||||
function applySearch() {
|
||||
setFrom(0);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteRoom) return;
|
||||
try {
|
||||
const id = await DeleteRoom(deleteRoom.room_id, reason, purge, block);
|
||||
notifications.show({
|
||||
title: "Room delete scheduled",
|
||||
message: `delete_id: ${id}`,
|
||||
color: "green",
|
||||
});
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
setPurge(true);
|
||||
setBlock(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Delete failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAdminToken) {
|
||||
return (
|
||||
<Alert color="orange" icon={<IconAlertCircle size={16} />}>
|
||||
Admin token not set. Click "Set admin token" on the top bar to enable
|
||||
room management.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={3}>Rooms</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFrom(0);
|
||||
load();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="md" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Search name/alias"
|
||||
placeholder="general"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
w={280}
|
||||
/>
|
||||
<Button variant="default" onClick={applySearch}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Room ID</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
<Table.Th>Encrypted</Table.Th>
|
||||
<Table.Th>Public</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data?.rooms?.map((r) => (
|
||||
<Table.Tr key={r.room_id}>
|
||||
<Table.Td>
|
||||
<Code>{r.room_id}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.name || r.canonical_alias || "-"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{r.joined_members}{" "}
|
||||
<Text component="span" size="xs" c="dimmed">
|
||||
({r.joined_local} local)
|
||||
</Text>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.encrypted ? (
|
||||
<Badge color="green" size="sm">
|
||||
E2EE
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
no
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.public ? (
|
||||
<Badge color="blue" size="sm">
|
||||
public
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
private
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => setDeleteRoom(r)}
|
||||
>
|
||||
Delete room
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!data?.rooms?.length && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No rooms.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Total: {data?.total_count ?? 0} · Showing from {from}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={from === 0}
|
||||
onClick={() => setFrom(Math.max(0, from - limit))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!data || data.next_token < 0}
|
||||
onClick={() => data && data.next_token >= 0 && setFrom(data.next_token)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={!!deleteRoom}
|
||||
onClose={() => {
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
}}
|
||||
title="Delete room"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
About to delete <Code>{deleteRoom?.room_id}</Code>. This is
|
||||
asynchronous.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason (shown to members)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Purge all messages and state — IRREVERSIBLE"
|
||||
checked={purge}
|
||||
onChange={(e) => setPurge(e.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Block — prevent new users from joining"
|
||||
checked={block}
|
||||
onChange={(e) => setBlock(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Alert, Box, Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconInfoCircle, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
export default function SessionsTab() {
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap="md">
|
||||
<Title order={3}>Sessions</Title>
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
<Text size="sm">
|
||||
TBD: MAS admin API integracion. Hoy la API admin de MAS sigue cerrada
|
||||
(issue 0163 v0.1.0). Cuando este disponible, esta vista listara
|
||||
tokens activos, dispositivos OIDC y permitira revocar sesiones.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
disabled
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Menu,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDots,
|
||||
IconKey,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconUserOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
ListUsers,
|
||||
DeactivateUser,
|
||||
ResetUserPassword,
|
||||
} from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface AdminUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
admin: boolean;
|
||||
deactivated: boolean;
|
||||
is_guest: boolean;
|
||||
creation_ts: number;
|
||||
last_seen_ts: number;
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
users: AdminUser[];
|
||||
total_count: number;
|
||||
next_token: number;
|
||||
}
|
||||
|
||||
type TriFilter = "any" | "yes" | "no";
|
||||
|
||||
function triToFilter(v: TriFilter): { set: boolean; val: boolean } {
|
||||
if (v === "yes") return { set: true, val: true };
|
||||
if (v === "no") return { set: true, val: false };
|
||||
return { set: false, val: false };
|
||||
}
|
||||
|
||||
function fmtTs(ts: number): string {
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts);
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export default function UsersTab({ hasAdminToken }: { hasAdminToken: boolean }) {
|
||||
const [data, setData] = useState<ListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [deactivated, setDeactivated] = useState<TriFilter>("any");
|
||||
const [admins, setAdmins] = useState<TriFilter>("any");
|
||||
const [from, setFrom] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
// Deactivate modal
|
||||
const [deactivateUser, setDeactivateUser] = useState<AdminUser | null>(null);
|
||||
const [erase, setErase] = useState(false);
|
||||
|
||||
// Reset password modal
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [logoutDevices, setLogoutDevices] = useState(true);
|
||||
|
||||
async function load() {
|
||||
if (!hasAdminToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = triToFilter(deactivated);
|
||||
const a = triToFilter(admins);
|
||||
const res = (await ListUsers({
|
||||
from,
|
||||
limit,
|
||||
search_term: search,
|
||||
deactivated_set: d.set,
|
||||
deactivated: d.val,
|
||||
admins_set: a.set,
|
||||
admins: a.val,
|
||||
})) as unknown as ListResult;
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAdminToken) load();
|
||||
|
||||
}, [hasAdminToken, from, deactivated, admins]);
|
||||
|
||||
function applySearch() {
|
||||
setFrom(0);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
if (!deactivateUser) return;
|
||||
try {
|
||||
await DeactivateUser(deactivateUser.user_id, erase);
|
||||
notifications.show({
|
||||
title: "User deactivated",
|
||||
message: deactivateUser.user_id,
|
||||
color: "green",
|
||||
});
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Deactivate failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!resetUser) return;
|
||||
try {
|
||||
await ResetUserPassword(resetUser.user_id, newPassword, logoutDevices);
|
||||
notifications.show({
|
||||
title: "Password reset",
|
||||
message: resetUser.user_id,
|
||||
color: "green",
|
||||
});
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
setLogoutDevices(true);
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Reset failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAdminToken) {
|
||||
return (
|
||||
<Alert color="orange" icon={<IconAlertCircle size={16} />}>
|
||||
Admin token not set. Click "Set admin token" on the top bar to enable user
|
||||
management.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={3}>Users</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFrom(0);
|
||||
load();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="md" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Search user_id"
|
||||
placeholder="@user:server"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
w={280}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Deactivated
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={deactivated}
|
||||
onChange={(v) => {
|
||||
setDeactivated(v as TriFilter);
|
||||
setFrom(0);
|
||||
}}
|
||||
data={[
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Admins
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={admins}
|
||||
onChange={(v) => {
|
||||
setAdmins(v as TriFilter);
|
||||
setFrom(0);
|
||||
}}
|
||||
data={[
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
<Button variant="default" onClick={applySearch}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User ID</Table.Th>
|
||||
<Table.Th>Display name</Table.Th>
|
||||
<Table.Th>Admin</Table.Th>
|
||||
<Table.Th>Deactivated</Table.Th>
|
||||
<Table.Th>Last seen</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data?.users?.map((u) => (
|
||||
<Table.Tr key={u.user_id}>
|
||||
<Table.Td>
|
||||
<Code>{u.user_id}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{u.display_name || "-"}</Table.Td>
|
||||
<Table.Td>
|
||||
{u.admin ? (
|
||||
<Badge color="green" size="sm">
|
||||
admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{u.deactivated ? (
|
||||
<Badge color="red" size="sm">
|
||||
yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
no
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{fmtTs(u.last_seen_ts)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconUserOff size={14} />}
|
||||
disabled={u.deactivated}
|
||||
onClick={() => setDeactivateUser(u)}
|
||||
>
|
||||
Deactivate
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconKey size={14} />}
|
||||
onClick={() => setResetUser(u)}
|
||||
>
|
||||
Reset password
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!data?.users?.length && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No users.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Total: {data?.total_count ?? 0} · Showing from {from}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={from === 0}
|
||||
onClick={() => setFrom(Math.max(0, from - limit))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!data || data.next_token < 0}
|
||||
onClick={() => data && data.next_token >= 0 && setFrom(data.next_token)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Deactivate modal */}
|
||||
<Modal
|
||||
opened={!!deactivateUser}
|
||||
onClose={() => {
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
}}
|
||||
title="Deactivate user"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
About to deactivate <Code>{deactivateUser?.user_id}</Code>. This is{" "}
|
||||
<strong>destructive</strong> and cannot be reversed via this panel.
|
||||
</Text>
|
||||
<Checkbox
|
||||
label="Erase all user data (purge messages, profile, etc.) — IRREVERSIBLE"
|
||||
checked={erase}
|
||||
onChange={(e) => setErase(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeactivate}>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Reset password modal */}
|
||||
<Modal
|
||||
opened={!!resetUser}
|
||||
onClose={() => {
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
}}
|
||||
title="Reset password"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
New password for <Code>{resetUser?.user_id}</Code>:
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Checkbox
|
||||
label="Logout all devices"
|
||||
checked={logoutDevices}
|
||||
onChange={(e) => setLogoutDevices(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="violet"
|
||||
onClick={handleReset}
|
||||
disabled={!newPassword.trim()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import App from "./App";
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "violet",
|
||||
defaultRadius: "md",
|
||||
fontFamily: "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider defaultColorScheme="dark" theme={theme}>
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
Reference in New Issue
Block a user