diff --git a/README.md b/README.md
index eefbad1..af3dec2 100644
--- a/README.md
+++ b/README.md
@@ -173,13 +173,41 @@ using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
```
+## Multitenant napojení klientů (CSBOT MS365 Connector)
+
+Služba je stavěná pro jednu multitenant Entra aplikaci CSBOT, přes kterou se připojují
+Microsoft 365 tenanty jednotlivých klientů. Podrobné zadání je v Notion stránce
+"MS365 práva konektoru".
+
+- `MS365_CLIENT_ID` a `MS365_CLIENT_SECRET` patří aplikaci CSBOT a jsou pro všechny klienty stejné.
+- `MS365_TENANT_ID` je tenant konkrétního klienta. Token se získává proti
+ `https://login.microsoftonline.com/{tenant klienta}/oauth2/v2.0/token`.
+- Při volání za různé klienty se tenant id posílá v hlavičce `X-MS365-Tenant-Id`
+ (viz "Credentials v request hlavičkách"), client id a secret zůstávají stejné.
+- Klient nevytváří vlastní App Registration ani nepředává secret. Jeho administrátor jen
+ schválí admin consent. URL pro consent vrací `GET /admin-consent/url?redirect_uri=...&state=...`
+ (bere tenant id a client id z hlaviček nebo z env). Callback po consentu (parametry `tenant`
+ a `admin_consent=True`) zpracovává backend CSBOT, ne tato služba.
+- Služba je bezstavová a neověřuje, zda mailbox, plan id nebo bucket id patří danému
+ zákazníkovi. Tenant isolation (customer id -> tenant id, mailbox, plan id, bucket id)
+ musí držet volající backend, hodnoty nesmí přicházet přímo od LLM.
+
+Postup testu po consentu klienta: `GET /status`, `GET /users/{mailbox}/calendar/view`,
+`POST /users/{mailbox}/calendar/events` + `DELETE`, `GET /planner/plans/{plan_id}/tasks`,
+`POST /planner/tasks` + `DELETE`.
+
## Implementované služby
- Users: výpis a načtení uživatelů.
- Mail: výpis zpráv a odesílání e-mailů včetně příloh.
-- Calendar: výpis a vytváření událostí včetně Teams online meetingů.
+- Calendar: výpis událostí, calendarView v zadaném rozsahu, volné termíny (getSchedule),
+ načtení, vytvoření, úprava a smazání události včetně Teams online meetingů.
+- Planner: výpis plánů skupiny, bucketů a tasků plánu, načtení, vytvoření, úprava
+ a smazání tasku. Úprava a smazání používají ETag (`If-Match`). Když hlavička chybí,
+ služba si aktuální ETag načte sama.
- OneDrive: výpis souborů v rootu a upload malých souborů.
- Groups a Teams: výpis skupin a výpis kanálů týmu.
+- Admin consent: sestavení consent URL pro administrátora klientského tenantu.
## API
@@ -191,14 +219,32 @@ GET /users?top=25&search=jane
GET /users/{user_id}
GET /users/{user_id}/mail/messages?folder=Inbox&top=25
POST /users/{user_id}/mail/send
+GET /admin-consent/url?redirect_uri=...&state=...
GET /users/{user_id}/calendar/events?top=25
POST /users/{user_id}/calendar/events
+GET /users/{user_id}/calendar/view?start=...&end=...&top=25&time_zone=Europe/Prague
+POST /users/{user_id}/calendar/schedule
+GET /users/{user_id}/calendar/events/{event_id}
+PATCH /users/{user_id}/calendar/events/{event_id}
+DELETE /users/{user_id}/calendar/events/{event_id}
GET /users/{user_id}/drive/root/children?top=25
PUT /users/{user_id}/drive/root/{path}
GET /groups?top=25
+GET /groups/{group_id}/planner/plans
GET /teams/{team_id}/channels
+GET /planner/plans/{plan_id}/buckets
+GET /planner/plans/{plan_id}/tasks
+POST /planner/tasks
+GET /planner/tasks/{task_id}
+PATCH /planner/tasks/{task_id} (volitelně hlavička If-Match)
+DELETE /planner/tasks/{task_id} (volitelně hlavička If-Match)
```
+Parametry `start` a `end` u calendarView jsou ISO 8601 s offsetem, například
+`2026-09-08T08:00:00+02:00`. Datumy u Planneru (`due_date_time`, `start_date_time`)
+jsou ISO 8601 s offsetem, například `2026-09-10T16:00:00Z`. Řešitelé se předávají
+jako Graph user id v `assign_user_ids`, odebírají v `unassign_user_ids`.
+
`user_id` může být Microsoft Graph user id nebo user principal name, například `jane@example.com`.
## Požadovaná Graph oprávnění
@@ -208,10 +254,22 @@ Přidělte pouze oprávnění, která dané nasazení skutečně používá:
- Users: `User.Read.All`
- Čtení mailů: `Mail.Read`
- Odesílání mailů: `Mail.Send`
-- Čtení a zápis do kalendáře: `Calendars.ReadWrite`
+- Čtení a zápis do kalendáře, calendarView, getSchedule: `Calendars.ReadWrite`
+- Planner: `Tasks.ReadWrite.All` (zahrnuje i čtení, `Tasks.Read.All` netřeba)
- Čtení a zápis do OneDrive: `Files.ReadWrite.All`
- Výpis skupin a Teams kanálů: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`
+Pro konektor CSBOT (Calendar + Planner) je minimum `Calendars.ReadWrite` a `Tasks.ReadWrite.All`,
+obě jako Application permissions. `Calendars.ReadWrite` platí na všechny mailboxy tenantu,
+omezení na konkrétní mailbox se řeší na straně klienta v Exchange Online
+(application access policy), ne v této službě.
+
+## Ukázky pro klienty
+
+- `examples/csharp/ListUsersTop10`: konzolová aplikace .NET 8, volá `GET /users?top=10`
+ a obsahuje třídu `CredentialEncoder` pro šifrování hlaviček `X-MS365-*`.
+ Popis v `examples/csharp/README.md`.
+
## Lokální spuštění
```bash
@@ -223,3 +281,11 @@ Vygenerované OpenAPI UI otevřete na `http://localhost:8000/docs`.
# job queue test Thu May 28 02:12:07 PM CEST 2026
# job queue test Thu May 28 02:12:28 PM CEST 2026
# websocket logs test Fri May 29 11:03:58 AM CEST 2026
+
+## Záznam změn
+
+- 2026-09-09: verze 1.1.0. Podle Notion zadání "MS365 práva konektoru" doplněn Calendar
+ (calendarView, getSchedule, get/patch/delete události), Planner (plány, buckety, tasky,
+ create/patch/delete s ETag) a `GET /admin-consent/url`. Kód ověřen jen importem aplikace
+ a serializací schémat, proti reálnému tenantu netestováno (chybí DEV App Registration).
+- 2026-09-09: přidána C# ukázka `examples/csharp/ListUsersTop10` (top 10 z `/users`).
diff --git a/app.yml b/app.yml
index 3eb5c7a..4734042 100644
--- a/app.yml
+++ b/app.yml
@@ -1,7 +1,7 @@
id: microsoft-365-service
name: Microsoft 365 Service
language: python
-version: 1.0.1
+version: 1.1.0
base_path: /apps/microsoft-365-service
port: 8000
status: development
diff --git a/app/config.py b/app/config.py
index 59032e5..fc6c2cd 100644
--- a/app/config.py
+++ b/app/config.py
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service")
- app_version: str = os.getenv("APP_VERSION", "1.0.1")
+ app_version: str = os.getenv("APP_VERSION", "1.1.0")
root_path: str = os.getenv("ROOT_PATH", "")
tenant_id: str = os.getenv("MS365_TENANT_ID", "")
client_id: str = os.getenv("MS365_CLIENT_ID", "")
diff --git a/app/routes.py b/app/routes.py
index 133ab72..d87205d 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -1,12 +1,21 @@
from typing import Any
+from urllib.parse import urlencode
-from fastapi import APIRouter, Body, Depends, Query, Response, status
+from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status
from .config import Settings
from .credentials import get_request_settings
from .graph_client import MicrosoftGraphClient
-from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
-from .services import CalendarService, DriveService, GroupsService, MailService, UsersService
+from .schemas import (
+ CalendarEventRequest,
+ CalendarEventUpdateRequest,
+ DriveUploadRequest,
+ PlannerTaskCreateRequest,
+ PlannerTaskUpdateRequest,
+ ScheduleRequest,
+ SendMailRequest,
+)
+from .services import CalendarService, DriveService, GroupsService, MailService, PlannerService, UsersService
router = APIRouter(tags=["microsoft365"])
@@ -35,6 +44,10 @@ def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client))
return GroupsService(graph)
+def get_planner_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> PlannerService:
+ return PlannerService(graph)
+
+
@router.get("/status")
def microsoft365_status(request_settings: Settings = Depends(get_request_settings)) -> dict[str, Any]:
return {
@@ -46,6 +59,31 @@ def microsoft365_status(request_settings: Settings = Depends(get_request_setting
}
+@router.get("/admin-consent/url")
+def admin_consent_url(
+ redirect_uri: str = Query(..., description="HTTPS callback URL registered on the Entra app registration."),
+ state: str | None = Query(None, description="Opaque value returned to the callback, e.g. customer id."),
+ request_settings: Settings = Depends(get_request_settings),
+) -> dict[str, Any]:
+ """Build the admin consent URL a client tenant administrator must open to approve the multitenant app."""
+ if not request_settings.tenant_id or not request_settings.client_id:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Tenant id and client id are required to build the admin consent URL.",
+ )
+ query: dict[str, str] = {
+ "client_id": request_settings.client_id,
+ "scope": request_settings.graph_scope,
+ "redirect_uri": redirect_uri,
+ }
+ if state:
+ query["state"] = state
+ return {
+ "tenant_id": request_settings.tenant_id,
+ "url": f"https://login.microsoftonline.com/{request_settings.tenant_id}/v2.0/adminconsent?{urlencode(query)}",
+ }
+
+
@router.get("/users")
async def list_users(
top: int = Query(25, ge=1, le=999),
@@ -99,6 +137,56 @@ async def create_event(
return await service.create_event(user_id=user_id, request=request)
+@router.get("/users/{user_id}/calendar/view")
+async def list_calendar_view(
+ user_id: str,
+ start: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00+02:00."),
+ end: str = Query(..., description="ISO 8601 with offset, e.g. 2026-09-08T18:00:00+02:00."),
+ top: int = Query(25, ge=1, le=100),
+ time_zone: str | None = Query(None, description="Time zone for returned dates, e.g. Europe/Prague."),
+ service: CalendarService = Depends(get_calendar_service),
+) -> Any:
+ return await service.list_calendar_view(user_id=user_id, start=start, end=end, top=top, time_zone=time_zone)
+
+
+@router.post("/users/{user_id}/calendar/schedule")
+async def get_schedule(
+ user_id: str,
+ request: ScheduleRequest,
+ service: CalendarService = Depends(get_calendar_service),
+) -> Any:
+ return await service.get_schedule(user_id=user_id, request=request)
+
+
+@router.get("/users/{user_id}/calendar/events/{event_id}")
+async def get_event(
+ user_id: str,
+ event_id: str,
+ service: CalendarService = Depends(get_calendar_service),
+) -> Any:
+ return await service.get_event(user_id=user_id, event_id=event_id)
+
+
+@router.patch("/users/{user_id}/calendar/events/{event_id}")
+async def update_event(
+ user_id: str,
+ event_id: str,
+ request: CalendarEventUpdateRequest,
+ service: CalendarService = Depends(get_calendar_service),
+) -> Any:
+ return await service.update_event(user_id=user_id, event_id=event_id, request=request)
+
+
+@router.delete("/users/{user_id}/calendar/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_event(
+ user_id: str,
+ event_id: str,
+ service: CalendarService = Depends(get_calendar_service),
+) -> Response:
+ await service.delete_event(user_id=user_id, event_id=event_id)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
@router.get("/users/{user_id}/drive/root/children")
async def list_drive_root_children(
user_id: str,
@@ -129,3 +217,59 @@ async def list_groups(
@router.get("/teams/{team_id}/channels")
async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any:
return await service.list_team_channels(team_id=team_id)
+
+
+@router.get("/groups/{group_id}/planner/plans")
+async def list_group_plans(group_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
+ return await service.list_group_plans(group_id=group_id)
+
+
+@router.get("/planner/plans/{plan_id}/buckets")
+async def list_plan_buckets(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
+ return await service.list_plan_buckets(plan_id=plan_id)
+
+
+@router.get("/planner/plans/{plan_id}/tasks")
+async def list_plan_tasks(plan_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
+ return await service.list_plan_tasks(plan_id=plan_id)
+
+
+@router.post("/planner/tasks", status_code=status.HTTP_201_CREATED)
+async def create_planner_task(
+ request: PlannerTaskCreateRequest,
+ service: PlannerService = Depends(get_planner_service),
+) -> Any:
+ return await service.create_task(request=request)
+
+
+@router.get("/planner/tasks/{task_id}")
+async def get_planner_task(task_id: str, service: PlannerService = Depends(get_planner_service)) -> Any:
+ return await service.get_task(task_id=task_id)
+
+
+@router.patch("/planner/tasks/{task_id}")
+async def update_planner_task(
+ task_id: str,
+ request: PlannerTaskUpdateRequest,
+ if_match: str | None = Header(
+ None,
+ alias="If-Match",
+ description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.",
+ ),
+ service: PlannerService = Depends(get_planner_service),
+) -> Any:
+ return await service.update_task(task_id=task_id, request=request, if_match=if_match)
+
+
+@router.delete("/planner/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_planner_task(
+ task_id: str,
+ if_match: str | None = Header(
+ None,
+ alias="If-Match",
+ description="Task ETag (@odata.etag). When omitted, the service loads the current ETag first.",
+ ),
+ service: PlannerService = Depends(get_planner_service),
+) -> Response:
+ await service.delete_task(task_id=task_id, if_match=if_match)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/app/schemas.py b/app/schemas.py
index 613ed8f..6382179 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -69,6 +69,125 @@ class CalendarEventRequest(BaseModel):
online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$")
+class CalendarEventUpdateRequest(BaseModel):
+ """Partial update; only fields that are set are sent to Microsoft Graph."""
+
+ subject: str | None = None
+ body: str | None = None
+ body_content_type: str = Field("HTML", pattern="^(HTML|Text)$")
+ start: DateTimeTimeZone | None = None
+ end: DateTimeTimeZone | None = None
+ location: str | None = None
+ attendees: list[CalendarAttendee] | None = None
+ is_online_meeting: bool | None = None
+ online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$")
+
+ def as_graph_patch(self) -> dict[str, Any]:
+ payload: dict[str, Any] = {}
+ if self.subject is not None:
+ payload["subject"] = self.subject
+ if self.body is not None:
+ payload["body"] = {"contentType": self.body_content_type, "content": self.body}
+ if self.start is not None:
+ payload["start"] = self.start.model_dump(by_alias=True)
+ if self.end is not None:
+ payload["end"] = self.end.model_dump(by_alias=True)
+ if self.location is not None:
+ payload["location"] = {"displayName": self.location}
+ if self.attendees is not None:
+ payload["attendees"] = [attendee.as_graph_attendee() for attendee in self.attendees]
+ if self.is_online_meeting is not None:
+ payload["isOnlineMeeting"] = self.is_online_meeting
+ if self.online_meeting_provider is not None:
+ payload["onlineMeetingProvider"] = self.online_meeting_provider
+ return payload
+
+
+class ScheduleRequest(BaseModel):
+ """Free/busy lookup (Graph getSchedule) for one or more mailboxes."""
+
+ schedules: list[EmailStr] = Field(..., min_length=1, description="Mailboxes whose availability is requested.")
+ start: DateTimeTimeZone
+ end: DateTimeTimeZone
+ availability_view_interval: int = Field(30, ge=5, le=1440, description="Slot length in minutes.")
+
+ def as_graph_payload(self) -> dict[str, Any]:
+ return {
+ "schedules": [str(address) for address in self.schedules],
+ "startTime": self.start.model_dump(by_alias=True),
+ "endTime": self.end.model_dump(by_alias=True),
+ "availabilityViewInterval": self.availability_view_interval,
+ }
+
+
+def _planner_assignments(assign_user_ids: list[str], unassign_user_ids: list[str]) -> dict[str, Any]:
+ assignments: dict[str, Any] = {
+ user_id: {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
+ for user_id in assign_user_ids
+ }
+ for user_id in unassign_user_ids:
+ assignments[user_id] = None
+ return assignments
+
+
+class PlannerTaskCreateRequest(BaseModel):
+ plan_id: str
+ bucket_id: str | None = None
+ title: str
+ start_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-08T08:00:00Z.")
+ due_date_time: str | None = Field(None, description="ISO 8601 with offset, e.g. 2026-09-10T16:00:00Z.")
+ percent_complete: int | None = Field(None, ge=0, le=100)
+ priority: int | None = Field(None, ge=0, le=10, description="Graph priority: 1 urgent, 3 important, 5 medium, 9 low.")
+ assign_user_ids: list[str] = Field(default_factory=list, description="Microsoft Graph user ids to assign.")
+
+ def as_graph_payload(self) -> dict[str, Any]:
+ payload: dict[str, Any] = {"planId": self.plan_id, "title": self.title}
+ if self.bucket_id:
+ payload["bucketId"] = self.bucket_id
+ if self.start_date_time:
+ payload["startDateTime"] = self.start_date_time
+ if self.due_date_time:
+ payload["dueDateTime"] = self.due_date_time
+ if self.percent_complete is not None:
+ payload["percentComplete"] = self.percent_complete
+ if self.priority is not None:
+ payload["priority"] = self.priority
+ if self.assign_user_ids:
+ payload["assignments"] = _planner_assignments(self.assign_user_ids, [])
+ return payload
+
+
+class PlannerTaskUpdateRequest(BaseModel):
+ """Partial update; only fields that are set are sent to Microsoft Graph."""
+
+ title: str | None = None
+ bucket_id: str | None = None
+ start_date_time: str | None = None
+ due_date_time: str | None = None
+ percent_complete: int | None = Field(None, ge=0, le=100)
+ priority: int | None = Field(None, ge=0, le=10)
+ assign_user_ids: list[str] = Field(default_factory=list)
+ unassign_user_ids: list[str] = Field(default_factory=list)
+
+ def as_graph_patch(self) -> dict[str, Any]:
+ payload: dict[str, Any] = {}
+ if self.title is not None:
+ payload["title"] = self.title
+ if self.bucket_id is not None:
+ payload["bucketId"] = self.bucket_id
+ if self.start_date_time is not None:
+ payload["startDateTime"] = self.start_date_time
+ if self.due_date_time is not None:
+ payload["dueDateTime"] = self.due_date_time
+ if self.percent_complete is not None:
+ payload["percentComplete"] = self.percent_complete
+ if self.priority is not None:
+ payload["priority"] = self.priority
+ if self.assign_user_ids or self.unassign_user_ids:
+ payload["assignments"] = _planner_assignments(self.assign_user_ids, self.unassign_user_ids)
+ return payload
+
+
class DriveUploadRequest(BaseModel):
content_base64: str
content_type: str = "application/octet-stream"
diff --git a/app/services.py b/app/services.py
index d140206..4d922e4 100644
--- a/app/services.py
+++ b/app/services.py
@@ -6,7 +6,15 @@ from urllib.parse import quote
from fastapi import HTTPException, status
from .graph_client import MicrosoftGraphClient
-from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest
+from .schemas import (
+ CalendarEventRequest,
+ CalendarEventUpdateRequest,
+ DriveUploadRequest,
+ PlannerTaskCreateRequest,
+ PlannerTaskUpdateRequest,
+ ScheduleRequest,
+ SendMailRequest,
+)
def graph_segment(value: str) -> str:
@@ -74,6 +82,11 @@ class MailService:
)
+CALENDAR_EVENT_SELECT = (
+ "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting,showAs,isCancelled,organizer"
+)
+
+
class CalendarService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
@@ -82,10 +95,66 @@ class CalendarService:
params = {
"$top": top,
"$orderby": "start/dateTime",
- "$select": "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting",
+ "$select": CALENDAR_EVENT_SELECT,
}
return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params)
+ async def list_calendar_view(
+ self,
+ user_id: str,
+ start: str,
+ end: str,
+ top: int = 25,
+ time_zone: str | None = None,
+ ) -> Any:
+ params = {
+ "startDateTime": start,
+ "endDateTime": end,
+ "$top": top,
+ "$orderby": "start/dateTime",
+ "$select": CALENDAR_EVENT_SELECT,
+ }
+ headers = {"Prefer": f'outlook.timezone="{time_zone}"'} if time_zone else None
+ return await self._graph.request(
+ "GET",
+ f"/users/{graph_segment(user_id)}/calendarView",
+ params=params,
+ headers=headers,
+ )
+
+ async def get_schedule(self, user_id: str, request: ScheduleRequest) -> Any:
+ return await self._graph.request(
+ "POST",
+ f"/users/{graph_segment(user_id)}/calendar/getSchedule",
+ json=request.as_graph_payload(),
+ )
+
+ async def get_event(self, user_id: str, event_id: str) -> Any:
+ return await self._graph.request(
+ "GET",
+ f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}",
+ params={"$select": CALENDAR_EVENT_SELECT + ",body"},
+ )
+
+ async def update_event(self, user_id: str, event_id: str, request: CalendarEventUpdateRequest) -> Any:
+ payload = request.as_graph_patch()
+ if not payload:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Calendar event update must contain at least one field.",
+ )
+ return await self._graph.request(
+ "PATCH",
+ f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}",
+ json=payload,
+ )
+
+ async def delete_event(self, user_id: str, event_id: str) -> None:
+ await self._graph.request(
+ "DELETE",
+ f"/users/{graph_segment(user_id)}/events/{graph_segment(event_id)}",
+ )
+
async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any:
payload: dict[str, Any] = {
"subject": request.subject,
@@ -103,6 +172,63 @@ class CalendarService:
return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload)
+class PlannerService:
+ """Microsoft Planner. Updates and deletes require the task ETag (Graph optimistic concurrency)."""
+
+ def __init__(self, graph: MicrosoftGraphClient) -> None:
+ self._graph = graph
+
+ async def list_group_plans(self, group_id: str) -> Any:
+ return await self._graph.request("GET", f"/groups/{graph_segment(group_id)}/planner/plans")
+
+ async def list_plan_buckets(self, plan_id: str) -> Any:
+ return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/buckets")
+
+ async def list_plan_tasks(self, plan_id: str) -> Any:
+ return await self._graph.request("GET", f"/planner/plans/{graph_segment(plan_id)}/tasks")
+
+ async def get_task(self, task_id: str) -> Any:
+ return await self._graph.request("GET", f"/planner/tasks/{graph_segment(task_id)}")
+
+ async def create_task(self, request: PlannerTaskCreateRequest) -> Any:
+ return await self._graph.request("POST", "/planner/tasks", json=request.as_graph_payload())
+
+ async def update_task(self, task_id: str, request: PlannerTaskUpdateRequest, if_match: str | None) -> Any:
+ payload = request.as_graph_patch()
+ if not payload:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Planner task update must contain at least one field.",
+ )
+ etag = await self._resolve_etag(task_id, if_match)
+ return await self._graph.request(
+ "PATCH",
+ f"/planner/tasks/{graph_segment(task_id)}",
+ json=payload,
+ headers={"If-Match": etag, "Prefer": "return=representation"},
+ )
+
+ async def delete_task(self, task_id: str, if_match: str | None) -> None:
+ etag = await self._resolve_etag(task_id, if_match)
+ await self._graph.request(
+ "DELETE",
+ f"/planner/tasks/{graph_segment(task_id)}",
+ headers={"If-Match": etag},
+ )
+
+ async def _resolve_etag(self, task_id: str, if_match: str | None) -> str:
+ if if_match:
+ return if_match
+ task = await self.get_task(task_id)
+ etag = task.get("@odata.etag") if isinstance(task, dict) else None
+ if not etag:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail={"message": "Microsoft Graph did not return an ETag for the Planner task.", "task_id": task_id},
+ )
+ return etag
+
+
class DriveService:
def __init__(self, graph: MicrosoftGraphClient) -> None:
self._graph = graph
diff --git a/examples/csharp/ListUsersTop10/ListUsersTop10.csproj b/examples/csharp/ListUsersTop10/ListUsersTop10.csproj
new file mode 100644
index 0000000..f704bf4
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/ListUsersTop10.csproj
@@ -0,0 +1,10 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/examples/csharp/ListUsersTop10/Program.cs b/examples/csharp/ListUsersTop10/Program.cs
new file mode 100644
index 0000000..db67014
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/Program.cs
@@ -0,0 +1,128 @@
+// Ukazka volani microsoft-365-service: vypis prvnich 10 uzivatelu z GET /users.
+//
+// Sluzba: https://services.csbot.cz/apps/microsoft-365-service
+// Dokumentace (Swagger): https://services.csbot.cz/apps/microsoft-365-service/docs
+//
+// Credentials se posilaji v hlavickach X-MS365-*. Kazda hodnota je zasifrovana
+// AES-256-GCM klicem odvozenym pres HKDF-SHA256 ze sdileneho secretu
+// (MS365_CREDENTIAL_ENCODING_SECRET na strane sluzby). Format hodnoty:
+// v1..
+//
+// Spusteni:
+// set MS365_SERVICE_BASE_URL=https://services.csbot.cz/apps/microsoft-365-service
+// set MS365_TENANT_ID=
+// set MS365_CLIENT_ID=
+// set MS365_CLIENT_SECRET=
+// set MS365_CREDENTIAL_ENCODING_SECRET=
+// dotnet run
+
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+var baseUrl = Env("MS365_SERVICE_BASE_URL", "https://services.csbot.cz/apps/microsoft-365-service");
+var tenantId = Env("MS365_TENANT_ID");
+var clientId = Env("MS365_CLIENT_ID");
+var clientSecret = Env("MS365_CLIENT_SECRET");
+var sharedSecret = Env("MS365_CREDENTIAL_ENCODING_SECRET");
+
+const int top = 10;
+
+using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
+http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+using var request = new HttpRequestMessage(HttpMethod.Get, $"users?top={top}");
+request.Headers.Add("X-MS365-Tenant-Id", CredentialEncoder.Encode(tenantId, "X-MS365-Tenant-Id", sharedSecret));
+request.Headers.Add("X-MS365-Client-Id", CredentialEncoder.Encode(clientId, "X-MS365-Client-Id", sharedSecret));
+request.Headers.Add("X-MS365-Client-Secret", CredentialEncoder.Encode(clientSecret, "X-MS365-Client-Secret", sharedSecret));
+request.Headers.Add("X-MS365-Credential-Version", "v1");
+
+using var response = await http.SendAsync(request);
+var body = await response.Content.ReadAsStringAsync();
+
+if (!response.IsSuccessStatusCode)
+{
+ // 400 = spatne/prosle hlavicky, 502 = chyba z Microsoft Graph (detail je v tele),
+ // 403 text/plain = request neprosel pres reverzni proxy (IP allowlist pro GET).
+ Console.Error.WriteLine($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
+ Console.Error.WriteLine(body);
+ return 1;
+}
+
+// Sluzba vraci odpoved Microsoft Graph beze zmeny: { "value": [ { ...user... } ], "@odata.nextLink": "..." }
+using var json = JsonDocument.Parse(body);
+var users = json.RootElement.GetProperty("value");
+
+Console.WriteLine($"Nacteno uzivatelu: {users.GetArrayLength()}");
+foreach (var user in users.EnumerateArray())
+{
+ var displayName = user.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
+ var upn = user.TryGetProperty("userPrincipalName", out var up) ? up.GetString() : null;
+ var mail = user.TryGetProperty("mail", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
+ var id = user.GetProperty("id").GetString();
+
+ Console.WriteLine($"{displayName} | {upn} | {mail ?? "-"} | {id}");
+}
+
+return 0;
+
+static string Env(string name, string? fallback = null)
+{
+ var value = Environment.GetEnvironmentVariable(name);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ if (fallback is not null)
+ {
+ return fallback;
+ }
+ throw new InvalidOperationException($"Chybi environment promenna {name}.");
+}
+
+///
+/// Sifrovani hodnot pro hlavicky X-MS365-*. Musi presne odpovidat dekodovani na strane sluzby
+/// (app/credentials.py): HKDF-SHA256 se salt "microsoft-365-service.credentials.v1"
+/// a info = nazev hlavicky, AES-256-GCM s 12B nonce, 16B tagem a AAD = nazev hlavicky.
+///
+static class CredentialEncoder
+{
+ private static readonly byte[] HkdfSalt = Encoding.UTF8.GetBytes("microsoft-365-service.credentials.v1");
+
+ public static string Encode(string value, string headerName, string sharedSecret, TimeSpan? validity = null)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var payload = JsonSerializer.Serialize(new
+ {
+ value,
+ issued_at = now.ToString("O"),
+ expires_at = now.Add(validity ?? TimeSpan.FromHours(1)).ToString("O"),
+ });
+
+ var headerBytes = Encoding.UTF8.GetBytes(headerName);
+ var key = HKDF.DeriveKey(
+ HashAlgorithmName.SHA256,
+ Encoding.UTF8.GetBytes(sharedSecret),
+ outputLength: 32,
+ salt: HkdfSalt,
+ info: headerBytes);
+
+ var nonce = RandomNumberGenerator.GetBytes(12);
+ var plaintext = Encoding.UTF8.GetBytes(payload);
+ var ciphertext = new byte[plaintext.Length];
+ var tag = new byte[16];
+
+ using var aes = new AesGcm(key, tagSizeInBytes: 16);
+ aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData: headerBytes);
+
+ var ciphertextAndTag = new byte[ciphertext.Length + tag.Length];
+ ciphertext.CopyTo(ciphertextAndTag, 0);
+ tag.CopyTo(ciphertextAndTag, ciphertext.Length);
+
+ return $"v1.{Base64Url(nonce)}.{Base64Url(ciphertextAndTag)}";
+ }
+
+ private static string Base64Url(byte[] data) =>
+ Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs
new file mode 100644
index 0000000..2217181
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs
@@ -0,0 +1,4 @@
+//
+using System;
+using System.Reflection;
+[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs
new file mode 100644
index 0000000..b3bf715
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfo.cs
@@ -0,0 +1,22 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+using System;
+using System.Reflection;
+
+[assembly: System.Reflection.AssemblyCompanyAttribute("ListUsersTop10")]
+[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
+[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
+[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+275bd6c46764ec8db008fa2fa8c30673bb96f3d8")]
+[assembly: System.Reflection.AssemblyProductAttribute("ListUsersTop10")]
+[assembly: System.Reflection.AssemblyTitleAttribute("ListUsersTop10")]
+[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
+
+// Generated by the MSBuild WriteCodeFragment class.
+
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache
new file mode 100644
index 0000000..48ca0b9
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.AssemblyInfoInputs.cache
@@ -0,0 +1 @@
+502171e0ece4b0be631032bff07cea1c6b79151ad77b399268a548f2c009e1d0
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig
new file mode 100644
index 0000000..7dd5c4e
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GeneratedMSBuildEditorConfig.editorconfig
@@ -0,0 +1,18 @@
+is_global = true
+build_property.TargetFramework = net8.0
+build_property.TargetFrameworkIdentifier = .NETCoreApp
+build_property.TargetFrameworkVersion = v8.0
+build_property.TargetPlatformMinVersion =
+build_property.UsingMicrosoftNETSdkWeb =
+build_property.ProjectTypeGuids =
+build_property.InvariantGlobalization =
+build_property.PlatformNeutralAssembly =
+build_property.EnforceExtendedAnalyzerRules =
+build_property.EntryPointFilePath =
+build_property._SupportedPlatformList = Linux,macOS,Windows
+build_property.RootNamespace = ListUsersTop10
+build_property.ProjectDir = D:\GitHubRepository\Hracicky\x\ms365\microsoft-365-service\examples\csharp\ListUsersTop10\
+build_property.EnableComHosting =
+build_property.EnableGeneratedComInterfaceComImportInterop =
+build_property.EffectiveAnalysisLevelStyle = 8.0
+build_property.EnableCodeStyleSeverity =
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs
new file mode 100644
index 0000000..d12bcbc
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.GlobalUsings.g.cs
@@ -0,0 +1,8 @@
+//
+global using System;
+global using System.Collections.Generic;
+global using System.IO;
+global using System.Linq;
+global using System.Net.Http;
+global using System.Threading;
+global using System.Threading.Tasks;
diff --git a/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache
new file mode 100644
index 0000000..297ea05
Binary files /dev/null and b/examples/csharp/ListUsersTop10/obj/Debug/net8.0/ListUsersTop10.assets.cache differ
diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json
new file mode 100644
index 0000000..80ce806
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.dgspec.json
@@ -0,0 +1,77 @@
+{
+ "format": 1,
+ "restore": {
+ "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {}
+ },
+ "projects": {
+ "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj": {
+ "version": "1.0.0",
+ "restore": {
+ "projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
+ "projectName": "ListUsersTop10",
+ "projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
+ "packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
+ "outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
+ "projectStyle": "PackageReference",
+ "fallbackFolders": [
+ "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
+ ],
+ "configFilePaths": [
+ "C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
+ "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
+ "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
+ ],
+ "originalTargetFrameworks": [
+ "net8.0"
+ ],
+ "sources": {
+ "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
+ "C:\\Program Files\\dotnet\\library-packs": {},
+ "D:\\CustomNuGetPackages": {},
+ "https://api.nuget.org/v3/index.json": {}
+ },
+ "frameworks": {
+ "net8.0": {
+ "framework": "net8.0",
+ "targetAlias": "net8.0",
+ "projectReferences": {}
+ }
+ },
+ "warningProperties": {
+ "warnAsError": [
+ "NU1605"
+ ]
+ },
+ "restoreAuditProperties": {
+ "enableAudit": "true",
+ "auditLevel": "low",
+ "auditMode": "direct"
+ },
+ "SdkAnalysisLevel": "10.0.400"
+ },
+ "frameworks": {
+ "net8.0": {
+ "framework": "net8.0",
+ "targetAlias": "net8.0",
+ "imports": [
+ "net461",
+ "net462",
+ "net47",
+ "net471",
+ "net472",
+ "net48",
+ "net481"
+ ],
+ "assetTargetFallback": true,
+ "warn": true,
+ "frameworkReferences": {
+ "Microsoft.NETCore.App": {
+ "privateAssets": "all"
+ }
+ },
+ "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props
new file mode 100644
index 0000000..31462d3
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.props
@@ -0,0 +1,16 @@
+
+
+
+ True
+ NuGet
+ $(MSBuildThisFileDirectory)project.assets.json
+ $(UserProfile)\.nuget\packages\
+ C:\Users\GamingPC\.nuget\packages\;C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages
+ PackageReference
+ 7.0.0
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets
new file mode 100644
index 0000000..3dc06ef
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/ListUsersTop10.csproj.nuget.g.targets
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/examples/csharp/ListUsersTop10/obj/project.assets.json b/examples/csharp/ListUsersTop10/obj/project.assets.json
new file mode 100644
index 0000000..8d2ed4e
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/project.assets.json
@@ -0,0 +1,83 @@
+{
+ "version": 4,
+ "targets": {
+ "net8.0": {}
+ },
+ "libraries": {},
+ "projectFileDependencyGroups": {
+ "net8.0": []
+ },
+ "packageFolders": {
+ "C:\\Users\\GamingPC\\.nuget\\packages\\": {},
+ "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
+ },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectUniqueName": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
+ "projectName": "ListUsersTop10",
+ "projectPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
+ "packagesPath": "C:\\Users\\GamingPC\\.nuget\\packages\\",
+ "outputPath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\obj\\",
+ "projectStyle": "PackageReference",
+ "fallbackFolders": [
+ "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
+ ],
+ "configFilePaths": [
+ "C:\\Users\\GamingPC\\AppData\\Roaming\\NuGet\\NuGet.Config",
+ "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
+ "C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
+ ],
+ "originalTargetFrameworks": [
+ "net8.0"
+ ],
+ "sources": {
+ "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
+ "C:\\Program Files\\dotnet\\library-packs": {},
+ "D:\\CustomNuGetPackages": {},
+ "https://api.nuget.org/v3/index.json": {}
+ },
+ "frameworks": {
+ "net8.0": {
+ "framework": "net8.0",
+ "targetAlias": "net8.0",
+ "projectReferences": {}
+ }
+ },
+ "warningProperties": {
+ "warnAsError": [
+ "NU1605"
+ ]
+ },
+ "restoreAuditProperties": {
+ "enableAudit": "true",
+ "auditLevel": "low",
+ "auditMode": "direct"
+ },
+ "SdkAnalysisLevel": "10.0.400"
+ },
+ "frameworks": {
+ "net8.0": {
+ "framework": "net8.0",
+ "targetAlias": "net8.0",
+ "imports": [
+ "net461",
+ "net462",
+ "net47",
+ "net471",
+ "net472",
+ "net48",
+ "net481"
+ ],
+ "assetTargetFallback": true,
+ "warn": true,
+ "frameworkReferences": {
+ "Microsoft.NETCore.App": {
+ "privateAssets": "all"
+ }
+ },
+ "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.400/PortableRuntimeIdentifierGraph.json"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/csharp/ListUsersTop10/obj/project.nuget.cache b/examples/csharp/ListUsersTop10/obj/project.nuget.cache
new file mode 100644
index 0000000..0163ef1
--- /dev/null
+++ b/examples/csharp/ListUsersTop10/obj/project.nuget.cache
@@ -0,0 +1,8 @@
+{
+ "version": 2,
+ "dgSpecHash": "4FfrW7UlSBU=",
+ "success": true,
+ "projectFilePath": "D:\\GitHubRepository\\Hracicky\\x\\ms365\\microsoft-365-service\\examples\\csharp\\ListUsersTop10\\ListUsersTop10.csproj",
+ "expectedPackageFiles": [],
+ "logs": []
+}
\ No newline at end of file
diff --git a/examples/csharp/README.md b/examples/csharp/README.md
new file mode 100644
index 0000000..bce7c23
--- /dev/null
+++ b/examples/csharp/README.md
@@ -0,0 +1,36 @@
+# Ukazky volani sluzby v C#
+
+## ListUsersTop10
+
+Konzolova aplikace (.NET 8), ktera zavola `GET /users?top=10` a vypise
+displayName, userPrincipalName, mail a id kazdeho uzivatele.
+
+Obsahuje tridu `CredentialEncoder`, ktera sifruje hodnoty pro hlavicky
+`X-MS365-Tenant-Id`, `X-MS365-Client-Id` a `X-MS365-Client-Secret` presne tak,
+jak je sluzba dekoduje v `app/credentials.py`. Tridu lze zkopirovat do
+libovolneho projektu, nema zadne externi zavislosti.
+
+### Spusteni
+
+```powershell
+cd examples/csharp/ListUsersTop10
+$env:MS365_SERVICE_BASE_URL = "https://services.csbot.cz/apps/microsoft-365-service"
+$env:MS365_TENANT_ID = ""
+$env:MS365_CLIENT_ID = ""
+$env:MS365_CLIENT_SECRET = ""
+$env:MS365_CREDENTIAL_ENCODING_SECRET = ""
+dotnet run
+```
+
+### Co ocekavat
+
+- `200` a JSON ve tvaru Microsoft Graph: `{ "value": [ ... ], "@odata.nextLink": "..." }`.
+- `400` kdyz hlavicky chybi, jsou neuplne, prosle nebo je spatny sdileny secret.
+- `502` kdyz Microsoft Graph nebo prihlaseni k Entra ID selze, detail je v tele odpovedi.
+- `503` kdyz sluzba nema nastaveny `MS365_CREDENTIAL_ENCODING_SECRET`.
+- `403 text/plain` kdyz GET neprojde pres reverzni proxy (IP allowlist), tj. problem
+ neni ve sluzbe, ale v sitovem pristupu klienta.
+
+Hlavicky lze vynechat jen tehdy, kdyz ma sluzba credentials nastavene
+v environment promennych na serveru. Instance na services.csbot.cz je nema
+(`GET /status` vraci `configured: false`), takze tam jsou hlavicky povinne.