@@ -263,29 +258,15 @@ function GroupRow({ group, canWrite, canAdd, onOpenDialog }: RowActionsProps & {
{pluralize(group.members.length, "member")}
- {canWrite ? (
-
- onOpenDialog(group.id, "add")}
- />
- onOpenDialog(group.id, "remove")}
- />
-
- ) : null}
+
+ onOpenDialog(group.id)}
+ />
+
)
}
diff --git a/src/features/azure/membership-dialog.tsx b/src/features/azure/membership-dialog.tsx
index 3a1f7a1..b17f690 100644
--- a/src/features/azure/membership-dialog.tsx
+++ b/src/features/azure/membership-dialog.tsx
@@ -2,7 +2,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { useRouter } from "@tanstack/react-router"
import { useServerFn } from "@tanstack/react-start"
import { Command as CommandPrimitive } from "cmdk"
-import { Search, X } from "lucide-react"
+import { Search, UserMinus, UserPlus, X } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
@@ -17,48 +17,33 @@ import {
scrimClasses,
} from "@/components/primitives"
import { dialogTitleText, dialogDescriptionText } from "@/components/primitives/confirm-dialog"
-import { appToast } from "@/components/shell"
+import { appToast, useCan } from "@/components/shell"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Dialog, DialogDescription, DialogOverlay, DialogPortal, DialogTitle } from "@/components/ui/dialog"
import { addAzureGroupMember, removeAzureGroupMember } from "@/features/azure/groups.functions"
import type { AzureGroup, AzureMember } from "@/lib/api/types"
+import { pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"
-export type MembershipMode = "add" | "remove"
-
type Choice = { id: string; name: string | null; mail: string | null }
type MembershipDialogProps = {
group: AzureGroup
- directoryMembers: AzureMember[]
- mode: MembershipMode
+ directoryMembers: AzureMember[] | null
open: boolean
onOpenChange: (open: boolean) => void
}
-const copy = {
- add: {
- title: "Add a group member",
- lead: "Choose a directory user to add to",
- tail: "",
- heading: "Available users",
- empty: "No available users found.",
- },
- remove: {
- title: "Remove a group member",
- lead: "Choose a member to remove from",
- tail: " Their Microsoft 365 account is not deleted.",
- heading: "Current members",
- empty: "No group members found.",
- },
-}
-
const PERMISSION_ERROR = "You don't have permission to manage this group."
-/** Add or remove one Microsoft 365 group member; removal swaps to a confirmation before it runs. */
-export function MembershipDialog({ group, directoryMembers, mode, open, onOpenChange }: MembershipDialogProps) {
+/** One member list with an add picker and a per-member removal confirmation. */
+export function MembershipDialog({ group, directoryMembers, open, onOpenChange }: MembershipDialogProps) {
+ const canWrite = useCan("azure:groups:write")
+ const canAdd = canWrite && directoryMembers !== null
+ const [adding, setAdding] = useState(false)
+ const [query, setQuery] = useState("")
const router = useRouter()
const addMember = useServerFn(addAzureGroupMember)
const removeMember = useServerFn(removeAzureGroupMember)
@@ -69,19 +54,19 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
const popupRef = useRef
(null)
const inputRef = useRef(null)
const keepRef = useRef(null)
- const adding = mode === "add"
- const text = copy[mode]
const choices = useMemo(() => {
const memberIds = new Set(group.members.map((member) => member.id))
+ const directory = directoryMembers ?? []
+ const emails = new Map(directory.map((member) => [member.id, member.mail]))
const list = adding
- ? directoryMembers
+ ? directory
.filter((member) => !memberIds.has(member.id))
.map((member) => ({ id: member.id, name: member.displayName, mail: member.mail }))
: group.members.map((member) => ({
id: member.id,
name: member.displayName || null,
- mail: directoryMembers.find((candidate) => candidate.id === member.id)?.mail ?? null,
+ mail: emails.get(member.id) ?? null,
}))
// Unnamed users go last.
return list.toSorted((a, b) => {
@@ -99,12 +84,15 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
}
function choose(choice: Choice) {
+ if (!canWrite || pending || (adding && !canAdd)) return
setError(null)
setSelected(choice)
if (!adding) setConfirming(true)
}
- function keepMember() {
+ function showMembers() {
+ setAdding(false)
+ setQuery("")
setError(null)
setConfirming(false)
setSelected(null)
@@ -112,7 +100,7 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
}
async function submit() {
- if (!selected || pending) return
+ if (!selected || pending || !canWrite || (adding && !canAdd)) return
const name = selected.name ?? "Unnamed user"
const input = { data: { groupId: group.id, userId: selected.id } }
setPending(true)
@@ -123,14 +111,17 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
setError("Microsoft 365 could not complete the change. Try again.")
return
}
+ let refreshed = true
try {
await router.invalidate({ sync: true })
} catch (caught) {
console.error(caught)
+ refreshed = false
appToast.warning("The membership was updated, but the latest group data could not be refreshed.")
}
appToast.success(adding ? `${name} added to ${group.displayName}.` : `${name} removed from ${group.displayName}.`)
- onOpenChange(false)
+ if (refreshed) showMembers()
+ else onOpenChange(false)
} catch (caught) {
console.error(caught)
setError(
@@ -151,6 +142,19 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
}
const selectedName = selected?.name ?? "Unnamed user"
+ const search = query.trim().toLocaleLowerCase()
+ const visibleMembers = choices.filter((choice) =>
+ `${choice.name ?? "Unnamed user"} ${choice.mail ?? ""}`.toLocaleLowerCase().includes(search)
+ )
+
+ function showAddPicker() {
+ if (!canAdd || pending) return
+ setAdding(true)
+ setSelected(null)
+ setError(null)
+ setQuery("")
+ requestAnimationFrame(() => inputRef.current?.focus())
+ }
return (
@@ -254,7 +335,7 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
type="button"
variant="outline"
disabled={pending}
- onClick={keepMember}
+ onClick={showMembers}
className={dialogFooterButton}
>
Keep member
@@ -269,10 +350,10 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
type="button"
variant="outline"
disabled={pending}
- onClick={close}
+ onClick={adding ? showMembers : close}
className={dialogFooterButton}
>
- Cancel
+ {adding ? "Back to members" : "Close"}
{adding ? (