feat: overhaul groups management, remove global team_admin and isolate group administration
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-25 17:14:15 +01:00
1 parent 60c302e23e
commit e37302f73c
11 files changed
+936 -388

No files matched your search

@@ -10,16 +10,13 @@ import {
Copy,
Check,
ArrowLeft,
Share2,
Send,
AlertCircle,
Building2,
UserPlus,
Share2,
} from 'lucide-react';
import {
Organization,
SystemSettings,
Team,
User,
UserInvite,
UserRole,
@@ -31,7 +28,7 @@ interface AdminSettingsViewProps {
onBack: () => void;
}
type AdminSection = 'users' | 'teams' | 'server' | 'smtp';
type AdminSection = 'users' | 'server' | 'smtp';
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
currentUser,
@@ -50,19 +47,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [inviteTeamId, setInviteTeamId] = useState('');
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
// --- Teams & Groups State ---
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [newTeamName, setNewTeamName] = useState('');
const [newTeamDesc, setNewTeamDesc] = useState('');
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, string>>({});
// --- Server & SMTP Settings State ---
const [settings, setSettings] = useState<SystemSettings>({
sharingMode: 'teams_only',
@@ -83,14 +72,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const loadAll = async () => {
try {
const [uList, invList, teamList] = await Promise.all([
const [uList, invList] = await Promise.all([
api.adminGetUsers(),
api.adminGetInvites(),
api.getUserTeams(),
]);
setUsers(uList);
setInvites(invList);
setAllTeams(teamList);
if (isServerAdmin) {
const sys = await api.adminGetSettings();
@@ -149,7 +136,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const inv = await api.adminCreateInvite({
email: inviteEmail.trim(),
role: inviteRole,
teamId: inviteTeamId,
});
setInvites((prev) => [inv, ...prev]);
setInviteEmail('');
@@ -231,64 +217,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// ============================================================================
// HANDLERS: TEAMS & GRUPPEN
// ============================================================================
const handleCreateTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTeamName.trim()) return;
setStatusMsg(null);
try {
const created = await api.createTeam(
newTeamName.trim(),
newTeamDesc.trim()
);
setNewTeamName('');
setNewTeamDesc('');
await loadAll();
setStatusMsg({ type: 'ok', text: `Gruppe / Team "${created.name}" wurde erstellt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleDeleteTeam = async (team: Team) => {
if (!window.confirm(`Gruppe / Team "${team.name}" wirklich löschen?`)) return;
setStatusMsg(null);
try {
await api.deleteTeam(team.id);
await loadAll();
setStatusMsg({ type: 'ok', text: `Gruppe / Team "${team.name}" gelöscht.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleAddMemberToTeam = async (teamId: string) => {
const selectedUserId = memberSelectByTeam[teamId];
if (!selectedUserId) return;
const role = memberRoleByTeam[teamId] || 'member';
setStatusMsg(null);
try {
await api.addTeamMember(teamId, { userId: selectedUserId, role });
setMemberSelectByTeam((prev) => ({ ...prev, [teamId]: '' }));
await loadAll();
setStatusMsg({ type: 'ok', text: 'Mitglied zur Gruppe hinzugefügt.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleRemoveMemberFromTeam = async (teamId: string, userId: string) => {
setStatusMsg(null);
try {
await api.removeTeamMember(teamId, userId);
await loadAll();
setStatusMsg({ type: 'ok', text: 'Mitglied aus Gruppe entfernt.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
// ============================================================================
// HANDLERS: SERVER & SMTP SETTINGS
@@ -327,9 +256,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// All teams for invite dropdown
const flattenedTeams: Team[] = allTeams;
return (
<div className="w-full space-y-4">
{/* Top Page Header */}
@@ -346,17 +272,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<span>Administration & Team-Steuerung</span>
<span>Server-Administration</span>
</h1>
<p className="text-xs text-muted">
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
</p>
</div>
</div>
<span className="badge-accent">
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
</span>
<span className="badge-accent">Server-Administrator</span>
</div>
{statusMsg && (
@@ -401,21 +325,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
</button>
<button
onClick={() => setSection('teams')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'teams'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Users className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Teams & Gruppen</p>
<p className="text-[10px] text-faint">Familie, Arbeit, Freunde zum Teilen</p>
</div>
</button>
{isServerAdmin && (
<>
<button
@@ -520,8 +429,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied (Standard)</option>
<option value="team_admin">Team-Admin (Darf einladen)</option>
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</option>}
<option value="admin">Server-Admin (Vollzugriff)</option>
</select>
</div>
</div>
@@ -546,7 +454,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">
Gebundene E-Mail (optional)
@@ -561,33 +469,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Zugewiesene Rolle</label>
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as UserRole)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
{isServerAdmin && <option value="admin">Server-Admin</option>}
</select>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">
Automatisch Team zuweisen (optional)
</label>
<select
value={inviteTeamId}
onChange={(e) => setInviteTeamId(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="">Kein direktes Team</option>
{flattenedTeams.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
<option value="admin">Server-Admin</option>
</select>
</div>
</div>
@@ -690,8 +579,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
title="Benutzerrolle ändern"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
{isServerAdmin && <option value="admin">Server-Admin</option>}
<option value="admin">Server-Admin</option>
</select>
{isServerAdmin && (
@@ -758,203 +646,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: TEAMS & GRUPPEN */}
{/* ================================================================= */}
{section === 'teams' && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Teams & Gruppen verwalten</h2>
<p className="text-muted mt-0.5">
Erstelle Gruppen wie Familie, Arbeit oder Freunde. So kannst du Notizen und Listen
direkt für eine gesamte Gruppe freigeben, ohne jeden Benutzer einzeln auswählen zu müssen.
</p>
</div>
{/* Create Team/Group Card */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-2">
<Plus className="w-4 h-4 text-accent" />
<span>Neue Gruppe / Neues Team anlegen</span>
</h3>
<form onSubmit={handleCreateTeam} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-muted text-[11px] mb-1 font-medium">
Name der Gruppe / des Teams *
</label>
<input
type="text"
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder="z. B. Familie, Arbeit, Freunde…"
required
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div>
<label className="block text-muted text-[11px] mb-1 font-medium">
Beschreibung (optional)
</label>
<input
type="text"
value={newTeamDesc}
onChange={(e) => setNewTeamDesc(e.target.value)}
placeholder="z. B. Gemeinsame Einkaufslisten & Haushaltsnotizen…"
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
</div>
<div className="flex justify-end">
<button type="submit" className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" />
<span>Gruppe / Team anlegen</span>
</button>
</div>
</form>
</div>
{/* Teams & Groups List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-main text-xs uppercase tracking-wider text-muted">
Bestehende Gruppen & Teams ({allTeams.length})
</h3>
</div>
{allTeams.length === 0 ? (
<div className="p-8 bg-canvas border border-border rounded-card text-center space-y-2">
<Users className="w-8 h-8 text-faint mx-auto opacity-50" />
<p className="text-muted text-xs font-medium">Noch keine Teams oder Gruppen vorhanden.</p>
<p className="text-faint text-[11px]">
Lege oben deine erste Gruppe an (z. B. &quot;Familie&quot; oder &quot;Arbeit&quot;), um Notizen und Listen gemeinsam zu nutzen.
</p>
</div>
) : (
allTeams.map((team) => {
const members = Array.isArray(team.members) ? team.members : [];
const availableUsers = users.filter(
(u) => !members.some((m) => m.userId === u.id)
);
return (
<div
key={team.id}
className="p-4 bg-canvas border border-border rounded-card space-y-4"
>
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex items-start gap-2.5">
<div className="w-8 h-8 rounded-btn bg-accent/10 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
<Users className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-main">{team.name}</span>
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
</span>
</div>
{team.description && (
<p className="text-xs text-muted mt-1">
{team.description}
</p>
)}
</div>
</div>
<button
onClick={() => handleDeleteTeam(team)}
className="btn-icon btn-icon-danger"
title="Gruppe / Team löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Members Chips */}
<div className="space-y-2">
<p className="text-[11px] font-semibold text-muted">
Mitglieder ({members.length}):
</p>
{members.length === 0 ? (
<p className="text-[11px] text-faint">
Noch keine Mitglieder in dieser Gruppe.
</p>
) : (
<div className="flex flex-wrap gap-2">
{members.map((m) => (
<div
key={m.userId}
className="px-2.5 py-1 bg-panel border border-border rounded-btn flex items-center gap-2 text-xs"
>
<span className="text-main font-medium">
{m.displayName}
</span>
<span className="text-[10px] text-muted">
({m.role === 'team_admin' ? 'Gruppen-Admin' : 'Mitglied'})
</span>
<button
onClick={() => handleRemoveMemberFromTeam(team.id, m.userId)}
className="text-faint hover:text-status-err font-bold ml-0.5"
title="Aus Gruppe entfernen"
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Add User to Team Row */}
<div className="pt-3 border-t border-border flex flex-wrap items-center gap-2">
<select
value={memberSelectByTeam[team.id] || ''}
onChange={(e) =>
setMemberSelectByTeam((prev) => ({
...prev,
[team.id]: e.target.value,
}))
}
className="flex-1 min-w-[200px] h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">Benutzer auswählen zum Hinzufügen…</option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName} ({u.email})
</option>
))}
</select>
<select
value={memberRoleByTeam[team.id] || 'member'}
onChange={(e) =>
setMemberRoleByTeam((prev) => ({
...prev,
[team.id]: e.target.value,
}))
}
className="h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="member">Mitglied</option>
<option value="team_admin">Gruppen-Admin</option>
</select>
<button
type="button"
onClick={() => handleAddMemberToTeam(team.id)}
disabled={!memberSelectByTeam[team.id]}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40"
>
<UserPlus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
);
})
)}
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}