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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/de
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. 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`. Users and groups themselves cannot be edited from the dashboard.
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.
- **Shared UI**: `src/components/shell`, `src/components/primitives`, `src/components/ui`.

## Conventions
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# PoliNetwork Admin

The dashboard manages Telegram and WhatsApp groups, labels, Telegram users and grants, website content and reports.
Microsoft 365 supports a read-only member directory, creating a new association member through the backend's fixed
workflow, and group membership management. Directory access requires `azure:members:read`; creation independently
requires `azure:members:create`. Groups require `azure:groups:read`; adding and removing members `azure:groups:write`.
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`.

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
Expand Down
Binary file added docs/screenshots/azure/create-custom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/create-mail-failed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/create-standard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/member-number-confirm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/member-number-conflict.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/member-number-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/member-number-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/member-number-saved.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dnd-kit/react": "^0.4.0",
"@polinetwork/backend": "^1.11.0",
"@polinetwork/backend": "^2.3.0",
"@t3-oss/env-core": "^0.13.10",
"@tanstack/charts": "1.1.0",
"@tanstack/react-charts": "1.1.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 33 additions & 3 deletions src/features/azure/azure.functions.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
import { createServerFn } from "@tanstack/react-start"
import { z } from "zod"

import { azureMembersCreateMiddleware, azureMembersReadMiddleware } from "@/server/auth.middleware"
import { azureEmailSchema } from "@/features/azure/email"
import {
azureMembersCreateMiddleware,
azureMembersReadMiddleware,
azureMembersWriteMiddleware,
} from "@/server/auth.middleware"

export const getAzureMembers = createServerFn({ method: "GET" })
.middleware([azureMembersReadMiddleware])
.handler(({ context }) => context.backend.azure.members.getAll.query())

const setMemberNumberResultSchema = z.object({
error: z.string().nullable(),
notificationSent: z.boolean().nullable().optional(),
})

export const createAzureMember = createServerFn({ method: "POST" })
.middleware([azureMembersCreateMiddleware])
.validator(
z.object({
firstName: z.string().trim().min(1),
lastName: z.string().trim().min(1),
assocNumber: z.number().int().positive(),
sendEmailTo: z.email(),
sendEmailTo: azureEmailSchema,
mailbox: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("standard") }),
z.object({ kind: z.literal("custom"), localPart: z.string().trim().min(1) }),
]),
})
)
.handler(async ({ data, context }) => {
const result = await context.backend.azure.members.create.mutate(data)
if (result.error) throw new Error(result.error)
if (result.error !== null) throw new Error(result.error)
return result
})
Comment thread
lorenzocorallo marked this conversation as resolved.

/** Throws when the number is not saved; `notificationSent` is false when only the email failed. */
export const setAzureMemberNumber = createServerFn({ method: "POST" })
.middleware([azureMembersWriteMiddleware])
.validator(
z.object({
userId: z.string().min(1),
assocNumber: z.number().int().positive(),
notifyEmail: azureEmailSchema.optional(),
})
)
.handler(async ({ data, context }) => {
const result = setMemberNumberResultSchema.parse(await context.backend.azure.members.setAssocNumber.mutate(data))
if (result.error !== null) throw new Error(result.error)
return result
})
Comment thread
lorenzocorallo marked this conversation as resolved.
4 changes: 4 additions & 0 deletions src/features/azure/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { z } from "zod"

/** Shared email validation for Azure member notifications. */
export const azureEmailSchema = z.email()
25 changes: 25 additions & 0 deletions src/features/azure/mailbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Mirrors the backend's `src/azure/mailbox.ts`, which builds and checks the address again. */
export const MAIL_DOMAIN = "polinetwork.org"

const LOCAL_PART = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/

export type MailboxKind = "standard" | "custom"

export function isLocalPart(value: string) {
return value.length <= 64 && LOCAL_PART.test(value)
}

/** Accents and apostrophes are dropped and spaces removed: "Niccolò", "D'Amico" -> "niccolo.damico". */
function namePart(name: string) {
return name.trim().normalize("NFD").replace(/\p{M}/gu, "").replace(/['\s]/g, "").toLowerCase()
}

/** `firstname.lastname`, or null when the name has letters an address cannot hold (e.g. "Łukasz"). */
export function standardLocalPart(firstName: string, lastName: string): string | null {
const localPart = `${namePart(firstName)}.${namePart(lastName)}`
return isLocalPart(localPart) ? localPart : null
}

export function isPoliNetworkAddress(email: string) {
return email.trim().toLowerCase().endsWith(`@${MAIL_DOMAIN}`)
}
Loading
Loading