feat: update permissions

This commit is contained in:
ivannoskov
2026-02-10 17:15:38 +03:00
parent 6941915d76
commit e1117e46c8
13 changed files with 961 additions and 523 deletions

View File

@@ -37,6 +37,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import type { WorkspaceInvite } from "@/lib/types/api";
// ============================================================================
@@ -51,8 +52,6 @@ export default function InvitesPage() {
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [username, setUsername] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadInvites();
@@ -74,27 +73,28 @@ export default function InvitesPage() {
if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
try {
setIsCreating(true);
setError(null);
// Remove @ if present
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowDialog(false);
setUsername("");
} catch (err: any) {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
} else {
setError(errorMessage || "Не удалось отправить приглашение");
await toast.promise(
(async () => {
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowDialog(false);
setUsername("");
return invite;
})(),
{
loading: "Отправка приглашения...",
success: `Приглашение отправлено @${cleanUsername}`,
error: (err) => {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
return `Пользователь @${cleanUsername} не зарегистрирован на платформе`;
}
return errorMessage || "Не удалось отправить приглашение";
},
}
}
);
};
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
@@ -161,27 +161,22 @@ export default function InvitesPage() {
приглашения через бота
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="username">Telegram username</Label>
<Input
id="username"
placeholder="@username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateInvite();
}
}}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="username">Telegram username</Label>
<Input
id="username"
placeholder="@username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateInvite();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
@@ -191,13 +186,9 @@ export default function InvitesPage() {
</Button>
<Button
onClick={handleCreateInvite}
disabled={!username.trim() || isCreating}
disabled={!username.trim()}
>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
<Mail className="h-4 w-4 mr-2" />
Отправить
</Button>
</DialogFooter>

View File

@@ -4,12 +4,15 @@
// Workspace Settings Page
// ============================================================================
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react";
import { toast } from "sonner";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { useAuth } from "@/components/auth/auth-provider";
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
import type { User } from "@/lib/types/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -51,7 +54,13 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { PERMISSION_LABELS } from "@/lib/types/api";
import type { WorkspaceMember, WorkspaceInvite, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
import { PermissionModal } from "@/components/ui/permission-modal";
import {
handleApiError,
showPermissionErrorToast,
showSuccessToast,
} from "@/lib/utils/error-handler";
// ============================================================================
// Component
@@ -62,6 +71,8 @@ export default function SettingsPage() {
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
const { user } = useAuth();
const currentUser = user;
const [name, setName] = useState(currentWorkspace?.name || "");
const [isUpdating, setIsUpdating] = useState(false);
@@ -91,6 +102,14 @@ export default function SettingsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const isOnlyAdmin = useMemo(() => {
if (!currentUser || !members.length) return false;
const adminMembers = members.filter((m) =>
m.permissions.some((p) => p.key === "admin_full")
);
return adminMembers.length === 1 && adminMembers[0]?.user.id === currentUser.id;
}, [currentUser, members]);
useEffect(() => {
if (currentWorkspace) {
setName(currentWorkspace.name);
@@ -139,59 +158,142 @@ export default function SettingsPage() {
wsId: string,
memberId: string,
userId: string,
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
) => {
await membersApi.updatePermissions(wsId, userId, { permissions });
await loadMembers();
await toast.promise(
membersApi.updatePermissions(wsId, memberId, { permissions }),
{
loading: "Сохранение прав доступа...",
success: () => {
loadMembers();
return "Права доступа обновлены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить права";
},
}
);
setShowPermissionModal(false);
};
const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return;
try {
setIsUpdating(true);
await workspacesApi.update(workspaceId, { name: name.trim() });
await refreshWorkspaces();
} catch (err) {
console.error("Failed to update workspace:", err);
} finally {
setIsUpdating(false);
}
await toast.promise(
workspacesApi.update(workspaceId, { name: name.trim() }),
{
loading: "Сохранение названия...",
success: () => {
refreshWorkspaces();
return "Название обновлено";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить название";
},
}
);
setIsUpdating(false);
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsUploadingAvatar(true);
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file);
await refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
await toast.promise(
workspacesApi.uploadAvatar(workspaceId, file),
{
loading: "Загрузка аватара...",
success: () => {
refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setAvatarFile(null);
return "Аватар загружен";
},
error: (err) => {
handleApiError(err);
return "Не удалось загрузить аватар";
},
}
setAvatarFile(null);
} catch (err) {
console.error("Failed to upload avatar:", err);
} finally {
setIsUploadingAvatar(false);
}
);
setIsUploadingAvatar(false);
};
const handleAvatarDelete = async () => {
await toast.promise(
workspacesApi.deleteAvatar(workspaceId),
{
loading: "Удаление аватара...",
success: () => {
refreshWorkspaces();
return "Аватар удалён";
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить аватар";
},
}
);
setIsDeletingAvatar(false);
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
await toast.promise(
invitesApi.create(workspaceId, {
username: cleanUsername,
}),
{
loading: "Отправка приглашения...",
success: (invite) => {
setInvites((prev) => [invite, ...prev]);
setShowInviteDialog(false);
setUsername("");
return `Приглашение отправлено @${cleanUsername}`;
},
error: (err: any) => {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе.`);
return "Пользователь не найден";
}
if (errorMessage?.includes("already exists") || errorMessage?.includes("уже")) {
return "Приглашение уже существует или пользователь уже участник";
}
handleApiError(err);
return "Не удалось отправить приглашение";
},
}
);
setIsCreating(false);
};
const handleDelete = async () => {
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
try {
setIsDeleting(true);
await workspacesApi.delete(workspaceId);
router.replace("/");
} catch (err) {
console.error("Failed to delete workspace:", err);
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteConfirm("");
}
await toast.promise(
workspacesApi.delete(workspaceId),
{
loading: "Удаление воркспейса...",
success: () => {
router.replace("/");
return "Воркспейс удалён";
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить воркспейс";
},
}
);
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteConfirm("");
};
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
@@ -522,20 +624,20 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
projects={projects}
onSave={handlePermissionSave}
workspaceId={workspaceId}
currentUserId={currentUser?.id || ""}
isOnlyAdmin={isOnlyAdmin}
/>
)}
</div>