mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
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:
parent
66cc71d228
commit
f3887a215c
2 changed files with 167 additions and 9 deletions
65
src/app/components/ConfirmDialog.tsx
Normal file
65
src/app/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue