Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de
`openid-client`. ID tokens are signature-checked. Tokens remain in the dedicated Redis server session store.
`src/start.ts` validates the configured Origin on all state-changing requests. There is no Better Auth proxy.
- **Server functions** attach `adminMiddleware` for reads, and their exact permission middleware for mutations:
grants, Telegram groups, WhatsApp groups, labels, website content, reports, Microsoft 365 group memberships and
dedicated member creation.
grants, Telegram groups, WhatsApp groups, labels, website content, reports, and Microsoft 365 member creation and
member-number changes.
The backend independently checks every procedure. `context.backend` forwards a bearer token, never cookies.
- **Features** (`src/features/<area>/`): pages, dialogs, validation and `*.functions.ts` server functions.
- **Microsoft 365**: read the member directory with `azure:members:read` and create with `azure:members:create` through
the backend's fixed new-member workflow, choosing a standard or custom address; a welcome email to a PoliNetwork
address asks for confirmation. Change a member's ID with `azure:members:write`, after a confirmation, optionally
emailing them; the backend refuses IDs already assigned and accepts this call only with a user token, so it fails in
`AGENT_MODE`. List groups with `azure:groups:read`; add or remove existing directory users in a group with
`azure:groups:write`; the add picker lists the directory, so it is disabled without `azure:members:read`. Nothing
else about users or groups can be edited from the dashboard.
`AGENT_MODE`. List groups and their members, read-only, with `azure:groups:read`. Group membership is never
edited from the dashboard: the backend's create workflow adds only the new user to Soci, from which the IdP derives
the Socio role, so adding or removing anyone in a group would grant or revoke IdP roles. Nothing else about users or
groups can be edited from the dashboard.
- **People** (`src/features/people/`): every page reads and writes through `peopleApi(context)`, which parses the
backend's `people.*` responses with `people.contract.ts` and, in agent mode, uses the in-memory fake
(`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). `/dashboard` admits `people:self` without `admin:access`;
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

The dashboard manages Telegram and WhatsApp groups, labels, Telegram users and grants, website content and reports.
Microsoft 365 supports a member directory, creating a new association member through the backend's fixed workflow
(with a standard `firstname.lastname` or custom address), changing a member's ID with an optional email to them, and
group membership management. Directory access requires `azure:members:read`; creation independently requires
`azure:members:create`, and changing member IDs `azure:members:write`. Groups require `azure:groups:read`; adding and
removing members `azure:groups:write`.
(with a standard `firstname.lastname` or custom address), changing a member's ID with an optional email to them, and a
read-only list of groups. Directory access requires `azure:members:read`; creation independently requires
`azure:members:create`, and changing member IDs `azure:members:write`. Groups require `azure:groups:read`. Group
membership is never edited: creating a member adds only that new user to Soci, which gives them the Socio role.

**People** keeps one record per volunteer and member (Overview, Member register, Admins, Teams, Census, Linking), plus
Telegram › Capo Admin and Account › My area for volunteers themselves. The dashboard talks to a typed `people.*`
Expand Down
155 changes: 155 additions & 0 deletions src/features/azure/group-members-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { Search, X } from "lucide-react"
import { useMemo, useRef, useState } from "react"

import {
dialogFooter,
dialogFooterButton,
dialogMotion,
dialogPanel,
IconButton,
initialsOf,
scrimClasses,
} from "@/components/primitives"
import { dialogTitleText, dialogDescriptionText } from "@/components/primitives/confirm-dialog"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Dialog, DialogDescription, DialogOverlay, DialogPortal, DialogTitle } from "@/components/ui/dialog"
import type { AzureGroup, AzureMember } from "@/lib/api/types"
import { pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"

type Person = { id: string; name: string | null; mail: string | null }

type GroupMembersDialogProps = {
group: AzureGroup
directoryMembers: AzureMember[] | null
open: boolean
onOpenChange: (open: boolean) => void
}

/**
* A group's members, read-only: the dashboard never changes group membership. Emails come from the member directory,
* so they are missing without `azure:members:read`.
*/
export function GroupMembersDialog({ group, directoryMembers, open, onOpenChange }: GroupMembersDialogProps) {
const [query, setQuery] = useState("")
const popupRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)

const people = useMemo<Person[]>(() => {
const emails = new Map((directoryMembers ?? []).map((member) => [member.id, member.mail]))
const list = group.members.map((member) => ({
id: member.id,
name: member.displayName || null,
mail: emails.get(member.id) ?? null,
}))
// Unnamed users go last.
return list.toSorted((a, b) => {
if (a.name === null || b.name === null) return a.name === b.name ? 0 : a.name === null ? 1 : -1
return a.name.localeCompare(b.name)
})
}, [directoryMembers, group.members])

// As in `FormDialog`: the search on fine pointers; the panel itself otherwise, so no tooltip pops open.
function initialFocus() {
if (!window.matchMedia("(pointer: fine)").matches) return popupRef.current
return inputRef.current ?? popupRef.current
}

const search = query.trim().toLocaleLowerCase()
const visibleMembers = people.filter((person) =>
`${person.name ?? "Unnamed user"} ${person.mail ?? ""}`.toLocaleLowerCase().includes(search)
)

return (
<Dialog
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={(next) => {
if (!next) setQuery("")
}}
>
<DialogPortal>
<DialogOverlay className={scrimClasses} />
<DialogPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
className={cn(dialogPanel, dialogMotion, "max-w-[480px]")}
>
<header className="relative flex shrink-0 flex-col gap-1.5 px-5 pt-5 pr-14">
<DialogTitle className={dialogTitleText}>Members of {group.displayName}</DialogTitle>
<DialogDescription className={dialogDescriptionText}>
{pluralize(group.members.length, "member")}
{group.mailAddress ? (
<>
{" "}
on <span className="font-medium text-(--pn-fg)">{group.mailAddress}</span>
</>
) : null}
.
</DialogDescription>
<IconButton label="Close" icon={X} onClick={() => onOpenChange(false)} className="absolute top-3 right-3" />
</header>

<div className="flex min-h-0 flex-1 flex-col p-5">
<div className="flex h-[296px] flex-col overflow-hidden rounded-(--pn-r-3) border border-(--pn-line) bg-(--pn-surface)">
<div className="relative shrink-0 border-b border-(--pn-line)">
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-(--pn-fg-muted)"
/>
<input
ref={inputRef}
aria-label="Search group members"
placeholder="Search by name or email…"
value={query}
onChange={(event) => 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"
/>
</div>
<ul aria-label="Current members" className="min-h-0 flex-1 overflow-y-auto p-1">
{visibleMembers.length === 0 ? (
<li className="px-3 py-8 text-[13px] text-(--pn-fg-muted)">
{group.members.length === 0 ? "No members yet." : "No group members match your search."}
</li>
) : (
visibleMembers.map((person) => (
<li key={person.id} className="flex min-h-15 items-center gap-2.5 rounded-(--pn-r-2) px-2 py-2">
<PersonLine person={person} />
</li>
))
)}
</ul>
</div>
</div>

<div className={dialogFooter}>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className={dialogFooterButton}>
Close
</Button>
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
)
}

function PersonLine({ person }: { person: Person }) {
const name = person.name ?? "Unnamed user"
return (
<>
<Avatar size="sm" className="size-8 shrink-0">
<AvatarFallback className="bg-(--pn-muted) text-[11px] font-medium text-(--pn-fg-muted)">
{initialsOf(name)}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-[14px] leading-5 break-words text-(--pn-fg)">{name}</span>
{person.mail ? (
<span className="font-mono text-xs leading-4 break-all text-(--pn-fg-muted)">{person.mail}</span>
) : null}
</span>
</>
)
}
8 changes: 4 additions & 4 deletions src/features/azure/groups-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { AzureGroup, AzureMember } from "@/lib/api/types"
import { pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"

import { MembershipDialog } from "./membership-dialog"
import { GroupMembersDialog } from "./group-members-dialog"

type SectionId = "multi" | "single"
type OpenState = { multi: boolean; single: boolean }
Expand All @@ -36,8 +36,8 @@ function matches(group: AzureGroup, query: string) {
type DialogTarget = { groupId: string }

/**
* Microsoft 365 groups: groups with 2+ members and the rest, in two collapsibles. `directoryMembers` is null without
* the member read permission, so there is no one to add.
* Microsoft 365 groups, read-only: groups with 2+ members and the rest, in two collapsibles. `directoryMembers` is null
* without the member read permission, so member emails are not shown.
*/
export function AzureGroupsPage({
groups,
Expand Down Expand Up @@ -162,7 +162,7 @@ export function AzureGroupsPage({
/>
<PageContent>{renderContent()}</PageContent>
{dialog && dialogGroup ? (
<MembershipDialog
<GroupMembersDialog
key={dialog.groupId}
group={dialogGroup}
directoryMembers={directoryMembers}
Expand Down
19 changes: 1 addition & 18 deletions src/features/azure/groups.functions.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,7 @@
import { createServerFn } from "@tanstack/react-start"
import { z } from "zod"

import { azureGroupsReadMiddleware, azureGroupsWriteMiddleware } from "@/server/auth.middleware"
import { azureGroupsReadMiddleware } from "@/server/auth.middleware"

export const getAzureGroups = createServerFn({ method: "GET" })
.middleware([azureGroupsReadMiddleware])
.handler(({ context }) => context.backend.azure.groups.getAll.query())

const azureGroupMembershipInput = z.object({ groupId: z.string().min(1), userId: z.string().min(1) })

export const addAzureGroupMember = createServerFn({ method: "POST" })
.middleware([azureGroupsWriteMiddleware])
.validator(azureGroupMembershipInput)
.handler(async ({ data, context }) => ({
error: (await context.backend.azure.groups.addMember.mutate(data)) ? null : ("INTERNAL_SERVER_ERROR" as const),
}))

export const removeAzureGroupMember = createServerFn({ method: "POST" })
.middleware([azureGroupsWriteMiddleware])
.validator(azureGroupMembershipInput)
.handler(async ({ data, context }) => ({
error: (await context.backend.azure.groups.removeMember.mutate(data)) ? null : ("INTERNAL_SERVER_ERROR" as const),
}))
Loading
Loading