Skip to content
Open
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
20 changes: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ 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, and Microsoft 365 member creation and
member-number changes.
grants, Telegram groups, WhatsApp groups, labels, website content, reports, People (write, appoint, link, team), 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
Expand All @@ -23,12 +23,16 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de
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`;
Capo Admin and Team Lead come from the person's own record (`peopleRoles` in the route context) and only open their
sections. Member register, Receipts, Census and My area's fee card are unconnected mockups (#138): static sample data
from `mockup-data.ts`, no server functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be
rebuilt in Phase 2 (#144) and Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`.
backend's `people.*` responses with `people.contract.ts` (a copy of the backend's) and, in agent mode, uses the
in-memory fake (`AGENT_PERMISSIONS`, `AGENT_PERSON` pick the viewer). A person's roles are assignments (Admin, Capo
Admin, team member or Lead, nothing else): `pending` until the record is linked to an account, then held by
PoliNetwork Auth, which may refuse a change; pages show each assignment's status and Auth's refusal word for word.
`people:write` edits records, admins and invites, `people:appoint` Capo Admins and Leads, `people:link` links and
unlinks by hand. `/dashboard` admits `people:self` without `admin:access`; Capo Admin and Team Lead come from the
person's own record (`peopleRoles` in the route context) and only open their sections. Member register, Receipts,
Census and My area's fee card are unconnected mockups (#138): static sample data from `mockup-data.ts`, no server
functions, disabled actions and a `MockupBanner`, gated by `people:read`, to be rebuilt in Phase 2 (#144) and
Phase 3 (#145). Handoff notes, decisions and the plan: `docs/people/`.
- **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`.

## Conventions
Expand Down
267 changes: 76 additions & 191 deletions docs/people/contract.md

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions docs/people/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@

Permissions are additive and never imply one another. Administrative pages/actions below also need `admin:access`.
Only My area, an active Capo Admin's scope and a Team Lead's own teams work with `people:self` alone. Record roles
count only after account linking is confirmed. “Team member” means a volunteer in a team. `people:members:read`,
count only once PoliNetwork Auth holds them (the record is linked and the role applied). “Team member” means a volunteer in a team. `people:members:read`,
`people:members:write`, `people:fees:write` and `people:receipts:sign` were removed in #138.

| Viewer / extra permissions | Pages | Actions |
| ---------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Volunteer: `people:self`, P-0115 | My area, Sign-in & security | Read own record and roles |
| Team Lead: `people:self`, P-0034 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access |
| Capo Admin: `people:self`, P-0098 | My area + Telegram › Capo Admin | Contact the admins who have them as Capo Admin of reference |
| Volunteer: `people:self`, P-0021 | My area, Sign-in & security | Read own record and roles |
| Team Lead: `people:self`, P-0030 | Above + own team(s) under People | Add team members, edit team fields, end ordinary team memberships; never appoint or end a Lead without `people:appoint` + admin access |
| Capo Admin: `people:self`, P-0003 | My area + Telegram › Capo Admin | Read-only list of the admins who have them as Capo Admin of reference |
| `people:read` | People overview, person records, Admins, Teams, Linking | Read contacts and notes; no editing, linking or appointments |
| `people:link` | Link account and Unlink on records and Overview | Search the IdP's accounts, link one by hand, unlink (Auth revokes the roles first) |
| `people:read` (mockups) | Member register, Receipts, Census; the fee card in My area | None: unconnected mockups with sample data (#138), to be rebuilt in Phase 2 (#144) and Phase 3 (#145) |
| Master Admin: all explicit permissions, P-0007 | All permitted sections | All People mutations |

`people:write` enables profile, admin-role, team and linking mutations on readable pages;
`people:appoint` enables Capo/Lead appointments and ending those roles. Without their read prerequisites, write
permissions alone do not open a page. Buttons and row links follow these same rules; one available action is direct.
`people:write` enables record, admin-role, team and invite mutations on readable pages;
`people:appoint` enables Capo/Lead appointments and ending those roles; `people:link` enables linking by hand and
unlinking. Nobody gives themselves a role, though anyone may give one up: P-0007 (agent mode's default `AGENT_PERSON`)
can't add roles to their own record. Without their read prerequisites, write permissions alone do not open a page.
Buttons and row links follow these same rules; one available action is direct.

## Verification

Expand Down
12 changes: 12 additions & 0 deletions docs/people/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,15 @@ Newest last. One line per commit or session: date, what was done, what is next.
`people:members:read`, `people:members:write`, `people:fees:write` and `people:receipts:sign`. The schemas strip the
fields the current backend still sends until its own cleanup deploys.
The backend counterpart is PoliNetworkOrg/backend#104, which deploys after this.

- 2026-10-31: Phase 1 pages (#139), on the API from PoliNetworkOrg/backend#105 and #106. The contract is a copy of
the backend's: records with a name and contacts, and assignments (Admin, Capo Admin, team member or Lead) whose
status says where each stands with PoliNetwork Auth (pending until linked, waiting for Auth, applied, refused with
Auth's reason). Overview filters by role, team, account and status; the person page lists each role with its status,
the past roles and the history, with actors named from linked records. Add person needs only a name, and can start
with roles. Admins shows admins with their Capo Admin of reference and the Capo Admins with their scope; the Capo
Admin area is read-only. Teams honour which roles Auth binds. New permission `people:link` for linking by hand and
unlinking (with a reason; Auth revokes the roles first). Course, year and campus are gone from records, so #132's
"—" handling has nothing left to apply to; the Design team is Design & Marketing. Agent-mode fixtures cover linked,
invited and unlinked records and every assignment status; fake Auth confirms changes after a few seconds and refuses
every change for P-0012.
41 changes: 12 additions & 29 deletions src/features/people/account-action-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { useServerFn } from "@tanstack/react-start"
import { Mail, RefreshCw, Unlink, X } from "lucide-react"
import { Mail, RefreshCw, X } from "lucide-react"

import { ConfirmDialog } from "@/components/primitives"

import { useRefreshAfter } from "./people-hooks"
import { revokePersonInvite, sendPersonInvite, unlinkPersonAccount } from "./people-write.functions"
import { revokePersonInvite, sendPersonInvite } from "./people-invites.functions"

export type AccountAction = {
kind: "invite" | "resend" | "revoke" | "unlink"
kind: "invite" | "resend" | "revoke"
personId: string
name: string
/** The invite address, or the linked sign-in for `unlink`. */
/** The invite address: the record's contact email. */
email: string
}

/** Send, resend or revoke an invite, or unlink an account. Keep `action` after closing so the text survives the exit. */
/** Send, resend or revoke an invite. Keep `action` after closing so the text survives the exit. */
export function AccountActionDialog({
action,
open,
Expand All @@ -26,7 +26,6 @@ export function AccountActionDialog({
}) {
const send = useServerFn(sendPersonInvite)
const revoke = useServerFn(revokePersonInvite)
const unlink = useServerFn(unlinkPersonAccount)
const refresh = useRefreshAfter()
if (!action) return null
const data = { personId: action.personId }
Expand All @@ -40,7 +39,7 @@ export function AccountActionDialog({
tone="primary"
icon={resend ? RefreshCw : Mail}
title={resend ? "Resend invite?" : "Send invite?"}
description={`${action.name} gets an email at ${action.email} with a link to sign in and link their account. The link expires in 14 days${resend ? " and the previous one stops working." : "."}`}
description={`${action.name} gets an email at ${action.email} with a link that works once. They can sign in with any account, not only this address. It expires in 14 days${resend ? ", and the previous link stops working." : "."}`}
confirmLabel={resend ? "Resend invite" : "Send invite"}
onConfirm={async () => {
await send({ data })
Expand All @@ -49,33 +48,17 @@ export function AccountActionDialog({
/>
)
}
if (action.kind === "revoke") {
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
icon={X}
title="Revoke invite?"
description={`The link sent to ${action.email} stops working. ${action.name} stays on the list as Not linked.`}
confirmLabel="Revoke invite"
onConfirm={async () => {
await revoke({ data })
await refresh("Invite revoked.")
}}
/>
)
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
icon={Unlink}
title="Unlink account?"
description={`${action.email} can no longer see ${action.name}'s record. The record and its history stay.`}
confirmLabel="Unlink account"
icon={X}
title="Revoke invite?"
description={`The link sent to ${action.email} stops working. ${action.name} stays on the list as Not linked.`}
confirmLabel="Revoke invite"
onConfirm={async () => {
await unlink({ data })
await refresh("Account unlinked.")
await revoke({ data })
await refresh("Invite revoked.")
}}
/>
)
Expand Down
Loading
Loading