feat: member status actions, roles/audit-log UI, member deactivation sync
- PATCH /api/members/:id/status (suspend/reactivate/leave) with Matrix deactivation - Reconciliation worker comparing local member status against Synapse state - Roles page (assign/revoke) and audit log page on the frontend - Dashboard tiles for departments/teams now link to their pages - Reference bashclub/zamba-lxc-toolbox as a Proxmox-LXC autoinstaller option in Synapse docs Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
This commit is contained in:
1 parent
5e6ee2c456
commit
b38a5a4a57
8 files changed
+345
-18
No files matched your search
@@ -7,6 +7,10 @@ import { LoginPage } from "@/pages/LoginPage";
|
||||
import { RegisterPage } from "@/pages/RegisterPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { MembersPage } from "@/pages/MembersPage";
|
||||
import { DepartmentsPage } from "@/pages/DepartmentsPage";
|
||||
import { TeamsPage } from "@/pages/TeamsPage";
|
||||
import { RolesPage } from "@/pages/RolesPage";
|
||||
import { AuditLogPage } from "@/pages/AuditLogPage";
|
||||
import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage";
|
||||
|
||||
function App() {
|
||||
@@ -21,6 +25,10 @@ function App() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/members" element={<MembersPage />} />
|
||||
<Route path="/departments" element={<DepartmentsPage />} />
|
||||
<Route path="/teams" element={<TeamsPage />} />
|
||||
<Route path="/roles" element={<RolesPage />} />
|
||||
<Route path="/audit-log" element={<AuditLogPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -22,8 +22,8 @@ interface TileDef {
|
||||
|
||||
const tileDefs: TileDef[] = [
|
||||
{ key: "members", label: "Mitglieder", icon: Users, href: "/members" },
|
||||
{ key: "departments", label: "Abteilungen", icon: Layers },
|
||||
{ key: "teams", label: "Mannschaften", icon: Shield },
|
||||
{ key: "departments", label: "Abteilungen", icon: Layers, href: "/departments" },
|
||||
{ key: "teams", label: "Mannschaften", icon: Shield, href: "/teams" },
|
||||
{ key: "chatRooms", label: "Chatgruppen", icon: MessageSquare },
|
||||
{ key: "invitations", label: "Einladungen", icon: Mail },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
@@ -14,6 +14,7 @@ interface UseMembersResult {
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -28,26 +29,23 @@ export function useMembers(
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (!token || !organizationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
organizationId: organizationId!,
|
||||
organizationId,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(page * PAGE_SIZE),
|
||||
});
|
||||
const res = await fetch(`/api/members?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status}`);
|
||||
@@ -62,13 +60,17 @@ export function useMembers(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, organizationId, page],
|
||||
);
|
||||
|
||||
void load();
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [token, organizationId, page]);
|
||||
}, [load]);
|
||||
|
||||
return { members, total, loading, error };
|
||||
return { members, total, loading, error, refresh: () => load() };
|
||||
}
|
||||
|
||||
export { PAGE_SIZE };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useOrganization } from "@/lib/organization-context";
|
||||
import { useApiList } from "@/hooks/use-api-list";
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
metadata: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AuditLogPage() {
|
||||
const { token } = useAuth();
|
||||
const { activeOrganizationId } = useOrganization();
|
||||
|
||||
const path = activeOrganizationId
|
||||
? `/api/audit-logs?organizationId=${activeOrganizationId}`
|
||||
: null;
|
||||
const { data: logs, loading, error } = useApiList<AuditLog>(token, path, "logs");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Aktivitäten</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zeitpunkt</TableHead>
|
||||
<TableHead>Aktion</TableHead>
|
||||
<TableHead>Ziel</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
Lädt…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
Keine Einträge
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{new Date(log.createdAt).toLocaleString("de-DE")}</TableCell>
|
||||
<TableCell>{log.action}</TableCell>
|
||||
<TableCell>
|
||||
{log.targetType} · {log.targetId.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{log.metadata ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DashboardTiles } from "@/components/dashboard/DashboardTiles";
|
||||
import { OrganizationSwitcher } from "@/components/dashboard/OrganizationSwitcher";
|
||||
@@ -18,10 +19,18 @@ export function DashboardPage() {
|
||||
{user && <p className="text-sm text-muted-foreground">Angemeldet als {user.email}</p>}
|
||||
<OrganizationSwitcher />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/roles">Rollen & Rechte</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/audit-log">Aktivitäten</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={logout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!organizationsLoading && organizations.length === 0 ? (
|
||||
<CreateOrganizationForm />
|
||||
) : (
|
||||
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useOrganization } from "@/lib/organization-context";
|
||||
import { useMembers, PAGE_SIZE, type Member } from "@/hooks/use-members";
|
||||
@@ -30,11 +37,30 @@ export function MembersPage() {
|
||||
const { token } = useAuth();
|
||||
const { activeOrganizationId } = useOrganization();
|
||||
const [page, setPage] = useState(0);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
|
||||
const { members, total, loading, error } = useMembers(token, activeOrganizationId, page);
|
||||
const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page);
|
||||
|
||||
const hasNextPage = (page + 1) * PAGE_SIZE < total;
|
||||
|
||||
async function onChangeStatus(memberId: string, status: Member["status"]) {
|
||||
setStatusError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/members/${memberId}/status`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
throw new Error(body.error ?? `Fehler (${res.status})`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatusError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -48,6 +74,7 @@ export function MembersPage() {
|
||||
<p className="text-sm text-muted-foreground">Kein Verein ausgewählt.</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">Fehler beim Laden: {error}</p>}
|
||||
{statusError && <p className="text-sm text-destructive">{statusError}</p>}
|
||||
|
||||
{activeOrganizationId && (
|
||||
<>
|
||||
@@ -58,18 +85,19 @@ export function MembersPage() {
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Mitglied seit</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Lädt…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Keine Mitglieder
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -88,6 +116,32 @@ export function MembersPage() {
|
||||
<TableCell>
|
||||
{new Date(member.createdAt).toLocaleDateString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{member.status !== "active" && (
|
||||
<DropdownMenuItem onClick={() => onChangeStatus(member.id, "active")}>
|
||||
Reaktivieren
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{member.status !== "suspended" && (
|
||||
<DropdownMenuItem onClick={() => onChangeStatus(member.id, "suspended")}>
|
||||
Sperren
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{member.status !== "left" && (
|
||||
<DropdownMenuItem onClick={() => onChangeStatus(member.id, "left")}>
|
||||
Austritt vermerken
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useOrganization } from "@/lib/organization-context";
|
||||
import { useApiList } from "@/hooks/use-api-list";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
interface RoleAssignment {
|
||||
id: string;
|
||||
memberId: string;
|
||||
role: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
"ORG_ADMIN",
|
||||
"ORG_MANAGER",
|
||||
"DEPARTMENT_ADMIN",
|
||||
"TEAM_MANAGER",
|
||||
"TEAM_LEAD",
|
||||
"ASSISTANT_LEAD",
|
||||
"SUPERVISOR",
|
||||
"MEMBER",
|
||||
"GUARDIAN",
|
||||
];
|
||||
|
||||
export function RolesPage() {
|
||||
const { token } = useAuth();
|
||||
const { activeOrganizationId } = useOrganization();
|
||||
const [memberId, setMemberId] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null;
|
||||
const { data: members } = useApiList<Member>(
|
||||
token,
|
||||
orgQuery ? `/api/members?${orgQuery}&limit=100` : null,
|
||||
"members",
|
||||
);
|
||||
const { data: roles, loading, refresh } = useApiList<RoleAssignment>(
|
||||
token,
|
||||
orgQuery ? `/api/organization-roles?${orgQuery}` : null,
|
||||
"roles",
|
||||
);
|
||||
|
||||
async function onAssign() {
|
||||
if (!activeOrganizationId || !memberId || !role) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/organization-roles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ organizationId: activeOrganizationId, memberId, role }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
throw new Error(body.error ?? `Fehler (${res.status})`);
|
||||
}
|
||||
setMemberId("");
|
||||
setRole("");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/organization-roles/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
throw new Error(body.error ?? `Fehler (${res.status})`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Rollen & Rechte</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="pt-6 flex gap-2">
|
||||
<Select value={memberId} onValueChange={setMemberId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Mitglied" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{members.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.firstName} {m.lastName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Rolle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={onAssign} disabled={!memberId || !role}>
|
||||
Zuweisen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Lädt…</p>
|
||||
) : roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Rollen vergeben</p>
|
||||
) : (
|
||||
roles.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between rounded-md border p-3">
|
||||
<span>
|
||||
{r.firstName} {r.lastName} — <Badge variant="secondary">{r.role}</Badge>
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => onRevoke(r.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
# Synapse Setup (lokal/Server)
|
||||
|
||||
Minimal-Setup für einen Matrix-Synapse-Homeserver als Backend für TalkTime. Zwei Installationswege — Docker (empfohlen, einfacher zu warten) oder Bare-Metal (Debian/Ubuntu, kein Docker verfügbar/gewünscht).
|
||||
Minimal-Setup für einen Matrix-Synapse-Homeserver als Backend für TalkTime. Drei Installationswege — Docker (empfohlen, einfacher zu warten), Bare-Metal manuell (Debian/Ubuntu, kein Docker verfügbar/gewünscht), oder ein fertiges Autoinstaller-Skript für Proxmox-LXC.
|
||||
|
||||
## Option C: Autoinstaller für Proxmox-LXC
|
||||
|
||||
[bashclub/zamba-lxc-toolbox](https://github.com/bashclub/zamba-lxc-toolbox/tree/main/src/matrix) bringt ein fertiges `install-service.sh` für Debian-LXC mit: installiert Synapse (apt-Repo) + PostgreSQL + nginx + Element Web, stellt auf Postgres um, setzt `registration_shared_secret`, legt per `register_new_matrix_user` einen Admin-User an, generiert Self-Signed-TLS.
|
||||
|
||||
Praktisch als Schnellstart, wenn ein Proxmox-LXC zur Verfügung steht und Nginx+Element gleich mit dabei sein sollen. Skript vor Ausführung selbst prüfen (Repo-README/`zamba.conf` lesen) — nicht ungeprüft als root laufen lassen. Nach Lauf trotzdem `SYNAPSE_ADMIN_TOKEN` wie in Schritt 7/8 unten beschrieben ziehen und in TalkTime eintragen; `enable_registration` danach auf `false` setzen, da TalkTime die Benutzeranlage selbst übernimmt (siehe Schritt 5).
|
||||
|
||||
Für volle Kontrolle (kein extra nginx/Element, schlankeres Setup) weiter mit Option A oder B unten.
|
||||
|
||||
## Voraussetzungen (beide Varianten)
|
||||
|
||||
|
||||
Reference in new issue
Block a user