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:
Egutierrez
2026-05-25 01:05:43 +02:00
commit 0e3c5f5e84
30 changed files with 4283 additions and 0 deletions
+12
View File
@@ -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>
+26
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
935cdc8db32c31326419659d899b94e2
+1445
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+138
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+316
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+443
View File
@@ -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>
);
}
+23
View File
@@ -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>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+31
View File
@@ -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"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})