diff --git a/AGENTS.md b/AGENTS.md index 7fc0b114..f6b1e9e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,13 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de 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/`. +- **Regolamenti** (`src/features/regulations/`): pages talk to `regulations.*` through `regulationsApi(context)`, + which parses responses with `regulations.contract.ts` (a copy of the backend's) and, in agent mode, uses the fake with + its local Hocuspocus server and `xelatex`. Per-draft access (viewer, commenter, editor) and deleghe are backend data: + `regulations.me.get` admits users without `admin:access` to Bozze only, draft-scoped server functions use + `regulationsAssigneeMiddleware`, and pages act on the draft's `can` from the backend. The editor connects to the + collab service with a single-use ticket from a server function, never a token. UI copy is Italian. Details: + `docs/regulations/README.md`. - **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`. ## Conventions diff --git a/README.md b/README.md index b22dc492..4264a6f4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ rebuilt in Phase 2 (#144) and Phase 3 (#145). Permissions: `people:read` (which `people:write`, `people:appoint`, and `people:self`, which alone admits non-admins to My area and, by the role on their record, Capo Admin or their team. See `docs/people/README.md`. +**Regolamenti** edits, approves, publishes and notifies the governance documents (v1: the Raccolta dei Regolamenti +Interni): a collaborative LaTeX editor (CodeMirror and Yjs over the collab service) with a live PDF preview, comments, +history, merges between drafts, the approval procedure with "Emana e notifica", the differences view and the +Segretario's deleghe. It talks to the backend's `regulations.*` contract (`src/features/regulations/regulations.contract.ts`); +agent mode serves a fake with a local collab server and `xelatex`. Permissions: `regulations:read`, `regulations:write`, +`regulations:manage`, `regulations:rectify`; a draft assignment or a delega alone admits anyone to Bozze. See +[`docs/regulations/README.md`](docs/regulations/README.md). + Built with TanStack Start (React 19), Vite+, Nitro, Tailwind CSS v4 and shadcn/Base UI. The server is an OIDC BFF: only it holds the IdP access/refresh/ID tokens. Browsers hold an opaque, HttpOnly session cookie; sessions live in a **dedicated Redis**, separate from the shared backend Redis. Backend requests carry a user bearer token, and permissions @@ -32,23 +40,26 @@ Signing in as a real user needs a development client registered in the IdP. Set the following runtime variables (the build does not need secrets): -| Variable | Meaning | -| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | -| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | -| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | -| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | -| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | -| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | -| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | -| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | -| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | -| `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | -| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | -| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo). `P-0098` is a Capo Admin, `P-0034` the IT Team Lead, `P-0115` an IT member; `none` previews a sign-in without a record. Sending an invite prints its link in the dev server's console. | -| `AGENT_MODE` | Fake all-permission user in development only. It has no access token, so the backend refuses its calls; People serves sample data. Production ignores this mode. | -| `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | -| `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | +| Variable | Meaning | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APP_URL` | Dashboard's public origin. Required outside development; HTTPS in production. Development defaults to `http://localhost:$PORT` (3001). Used for exact Origin CSRF validation and callback/logout URLs. | +| `BACKEND_URL` | tRPC backend origin; use the in-cluster service address in production. Defaults to `http://localhost:3000` in development. | +| `IDP_URL` | Public IdP origin; defaults to `https://auth.polinetwork.org`. Issuer is `$IDP_URL/api/auth`. Browser redirects retain this public origin. | +| `IDP_INTERNAL_URL` | Optional internal IdP origin, e.g. `http://auth-service.auth.svc.cluster.local:3000`. Rewrites discovery, token, JWKS and revocation transport only; issuer, JWT assertion audiences (public token/revoke endpoints) and browser URLs remain public. | +| `OIDC_CLIENT_ID` | Registered confidential dashboard client, with authorization code and refresh grants, scopes `openid profile email offline_access backend:admin`, and backend resource link. | +| `OIDC_CLIENT_PRIVATE_JWK` | Private signing JWK JSON with `kid` and `alg` (`EdDSA`/Ed25519 or `ES256`/P-256); inject from this service's Key Vault. Register only its public JWKS in the IdP. | +| `OIDC_CLIENT_SECRET` | Alternative `client_secret_basic` credential; set exactly one client credential mode. | +| `BACKEND_RESOURCE` | Backend OAuth resource/audience; defaults to `https://backend.internal.polinetwork.org`. Sent on every authorize, code exchange and refresh request. | +| `REDIS_URL` | Dedicated dashboard Redis connection. Required outside development; development defaults to `redis://localhost:6379`. Never point to shared backend Redis. | +| `SESSION_SECRET` | Independent random secret of at least 32 characters, encrypting the 10-minute state/nonce/PKCE cookie. Rotation invalidates pending login attempts. | +| `AGENT_PERMISSIONS` | Development only. Comma-separated permissions for the agent-mode user (default: all). `people:self` alone previews the restricted People view. | +| `AGENT_PERSON` | Development only. The agent-mode user's People record (default `P-0007`, Direttivo). `P-0098` is a Capo Admin, `P-0034` the IT Team Lead, `P-0115` an IT member; `none` previews a sign-in without a record. Sending an invite prints its link in the dev server's console. | +| `AGENT_BACKEND_TOKEN` | Development only. A bearer token agent mode sends to the backend (e.g. from the backend's dev IdP shim): permissions come from that backend's `me.access` and Regolamenti uses it instead of the fake. Two dev servers with two tokens are two people. | +| `REGULATIONS_DEV_LEGAL_DIR` | Development only. A checkout of PoliNetworkOrg/legal: agent mode's Regolamenti fake serves the real RRI and compiles it with the real class. | +| `REGULATIONS_DEV_COLLAB_PORT` | Development only. Port of agent mode's local collab server (default `PORT + 1`). | +| `AGENT_MODE` | Fake all-permission user in development only. It has no access token, so the backend refuses its calls; People serves sample data. Production ignores this mode. | +| `PORT` | Local listening port (3001 by default); use a port ≥10000 for agent previews. | +| `TEST_REDIS_URL` | Opt-in disposable Redis for integration tests. Tests create/delete only UUID session keys. | Before review run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Run real Redis tests with `TEST_REDIS_URL=redis://127.0.0.1: pnpm test`. The OIDC integration test uses a local fake provider with diff --git a/docs/design.md b/docs/design.md index d83212f3..63b13ae4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -117,7 +117,7 @@ service title ("PoliNetwork Admin" on Overview), Search, the theme toggle and th as horizontally scrolling pills that scroll away with the page. The current pill stays in view after navigation, font loading and changes to the bar's width, including when it first appears below 760px. A 64px bottom nav lists the visible services (current one accent with a top bar, Reports with the open-reports dot). The device's bottom safe area adds space below the 64px -bar. Below 360px, WhatsApp uses "WA" so all labels remain readable. With seven services, WhatsApp uses "WA" below 440px and Telegram uses "TG" below 360px. Section pills and bottom nav are the only navigation; there +bar. Below 360px, WhatsApp uses "WA" so all labels remain readable. With seven services, WhatsApp uses "WA" below 440px and Telegram uses "TG" below 360px. With eight (Regolamenti, "Regole"), Telegram uses "TG" below 420px. Section pills and bottom nav are the only navigation; there is no sheet. **Command palette** (`⌘K`/`Ctrl+K`, the rail and top bar Search, the Overview search): sections, "Find “…” in @@ -406,6 +406,10 @@ the theme reveal and the skeleton pulse switch off; the signed box is removed at ## 7. Copy +Regolamenti is the one Italian part of the dashboard: its pages, dialogs, toasts and empty states follow these rules in +Italian ("Crea bozza", "Bozza creata.", "Non è stato possibile creare la bozza."), dates as the documents print them +("12/03/2026", `regulations-format.ts`), and pass `locale="it"` to the primitives that print their own words. + - Sentence case everywhere. Verb first, object named: "Delete passkey", "Save labels". - No "please", "successfully", exclamation marks or "Are you sure". Contractions are fine in errors. - "Microsoft 365", never "Azure" or "Office 365", in user-facing text ("Microsoft Entra" when naming the directory). @@ -613,4 +617,11 @@ Rules that the templates above don't imply. - **Capo Admin** (Telegram) and **My area** (Account): self-service pages that open with `people:self` and a role on the person's own record; no personal emails of others, only phone, Telegram and PoliNetwork addresses. +- **Regolamenti**: Documenti is a card per document; a document page lists its versions (the newest carries "In vigore", + each has its PDF, release, differences and "Nuova bozza da qui") and its drafts. Bozze adds "Il tuo accesso" for people + admitted only by an assignment, who see no other section. The editor is a workspace (`PageContent fill`); the + procedure page is a record with the states as a stepper, disabled transitions showing why, and the emanation's steps + live; the differences page is read-only (category summary, then old and new side by side for changed articles only). + Deleghe is a list with a segmented Attive/Tutte filter. + Receipt previews use eSign’s `PdfViewer` in its compact mode. Receipt requests in Signatures show their receipt reference; their signature boxes are fixed. diff --git a/docs/regulations/README.md b/docs/regulations/README.md new file mode 100644 index 00000000..1e75d05d --- /dev/null +++ b/docs/regulations/README.md @@ -0,0 +1,87 @@ +# Regolamenti + +The dashboard side of the regulations editor: edit, approve, publish and notify PoliNetwork's governance documents +(v1: the Raccolta dei Regolamenti Interni) instead of hand-editing LaTeX on GitHub. The shared spec, decided with the +owner for every repository involved, is the source of truth: +`regulations-editor-spec.md` (kept outside the repositories, next to the checkouts). The LaTeX grammar is +PoliNetworkOrg/legal `docs/grammar.md`. + +The UI is in Italian, the only part of the dashboard that is: the documents, the procedure and the people who use it +are. Code, comments and commits stay in English. Shared primitives that print their own words (`FormDialog`, `Count`, +`DateField`) take an Italian variant (`locale="it"`, `placeholder`); a few shared table words ("Actions", pagination) +stay English. + +## Pages + +| Path | Page | Opens with | +| --------------------------------------------- | ----------------------- | ----------------------------------------------------- | +| `/dashboard/regulations` | Documenti | `regulations:read` | +| `/dashboard/regulations/documents/$key` | Versions and drafts | `regulations:read` | +| `/dashboard/regulations/drafts` | Bozze | `regulations:read`, a draft assignment, or a delega | +| `/dashboard/regulations/drafts/$id` | Editor | as Bozze; the backend answers only for visible drafts | +| `/dashboard/regulations/drafts/$id/procedure` | Procedure, members | as Bozze | +| `/dashboard/regulations/drafts/$id/diff` | Differences (read-only) | as Bozze | +| `/dashboard/regulations/drafts/$id/merge` | Merge another draft | `regulations:write` | +| `/dashboard/regulations/deleghe` | Deleghe | `regulations:rectify` (Segretario) | + +Every section but Bozze also needs `admin:access`, like the rest of the dashboard. + +## Access + +- **IdP permissions** (`src/server/permissions.ts`): `regulations:read` sees everything as a viewer, `regulations:write` + (Direttivo) creates drafts, assigns members, edits, merges and sends to approval, `regulations:manage` (Presidente, + Segretario) records the approval, emanates and regenerates the diff, `regulations:rectify` (Segretario) rectifies and + manages deleghe. +- **Per-draft access is backend data.** Anyone with an IdP account can be assigned to a draft as viewer, commenter or + editor, and a delega lets anyone rectify. `loadAccess` admits a user without `admin:access` or `people:self` when + `regulations.me.get` reports an assignment or a delega; `/dashboard` then shows them only Regolamenti › Bozze + (`peopleRoles.regulations`, the `regulations` section role in `nav.ts`), and the logo leads there. +- **Middleware** (`src/server/auth.middleware.ts`): `regulationsReadMiddleware`, `regulationsWriteMiddleware`, + `regulationsManageMiddleware` and `regulationsRectifyMiddleware` check the permission; draft-scoped calls (collab + ticket, comments, snapshots, rename and delete, rettifiche) use `regulationsAssigneeMiddleware`, which admits any + dashboard user because only the backend knows the assignments. Pages show actions from the draft's `can`, which the + backend computes, never from rules of their own. + +## Backend contract + +`src/features/regulations/regulations.contract.ts` is a verbatim copy of the backend's `src/regulations/contract.ts` +(PoliNetworkOrg/backend#110); `regulations.api.server.ts` maps one method to each procedure and parses every response. +Procedures: `me.get`, `documents.list|get|sync`, `versions.list|pdf`, `drafts.list|get|files|create|rename|delete|transition`, +`assignments.list|set|remove`, `users.search`, `collabTicket`, `compile.status|pdf`, `snapshots.list|get|create|restore`, +`comments.list|create|reply|resolve|reopen|delete`, `merge.preview|execute`, `diff.get|regenerate|pdf`, +`emanation.get|start|retry`, `deleghe.list|create|revoke`. Files (PDFs) come back as `{ fileName, contentType, base64 }` +because the browser never reaches the backend. Refusals carry an Italian message shown as is; input validation and the +policy's English denials get the page's own message (`failureMessage`). + +## Collaboration + +The text of a draft lives in the collab service (Hocuspocus): document name = draft id, `ydoc.getMap("files")` maps +each source path to its `Y.Text`. The browser connects with a single-use ticket from `issueCollabTicket` (a server +function, so no IdP token reaches the browser), asked again on every reconnect. Stateless messages: `compile` (a new PDF +for a source hash) and `state` (the draft froze or reopened: the editor drops its provider and Y.Doc and connects again +with a new ticket). The server inserts missing node ids about 1.5 s after typing stops. + +## Agent mode + +`regulations.fake.server.ts` answers every procedure with the backend's rules over an in-memory store (seeded by +`regulations.fixtures.server.ts`: three drafts, two versions, a delega, a comment). It runs a real Hocuspocus server and +compiles with the machine's `xelatex` (`regulations.dev-collab.server.ts`), on `PORT + 1` (override with +`REGULATIONS_DEV_COLLAB_PORT`), only in development builds. + +- `PORT=1xxxx AGENT_MODE=true pnpm dev`: everything, as a Direttivo member with every permission. +- `AGENT_PERMISSIONS=none`: no permissions; the agent is still an editor of one sample draft and a commenter of another, + so the dashboard opens Bozze only, as for an invited Socio. +- `REGULATIONS_DEV_LEGAL_DIR=`: the fake serves the real RRI sources and compiles them + with the real class and fonts (they stay on the machine; the scripted draft edits and comments then don't apply). + The parser test also reads the grammar's own example from there. + +The dev compiler has no sandbox; the real compile worker does. + +## Against a local backend + +`AGENT_BACKEND_TOKEN=` makes agent mode send that token to `BACKEND_URL` (default `http://localhost:3000`) +instead of none: access comes from the backend's `me.access` and Regolamenti calls the backend, not the fake. The +backend's local stack mints such tokens with its dev IdP shim for seeded accounts (Segretario, Direttivo, an editor and +a commenter with no permissions). Run one dev server per account, on two ports, to see two people edit one draft. +Signing in for real (`AGENT_MODE=false`) needs a development OIDC client on auth.polinetwork.org allowed the backend +resource, and a backend able to read access snapshots: both are registrations and secrets only the owner can provide. diff --git a/package.json b/package.json index b453317e..adb1eb83 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", + "diff": "^9.0.0", "lucide-react": "^0.545.0", "motion": "^12.42.2", "nitro": "3.0.260610-beta", @@ -44,9 +45,11 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "yjs": "^13.6.33", "zod": "^4.3.5" }, "devDependencies": { + "@hocuspocus/server": "^4.8.0", "@tailwindcss/vite": "^4.3.2", "@trpc/server": "11.5.1", "@types/node": "^22.10.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7398e31f..ab2feda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: date-fns: specifier: ^4.4.0 version: 4.4.0 + diff: + specifier: ^9.0.0 + version: 9.0.0 lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.7) @@ -109,10 +112,16 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + yjs: + specifier: ^13.6.33 + version: 13.6.33 zod: specifier: ^4.3.5 version: 4.3.5 devDependencies: + '@hocuspocus/server': + specifier: ^4.8.0 + version: 4.8.0(srvx@0.11.21)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(@voidzero-dev/vite-plus-core@0.2.9(@types/node@22.20.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) @@ -497,6 +506,16 @@ packages: '@github/browserslist-config@1.0.0': resolution: {integrity: sha512-gIhjdJp/c2beaIWWIlsXdqXVRUz3r2BxBCpfz/F3JXHvSAQ1paMYjLH+maEATtENg+k5eLV7gA+9yPp762ieuw==} + '@hocuspocus/common@4.8.0': + resolution: {integrity: sha512-UI8Vye3mds2aMUHaWfwkDq0+257JUpvKa0nyAOjXWLfyhYHsAq8eku+ynZc/PaShyOci/VD54cW/rLQJGHHPKg==} + + '@hocuspocus/server@4.8.0': + resolution: {integrity: sha512-P8U4I4R4Ft+B37d4CFs4tG8oPJ3rnJJiFx3CXBJpKSIrXTz5YDldRmv7eGqmUq4QRSoWR6FjN4DUIX1QO0PccA==} + engines: {node: '>=22'} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -2244,6 +2263,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -2445,6 +2467,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.17: + resolution: {integrity: sha512-YG2kyfafNXKmu7e6k64yrj6whrw7m5sBIsOSXOlJ/72X3TQWRQuw8DhvXFDcBk8HF77PnTSygnm7xpYCTnFpow==} + peerDependencies: + srvx: '>=0.11.5' + peerDependenciesMeta: + srvx: + optional: true + crossws@0.4.9: resolution: {integrity: sha512-iWx+1OMSG2aOHpjyf9AESOzkwsVdS49cXM9dVrI2PDhxU5l2RIWE/KG56gk4BbAnsMoycvniJ9OnOxO9LRzHVA==} peerDependencies: @@ -2693,6 +2723,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3562,6 +3596,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3661,6 +3698,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.119: + resolution: {integrity: sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -5121,6 +5163,12 @@ packages: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -5129,6 +5177,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yjs@13.6.33: + resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5619,6 +5671,22 @@ snapshots: '@github/browserslist-config@1.0.0': {} + '@hocuspocus/common@4.8.0': + dependencies: + lib0: 0.2.119 + + '@hocuspocus/server@4.8.0(srvx@0.11.21)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': + dependencies: + '@hocuspocus/common': 4.8.0 + async-mutex: 0.5.0 + crossws: 0.4.17(srvx@0.11.21) + kleur: 4.1.5 + lib0: 0.2.119 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + transitivePeerDependencies: + - srvx + '@hono/node-server@1.19.14(hono@4.12.29)': dependencies: hono: 4.12.29 @@ -7086,6 +7154,10 @@ snapshots: async-function@1.0.0: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + atomically@1.7.0: {} available-typed-arrays@1.0.7: @@ -7282,6 +7354,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.17(srvx@0.11.21): + optionalDependencies: + srvx: 0.11.21 + crossws@0.4.9(srvx@0.11.21): optionalDependencies: srvx: 0.11.21 @@ -7480,6 +7556,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -8450,6 +8528,8 @@ snapshots: isexe@3.1.5: {} + isomorphic.js@0.2.5: {} + jiti@2.7.0: {} jose@6.1.3: {} @@ -8530,6 +8610,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.119: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.32.0: optional: true @@ -10008,10 +10092,19 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.1.1 + y-protocols@1.0.7(yjs@13.6.33): + dependencies: + lib0: 0.2.119 + yjs: 13.6.33 + yallist@3.1.1: {} yaml@2.9.0: {} + yjs@13.6.33: + dependencies: + lib0: 0.2.119 + yocto-queue@0.1.0: {} yocto-spinner@1.2.1: diff --git a/src/components/primitives/date-field.tsx b/src/components/primitives/date-field.tsx index 5e39de4e..63521d63 100644 --- a/src/components/primitives/date-field.tsx +++ b/src/components/primitives/date-field.tsx @@ -41,6 +41,7 @@ export function DateField({ disabled = false, pastYears = 10, futureYears = 1, + placeholder = "Choose a date", }: { id: string value: string @@ -50,6 +51,7 @@ export function DateField({ disabled?: boolean pastYears?: number futureYears?: number + placeholder?: string }) { const selected = parseDateOnly(value) const [open, setOpen] = useState(false) @@ -87,7 +89,7 @@ export function DateField({ > - {selected ? formatDate(selected, "local") : "Choose a date"} + {selected ? formatDate(selected, "local") : placeholder} `Your edits to this ${noun} will be lost.`, + discard: "Discard", + keepEditing: "Keep editing", + }, + it: { + cancel: "Annulla", + close: "Chiudi", + discardTitle: "Scartare le modifiche?", + discardText: (noun: string) => `Le modifiche a ${noun} andranno perse.`, + discard: "Scarta", + keepEditing: "Continua a modificare", + }, +} as const + /** Form dialog: header, scrolling body, Cancel + primary footer, keyboard submit, dirty-close confirmation. */ export function FormDialog({ open, onOpenChange, size = "md", + locale = "en", title, description, noun, @@ -171,7 +193,7 @@ export function FormDialog({ {title} {description && {description}} - +
{footerStart &&
{footerStart}
} {submitLabel !== undefined && ( onOpenChange(false)} /> diff --git a/src/components/shell/mobile-nav.tsx b/src/components/shell/mobile-nav.tsx index 07c8bf7a..45e323f2 100644 --- a/src/components/shell/mobile-nav.tsx +++ b/src/components/shell/mobile-nav.tsx @@ -10,7 +10,7 @@ import { isRailService, type DashboardPath, type PageMatch, type PendingCounts, import { ServiceDot } from "./rail" import { ServiceGlyph } from "./service-glyph" import { themeToggleLabel, useTheme } from "./theme" -import { useHasAdminAccess, useVisibleServices } from "./use-can" +import { useHome, useVisibleServices } from "./use-can" const barButton = "size-10 text-(--pn-fg-muted) hover:text-(--pn-fg) active:not-aria-[haspopup]:translate-y-0" @@ -29,7 +29,7 @@ export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTo const service = serviceFor(match) const { theme, toggleTheme } = useTheme() const title = service.id === "overview" ? "PoliNetwork Admin" : service.title - const hasAdminAccess = useHasAdminAccess() + const home = useHome() const accountPath = useVisibleServices().find((candidate) => candidate.id === "account")?.path ?? "/dashboard/account" return ( @@ -40,8 +40,8 @@ export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTo )} > @@ -85,6 +85,8 @@ export type MobileBottomNavProps = { export function MobileBottomNav({ match, serviceHref, pending, className }: MobileBottomNavProps) { const services = useVisibleServices().filter(isRailService) const compactLabels = services.length > 6 + // Eight services (with Regolamenti) leave even less room: Telegram shortens from 420px. + const crowded = services.length > 7 const activeId = serviceFor(match).id return ( @@ -121,8 +123,8 @@ export function MobileBottomNav({ match, serviceHref, pending, className }: Mobi ) : service.id === "telegram" && compactLabels ? ( <> - TG - {service.short} + TG + {service.short} ) : ( service.short diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index c4a6066d..0f186513 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -20,6 +20,9 @@ import { LayoutDashboard, Receipt, type LucideIcon, + Landmark, + Scale, + ScrollText, MessageCircle, MessagesSquare, MonitorDown, @@ -51,6 +54,7 @@ export type ServiceId = | "m365" | "web" | "esign" + | "regulations" | "reports" | "account" @@ -73,11 +77,19 @@ export type Section = { selfService?: boolean } -/** Roles that come from the person's record (`people.me.roles`), not from IdP permissions. */ -export type PeopleRole = "capo" | "lead" +/** + * Roles that come from backend data, not from IdP permissions: the person's People record (`people.me.roles`), and + * Regolamenti drafts they're assigned to or deleghe to rectify (`regulations.me.get`). + */ +export type PeopleRole = "capo" | "lead" | "regulations" /** What the signed-in person's record grants them in the navigation. */ -export type PeopleRoles = { capoScope: string | null; leads: readonly string[] } +export type PeopleRoles = { + capoScope: string | null + leads: readonly string[] + /** A draft assignment or a delega opens Regolamenti › Bozze, with or without `admin:access`. */ + regulations?: boolean +} export const NO_PEOPLE_ROLES: PeopleRoles = { capoScope: null, leads: [] } @@ -133,6 +145,14 @@ export type PageMatch = | { kind: "esign-sign" } | { kind: "esign-drives" } | { kind: "esign-setup" } + | { kind: "regulations-documents" } + | { kind: "regulations-document"; documentKey: string } + | { kind: "regulations-drafts" } + | { kind: "regulations-draft"; draftId: string } + | { kind: "regulations-procedure"; draftId: string } + | { kind: "regulations-diff"; draftId: string } + | { kind: "regulations-merge"; draftId: string } + | { kind: "regulations-deleghe" } | { kind: "reports-open" } | { kind: "reports-closed" } | { kind: "not-found" } @@ -390,6 +410,39 @@ const esign: Service = { ], } +const regulations: Service = { + id: "regulations", + title: "Regolamenti", + short: "Regole", + icon: Scale, + path: "/dashboard/regulations", + sections: [ + { + id: "documents", + title: "Documenti", + icon: Landmark, + path: "/dashboard/regulations", + permission: "regulations:read", + }, + { + id: "drafts", + title: "Bozze", + icon: ScrollText, + path: "/dashboard/regulations/drafts", + permission: "regulations:read", + role: "regulations", + searchPlaceholder: "Cerca per titolo o autore…", + }, + { + id: "deleghe", + title: "Deleghe", + icon: KeyRound, + path: "/dashboard/regulations/deleghe", + permission: "regulations:rectify", + }, + ], +} + const reports: Service = { id: "reports", title: "Reports", @@ -417,7 +470,18 @@ const reports: Service = { } /** Rail order: Overview, services with sections, then Account. */ -export const services: Service[] = [overview, people, telegram, whatsapp, m365, web, esign, reports, account] +export const services: Service[] = [ + overview, + people, + telegram, + whatsapp, + m365, + web, + esign, + regulations, + reports, + account, +] /** The services that own a panel, in rail order. */ export const panelServices: Service[] = services.filter((service) => service.sections.length > 0) @@ -434,6 +498,7 @@ export function isRailService(service: Service) { export function sectionVisible(section: Section, permissions: readonly string[], roles: PeopleRoles) { if (hasPermission(permissions, "people:self") && section.role === "capo" && roles.capoScope !== null) return true if (hasPermission(permissions, "people:self") && section.role === "lead" && roles.leads.length > 0) return true + if (section.role === "regulations" && roles.regulations) return true if (section.role && !section.permission && !section.anyPermission) return false if (!section.selfService && !hasPermission(permissions, "admin:access")) return false return ( @@ -482,6 +547,13 @@ export function matchPath(pathname: string): PageMatch { if (head === "web" && second === "groups-by-label" && third !== undefined) { return { kind: "web-category", path: urlSegmentsToLabelPath([third, ...rest]) } } + if (head === "regulations" && second === "drafts" && third !== undefined && rest.length <= 1) { + const [page] = rest + if (page === undefined) return { kind: "regulations-draft", draftId: third } + if (page === "procedure") return { kind: "regulations-procedure", draftId: third } + if (page === "diff") return { kind: "regulations-diff", draftId: third } + if (page === "merge") return { kind: "regulations-merge", draftId: third } + } if (rest.length > 0) return { kind: "not-found" } if (head === "people") { @@ -543,6 +615,13 @@ export function matchPath(pathname: string): PageMatch { } } + if (head === "regulations") { + if (second === undefined) return { kind: "regulations-documents" } + if (second === "drafts" && third === undefined) return { kind: "regulations-drafts" } + if (second === "deleghe" && third === undefined) return { kind: "regulations-deleghe" } + if (second === "documents" && third !== undefined) return { kind: "regulations-document", documentKey: third } + } + if (head === "reports" && third === undefined) { if (second === "group-links") return { kind: "reports-open" } if (second === "resolved") return { kind: "reports-closed" } @@ -598,6 +677,15 @@ export function serviceFor(match: PageMatch): Service { case "esign-drives": case "esign-setup": return esign + case "regulations-documents": + case "regulations-document": + case "regulations-drafts": + case "regulations-draft": + case "regulations-procedure": + case "regulations-diff": + case "regulations-merge": + case "regulations-deleghe": + return regulations case "reports-open": case "reports-closed": return reports @@ -671,6 +759,17 @@ export function sectionFor(match: PageMatch): Section | null { return section(esign, "drives") case "esign-setup": return section(esign, "setup") + case "regulations-documents": + case "regulations-document": + return section(regulations, "documents") + case "regulations-drafts": + case "regulations-draft": + case "regulations-procedure": + case "regulations-diff": + case "regulations-merge": + return section(regulations, "drafts") + case "regulations-deleghe": + return section(regulations, "deleghe") case "reports-open": return section(reports, "open") case "reports-closed": @@ -689,7 +788,12 @@ export function isDeepPage(match: PageMatch): boolean { match.kind === "people-campaign" || match.kind === "esign-request" || match.kind === "esign-new" || - match.kind === "esign-sign" + match.kind === "esign-sign" || + match.kind === "regulations-document" || + match.kind === "regulations-draft" || + match.kind === "regulations-procedure" || + match.kind === "regulations-diff" || + match.kind === "regulations-merge" ) } diff --git a/src/components/shell/page-bar.tsx b/src/components/shell/page-bar.tsx index 2b599f5b..1f8625f2 100644 --- a/src/components/shell/page-bar.tsx +++ b/src/components/shell/page-bar.tsx @@ -242,7 +242,12 @@ export function BackButton({ label, link }: PageBarBack) { } export type CountPart = { value: number; total?: number; noun: string; plural?: string } -export type CountProps = CountPart & { parts?: CountPart[]; className?: string } +export type CountProps = CountPart & { + parts?: CountPart[] + className?: string + /** "12 di 680 bozze" on the Italian Regolamenti pages. */ + locale?: "en" | "it" +} const numberFormat = new Intl.NumberFormat("en-GB") const pluralRules = new Intl.PluralRules("en-GB") @@ -255,17 +260,17 @@ function pluralize(noun: string, plural: string | undefined, count: number) { return `${noun}s` } -function formatCountPart({ value, total, noun, plural }: CountPart) { +function formatCountPart({ value, total, noun, plural }: CountPart, of: string) { if (total === undefined) return `${numberFormat.format(value)} ${pluralize(noun, plural, value)}` - return `${numberFormat.format(value)} of ${numberFormat.format(total)} ${pluralize(noun, plural, total)}` + return `${numberFormat.format(value)} ${of} ${numberFormat.format(total)} ${pluralize(noun, plural, total)}` } /** * "{n} {noun}" or "{n} of {total} {noun}", pluralized with Intl.PluralRules; `parts` append * ", {n} {noun}" segments. Pass the singular noun: ``. */ -export function Count({ parts = [], className, ...first }: CountProps) { - const text = [first, ...parts].map(formatCountPart).join(", ") +export function Count({ parts = [], className, locale = "en", ...first }: CountProps) { + const text = [first, ...parts].map((part) => formatCountPart(part, locale === "it" ? "di" : "of")).join(", ") return ( (null) const [focusedIndex, setFocusedIndex] = useState(null) const { theme, toggleTheme } = useTheme() const modifierKey = useModifierKey() const activeId = serviceFor(match).id - const overview = serviceById("overview") const account = visibleServices.find((service) => service.id === "account") ?? serviceById("account") // Item order: logo (Overview), search, services…, theme, account. @@ -232,10 +231,10 @@ export function Rail({ match, serviceHref, onOpenPalette, user, pending, classNa > {/* The logo uses the same active fill and left indicator as every other rail item. */} diff --git a/src/components/shell/use-can.ts b/src/components/shell/use-can.ts index a182b1a3..b0546305 100644 --- a/src/components/shell/use-can.ts +++ b/src/components/shell/use-can.ts @@ -2,7 +2,7 @@ import { getRouteApi } from "@tanstack/react-router" import { hasPermission, type Permission } from "@/server/permissions" -import { NO_PEOPLE_ROLES, sectionVisible, services } from "./nav" +import { type DashboardPath, NO_PEOPLE_ROLES, sectionVisible, services } from "./nav" const dashboardRoute = getRouteApi("/dashboard") @@ -38,3 +38,19 @@ export function useVisibleServices() { return sections.length ? [{ ...service, sections, path: sections[0].path }] : [] }) } + +/** The logo's destination and its accessible name. */ +export type Home = { path: DashboardPath; label: string } + +/** + * Where the logo leads: Overview with `admin:access`, My area with `people:self`, otherwise the first service the user + * can open (Regolamenti for someone admitted only by a draft assignment or a delega). + */ +export function useHome(): Home { + const permissions = dashboardRoute.useRouteContext({ select: (context) => context.permissions }) + const visible = useVisibleServices() + if (hasPermission(permissions, "admin:access")) return { path: "/dashboard", label: "Overview" } + if (hasPermission(permissions, "people:self")) return { path: "/dashboard/account/me", label: "My area" } + const first = visible.find((service) => service.id !== "account" && service.id !== "overview") + return first ? { path: first.path, label: first.title } : { path: "/dashboard/account", label: "Account" } +} diff --git a/src/env.ts b/src/env.ts index 1040ed07..7877dfc3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -13,6 +13,11 @@ export const env = createEnv({ AGENT_PERMISSIONS: z.string().optional(), /** Development only: the agent-mode user's person record in the People sample data. */ AGENT_PERSON: z.string().default("P-0007"), + /** + * Development only: a bearer token agent mode sends to the backend instead of none, so pages read a real local + * backend (e.g. one minted by the backend's dev IdP shim). Permissions then come from the backend's `me.access`. + */ + AGENT_BACKEND_TOKEN: z.string().optional(), NODE_ENV: z.enum(["development", "test", "production"]), BACKEND_URL: z.url(), }, diff --git a/src/features/regulations/approve-dialog.tsx b/src/features/regulations/approve-dialog.tsx new file mode 100644 index 00000000..44389aa4 --- /dev/null +++ b/src/features/regulations/approve-dialog.tsx @@ -0,0 +1,142 @@ +import { useServerFn } from "@tanstack/react-start" +import { useState } from "react" + +import { + DateField, + fieldControl, + fieldHintId, + FormDialog, + FormField, + RadioCard, + RadioCardGroup, +} from "@/components/primitives" +import { Input } from "@/components/ui/input" + +import { BODY_LABELS, todayInRome } from "./regulations-format" +import { failureMessage, useRefreshAfter } from "./regulations-hooks" +import { approveDraft } from "./regulations-manage.functions" +import type { DeliberaBody, Draft } from "./regulations.contract" + +const APPROVE_ERROR = "Non è stato possibile registrare l'approvazione. Riprova." + +function validUrl(value: string) { + return URL.canParse(value) && /^https?:$/.test(new URL(value).protocol) +} + +/** + * "Registra l'approvazione": the outcome of a vote held outside the dashboard. The body, the day of the vote and, + * optionally, a link to the verbale; the document then prints "Approvato dal … il …". + */ +export function ApproveDialog({ + open, + onOpenChange, + draft, +}: { + open: boolean + onOpenChange: (open: boolean) => void + draft: Pick +}) { + const approve = useServerFn(approveDraft) + const refresh = useRefreshAfter() + const [body, setBody] = useState(null) + const [date, setDate] = useState(todayInRome()) + const [verbale, setVerbale] = useState("") + const [submitted, setSubmitted] = useState(false) + + const bodyError = submitted && !body ? "Scegli l'organo che ha approvato." : undefined + const dateError = + submitted && !date + ? "Indica il giorno della votazione." + : date > todayInRome() + ? "La data è nel futuro." + : undefined + const verbaleError = + submitted && verbale.trim() && !validUrl(verbale.trim()) ? "Inserisci un indirizzo http o https." : undefined + + async function submit() { + setSubmitted(true) + if (!body || !date || date > todayInRome() || (verbale.trim() && !validUrl(verbale.trim()))) return + try { + await approve({ + data: { + draftId: draft.id, + to: "approvata", + delibera: { body, date, verbaleUrl: verbale.trim() || null }, + }, + }) + } catch (caught) { + console.error(caught) + throw new Error(failureMessage(caught, APPROVE_ERROR), { cause: caught }) + } + await refresh("Approvazione registrata.") + onOpenChange(false) + } + + return ( + +
+ + + + + {bodyError ? ( +

+ {bodyError} +

+ ) : null} + + + + + setVerbale(event.target.value)} + placeholder="https://" + aria-invalid={verbaleError ? true : undefined} + aria-describedby={fieldHintId("approve-verbale")} + className={fieldControl} + /> + +
+
+ ) +} diff --git a/src/features/regulations/delega-dialog.tsx b/src/features/regulations/delega-dialog.tsx new file mode 100644 index 00000000..253b399a --- /dev/null +++ b/src/features/regulations/delega-dialog.tsx @@ -0,0 +1,170 @@ +import { useServerFn } from "@tanstack/react-start" +import { useDeferredValue, useMemo, useState } from "react" + +import { + DateField, + fieldControl, + FormDialog, + FormField, + InlineAlert, + RadioCard, + RadioCardGroup, + SelectField, + Spinner, +} from "@/components/primitives" +import { SearchField } from "@/components/shell" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +import { todayInRome } from "./regulations-format" +import { failureMessage, useLoaded, useRefreshAfter } from "./regulations-hooks" +import { createDelega } from "./regulations-rectify.functions" +import type { DocumentInfo } from "./regulations.contract" +import { searchRegulationUsers } from "./regulations.functions" + +const ALL = "__all__" + +/** + * The Segretario delegates rettifiche to any IdP account (Statuto, article "Direttivo", comma 9): optionally for one + * document and until a date. The Segretario stays responsible; the backend emails the Direttivo. + */ +export function DelegaDialog({ + open, + onOpenChange, + documents, +}: { + open: boolean + onOpenChange: (open: boolean) => void + documents: DocumentInfo[] +}) { + const search = useServerFn(searchRegulationUsers) + const create = useServerFn(createDelega) + const refresh = useRefreshAfter() + const [query, setQuery] = useState("") + const deferredQuery = useDeferredValue(query.trim()) + const [userId, setUserId] = useState(null) + const [documentKey, setDocumentKey] = useState(documents.length === 1 ? documents[0].key : ALL) + const [expiresOn, setExpiresOn] = useState("") + const [note, setNote] = useState("") + const [submitted, setSubmitted] = useState(false) + + const { data: users, failed } = useLoaded( + useMemo( + () => (open && deferredQuery.length >= 2 ? () => search({ data: { q: deferredQuery } }) : null), + [open, deferredQuery, search] + ), + true + ) + const expiryError = + submitted && expiresOn !== "" && expiresOn < todayInRome() ? "Scegli una data da oggi in poi." : undefined + + async function submit() { + setSubmitted(true) + if (!userId) throw new Error("Scegli a chi delegare.") + if (expiresOn !== "" && expiresOn < todayInRome()) return + try { + await create({ + data: { + userId, + documentKey: documentKey === ALL ? null : documentKey, + // The end of the chosen day, in the browser's zone (Italy for the Segretario). + expiresAt: expiresOn ? new Date(`${expiresOn}T23:59:59`).toISOString() : null, + note: note.trim() || null, + }, + }) + } catch (caught) { + console.error(caught) + throw new Error(failureMessage(caught, "Non è stato possibile creare la delega."), { cause: caught }) + } + await refresh("Delega creata. Il Direttivo riceve una email.") + onOpenChange(false) + } + + return ( + +
+
+ + {failed ? ( + Non è stato possibile cercare gli account. Riprova. + ) : deferredQuery.length < 2 ? ( +

Qualsiasi account PoliNetwork, anche chi non è Socio.

+ ) : users === null ? ( +
+ Ricerca… +
+ ) : users.length === 0 ? ( +

Nessun account corrisponde.

+ ) : ( + + {users.map((user) => ( + {user.email} : undefined} + /> + ))} + + )} +
+
+ + ({ value: document.key, label: document.title })), + ]} + /> + + + + +
+ +