feat: organizations decide who sees whom; sharing only by owners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
- Server admins manage organizations (one per user); new admin section, organization on user creation and invites, setup creates the first one - Sharing modes private / organizations / open, enforced centrally (CanSee, CanShareWith) for directory, all shares and groups; unknown and invisible targets give the same error - Losing visibility (org change, mode change, leaving a group) prunes shares, group memberships and task assignments; dry run warns before tightening - Only owners share; editors can no longer reshare lists or remove others' shares; legacy task_lists.team_id and old /organizations endpoints removed (FLAW-06, FLAW-10, FLAW-24) - Schema version: outdated databases are wiped and recreated on start - Fix setup sample data (createSampleData was ignored); setup and admin use a shared ToggleSwitch instead of checkboxes - Docs: sharing-and-permissions rewritten, ui-patterns, REST, DB, flaws Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73fad1c5db
commit
84395f3322
26 files changed
+1421
-609
No files matched your search
+16
-7
@@ -32,6 +32,7 @@ import {
|
||||
triggerHaptic,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
isSharingDisabled,
|
||||
} from './lib/api';
|
||||
import {
|
||||
AVAILABLE_THEMES,
|
||||
@@ -273,12 +274,16 @@ export const App: React.FC = () => {
|
||||
badge: stats.listsCount,
|
||||
onClick: () => setActiveTab('lists'),
|
||||
},
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Teams & Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
...(isSharingDisabled()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -618,7 +623,7 @@ export const App: React.FC = () => {
|
||||
tasks: 'Aufgaben',
|
||||
workspace: 'Workspace',
|
||||
lists: 'Listen',
|
||||
groups: 'Teams & Gruppen',
|
||||
groups: 'Gruppen',
|
||||
user_settings: 'Einstellungen',
|
||||
admin_settings: 'Administration',
|
||||
help: 'Hilfe',
|
||||
@@ -802,6 +807,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -815,6 +821,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleOpenHelp}
|
||||
@@ -1367,6 +1374,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1381,6 +1389,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// On/off switch for settings; used instead of checkboxes (Doku/ui-patterns.md)
|
||||
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange, title, disabled = false }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-default ${
|
||||
checked ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
import { isSharingDisabled } from '../lib/api';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -147,7 +148,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
)}
|
||||
|
||||
{/* Add Share Section (Only if owner / manager) */}
|
||||
{isOwner ? (
|
||||
{isSharingDisabled() ? (
|
||||
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted">
|
||||
Teilen ist auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).
|
||||
</div>
|
||||
) : isOwner ? (
|
||||
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, User } from '../../lib/types';
|
||||
import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
@@ -29,9 +30,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const [setupPassword, setSetupPassword] = useState('');
|
||||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
|
||||
'teams_only'
|
||||
);
|
||||
const [sharingMode, setSharingMode] = useState<SharingMode>('organizations');
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
const [createSampleData, setCreateSampleData] = useState(false);
|
||||
|
||||
@@ -241,31 +240,31 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('strict_private')}
|
||||
onClick={() => setSharingMode('private')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'strict_private'
|
||||
sharingMode === 'private'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Strikt Privat</p>
|
||||
<p className="font-bold text-xs">Privat</p>
|
||||
<p className="text-[10px] text-muted mt-0.5">
|
||||
Keiner sieht andere Nutzer. Komplett isoliert.
|
||||
Keiner sieht andere Nutzer. Teilen und Gruppen sind aus.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('teams_only')}
|
||||
onClick={() => setSharingMode('organizations')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'teams_only'
|
||||
sharingMode === 'organizations'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Teams isoliert</p>
|
||||
<p className="font-bold text-xs">Organisationen</p>
|
||||
<p className="text-[10px] text-muted mt-0.5">
|
||||
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
|
||||
Nutzer sehen nur Mitglieder ihrer Organisation. Du startest in einer Organisation mit dem Namen der Instanz.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -287,7 +286,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<label className="flex items-center justify-between cursor-pointer select-none">
|
||||
<div className="flex items-center justify-between gap-3 select-none">
|
||||
<div>
|
||||
<span className="font-medium text-main block">
|
||||
Öffentliche Selbstregistrierung erlauben
|
||||
@@ -297,30 +296,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
Admin angelegt werden.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRegistration}
|
||||
onChange={(e) => setAllowRegistration(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<ToggleSwitch checked={allowRegistration} onChange={setAllowRegistration} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
|
||||
<div className="flex items-center justify-between gap-3 select-none pt-1">
|
||||
<div>
|
||||
<span className="font-medium text-main block">
|
||||
Start-Beispieldaten anlegen
|
||||
Beispieldaten anlegen
|
||||
</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
|
||||
Füllt dein Konto mit Beispiel-Gedanken, Notizen, Aufgabenlisten und Einkaufslisten zum Ausprobieren.
|
||||
Es werden keine weiteren Benutzer angelegt.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createSampleData}
|
||||
onChange={(e) => setCreateSampleData(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<ToggleSwitch checked={createSampleData} onChange={setCreateSampleData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -224,10 +224,10 @@ export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-accent" />
|
||||
<span>Aufgaben, Listen & Team-Zusammenarbeit</span>
|
||||
<span>Aufgaben, Listen & Zusammenarbeit</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit.
|
||||
Verwalte Aufgabenlisten und Einkaufslisten und teile sie in Echtzeit mit Personen oder Gruppen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -238,8 +238,9 @@ export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Jede Aufgabenliste kann über den Button <strong>„Teilen"</strong> einem gemeinsamen Team-Space zugeordnet werden.
|
||||
Im Header und auf den Tabs zeigt der Avatar-Stack sofort an, wer Eigentümer ist und welche Teammitglieder Zugriff haben.
|
||||
Über den Button <strong>„Teilen"</strong> gibt der Eigentümer eine Aufgabenliste an Personen oder Gruppen frei.
|
||||
Welche Personen du findest, legt der Server fest: je nach Einstellung alle, nur Mitglieder deiner Organisation oder niemand.
|
||||
Auf den Tabs zeigen kleine Profilbilder, wer Zugriff hat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,22 +13,47 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Building2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
SharingMode,
|
||||
SystemSettings,
|
||||
User,
|
||||
UserInvite,
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type AdminSection = 'users' | 'server' | 'smtp';
|
||||
type AdminSection = 'users' | 'organizations' | 'server' | 'smtp';
|
||||
|
||||
const SHARING_MODES: { id: SharingMode; title: string; text: string }[] = [
|
||||
{
|
||||
id: 'private',
|
||||
title: '1. Privat (Keiner sieht andere)',
|
||||
text: 'Jeder arbeitet für sich allein. Keine Benutzersuche, kein Teilen, keine Gruppen.',
|
||||
},
|
||||
{
|
||||
id: 'organizations',
|
||||
title: '2. Organisationen (Empfohlen)',
|
||||
text: 'Benutzer sehen nur Mitglieder ihrer eigenen Organisation. Wer keiner Organisation angehört, sieht niemanden.',
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
title: '3. Offen (Alle sehen alle)',
|
||||
text: 'Alle Benutzer dieser Instanz können sich gegenseitig finden und miteinander teilen.',
|
||||
},
|
||||
];
|
||||
// Stricter modes come first; switching to a stricter one can remove shares
|
||||
const modeRank = (m: SharingMode) => SHARING_MODES.findIndex((x) => x.id === m);
|
||||
|
||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
currentUser,
|
||||
@@ -44,9 +69,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
|
||||
const [newUserOrg, setNewUserOrg] = useState('');
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteOrg, setInviteOrg] = useState('');
|
||||
|
||||
// --- Organizations State ---
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [renamingOrgId, setRenamingOrgId] = useState<string | null>(null);
|
||||
const [renameOrgVal, setRenameOrgVal] = useState('');
|
||||
const [deletingOrgId, setDeletingOrgId] = useState<string | null>(null);
|
||||
// Moving someone out of an organization removes their shares there, so it needs a confirmation
|
||||
const [pendingMove, setPendingMove] = useState<{ user: User; orgId: string } | null>(null);
|
||||
const [pendingMode, setPendingMode] = useState<{ mode: SharingMode; affected: number } | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
@@ -54,7 +91,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
sharingMode: 'organizations',
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
smtpHost: '',
|
||||
@@ -80,9 +117,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
setInvites(invList);
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]);
|
||||
setOrganizations(orgs);
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'teams_only',
|
||||
sharingMode: sys.sharingMode || 'organizations',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
@@ -116,8 +154,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
password: newUserPassword,
|
||||
displayName: newUserName.trim() || newUserEmail.split('@')[0],
|
||||
role: newUserRole,
|
||||
organizationId: newUserOrg || undefined,
|
||||
});
|
||||
setUsers((prev) => [...prev, created]);
|
||||
if (newUserOrg) await reloadOrganizations();
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
@@ -136,6 +176,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
organizationId: inviteOrg || undefined,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
@@ -222,6 +263,86 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
// ============================================================================
|
||||
// HANDLERS: SERVER & SMTP SETTINGS
|
||||
// ============================================================================
|
||||
// ============================================================================
|
||||
// HANDLERS: ORGANIZATIONS
|
||||
// ============================================================================
|
||||
const reloadOrganizations = async () => {
|
||||
try {
|
||||
setOrganizations(await api.adminGetOrganizations());
|
||||
} catch {
|
||||
// keep the old list
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOrganization = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
try {
|
||||
await api.adminCreateOrganization(newOrgName.trim());
|
||||
setNewOrgName('');
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen der Organisation' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameOrganization = async (orgId: string) => {
|
||||
try {
|
||||
await api.adminRenameOrganization(orgId, renameOrgVal.trim());
|
||||
setRenamingOrgId(null);
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Umbenennen' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async (orgId: string) => {
|
||||
try {
|
||||
await api.adminDeleteOrganization(orgId);
|
||||
setDeletingOrgId(null);
|
||||
setUsers((prev) => prev.map((u) => (u.organizationId === orgId ? { ...u, organizationId: '' } : u)));
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen' });
|
||||
}
|
||||
};
|
||||
|
||||
const applyUserOrganization = async (u: User, orgId: string) => {
|
||||
setPendingMove(null);
|
||||
try {
|
||||
await api.adminSetUserOrganization(u.id, orgId);
|
||||
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, organizationId: orgId } : x)));
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Zuordnen' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUserOrganization = (u: User, orgId: string) => {
|
||||
if (u.organizationId) setPendingMove({ user: u, orgId });
|
||||
else applyUserOrganization(u, orgId);
|
||||
};
|
||||
|
||||
const orgName = (id?: string) => organizations.find((o) => o.id === id)?.name || '';
|
||||
|
||||
// Tightening the mode first asks how much sharing would be lost
|
||||
const handleSelectSharingMode = async (mode: SharingMode) => {
|
||||
if (mode === settings.sharingMode) return;
|
||||
if (modeRank(mode) < modeRank(settings.sharingMode)) {
|
||||
try {
|
||||
const affected = await api.adminSharingImpact(mode);
|
||||
if (affected > 0) {
|
||||
setPendingMode({ mode, affected });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through and save
|
||||
}
|
||||
}
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
};
|
||||
|
||||
const handleSaveSystemSettings = async (patch?: Partial<SystemSettings>) => {
|
||||
setStatusMsg(null);
|
||||
const next = { ...settings, ...(patch || {}) };
|
||||
@@ -229,6 +350,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
try {
|
||||
const saved = await api.adminUpdateSettings(next);
|
||||
setSettings((prev) => ({ ...prev, ...saved }));
|
||||
setCurrentSharingMode(saved.sharingMode);
|
||||
setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
@@ -327,6 +449,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSection('organizations')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'organizations'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Organisationen</p>
|
||||
<p className="text-[10px] text-faint">Wer wen sehen darf</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('server')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
@@ -383,7 +520,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Anzeigename</label>
|
||||
<input
|
||||
@@ -432,6 +569,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isServerAdmin && (
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Organisation</label>
|
||||
<select
|
||||
value={newUserOrg}
|
||||
onChange={(e) => setNewUserOrg(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -454,7 +609,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
@@ -479,6 +634,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isServerAdmin && (
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Organisation</label>
|
||||
<select
|
||||
value={inviteOrg}
|
||||
onChange={(e) => setInviteOrg(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -511,6 +684,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
</span>
|
||||
{inv.usedBy ? (
|
||||
<span className="badge-neutral text-[10px]">
|
||||
@@ -568,7 +742,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
<p className="text-muted text-[11px]">
|
||||
{u.email}
|
||||
{isServerAdmin && settings.sharingMode === 'organizations' && !u.organizationId && (
|
||||
<span className="text-status-warn"> · ohne Organisation, sieht niemanden</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -585,6 +764,22 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
|
||||
{isServerAdmin && (
|
||||
<select
|
||||
value={u.organizationId || ''}
|
||||
onChange={(e) => handleChangeUserOrganization(u, e.target.value)}
|
||||
className="h-7 px-2 input-field text-[11px] bg-panel max-w-[11rem]"
|
||||
title="Organisation"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
@@ -619,6 +814,25 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingMove?.user.id === u.id && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-muted">
|
||||
{u.displayName} verlässt „{orgName(u.organizationId)}“ und verliert dort alle Freigaben,
|
||||
Gruppen und Aufgaben-Zuweisungen.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => applyUserOrganization(u, pendingMove.orgId)}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
{pendingMove.orgId ? `Nach „${orgName(pendingMove.orgId)}“ verschieben` : 'Entfernen'}
|
||||
</button>
|
||||
<button onClick={() => setPendingMove(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetPwdUserId === u.id && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||||
<input
|
||||
@@ -651,6 +865,131 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: ORGANISATIONEN (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'organizations' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Organisationen</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Jeder Benutzer gehört höchstens einer Organisation an. Im Modus „Organisationen“ sehen sich nur
|
||||
Mitglieder derselben Organisation und können nur untereinander teilen und Gruppen bilden.
|
||||
Die Zuordnung änderst du in der Benutzerliste.
|
||||
</p>
|
||||
{settings.sharingMode !== 'organizations' && (
|
||||
<p className="mt-2 text-status-warn">
|
||||
Der aktuelle Sichtbarkeits-Modus ist nicht „Organisationen“; die Zuordnung wirkt sich derzeit nicht aus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateOrganization} className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="Name der neuen Organisation"
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Anlegen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-muted">Noch keine Organisation angelegt.</p>
|
||||
) : (
|
||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||
{organizations.map((o) => {
|
||||
const members = users.filter((u) => u.organizationId === o.id);
|
||||
return (
|
||||
<div key={o.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{renamingOrgId === o.id ? (
|
||||
<div className="flex flex-1 min-w-0 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameOrgVal}
|
||||
onChange={(e) => setRenameOrgVal(e.target.value)}
|
||||
className="flex-1 min-w-0 h-7 px-2 input-field"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => handleRenameOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
|
||||
Speichern
|
||||
</button>
|
||||
<button onClick={() => setRenamingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="font-bold text-main truncate">{o.name}</span>
|
||||
<span className="badge-count">{o.memberCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{renamingOrgId !== o.id && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenamingOrgId(o.id);
|
||||
setRenameOrgVal(o.name);
|
||||
}}
|
||||
className="btn-icon"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingOrgId(deletingOrgId === o.id ? null : o.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Organisation löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-[11px] text-main"
|
||||
>
|
||||
<UserAvatar user={m} size="xxs" showTitle={false} />
|
||||
<span>{m.displayName}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingOrgId === o.id && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-muted">
|
||||
Die {o.memberCount} Mitglieder sind danach ohne Organisation und verlieren ihre
|
||||
Freigaben, Gruppen und Zuweisungen untereinander.
|
||||
</span>
|
||||
<button onClick={() => handleDeleteOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
|
||||
Löschen
|
||||
</button>
|
||||
<button onClick={() => setDeletingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
@@ -661,8 +1000,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert
|
||||
arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
|
||||
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -698,54 +1037,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'strict_private'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">1. Strikt Privat (Keiner sieht andere)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
|
||||
Benutzersuche möglich.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'teams_only'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">2. Teams isoliert (Empfohlen)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
|
||||
teilen.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'open'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">3. Offen (Alle sehen alle)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
|
||||
miteinander teilen.
|
||||
</p>
|
||||
</button>
|
||||
{SHARING_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSharingMode(m.id)}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === m.id
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">{m.title}</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pendingMode && (
|
||||
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-main">
|
||||
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
|
||||
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const mode = pendingMode.mode;
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
}}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
Trotzdem wechseln
|
||||
</button>
|
||||
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
@@ -762,25 +1092,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.allowRegistration}
|
||||
onClick={() =>
|
||||
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||
}`}
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, isSharingDisabled, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
@@ -187,6 +187,18 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
!managedTeams.some((mt) => mt.id === t.id)
|
||||
);
|
||||
|
||||
if (isSharingDisabled()) {
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<button onClick={onBack} className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer">
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<p className="text-xs text-muted">Gruppen sind auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
@@ -203,13 +215,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
<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>Gruppen</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.
|
||||
Erstelle Gruppen (z. B. Familie, WG oder Projektteam) aus Personen, die du sehen kannst, und teile Notizen, Aufgaben und Listen mit allen auf einmal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1398,7 +1398,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span className="ml-1 badge-count">{tl.tasksCount}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||||
if (!isShared) return null;
|
||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||
if (otherMembers.length > 0) {
|
||||
@@ -1448,7 +1448,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||
? currentSelectedList.shares.length
|
||||
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
||||
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
||||
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
|
||||
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
||||
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||||
|
||||
|
||||
+51
-38
@@ -17,6 +17,7 @@ import {
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
@@ -124,10 +125,19 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
|
||||
let currentSharingMode: SharingMode = 'organizations';
|
||||
export const setCurrentSharingMode = (mode: SharingMode) => {
|
||||
currentSharingMode = mode;
|
||||
};
|
||||
export const isSharingDisabled = () => currentSharingMode === 'private';
|
||||
|
||||
export const api = {
|
||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
const st = await request<AuthStatus>('/api/v1/auth/status');
|
||||
if (st.sharingMode) setCurrentSharingMode(st.sharingMode);
|
||||
return st;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
@@ -147,7 +157,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
instanceName?: string;
|
||||
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
||||
sharingMode?: SharingMode;
|
||||
allowRegistration?: boolean;
|
||||
createSampleData?: boolean;
|
||||
}): Promise<{ token: string; user: User }> {
|
||||
@@ -830,41 +840,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
return ensureArray(res).map((o) => ({
|
||||
...o,
|
||||
teams: ensureArray(o.teams).map((t) => ({
|
||||
...t,
|
||||
members: ensureArray(t.members),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
async createOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getOrgTeams(orgId: string): Promise<Team[]> {
|
||||
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
||||
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- GROUPS (user-managed) ---
|
||||
async createTeam(name: string, description = ''): Promise<Team> {
|
||||
return request<Team>('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
@@ -924,6 +900,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
@@ -966,7 +943,7 @@ export const api = {
|
||||
async adminCreateInvite(payload: {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: string;
|
||||
organizationId?: string;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
@@ -989,6 +966,42 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// Counts shares and group memberships a switch to `mode` would remove
|
||||
async adminSharingImpact(mode: SharingMode): Promise<number> {
|
||||
const res = await request<{ affected: number }>(`/api/v1/admin/settings/sharing-impact?mode=${mode}`);
|
||||
return res.affected || 0;
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS (server admin only) ---
|
||||
async adminGetOrganizations(): Promise<Organization[]> {
|
||||
return ensureArray(await request<Organization[]>('/api/v1/admin/organizations'));
|
||||
},
|
||||
|
||||
async adminCreateOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/admin/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminRenameOrganization(id: string, name: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminSetUserOrganization(userId: string, organizationId: string): Promise<void> {
|
||||
await request(`/api/v1/admin/users/${userId}/organization`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ organizationId }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -7,8 +7,12 @@ export interface AuthStatus {
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
sharingMode?: SharingMode;
|
||||
}
|
||||
|
||||
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
|
||||
export type SharingMode = 'private' | 'organizations' | 'open';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -22,6 +26,7 @@ export interface User {
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
organizationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -30,7 +35,8 @@ export interface UserInvite {
|
||||
code: string;
|
||||
email?: string;
|
||||
role: UserRole;
|
||||
teamId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
createdBy: string;
|
||||
usedBy?: string;
|
||||
createdAt: string;
|
||||
@@ -101,8 +107,6 @@ export interface TaskList {
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
@@ -118,11 +122,11 @@ export interface TaskList {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Defined by server admins; every user belongs to at most one
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
teams?: Team[];
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -146,7 +150,7 @@ export interface TeamMember {
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
sharingMode: 'open' | 'teams_only' | 'strict_private';
|
||||
sharingMode: SharingMode;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
smtpHost?: string;
|
||||
|
||||
Reference in new issue
Block a user