diff --git a/docs/screenshots/pr-96/add-member.png b/docs/screenshots/pr-96/add-member.png new file mode 100644 index 0000000..bde45f4 Binary files /dev/null and b/docs/screenshots/pr-96/add-member.png differ diff --git a/docs/screenshots/pr-96/groups.png b/docs/screenshots/pr-96/groups.png new file mode 100644 index 0000000..79df6db Binary files /dev/null and b/docs/screenshots/pr-96/groups.png differ diff --git a/docs/screenshots/pr-96/members-mobile.png b/docs/screenshots/pr-96/members-mobile.png new file mode 100644 index 0000000..f218934 Binary files /dev/null and b/docs/screenshots/pr-96/members-mobile.png differ diff --git a/docs/screenshots/pr-96/members.png b/docs/screenshots/pr-96/members.png new file mode 100644 index 0000000..f4b73dd Binary files /dev/null and b/docs/screenshots/pr-96/members.png differ diff --git a/docs/screenshots/pr-96/remove-member.png b/docs/screenshots/pr-96/remove-member.png new file mode 100644 index 0000000..305aec6 Binary files /dev/null and b/docs/screenshots/pr-96/remove-member.png differ diff --git a/src/features/azure/groups-page.tsx b/src/features/azure/groups-page.tsx index b890994..13f7692 100644 --- a/src/features/azure/groups-page.tsx +++ b/src/features/azure/groups-page.tsx @@ -1,15 +1,15 @@ -import { ChevronRight, Database, UserMinus, UserPlus } from "lucide-react" +import { ChevronRight, Database, UsersRound } from "lucide-react" import { type ReactNode, useDeferredValue, useId, useLayoutEffect, useMemo, useState } from "react" import { AvatarGroup, EmptyState, IconButton, Reveal, SectionEmpty, Unset } from "@/components/primitives" import { Hint } from "@/components/primitives/hint" -import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" +import { Count, PageBar, PageContent, Toolbar } from "@/components/shell" import { Button } from "@/components/ui/button" import type { AzureGroup, AzureMember } from "@/lib/api/types" import { pluralize } from "@/lib/format" import { cn } from "@/lib/utils" -import { MembershipDialog, type MembershipMode } from "./membership-dialog" +import { MembershipDialog } from "./membership-dialog" type SectionId = "multi" | "single" type OpenState = { multi: boolean; single: boolean } @@ -33,7 +33,7 @@ function matches(group: AzureGroup, query: string) { return `${group.displayName} ${group.mailAddress ?? ""}`.toLocaleLowerCase().includes(query) } -type DialogTarget = { groupId: string; mode: MembershipMode } +type DialogTarget = { groupId: string } /** * Microsoft 365 groups: groups with 2+ members and the rest, in two collapsibles. `directoryMembers` is null without @@ -46,7 +46,6 @@ export function AzureGroupsPage({ groups: AzureGroup[] directoryMembers: AzureMember[] | null }) { - const canWrite = useCan("azure:groups:write") const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()) const [openState, setOpenState] = useState(DEFAULT_OPEN) @@ -76,12 +75,12 @@ export function AzureGroupsPage({ }) } - function openDialog(groupId: string, mode: MembershipMode) { - setDialog({ groupId, mode }) + function openDialog(groupId: string) { + setDialog({ groupId }) setDialogOpen(true) } - const rowProps = { canWrite, canAdd: directoryMembers !== null, onOpenDialog: openDialog } + const rowProps = { onOpenDialog: openDialog } const dialogGroup = dialog ? groups.find((group) => group.id === dialog.groupId) : undefined const revealClass = restored ? undefined : "transition-none" @@ -164,10 +163,9 @@ export function AzureGroupsPage({ {renderContent()} {dialog && dialogGroup ? ( @@ -181,9 +179,7 @@ function Surface({ children }: { children: ReactNode }) { } type RowActionsProps = { - canWrite: boolean - canAdd: boolean - onOpenDialog: (groupId: string, mode: MembershipMode) => void + onOpenDialog: (groupId: string) => void } type GroupSectionProps = RowActionsProps & { @@ -234,7 +230,7 @@ function GroupSection({ title, emptyTitle, groups, open, onToggle, revealClassNa ) } -function GroupRow({ group, canWrite, canAdd, onOpenDialog }: RowActionsProps & { group: AzureGroup }) { +function GroupRow({ group, onOpenDialog }: RowActionsProps & { group: AzureGroup }) { const people = useMemo( () => group.members @@ -242,7 +238,6 @@ function GroupRow({ group, canWrite, canAdd, onOpenDialog }: RowActionsProps & { .toSorted((a, b) => a.name.localeCompare(b.name)), [group.members] ) - const empty = group.members.length === 0 return (
  • @@ -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 ( (next ? onOpenChange(true) : close())} onOpenChangeComplete={(next) => { if (next) return + setAdding(false) + setQuery("") setSelected(null) setConfirming(false) setError(null) @@ -174,14 +180,26 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh {confirming ? `Remove ${selectedName}?` - : `${adding ? "Add a member to" : "Remove a member from"} ${group.displayName}`} + : `${adding ? "Add a member to" : "Members of"} ${group.displayName}`} {confirming ? ( "They lose access to this Microsoft 365 group. Their account is not deleted." + ) : !adding ? ( + <> + {pluralize(group.members.length, "member")} + {group.mailAddress ? ( + <> + {" "} + on {group.mailAddress} + + ) : null} + . + ) : ( <> - {text.lead} {group.displayName}.{text.tail} + Choose a directory user to add to{" "} + {group.displayName}. )} @@ -193,9 +211,9 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh
    - ) : ( + ) : adding ? (
    @@ -205,13 +223,16 @@ export function MembershipDialog({ group, directoryMembers, mode, open, onOpenCh />
    - {text.empty} - + + No available users found. + + {choices.map((choice) => (
    + ) : ( + <> + {canWrite ? ( +
    + +
    + ) : null} + {!canAdd && canWrite ? ( +

    + Adding members needs access to the member directory. +

    + ) : null} +
    +
    + + setQuery(event.target.value)} + className="h-10 w-full bg-transparent pr-3 pl-9 text-[14px] text-(--pn-fg) placeholder:text-(--pn-fg-subtle) pointer-coarse:text-base" + /> +
    +
      + {visibleMembers.length === 0 ? ( +
    • + {group.members.length === 0 ? "No members yet." : "No group members match your search."} +
    • + ) : ( + visibleMembers.map((choice) => ( +
    • + + {canWrite ? ( + choose(choice)} + /> + ) : null} +
    • + )) + )} +
    +
    + )} @@ -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 ? ( void submit()}> diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 5b62dea..aa53b27 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -228,7 +228,7 @@ test("dashboard mutations enforce their exact write scope", async () => { test("dashboard mutation controls use their server's write scope", async () => { const scopes = { - "src/features/azure/groups-page.tsx": "azure:groups:write", + "src/features/azure/membership-dialog.tsx": "azure:groups:write", "src/features/azure/members-page.tsx": "azure:members:create", "src/features/telegram/grants-page.tsx": "tg:grants:manage", "src/features/telegram/user-detail/profile.tsx": "tg:grants:manage",