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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
60c302e23e
commit
e37302f73c
11 files changed
+936
-388
No files matched your search
@@ -31,13 +31,14 @@
|
|||||||
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
|
- [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.
|
- 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.
|
- 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.
|
- Ö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 statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
|
- [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ü:
|
- 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).
|
- **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).
|
- **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.
|
- 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`):**
|
- [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'`).
|
- 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`**
|
1. **`users`**
|
||||||
- `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
|
- `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`
|
- `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
|
||||||
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
||||||
- `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
|
- `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`
|
- `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)
|
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
|
||||||
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
|
- `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`
|
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
|
||||||
4. **`organizations` & `organization_teams` & `team_members`**
|
4. **`organizations` & `organization_teams` & `team_members`**
|
||||||
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
||||||
|
|||||||
+44
-10
@@ -17,6 +17,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Share,
|
Share,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||||
import {
|
import {
|
||||||
@@ -42,12 +43,14 @@ import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
|||||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||||
import { HelpView } from './features/help/HelpView';
|
import { HelpView } from './features/help/HelpView';
|
||||||
|
import { GroupsView } from './features/teams/GroupsView';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
| 'triage'
|
| 'triage'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'workspace'
|
| 'workspace'
|
||||||
| 'lists'
|
| 'lists'
|
||||||
|
| 'groups'
|
||||||
| 'user_settings'
|
| 'user_settings'
|
||||||
| 'admin_settings'
|
| 'admin_settings'
|
||||||
| 'help';
|
| 'help';
|
||||||
@@ -403,13 +406,8 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStaff = user.role === 'admin' || user.role === 'team_admin';
|
const isServerAdmin = user.role === 'admin';
|
||||||
const roleLabel =
|
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
||||||
user.role === 'admin'
|
|
||||||
? 'Server-Admin'
|
|
||||||
: user.role === 'team_admin'
|
|
||||||
? 'Team-Admin'
|
|
||||||
: 'Mitglied';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
<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>
|
<span>Kontoeinstellungen</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleOpenHelp}
|
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 ${
|
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>
|
<span>Hilfe & Anleitung</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isStaff && (
|
{isServerAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -742,6 +754,13 @@ export const App: React.FC = () => {
|
|||||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'groups' && (
|
||||||
|
<GroupsView
|
||||||
|
currentUser={user}
|
||||||
|
onBack={() => setActiveTab(previousTab || 'triage')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'user_settings' && (
|
{activeTab === 'user_settings' && (
|
||||||
<UserSettingsView
|
<UserSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
@@ -755,7 +774,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'admin_settings' && isStaff && (
|
{activeTab === 'admin_settings' && isServerAdmin && (
|
||||||
<AdminSettingsView
|
<AdminSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
onBack={() => setActiveTab('triage')}
|
onBack={() => setActiveTab('triage')}
|
||||||
@@ -1004,6 +1023,21 @@ export const App: React.FC = () => {
|
|||||||
<span>Kontoeinstellungen</span>
|
<span>Kontoeinstellungen</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1018,7 +1052,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Hilfe & Anleitung</span>
|
<span>Hilfe & Anleitung</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isStaff && (
|
{isServerAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Share2,
|
|
||||||
Send,
|
Send,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Organization,
|
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
Team,
|
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -31,7 +28,7 @@ interface AdminSettingsViewProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminSection = 'users' | 'teams' | 'server' | 'smtp';
|
type AdminSection = 'users' | 'server' | 'smtp';
|
||||||
|
|
||||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -50,19 +47,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
|
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||||
const [inviteTeamId, setInviteTeamId] = useState('');
|
|
||||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
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 ---
|
// --- Server & SMTP Settings State ---
|
||||||
const [settings, setSettings] = useState<SystemSettings>({
|
const [settings, setSettings] = useState<SystemSettings>({
|
||||||
sharingMode: 'teams_only',
|
sharingMode: 'teams_only',
|
||||||
@@ -83,14 +72,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
const [uList, invList, teamList] = await Promise.all([
|
const [uList, invList] = await Promise.all([
|
||||||
api.adminGetUsers(),
|
api.adminGetUsers(),
|
||||||
api.adminGetInvites(),
|
api.adminGetInvites(),
|
||||||
api.getUserTeams(),
|
|
||||||
]);
|
]);
|
||||||
setUsers(uList);
|
setUsers(uList);
|
||||||
setInvites(invList);
|
setInvites(invList);
|
||||||
setAllTeams(teamList);
|
|
||||||
|
|
||||||
if (isServerAdmin) {
|
if (isServerAdmin) {
|
||||||
const sys = await api.adminGetSettings();
|
const sys = await api.adminGetSettings();
|
||||||
@@ -149,7 +136,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const inv = await api.adminCreateInvite({
|
const inv = await api.adminCreateInvite({
|
||||||
email: inviteEmail.trim(),
|
email: inviteEmail.trim(),
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
teamId: inviteTeamId,
|
|
||||||
});
|
});
|
||||||
setInvites((prev) => [inv, ...prev]);
|
setInvites((prev) => [inv, ...prev]);
|
||||||
setInviteEmail('');
|
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
|
// HANDLERS: SERVER & SMTP SETTINGS
|
||||||
@@ -327,9 +256,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// All teams for invite dropdown
|
|
||||||
const flattenedTeams: Team[] = allTeams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{/* Top Page Header */}
|
{/* Top Page Header */}
|
||||||
@@ -346,17 +272,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4 text-accent" />
|
<Shield className="w-4 h-4 text-accent" />
|
||||||
<span>Administration & Team-Steuerung</span>
|
<span>Server-Administration</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
|
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="badge-accent">
|
<span className="badge-accent">Server-Administrator</span>
|
||||||
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
@@ -401,21 +325,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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 && (
|
{isServerAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<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"
|
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied (Standard)</option>
|
<option value="member">Mitglied (Standard)</option>
|
||||||
<option value="team_admin">Team-Admin (Darf einladen)</option>
|
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||||
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</option>}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,7 +454,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
<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">
|
<div className="min-w-0">
|
||||||
<label className="block text-muted mb-1">
|
<label className="block text-muted mb-1">
|
||||||
Gebundene E-Mail (optional)
|
Gebundene E-Mail (optional)
|
||||||
@@ -561,33 +469,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<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
|
<select
|
||||||
value={inviteRole}
|
value={inviteRole}
|
||||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
<option value="team_admin">Team-Admin</option>
|
<option value="admin">Server-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>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -690,8 +579,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
title="Benutzerrolle ändern"
|
title="Benutzerrolle ändern"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
<option value="team_admin">Team-Admin</option>
|
<option value="admin">Server-Admin</option>
|
||||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{isServerAdmin && (
|
{isServerAdmin && (
|
||||||
@@ -758,203 +646,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</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. "Familie" oder "Arbeit"), 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) */}
|
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||||
|
|||||||
@@ -236,8 +236,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const roleBadgeLabel =
|
const roleBadgeLabel =
|
||||||
currentUser.role === 'admin'
|
currentUser.role === 'admin'
|
||||||
? 'Server-Admin'
|
? 'Server-Admin'
|
||||||
: currentUser.role === 'team_admin'
|
|
||||||
? 'Team-Admin'
|
|
||||||
: 'Mitglied';
|
: 'Mitglied';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 "Neue Gruppe", 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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> {
|
async deleteTeam(teamId: string): Promise<void> {
|
||||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
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> {
|
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
||||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
export type UserRole = 'admin' | 'team_admin' | 'member';
|
export type UserRole = 'admin' | 'member';
|
||||||
|
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
@@ -119,7 +119,7 @@ export interface Organization {
|
|||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
members?: TeamMember[];
|
members?: TeamMember[];
|
||||||
@@ -131,7 +131,7 @@ export interface TeamMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
|
role: 'admin' | 'member';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-21
@@ -150,8 +150,10 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||||
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
|
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
|
||||||
|
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
|
||||||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
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)
|
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||||
|
|
||||||
// API Keys Management
|
// API Keys Management
|
||||||
@@ -268,8 +270,8 @@ func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := s.store.GetUserByID(userID)
|
user, err := s.store.GetUserByID(userID)
|
||||||
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
|
if err != nil || user == nil || user.Role != "admin" {
|
||||||
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
|
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -1829,18 +1831,20 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Automatically add creator to team
|
// 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)
|
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||||
writeJSON(w, http.StatusCreated, team)
|
writeJSON(w, http.StatusCreated, team)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) isTeamAdmin(userID, teamID string) bool {
|
||||||
teamID := chi.URLParam(r, "id")
|
if userID == "" || teamID == "" {
|
||||||
if err := s.store.DeleteTeam(teamID); err != nil {
|
return false
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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) {
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Automatically add creator to team as team_admin
|
// Automatically add creator to team as group admin
|
||||||
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
|
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||||
writeJSON(w, http.StatusCreated, team)
|
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) {
|
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
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)
|
teams, err := s.store.GetTeamsForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
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) {
|
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
teamID := chi.URLParam(r, "id")
|
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 {
|
var req struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
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")
|
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||||
return
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
teamID := chi.URLParam(r, "id")
|
teamID := chi.URLParam(r, "id")
|
||||||
memberUserID := chi.URLParam(r, "userId")
|
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 {
|
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create an Invite as Admin and register with that Invite Code
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -163,7 +163,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
|||||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||||
"email": "invited@example.com",
|
"email": "invited@example.com",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
"displayName": "Invited Team Admin",
|
"displayName": "Invited Admin",
|
||||||
"inviteCode": createdInvite.Code,
|
"inviteCode": createdInvite.Code,
|
||||||
})
|
})
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
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"`
|
User domain.User `json:"user"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
||||||
if invitedRes.User.Role != "team_admin" {
|
if invitedRes.User.Role != "admin" {
|
||||||
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
|
t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ type User struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"` // admin | team_admin | member
|
Role string `json:"role"` // admin | member
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||||
AlarmEmail string `json:"alarmEmail"`
|
AlarmEmail string `json:"alarmEmail"`
|
||||||
@@ -112,7 +112,7 @@ type UserInvite struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Role string `json:"role"` // member | team_admin | admin
|
Role string `json:"role"` // member | admin
|
||||||
TeamID string `json:"teamId,omitempty"`
|
TeamID string `json:"teamId,omitempty"`
|
||||||
TeamName string `json:"teamName,omitempty"`
|
TeamName string `json:"teamName,omitempty"`
|
||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
@@ -212,7 +212,7 @@ type Organization struct {
|
|||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OrgID string `json:"orgId"`
|
OrgID string `json:"orgId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Members []TeamMember `json:"members"`
|
Members []TeamMember `json:"members"`
|
||||||
@@ -224,7 +224,7 @@ type TeamMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"` // team_admin | member | owner
|
Role string `json:"role"` // admin | member
|
||||||
JoinedAt string `json:"joinedAt"`
|
JoinedAt string `json:"joinedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+85
-19
@@ -178,7 +178,7 @@ func (s *Store) migrate() error {
|
|||||||
CREATE TABLE IF NOT EXISTS team_members (
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
team_id TEXT NOT NULL,
|
team_id TEXT NOT NULL,
|
||||||
user_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,
|
joined_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (team_id, user_id),
|
PRIMARY KEY (team_id, user_id),
|
||||||
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
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
|
// Reindex block tags if table is newly added or empty
|
||||||
var tagCount int
|
var tagCount int
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
|
_ = 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) {
|
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"
|
role = "member"
|
||||||
}
|
}
|
||||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||||
@@ -674,20 +679,13 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
|||||||
mode = sys.SharingMode
|
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()
|
return s.GetAllUsers()
|
||||||
}
|
}
|
||||||
if mode == "strict_private" {
|
if mode == "strict_private" {
|
||||||
return []domain.User{*viewer}, nil
|
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
|
// users sharing at least one team, organization or space with viewer
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
||||||
@@ -742,7 +740,7 @@ func (s *Store) UpdateUserFull(
|
|||||||
if email != nil {
|
if email != nil {
|
||||||
u.Email = strings.ToLower(strings.TrimSpace(*email))
|
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
|
u.Role = *role
|
||||||
}
|
}
|
||||||
if isActive != nil {
|
if isActive != nil {
|
||||||
@@ -3313,7 +3311,7 @@ func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, e
|
|||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") {
|
if viewer != nil && viewer.Role == "admin" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
|
`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(
|
rows, err := s.db.Query(
|
||||||
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
||||||
FROM teams t
|
FROM teams t
|
||||||
LEFT JOIN team_members tm ON tm.team_id = t.id
|
JOIN team_members tm ON tm.team_id = t.id
|
||||||
LEFT JOIN organizations o ON o.id = t.org_id
|
WHERE tm.user_id = ?
|
||||||
WHERE tm.user_id = ? OR o.owner_id = ?
|
|
||||||
ORDER BY t.created_at ASC`,
|
ORDER BY t.created_at ASC`,
|
||||||
userID, userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []domain.Team{}, err
|
return []domain.Team{}, err
|
||||||
@@ -3501,8 +3498,39 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
|||||||
return teams, nil
|
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 {
|
func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
||||||
if role == "" {
|
role = strings.ToLower(strings.TrimSpace(role))
|
||||||
|
if role != "admin" && role != "member" {
|
||||||
role = "member"
|
role = "member"
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -3513,8 +3541,46 @@ func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
|||||||
return err
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3545,7 +3611,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
|||||||
// --- USER INVITES ---
|
// --- USER INVITES ---
|
||||||
|
|
||||||
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
|
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"
|
role = "member"
|
||||||
}
|
}
|
||||||
if validDays <= 0 || validDays > 365 {
|
if validDays <= 0 || validDays > 365 {
|
||||||
|
|||||||
Reference in new issue
Block a user