feat(settings): simplify team management into flat teams and groups
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-25 12:42:45 +01:00
1 parent 1a37ba9342
commit cfc0477b5c
4 files changed
+170 -172

No files matched your search

@@ -31,7 +31,7 @@ interface AdminSettingsViewProps {
onBack: () => void;
}
type AdminSection = 'users' | 'orgs' | 'server' | 'smtp';
type AdminSection = 'users' | 'teams' | 'server' | 'smtp';
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
currentUser,
@@ -56,11 +56,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
// --- Organizations & Teams State ---
const [orgs, setOrgs] = useState<Organization[]>([]);
// --- Teams & Groups State ---
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [newOrgName, setNewOrgName] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState('');
const [newTeamName, setNewTeamName] = useState('');
const [newTeamDesc, setNewTeamDesc] = useState('');
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
@@ -86,19 +83,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const loadAll = async () => {
try {
const [uList, invList, orgList, teamList] = await Promise.all([
const [uList, invList, teamList] = await Promise.all([
api.adminGetUsers(),
api.adminGetInvites(),
api.getOrganizations(),
api.getUserTeams(),
]);
setUsers(uList);
setInvites(invList);
setOrgs(orgList);
setAllTeams(teamList);
if (orgList.length > 0 && !selectedOrgId) {
setSelectedOrgId(orgList[0].id);
}
if (isServerAdmin) {
const sys = await api.adminGetSettings();
@@ -240,62 +232,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
};
// ============================================================================
// HANDLERS: ORGANIZATIONS & TEAMS
// HANDLERS: TEAMS & GRUPPEN
// ============================================================================
const handleCreateOrg = async (e: React.FormEvent) => {
e.preventDefault();
if (!newOrgName.trim()) return;
setStatusMsg(null);
try {
const created = await api.createOrganization(newOrgName.trim());
setNewOrgName('');
setSelectedOrgId(created.id);
await loadAll();
setStatusMsg({ type: 'ok', text: `Organisation "${created.name}" angelegt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleDeleteOrg = async (org: Organization) => {
if (!window.confirm(`Organisation "${org.name}" inklusive aller zugehörigen Teams löschen?`))
return;
setStatusMsg(null);
try {
await api.deleteOrganization(org.id);
await loadAll();
setStatusMsg({ type: 'ok', text: `Organisation "${org.name}" gelöscht.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleCreateTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedOrgId || !newTeamName.trim()) return;
if (!newTeamName.trim()) return;
setStatusMsg(null);
try {
const created = await api.createOrgTeam(
selectedOrgId,
const created = await api.createTeam(
newTeamName.trim(),
newTeamDesc.trim()
);
setNewTeamName('');
setNewTeamDesc('');
await loadAll();
setStatusMsg({ type: 'ok', text: `Team "${created.name}" wurde erstellt.` });
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(`Team "${team.name}" wirklich löschen?`)) return;
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: `Team "${team.name}" gelöscht.` });
setStatusMsg({ type: 'ok', text: `Gruppe / Team "${team.name}" gelöscht.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
@@ -308,8 +271,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
setStatusMsg(null);
try {
await api.addTeamMember(teamId, { userId: selectedUserId, role });
setMemberSelectByTeam((prev) => ({ ...prev, [teamId]: '' }));
await loadAll();
setStatusMsg({ type: 'ok', text: 'Teammitglied hinzugefügt.' });
setStatusMsg({ type: 'ok', text: 'Mitglied zur Gruppe hinzugefügt.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
@@ -320,6 +284,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
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 });
}
@@ -362,11 +327,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// Collect all teams across orgs for invite dropdown
const flattenedTeams: Team[] =
orgs.flatMap((o) => o.teams || []).length > 0
? orgs.flatMap((o) => o.teams || [])
: allTeams;
// All teams for invite dropdown
const flattenedTeams: Team[] = allTeams;
return (
<div className="w-full space-y-4">
@@ -440,17 +402,17 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</button>
<button
onClick={() => setSection('orgs')}
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 === 'orgs'
section === 'teams'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
<Users className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Organisationen & Teams</p>
<p className="text-[10px] text-faint">Teams & Mitglieder zuweisen</p>
<p className="font-semibold">Teams & Gruppen</p>
<p className="text-[10px] text-faint">Familie, Arbeit, Freunde zum Teilen</p>
</div>
</button>
@@ -797,173 +759,142 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
)}
{/* ================================================================= */}
{/* SECTION 2: ORGANISATIONEN & TEAMS */}
{/* SECTION 2: TEAMS & GRUPPEN */}
{/* ================================================================= */}
{section === 'orgs' && (
{section === 'teams' && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Organisationen & Teams verwalten</h2>
<h2 className="text-sm font-bold text-main">Teams & Gruppen verwalten</h2>
<p className="text-muted mt-0.5">
Erstelle Organisationen und unterteile sie in Teams. Weise Benutzern Teams zu, damit
sie im Modus &quot;Teams isoliert&quot; gemeinsam an Listen und Aufgaben arbeiten können.
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 Organization & Team */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Create Team/Group Card */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main">1. Neue Organisation gründen</h3>
<form onSubmit={handleCreateOrg} className="flex gap-2">
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="z.B. Homelab, Familie, Firma…"
required
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
Anlegen
</button>
</form>
</div>
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main">2. Neues Team in Organisation anlegen</h3>
{orgs.length === 0 ? (
<p className="text-faint">
Bitte lege zuerst links eine Organisation an.
</p>
) : (
<form onSubmit={handleCreateTeam} className="space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
{orgs.map((o) => (
<option key={o.id} value={o.id}>
Org: {o.name}
</option>
))}
</select>
<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="Team-Name (z.B. Infrastruktur)"
placeholder="z. B. Familie, Arbeit, Freunde…"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div className="flex gap-2">
<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="Kurzbeschreibung (optional)…"
className="flex-1 min-w-0 h-8 px-2.5 input-field"
placeholder="z. B. Gemeinsame Einkaufslisten & Haushaltsnotizen…"
className="w-full h-8 px-2.5 input-field text-xs"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
Team erstellen
</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>
</div>
{/* Organizations & Nested Teams Display */}
{/* Teams & Groups List */}
<div className="space-y-4">
{orgs.length === 0 ? (
<div className="p-6 bg-canvas border border-border rounded-card text-center text-faint">
Noch keine Organisationen vorhanden.
</div>
) : (
orgs.map((org) => {
const orgTeams = Array.isArray(org.teams) ? org.teams : [];
return (
<div key={org.id} className="bg-canvas border border-border rounded-card overflow-hidden">
<div className="p-3 bg-panel-elevated border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-accent" />
<span className="font-bold text-sm text-main">{org.name}</span>
<span className="text-[11px] text-muted">
({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'})
</span>
</div>
<button
onClick={() => handleDeleteOrg(org)}
className="btn-icon btn-icon-danger"
title="Organisation löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<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>
<div className="p-3 space-y-3">
{orgTeams.length === 0 ? (
<p className="text-faint text-xs">
Noch keine Teams in dieser Organisation.
{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>
) : (
orgTeams.map((team) => {
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-3 bg-panel border border-border rounded-card space-y-3"
className="p-4 bg-canvas border border-border rounded-card space-y-4"
>
<div className="flex items-center justify-between border-b border-border pb-2">
<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>
<span className="font-bold text-main flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-accent" />
<span>{team.name}</span>
<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-[11px] text-muted mt-0.5">
<p className="text-xs text-muted mt-1">
{team.description}
</p>
)}
</div>
</div>
<button
onClick={() => handleDeleteTeam(team)}
className="btn-icon btn-icon-danger"
title="Team löschen"
title="Gruppe / Team löschen"
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Team Members */}
<div className="space-y-1.5">
{/* 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 zugewiesen.
Noch keine Mitglieder in dieser Gruppe.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-2">
{members.map((m) => (
<div
key={m.userId}
className="px-2 py-1 bg-canvas border border-border rounded-btn flex items-center gap-2"
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})
({m.role === 'team_admin' ? 'Gruppen-Admin' : 'Mitglied'})
</span>
<button
onClick={() =>
handleRemoveMemberFromTeam(team.id, m.userId)
}
className="text-faint hover:text-status-err"
title="Aus Team entfernen"
onClick={() => handleRemoveMemberFromTeam(team.id, m.userId)}
className="text-faint hover:text-status-err font-bold ml-0.5"
title="Aus Gruppe entfernen"
>
×
</button>
@@ -973,8 +904,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
)}
</div>
{/* Add User to Team */}
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
{/* 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) =>
@@ -983,10 +914,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
[team.id]: e.target.value,
}))
}
className="flex-1 min-w-[160px] h-7 px-2 input-field text-[11px] bg-canvas"
className="flex-1 min-w-[200px] h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">Benutzer auswählen…</option>
{users.map((u) => (
<option value="">Benutzer auswählen zum Hinzufügen…</option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName} ({u.email})
</option>
@@ -1001,19 +932,20 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
[team.id]: e.target.value,
}))
}
className="h-7 px-2 input-field text-[11px] bg-canvas"
className="h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
<option value="team_admin">Gruppen-Admin</option>
</select>
<button
type="button"
onClick={() => handleAddMemberToTeam(team.id)}
disabled={!memberSelectByTeam[team.id]}
className="btn-action h-7 px-2.5 text-[11px] disabled:opacity-40"
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40"
>
+ Zum Team hinzufügen
<UserPlus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
@@ -1022,11 +954,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
{/* ================================================================= */}
+7
View File
@@ -664,6 +664,13 @@ export const api = {
});
},
async createTeam(name: string, description = ''): Promise<Team> {
return request<Team>('/api/v1/teams', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
async deleteTeam(teamId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
},
+40
View File
@@ -144,6 +144,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
r.Get("/api/v1/teams", s.handleGetUserTeams)
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
@@ -1691,8 +1692,47 @@ func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
OrgID string `json:"orgId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
orgID := strings.TrimSpace(req.OrgID)
if orgID == "" {
orgID = "default"
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as team_admin
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
if (r.URL.Query().Get("all") == "true") || (user != nil && (user.Role == "admin" || user.Role == "team_admin")) {
teams, err := s.store.GetAllTeams()
if err == nil {
writeJSON(w, http.StatusOK, teams)
return
}
}
teams, err := s.store.GetTeamsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+24
View File
@@ -2752,6 +2752,30 @@ func (s *Store) DeleteTeam(teamID string) error {
return err
}
func (s *Store) GetAllTeams() ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`,
)
if err != nil {
return []domain.Team{}, err
}
defer rows.Close()
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,