diff --git a/src/app/components/ConfirmDialog.tsx b/src/app/components/ConfirmDialog.tsx
new file mode 100644
index 00000000..b8498bc3
--- /dev/null
+++ b/src/app/components/ConfirmDialog.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/app/shadcn_components/ui/dialog";
+import { Button } from "@/app/shadcn_components/ui/button";
+import type { ReactNode } from "react";
+
+// Controlled confirmation dialog. Pass `open` + `onOpenChange` to control
+// visibility (so the parent can stash any context needed by onConfirm),
+// and `onConfirm` is called when the user clicks the destructive action.
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmLabel = "Confirm",
+ cancelLabel = "Cancel",
+ destructive = false,
+ isPending = false,
+ onConfirm,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ destructive?: boolean;
+ isPending?: boolean;
+ onConfirm: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
index a5f61cf3..c6a07a5f 100644
--- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx
@@ -27,6 +27,8 @@ import {
fetchCollaborators,
type CollaboratorsResponse,
} from "./collaboratorsClient";
+import { ConfirmDialog } from "@/app/components/ConfirmDialog";
+import { useToast } from "@/app/hooks/use-toast";
type PendingInvitation = {
invitationId: string;
@@ -122,7 +124,12 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("read");
const [inviteName, setInviteName] = useState("");
+ const [pendingRemoval, setPendingRemoval] =
+ useState<{ portfolioUserId: string; email: string } | null>(null);
+ const [pendingRevoke, setPendingRevoke] =
+ useState<{ invitationId: string; email: string } | null>(null);
+ const { toast } = useToast();
const queryClient = useQueryClient();
const usersKey = COLLABORATORS_QUERY_KEY(portfolioId);
@@ -209,13 +216,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
role: Role;
name: string;
}) => invitePortfolioUser(portfolioId, email, role, name),
- onSuccess: () => {
+ onSuccess: (_data, vars) => {
invalidateBoth();
setInviteEmail("");
setInviteName("");
+ toast({
+ title: "Invitation sent",
+ description: `We've emailed ${vars.email} an invitation to this portfolio.`,
+ });
},
onError: (err) => {
console.error("Invite failed:", err);
+ toast({
+ title: "Couldn't send invitation",
+ description: err instanceof Error ? err.message : "Please try again.",
+ variant: "destructive",
+ });
},
});
@@ -239,14 +255,29 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
);
return { previous };
},
+ onSuccess: (_data, _portfolioUserId) => {
+ const email = pendingRemoval?.email;
+ toast({
+ title: "User removed",
+ description: email
+ ? `${email} no longer has access to this portfolio.`
+ : "User no longer has access to this portfolio.",
+ });
+ },
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(usersKey, context.previous);
}
console.error("Failed to remove user:", err);
+ toast({
+ title: "Couldn't remove user",
+ description: err instanceof Error ? err.message : "Please try again.",
+ variant: "destructive",
+ });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: usersKey });
+ setPendingRemoval(null);
},
});
@@ -263,14 +294,29 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
);
return { previous };
},
+ onSuccess: () => {
+ const email = pendingRevoke?.email;
+ toast({
+ title: "Invitation revoked",
+ description: email
+ ? `${email}'s invitation has been cancelled.`
+ : "The invitation has been cancelled.",
+ });
+ },
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(invitationsKey, context.previous);
}
console.error("Failed to revoke invitation:", err);
+ toast({
+ title: "Couldn't revoke invitation",
+ description: err instanceof Error ? err.message : "Please try again.",
+ variant: "destructive",
+ });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: invitationsKey });
+ setPendingRevoke(null);
},
});
@@ -286,14 +332,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
changeRoleMutation.mutate({ portfolioUserId, role });
}
- function onRemove(portfolioUserId: string) {
- if (!confirm("Remove this user from the portfolio?")) return;
- removeUserMutation.mutate(portfolioUserId);
+ function onRemove(portfolioUserId: string, email: string) {
+ setPendingRemoval({ portfolioUserId, email });
}
- function onRevokeInvitation(invitationId: string) {
- if (!confirm("Revoke this pending invitation?")) return;
- revokeInvitationMutation.mutate(invitationId);
+ function onRevokeInvitation(invitationId: string, email: string) {
+ setPendingRevoke({ invitationId, email });
+ }
+
+ function confirmRemove() {
+ if (!pendingRemoval) return;
+ removeUserMutation.mutate(pendingRemoval.portfolioUserId);
+ }
+
+ function confirmRevoke() {
+ if (!pendingRevoke) return;
+ revokeInvitationMutation.mutate(pendingRevoke.invitationId);
}
return (
@@ -419,7 +473,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {