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

+7 -6
View File
@@ -31,13 +31,14 @@
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
- Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf.
- Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen.
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden.
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin`) angelegt werden.
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten (`App.tsx`, `UserSettingsView.tsx`, `GroupsView.tsx`, `AdminSettingsView.tsx`):**
- Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü:
- **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export).
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver).
- **Gruppen** (`GroupsView.tsx` — für alle Benutzer; Self-Service Erstellung und Verwaltung von Gruppen, Mitgliedern und Rollen).
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin`; Vollbild-Ansicht für Benutzer, Einladungscodes, Server-Isolation und SMTP-Mailserver).
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
- 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied).
- Klares Rollensystem: Global `admin` (Server-Admin) und `member` (Standard-Benutzer). Innerhalb von Gruppen: `admin` (Gruppen-Admin) und `member` (Gruppen-Mitglied).
- Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt.
- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):**
- Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`).
@@ -178,7 +179,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif
1. **`users`**
- `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
- `role TEXT DEFAULT 'member'` (`admin`, `team_admin`, `member`)
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
- `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
- `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
@@ -187,7 +188,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif
- `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER`
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
- `role TEXT DEFAULT 'member'`, `team_id TEXT` (optional direkte Team-Zuweisung)
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
4. **`organizations` & `organization_teams` & `team_members`**
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
+44 -10
View File
@@ -17,6 +17,7 @@ import {
Download,
Smartphone,
Share,
Users,
} from 'lucide-react';
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
import {
@@ -42,12 +43,14 @@ import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
import { GroupsView } from './features/teams/GroupsView';
type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'groups'
| 'user_settings'
| 'admin_settings'
| 'help';
@@ -403,13 +406,8 @@ export const App: React.FC = () => {
);
}
const isStaff = user.role === 'admin' || user.role === 'team_admin';
const roleLabel =
user.role === 'admin'
? 'Server-Admin'
: user.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
@@ -572,6 +570,20 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('groups');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'groups' ? 'text-accent font-semibold' : 'text-main'
}`}
>
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
<button
onClick={handleOpenHelp}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
@@ -582,7 +594,7 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span>
</button>
{isStaff && (
{isServerAdmin && (
<button
onClick={() => {
triggerHaptic(10);
@@ -742,6 +754,13 @@ export const App: React.FC = () => {
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
)}
{activeTab === 'groups' && (
<GroupsView
currentUser={user}
onBack={() => setActiveTab(previousTab || 'triage')}
/>
)}
{activeTab === 'user_settings' && (
<UserSettingsView
currentUser={user}
@@ -755,7 +774,7 @@ export const App: React.FC = () => {
/>
)}
{activeTab === 'admin_settings' && isStaff && (
{activeTab === 'admin_settings' && isServerAdmin && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
@@ -1004,6 +1023,21 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setActiveTab('groups');
}}
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
activeTab === 'groups' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
}`}
>
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
<button
type="button"
onClick={() => {
@@ -1018,7 +1052,7 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span>
</button>
{isStaff && (
{isServerAdmin && (
<button
type="button"
onClick={() => {
@@ -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) */}
@@ -236,8 +236,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
: currentUser.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
+654
View File
@@ -0,0 +1,654 @@
import React, { useEffect, useState } from 'react';
import {
Users,
Plus,
Trash2,
ArrowLeft,
Check,
AlertCircle,
UserPlus,
Shield,
Pencil,
X,
LogOut,
} from 'lucide-react';
import { Team, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
interface GroupsViewProps {
currentUser: User;
onBack: () => void;
}
export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) => {
const [teams, setTeams] = useState<Team[]>([]);
const [directoryUsers, setDirectoryUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
// Create Group Modal State
const [showCreateModal, setShowCreateModal] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupDesc, setNewGroupDesc] = useState('');
const [creating, setCreating] = useState(false);
// Edit Group State
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
// Add Member State per Team
const [selectedUserByTeam, setSelectedUserByTeam] = useState<Record<string, User | null>>({});
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, 'admin' | 'member'>>({});
const loadData = async () => {
try {
const [userTeams, allUsers] = await Promise.all([
api.getUserTeams(),
api.getUsersDirectory().catch(() => []),
]);
setTeams(userTeams);
setDirectoryUsers(allUsers);
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' });
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
const name = newGroupName.trim();
if (!name) return;
setCreating(true);
setStatusMsg(null);
triggerHaptic(15);
try {
const created = await api.createTeam(name, newGroupDesc.trim());
setNewGroupName('');
setNewGroupDesc('');
setShowCreateModal(false);
await loadData();
setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' });
} finally {
setCreating(false);
}
};
const handleStartEdit = (team: Team) => {
setEditingTeamId(team.id);
setEditName(team.name);
setEditDesc(team.description || '');
};
const handleSaveEdit = async (teamId: string) => {
const name = editName.trim();
if (!name) return;
setStatusMsg(null);
triggerHaptic(10);
try {
await api.updateTeam(teamId, name, editDesc.trim());
setEditingTeamId(null);
await loadData();
setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' });
}
};
const handleDeleteTeam = async (team: Team) => {
if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) {
return;
}
setStatusMsg(null);
triggerHaptic(15);
try {
await api.deleteTeam(team.id);
await loadData();
setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' });
}
};
const handleAddMember = async (teamId: string) => {
const user = selectedUserByTeam[teamId];
if (!user) return;
const role = memberRoleByTeam[teamId] || 'member';
setStatusMsg(null);
triggerHaptic(10);
try {
await api.addTeamMember(teamId, { userId: user.id, role });
setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null }));
await loadData();
setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' });
}
};
const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => {
setStatusMsg(null);
triggerHaptic(10);
try {
await api.updateTeamMemberRole(teamId, memberUserId, newRole);
await loadData();
setStatusMsg({
type: 'ok',
text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' });
}
};
const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => {
const isSelf = memberUserId === currentUser.id;
const msg = isSelf
? 'Möchtest du diese Gruppe wirklich verlassen?'
: `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`;
if (!window.confirm(msg)) return;
setStatusMsg(null);
triggerHaptic(10);
try {
await api.removeTeamMember(teamId, memberUserId);
await loadData();
setStatusMsg({
type: 'ok',
text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' });
}
};
// Group teams into managed (admin) and member-only
const managedTeams = teams.filter((t) =>
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin')
);
const memberTeams = teams.filter(
(t) =>
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') &&
!managedTeams.some((mt) => mt.id === t.id)
);
return (
<div className="w-full space-y-5">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
title="Zurück"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Users className="w-4 h-4 text-accent" />
<span>Gruppen & Teams</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{teams.length}
</span>
</h1>
<p className="text-xs text-muted mt-0.5">
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
</p>
</div>
</div>
<button
onClick={() => {
triggerHaptic(10);
setShowCreateModal(true);
}}
className="btn-primary h-8 px-3.5 text-xs flex items-center gap-1.5 self-start sm:self-auto cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Gruppe</span>
</button>
</div>
{/* Status Messages */}
{statusMsg && (
<div
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
<div className="flex items-center gap-2">
{statusMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{statusMsg.text}</span>
</div>
<button
onClick={() => setStatusMsg(null)}
className="text-muted hover:text-main text-[11px] cursor-pointer"
>
Schließen
</button>
</div>
)}
{/* Modal: Neue Gruppe erstellen */}
{showCreateModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
onClick={() => setShowCreateModal(false)}
>
<div
className="w-full max-w-md bg-panel border border-border rounded-xl shadow-panel p-5 space-y-4 animate-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<Users className="w-4 h-4" />
</div>
<h3 className="font-bold text-sm text-main">Neue Gruppe erstellen</h3>
</div>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreateGroup} className="space-y-4">
<div>
<label className="block text-muted text-xs mb-1 font-medium">
Name der Gruppe *
</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="z. B. Familie, WG, Arbeit, Projekt X…"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div>
<label className="block text-muted text-xs mb-1 font-medium">
Beschreibung (optional)
</label>
<input
type="text"
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…"
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div className="p-3 bg-canvas border border-border rounded-btn text-[11px] text-muted space-y-1">
<p className="font-semibold text-main flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>Du wirst Gruppen-Admin</span>
</p>
<p>
Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten.
</p>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="btn-action h-8 px-3 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={creating || !newGroupName.trim()}
className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Gruppe anlegen</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* Main Content Area */}
{loading ? (
<div className="p-8 text-center text-muted text-xs">Lade Gruppen…</div>
) : teams.length === 0 ? (
<div className="p-10 bg-canvas border border-border rounded-card text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-panel-elevated text-muted mx-auto flex items-center justify-center">
<Users className="w-6 h-6 text-accent opacity-75" />
</div>
<div>
<h3 className="font-bold text-main text-sm">Noch keine Gruppen vorhanden</h3>
<p className="text-muted text-xs mt-1 max-w-md mx-auto">
Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen.
</p>
</div>
<button
onClick={() => {
triggerHaptic(10);
setShowCreateModal(true);
}}
className="btn-primary h-8 px-4 text-xs inline-flex items-center gap-1.5 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Erste Gruppe anlegen</span>
</button>
</div>
) : (
<div className="space-y-6">
{/* SECTION 1: Gruppen, die ich verwalte */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>Gruppen, die ich verwalte ({managedTeams.length})</span>
</h2>
</div>
{managedTeams.length === 0 ? (
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted">
Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf &quot;Neue Gruppe&quot;, um eine eigene Gruppe zu erstellen.
</div>
) : (
<div className="space-y-4">
{managedTeams.map((team) => {
const members = team.members || [];
const isEditing = editingTeamId === team.id;
const memberUserIds = members.map((m) => m.userId);
return (
<div
key={team.id}
className="p-4 bg-canvas border border-border rounded-card space-y-4 shadow-sm"
>
{/* Team Header */}
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex items-start gap-2.5 flex-1 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
<Users className="w-4 h-4" />
</div>
{isEditing ? (
<div className="flex-1 space-y-2 max-w-lg">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Gruppenname"
className="w-full h-8 px-2.5 input-field text-xs font-bold"
/>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
placeholder="Optionale Beschreibung"
className="w-full h-7 px-2.5 input-field text-xs"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSaveEdit(team.id)}
className="btn-primary h-7 px-3 text-[11px] cursor-pointer"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="btn-action h-7 px-2.5 text-[11px] cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
) : (
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-main truncate">
{team.name}
</span>
<span className="badge-accent text-[10px] py-0.5 px-2">
Gruppen-Admin
</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 break-words">
{team.description}
</p>
)}
</div>
)}
</div>
{!isEditing && (
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => handleStartEdit(team)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1 cursor-pointer"
title="Gruppe bearbeiten"
>
<Pencil className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Bearbeiten</span>
</button>
<button
onClick={() => handleDeleteTeam(team)}
className="btn-icon btn-icon-danger"
title="Gruppe löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Members List */}
<div className="space-y-2">
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
Mitglieder ({members.length})
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{members.map((m) => {
const isSelf = m.userId === currentUser.id;
const isAdmin = m.role === 'admin';
return (
<div
key={m.userId}
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-main font-medium truncate text-xs">
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
</p>
<p className="text-[10px] text-muted truncate">
{m.email}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<select
value={m.role}
onChange={(e) =>
handleChangeMemberRole(
team.id,
m.userId,
e.target.value as 'admin' | 'member'
)
}
className={`h-6 px-1.5 text-[10px] rounded border ${
isAdmin
? 'bg-accent/15 border-accent/40 text-accent font-semibold'
: 'bg-canvas border-border text-muted'
}`}
title="Rolle ändern"
>
<option value="member">Mitglied</option>
<option value="admin">Gruppen-Admin</option>
</select>
<button
onClick={() => handleRemoveMember(team.id, m.userId, m.displayName)}
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
title={isSelf ? 'Gruppe verlassen' : 'Mitglied entfernen'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Add Member Row */}
<div className="pt-3 border-t border-border flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex-1 min-w-[220px]">
<UserAutocomplete
users={directoryUsers}
selectedUser={selectedUserByTeam[team.id] || null}
onSelectUser={(u) =>
setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u }))
}
excludeUserIds={memberUserIds}
placeholder="Benutzer suchen zum Hinzufügen…"
/>
</div>
<select
value={memberRoleByTeam[team.id] || 'member'}
onChange={(e) =>
setMemberRoleByTeam((prev) => ({
...prev,
[team.id]: e.target.value as 'admin' | 'member',
}))
}
className="h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="member">Mitglied</option>
<option value="admin">Gruppen-Admin</option>
</select>
<button
type="button"
onClick={() => handleAddMember(team.id)}
disabled={!selectedUserByTeam[team.id]}
className="btn-primary h-8 px-3.5 text-xs flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-40"
>
<UserPlus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */}
{memberTeams.length > 0 && (
<div className="space-y-3 pt-2">
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-accent" />
<span>Meine Mitgliedschaften ({memberTeams.length})</span>
</h2>
<div className="space-y-3">
{memberTeams.map((team) => {
const members = team.members || [];
const admins = members.filter((m) => m.role === 'admin');
return (
<div
key={team.id}
className="p-4 bg-canvas border border-border rounded-card space-y-3 shadow-sm"
>
<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-panel-elevated text-muted 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={() => handleRemoveMember(team.id, currentUser.id, currentUser.displayName)}
className="btn-action text-status-err hover:bg-status-err-bg h-7 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
title="Gruppe verlassen"
>
<LogOut className="w-3.5 h-3.5" />
<span>Gruppe verlassen</span>
</button>
</div>
{/* Admins & Members list in read-only mode */}
<div className="space-y-1.5">
<p className="text-[11px] font-semibold text-muted">
Gruppen-Admins:{' '}
<span className="text-main font-normal">
{admins.map((a) => a.displayName).join(', ') || 'Keiner'}
</span>
</p>
<p className="text-[11px] font-semibold text-muted">
Alle Mitglieder:{' '}
<span className="text-faint font-normal">
{members.map((m) => m.displayName).join(', ')}
</span>
</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
};
+18
View File
@@ -723,6 +723,13 @@ export const api = {
});
},
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
return request<Team>(`/api/v1/teams/${teamId}`, {
method: 'PUT',
body: JSON.stringify({ name, description }),
});
},
async deleteTeam(teamId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
},
@@ -742,6 +749,17 @@ export const api = {
});
},
async updateTeamMemberRole(
teamId: string,
userId: string,
role: 'admin' | 'member'
): Promise<void> {
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
method: 'PUT',
body: JSON.stringify({ role }),
});
},
async removeTeamMember(teamId: string, userId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
},
+3 -3
View File
@@ -1,6 +1,6 @@
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
export type UserRole = 'admin' | 'team_admin' | 'member';
export type UserRole = 'admin' | 'member';
export interface AuthStatus {
needsSetup: boolean;
@@ -119,7 +119,7 @@ export interface Organization {
export interface Team {
id: string;
orgId: string;
orgId?: string;
name: string;
description: string;
members?: TeamMember[];
@@ -131,7 +131,7 @@ export interface TeamMember {
userId: string;
displayName: string;
email: string;
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
role: 'admin' | 'member';
joinedAt: string;
}
+106 -21
View File
@@ -150,8 +150,10 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
r.Get("/api/v1/teams", s.handleGetUserTeams)
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
// API Keys Management
@@ -268,8 +270,8 @@ func (s *Server) staffMiddleware(next http.Handler) http.Handler {
return
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
if err != nil || user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
return
}
next.ServeHTTP(w, r)
@@ -1829,18 +1831,20 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
return
}
// Automatically add creator to team
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id")
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
func (s *Server) isTeamAdmin(userID, teamID string) bool {
if userID == "" || teamID == "" {
return false
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
role, err := s.store.GetTeamMemberRole(teamID, userID)
if err != nil {
return false
}
return role == "admin"
}
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
@@ -1868,22 +1872,56 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as team_admin
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
// Automatically add creator to team as group admin
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
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
}
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
return
}
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
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())
@@ -1893,7 +1931,12 @@ func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
return
}
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
@@ -1916,7 +1959,39 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
role = "member"
}
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
return
}
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -1925,10 +2000,20 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
isSelf := userID == memberUserID
isAdmin := s.isTeamAdmin(userID, teamID)
if !isSelf && !isAdmin {
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
return
}
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
+4 -4
View File
@@ -145,7 +145,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
}
// 5. Create an Invite as Admin and register with that Invite Code
invBody := []byte(`{"role":"team_admin","validDays":7}`)
invBody := []byte(`{"role":"admin","validDays":7}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
req.Header.Set("Content-Type", "application/json")
@@ -163,7 +163,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
regWithInviteBody, _ := json.Marshal(map[string]string{
"email": "invited@example.com",
"password": "password123",
"displayName": "Invited Team Admin",
"displayName": "Invited Admin",
"inviteCode": createdInvite.Code,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
@@ -177,8 +177,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
if invitedRes.User.Role != "team_admin" {
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
if invitedRes.User.Role != "admin" {
t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role)
}
}
+4 -4
View File
@@ -98,7 +98,7 @@ type User struct {
Username string `json:"username,omitempty"`
PasswordHash string `json:"-"`
DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | team_admin | member
Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"`
@@ -112,7 +112,7 @@ type UserInvite struct {
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | team_admin | admin
Role string `json:"role"` // member | admin
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
CreatedBy string `json:"createdBy"`
@@ -212,7 +212,7 @@ type Organization struct {
type Team struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Members []TeamMember `json:"members"`
@@ -224,7 +224,7 @@ type TeamMember struct {
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // team_admin | member | owner
Role string `json:"role"` // admin | member
JoinedAt string `json:"joinedAt"`
}
+85 -19
View File
@@ -178,7 +178,7 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS team_members (
team_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor',
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL,
PRIMARY KEY (team_id, user_id),
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
@@ -391,6 +391,11 @@ func (s *Store) migrate() error {
}
}
// Clean up legacy roles in team_members and users
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
// Reindex block tags if table is newly added or empty
var tagCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
@@ -534,7 +539,7 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
}
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
if role != "admin" && role != "team_admin" && role != "member" {
if role != "admin" && role != "member" {
role = "member"
}
cleanEmail := strings.ToLower(strings.TrimSpace(email))
@@ -674,20 +679,13 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
mode = sys.SharingMode
}
if viewer.Role == "admin" || viewer.Role == "team_admin" || mode == "open" {
if viewer.Role == "admin" || mode == "open" || mode == "teams_only" {
return s.GetAllUsers()
}
if mode == "strict_private" {
return []domain.User{*viewer}, nil
}
// teams_only: if no teams exist yet, allow discovering users on this instance
var teamCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teamCount)
if teamCount == 0 {
return s.GetAllUsers()
}
// users sharing at least one team, organization or space with viewer
rows, err := s.db.Query(
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
@@ -742,7 +740,7 @@ func (s *Store) UpdateUserFull(
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "team_admin" || *role == "member") {
if role != nil && (*role == "admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
@@ -3313,7 +3311,7 @@ func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, e
var rows *sql.Rows
var err error
if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") {
if viewer != nil && viewer.Role == "admin" {
rows, err = s.db.Query(
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
)
@@ -3474,11 +3472,10 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
FROM teams t
LEFT JOIN team_members tm ON tm.team_id = t.id
LEFT JOIN organizations o ON o.id = t.org_id
WHERE tm.user_id = ? OR o.owner_id = ?
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.user_id = ?
ORDER BY t.created_at ASC`,
userID, userID,
userID,
)
if err != nil {
return []domain.Team{}, err
@@ -3501,8 +3498,39 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
return teams, nil
}
func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name für die Gruppe ist erforderlich")
}
_, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID)
if err != nil {
return nil, err
}
return s.GetTeamByID(teamID)
}
func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) {
var role string
err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *Store) CountTeamAdmins(teamID string) (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count)
return count, err
}
func (s *Store) AddTeamMember(teamID, userID, role string) error {
if role == "" {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
role = "member"
}
_, err := s.db.Exec(
@@ -3513,8 +3541,46 @@ func (s *Store) AddTeamMember(teamID, userID, role string) error {
return err
}
func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
return errors.New("Ungültige Rolle (erlaubt sind: admin, member)")
}
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "" {
return errors.New("Benutzer ist kein Mitglied dieser Gruppe")
}
if currentRole == "admin" && role != "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten")
}
}
_, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID)
return err
}
func (s *Store) RemoveTeamMember(teamID, userID string) error {
_, err := s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.")
}
}
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
return err
}
@@ -3545,7 +3611,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "team_admin" && role != "member" {
if role != "admin" && role != "member" {
role = "member"
}
if validDays <= 0 || validDays > 365 {