Polish user-access UX: shadcn confirm dialog + toast feedback

Two small UX upgrades on the portfolio user-access page:

- Replace native confirm() prompts (for "Remove user" and "Revoke
  invitation") with a shadcn-based ConfirmDialog. Shows the email of
  the user/invitation being acted on, disables buttons while the
  mutation is in flight, and matches the visual language of the rest
  of the app. New ConfirmDialog component is generic and reusable.

- Toast on every mutation outcome (invite/remove/revoke, success and
  failure), using the existing useToast hook. Invite success now
  surfaces "Invitation sent — We've emailed <email>…" so admins get
  immediate feedback that the email was dispatched, instead of just
  the form silently clearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 17:29:36 +00:00
parent 66cc71d228
commit f3887a215c
2 changed files with 167 additions and 9 deletions

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
{cancelLabel}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
className={destructive ? "bg-red-700 hover:bg-red-800" : ""}
onClick={onConfirm}
disabled={isPending}
>
{isPending ? "Working…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<Role>("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 }) {
<Button
variant="destructive"
className="bg-red-700"
onClick={() => onRemove(c.portfolioUserId)}
onClick={() =>
onRemove(c.portfolioUserId, c.email)
}
disabled={removeUserMutation.isPending}
>
{removeUserMutation.isPending &&
@ -485,7 +541,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
variant="destructive"
className="bg-red-700"
onClick={() =>
onRevokeInvitation(i.invitationId)
onRevokeInvitation(i.invitationId, i.email)
}
disabled={revokeInvitationMutation.isPending}
>
@ -507,6 +563,43 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
)}
</TableBody>
</Table>
<ConfirmDialog
open={pendingRemoval !== null}
onOpenChange={(open) => !open && setPendingRemoval(null)}
title="Remove user from this portfolio?"
description={
pendingRemoval ? (
<>
<span className="font-medium">{pendingRemoval.email}</span> will
immediately lose access. They can be re-invited later.
</>
) : null
}
confirmLabel="Remove"
destructive
isPending={removeUserMutation.isPending}
onConfirm={confirmRemove}
/>
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(open) => !open && setPendingRevoke(null)}
title="Revoke this pending invitation?"
description={
pendingRevoke ? (
<>
<span className="font-medium">{pendingRevoke.email}</span> won't
be able to accept this invitation. You can invite them again
later.
</>
) : null
}
confirmLabel="Revoke"
destructive
isPending={revokeInvitationMutation.isPending}
onConfirm={confirmRevoke}
/>
</div>
);
}