MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
Users,
|
|
User as UserIcon,
|
|
X,
|
|
Plus,
|
|
Trash2,
|
|
Lock,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { User, Team, UnifiedShare } from '../lib/types';
|
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
|
import { UserAvatar } from './UserAvatar';
|
|
|
|
export interface UnifiedShareModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title?: string;
|
|
resourceName: string;
|
|
resourceIcon?: string | React.ReactNode;
|
|
isOwner: boolean;
|
|
currentUserId?: string;
|
|
shares: UnifiedShare[];
|
|
teams: Team[];
|
|
collaborators: User[];
|
|
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
|
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
|
onRemoveShare: (shareId: string) => Promise<void>;
|
|
}
|
|
|
|
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
title = 'Freigeben & Berechtigungen',
|
|
resourceName,
|
|
resourceIcon,
|
|
isOwner,
|
|
currentUserId,
|
|
shares,
|
|
teams,
|
|
collaborators,
|
|
allowRoleSelection = true,
|
|
onAddShare,
|
|
onRemoveShare,
|
|
}) => {
|
|
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleAdd = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
|
|
if (!targetId) {
|
|
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
await onAddShare(shareType, targetId, selectedPermission);
|
|
setSelectedTeamId('');
|
|
setSelectedUser(null);
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (shareId: string) => {
|
|
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
await onRemoveShare(shareId);
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const excludedUserIds = [
|
|
...(currentUserId ? [currentUserId] : []),
|
|
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
|
|
];
|
|
|
|
const availableTeams = teams.filter(
|
|
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between pb-3 border-b border-border">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
|
{resourceIcon ? (
|
|
typeof resourceIcon === 'string' ? (
|
|
<span className="text-base">{resourceIcon}</span>
|
|
) : (
|
|
resourceIcon
|
|
)
|
|
) : (
|
|
<Users className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
|
|
<p className="text-[11px] text-muted truncate">
|
|
Objekt: <span className="font-semibold text-main">{resourceName}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
|
title="Schließen"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Share Section (Only if owner / manager) */}
|
|
{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>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShareType('team');
|
|
setSelectedUser(null);
|
|
setError(null);
|
|
}}
|
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
|
shareType === 'team'
|
|
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-3.5 h-3.5" />
|
|
<span>Gruppe</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShareType('user');
|
|
setSelectedTeamId('');
|
|
setError(null);
|
|
}}
|
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
|
shareType === 'user'
|
|
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<UserIcon className="w-3.5 h-3.5" />
|
|
<span>Benutzer</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{shareType === 'team' ? (
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<select
|
|
value={selectedTeamId}
|
|
onChange={(e) => setSelectedTeamId(e.target.value)}
|
|
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
|
|
disabled={loading}
|
|
>
|
|
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
|
|
{availableTeams.map((t) => (
|
|
<option key={t.id} value={t.id} className="bg-panel text-main">
|
|
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
|
</option>
|
|
))}
|
|
</select>
|
|
{allowRoleSelection && (
|
|
<select
|
|
value={selectedPermission}
|
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
|
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
|
disabled={loading}
|
|
>
|
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<div className="flex-1">
|
|
<UserAutocomplete
|
|
users={collaborators}
|
|
selectedUser={selectedUser}
|
|
onSelectUser={setSelectedUser}
|
|
excludeUserIds={excludedUserIds}
|
|
disabled={loading}
|
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
|
/>
|
|
</div>
|
|
{allowRoleSelection && (
|
|
<select
|
|
value={selectedPermission}
|
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
|
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
|
disabled={loading}
|
|
>
|
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end pt-1">
|
|
<button
|
|
type="submit"
|
|
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
|
|
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>
|
|
{loading
|
|
? 'Wird gespeichert…'
|
|
: shareType === 'team'
|
|
? 'Gruppe freigeben'
|
|
: 'Benutzer hinzufügen'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
|
<Lock className="w-4 h-4 text-faint shrink-0" />
|
|
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Shares List */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
|
<span>Aktive Freigaben</span>
|
|
<span className="font-mono text-[11px] font-normal lowercase">
|
|
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
|
{shares.length === 0 ? (
|
|
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
|
<Lock className="w-4 h-4 text-faint shrink-0" />
|
|
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
|
|
</div>
|
|
) : (
|
|
shares.map((sh) => {
|
|
const isTeam = sh.targetType === 'team';
|
|
const roleLabel =
|
|
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
|
|
|
|
return (
|
|
<div
|
|
key={sh.id}
|
|
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
{isTeam ? (
|
|
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
|
<Users className="w-3.5 h-3.5" />
|
|
</div>
|
|
) : (
|
|
<UserAvatar
|
|
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
|
|
name={sh.targetName || sh.targetEmail}
|
|
size="sm"
|
|
className="border border-border/80"
|
|
/>
|
|
)}
|
|
<div className="truncate">
|
|
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
|
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
|
<span
|
|
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
|
|
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
|
|
}`}
|
|
>
|
|
{isTeam ? 'Gruppe' : 'Benutzer'}
|
|
</span>
|
|
</div>
|
|
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
|
|
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span
|
|
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
|
roleLabel === 'Bearbeiter'
|
|
? 'bg-accent/15 text-accent border border-accent/30'
|
|
: 'bg-surface-2 text-muted border border-border'
|
|
}`}
|
|
>
|
|
{roleLabel}
|
|
</span>
|
|
{isOwner && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemove(sh.id)}
|
|
disabled={loading}
|
|
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
|
title="Freigabe entfernen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="pt-2 border-t border-border flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="btn-action text-xs h-8 px-4 cursor-pointer"
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|