-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add invite user to org feature, tighten auth redirect logic #344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| "use client"; | ||
|
|
||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| import { PlusIcon } from "lucide-react"; | ||
| import { type FormEvent, useState } from "react"; | ||
| import { toast } from "sonner"; | ||
| import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; | ||
| import { useOrganisations } from "@/hooks/useOrganisations"; | ||
| import { useTRPC } from "@/services/trpc/react"; | ||
| import { Button } from "@/shadcn/ui/button"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogDescription, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| DialogTrigger, | ||
| } from "@/shadcn/ui/dialog"; | ||
| import { Input } from "@/shadcn/ui/input"; | ||
|
|
||
| export default function InviteMemberDialog() { | ||
| const trpc = useTRPC(); | ||
| const queryClient = useQueryClient(); | ||
| const { currentOrganisation } = useOrganisations(); | ||
|
|
||
| const [dialogOpen, setDialogOpen] = useState(false); | ||
| const [name, setName] = useState(""); | ||
| const [email, setEmail] = useState(""); | ||
|
|
||
| const { mutate: inviteMember, isPending } = useMutation( | ||
| trpc.organisation.inviteMember.mutationOptions({ | ||
| onSuccess: () => { | ||
| toast.success("Invitation sent", { | ||
| description: `An invite has been sent to ${email}`, | ||
| }); | ||
| setName(""); | ||
| setEmail(""); | ||
| setDialogOpen(false); | ||
| queryClient.invalidateQueries({ | ||
| queryKey: trpc.organisation.listUsers.queryKey(), | ||
| }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error("Failed to send invitation", { | ||
| description: error.message, | ||
| }); | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| if (!currentOrganisation) { | ||
| return null; | ||
| } | ||
|
|
||
| const onSubmit = (e: FormEvent<HTMLFormElement>) => { | ||
| e.preventDefault(); | ||
| inviteMember({ | ||
| organisationId: currentOrganisation.id, | ||
| name, | ||
| email, | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> | ||
| <DialogTrigger asChild> | ||
| <Button variant="outline" size="sm" className="mt-4"> | ||
| <PlusIcon className="mr-2 h-4 w-4" /> | ||
| Invite Member | ||
| </Button> | ||
| </DialogTrigger> | ||
| <DialogContent className="sm:max-w-[425px]"> | ||
| <DialogHeader> | ||
| <DialogTitle>Invite Member</DialogTitle> | ||
| <DialogDescription> | ||
| Send an invitation to join {currentOrganisation.name}. | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
| <form onSubmit={onSubmit} className="flex flex-col gap-4"> | ||
| <FormFieldWrapper id="invite-name" label="Name"> | ||
| <Input | ||
| id="invite-name" | ||
| name="name" | ||
| type="text" | ||
| value={name} | ||
| onChange={(e) => setName(e.target.value)} | ||
| required | ||
| /> | ||
| </FormFieldWrapper> | ||
|
|
||
| <FormFieldWrapper id="invite-email" label="Email"> | ||
| <Input | ||
| id="invite-email" | ||
| name="email" | ||
| type="email" | ||
| value={email} | ||
| onChange={(e) => setEmail(e.target.value)} | ||
| required | ||
| /> | ||
| </FormFieldWrapper> | ||
|
|
||
| <Button disabled={isPending} type="submit" size="sm" className="mt-2"> | ||
| {isPending ? "Sending…" : "Send invitation"} | ||
| </Button> | ||
| </form> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| "use client"; | ||
|
|
||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { useOrganisations } from "@/hooks/useOrganisations"; | ||
| import { useTRPC } from "@/services/trpc/react"; | ||
| import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar"; | ||
| import { | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@/shadcn/ui/table"; | ||
|
|
||
| function getInitials(name: string) { | ||
| return name | ||
| .split(" ") | ||
| .map((part) => part[0]) | ||
| .join("") | ||
| .toUpperCase() | ||
| .slice(0, 2); | ||
| } | ||
|
|
||
| export default function OrganisationUsersTable() { | ||
| const trpc = useTRPC(); | ||
| const { currentOrganisation } = useOrganisations(); | ||
|
|
||
| const { data: users, isLoading } = useQuery( | ||
| trpc.organisation.listUsers.queryOptions( | ||
| { organisationId: currentOrganisation?.id ?? "" }, | ||
| { enabled: !!currentOrganisation }, | ||
| ), | ||
| ); | ||
|
|
||
| if (!currentOrganisation) { | ||
| return null; | ||
| } | ||
|
|
||
| if (isLoading) { | ||
| return <p className="text-sm text-muted-foreground">Loading members…</p>; | ||
| } | ||
|
|
||
| if (!users?.length) { | ||
| return <p className="text-sm text-muted-foreground">No members found.</p>; | ||
| } | ||
|
|
||
| return ( | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead className="w-12" /> | ||
| <TableHead>Name</TableHead> | ||
| <TableHead>Email</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {users.map((user) => ( | ||
| <TableRow key={user.id}> | ||
| <TableCell> | ||
| <Avatar className="h-8 w-8"> | ||
| {user.avatarUrl && <AvatarImage src={user.avatarUrl} />} | ||
| <AvatarFallback className="text-xs"> | ||
| {getInitials(user.name || user.email)} | ||
| </AvatarFallback> | ||
| </Avatar> | ||
| </TableCell> | ||
| <TableCell>{user.name || "-"}</TableCell> | ||
| <TableCell className="text-muted-foreground"> | ||
| {user.email} | ||
| </TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| "use client"; | ||
|
|
||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { CheckIcon, XIcon } from "lucide-react"; | ||
| import { toast } from "sonner"; | ||
| import { useTRPC } from "@/services/trpc/react"; | ||
| import { Button } from "@/shadcn/ui/button"; | ||
| import { | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@/shadcn/ui/table"; | ||
|
|
||
| export default function PendingInvitationsTable() { | ||
| const trpc = useTRPC(); | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| const { data: invitations, isLoading } = useQuery( | ||
| trpc.invitation.listForUser.queryOptions(), | ||
| ); | ||
|
|
||
| const { mutate: acceptInvite, isPending: isAccepting } = useMutation( | ||
| trpc.organisation.acceptInvite.mutationOptions({ | ||
| onSuccess: () => { | ||
| toast.success("Invitation accepted!"); | ||
| queryClient.invalidateQueries({ | ||
| queryKey: trpc.organisation.list.queryKey(), | ||
| }); | ||
| queryClient.invalidateQueries({ | ||
| queryKey: trpc.invitation.listForUser.queryKey(), | ||
| }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error("Failed to accept invitation", { | ||
| description: error.message, | ||
| }); | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const { mutate: rejectInvite, isPending: isRejecting } = useMutation( | ||
| trpc.organisation.rejectInvite.mutationOptions({ | ||
| onSuccess: () => { | ||
| toast.success("Invitation declined"); | ||
| queryClient.invalidateQueries({ | ||
| queryKey: trpc.invitation.listForUser.queryKey(), | ||
| }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error("Failed to decline invitation", { | ||
| description: error.message, | ||
| }); | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const isPending = isAccepting || isRejecting; | ||
|
||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <p className="text-sm text-muted-foreground">Loading invitations…</p> | ||
| ); | ||
| } | ||
|
|
||
| if (!invitations?.length) { | ||
| return ( | ||
| <p className="text-sm text-muted-foreground">No pending invitations.</p> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead>Organisation</TableHead> | ||
| <TableHead className="text-right">Actions</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {invitations.map((invitation) => ( | ||
| <TableRow key={invitation.id}> | ||
| <TableCell className="font-medium"> | ||
| {invitation.organisationName ?? "Unknown"} | ||
| </TableCell> | ||
| <TableCell className="text-right"> | ||
| <div className="flex justify-end gap-2"> | ||
| <Button | ||
| size="sm" | ||
| onClick={() => acceptInvite({ invitationId: invitation.id })} | ||
| disabled={isPending} | ||
| > | ||
| <CheckIcon className="mr-1 h-3 w-3" /> | ||
| {isAccepting ? "Accepting…" : "Accept"} | ||
| </Button> | ||
| <Button | ||
| size="sm" | ||
| variant="outline" | ||
| onClick={() => rejectInvite({ invitationId: invitation.id })} | ||
| disabled={isPending} | ||
| > | ||
| <XIcon className="mr-1 h-3 w-3" /> | ||
| {isRejecting ? "Declining…" : "Decline"} | ||
| </Button> | ||
| </div> | ||
| </TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The form clears name and email fields and closes the dialog on success, but if the user opens the dialog again and starts typing, they might submit a partial/incorrect invitation if they accidentally trigger the form submission. Consider resetting the form state when the dialog is opened (not just when it's successfully submitted) to prevent stale data from being accidentally submitted.