Compare commits
15 Commits
8447e78db9
...
e9a1530120
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a1530120 | |||
| 9176ae7db9 | |||
| 7e8e228155 | |||
| 61d7ada945 | |||
| 188a8a7fff | |||
| 130386622f | |||
| 3ba574612f | |||
| 2280f12eab | |||
| 5e56a96cd0 | |||
| 1ffd71243e | |||
| 464b70ddcc | |||
| e6fdccf19c | |||
| 59ff5184ff | |||
| 5ccf1688ea | |||
| 5f59e339ee |
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
phase: 10
|
||||||
|
title: Branding Data Foundation
|
||||||
|
status: ready-for-planning
|
||||||
|
created: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Context: Branding Data Foundation
|
||||||
|
|
||||||
|
## Decided Areas (from prior research + STATE.md)
|
||||||
|
|
||||||
|
These are locked — do not re-litigate during planning or execution.
|
||||||
|
|
||||||
|
| Decision | Value |
|
||||||
|
|---|---|
|
||||||
|
| Logo storage format | Base64 strings in JSON (not file paths) |
|
||||||
|
| MSP logo location | `BrandingSettings.cs` model → `branding.json` |
|
||||||
|
| Client logo location | On `TenantProfile` model (per-tenant) |
|
||||||
|
| File path after import | Discarded — only base64 persists |
|
||||||
|
| SVG support | Rejected (XSS risk) — PNG/JPG only |
|
||||||
|
| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` |
|
||||||
|
| Directory auto-load | No — explicit "Load Directory" button required |
|
||||||
|
| New NuGet packages | None — existing stack covers everything |
|
||||||
|
| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods |
|
||||||
|
|
||||||
|
## Discussed Areas
|
||||||
|
|
||||||
|
### 1. Logo Metadata Model
|
||||||
|
|
||||||
|
**Decision:** Store base64 + MIME type as separate fields in a shared `LogoData` record.
|
||||||
|
|
||||||
|
- Shared model: `LogoData { string Base64, string MimeType }` — used by both MSP logo (in `BrandingSettings`) and client logo (on `TenantProfile`)
|
||||||
|
- `MimeType` is `"image/png"` or `"image/jpeg"`, determined at import time from magic bytes
|
||||||
|
- No other metadata stored — no original filename, dimensions, or import date
|
||||||
|
- Export services concatenate `data:{MimeType};base64,{Base64}` for HTML `<img>` tags
|
||||||
|
- WPF preview converts `Base64` bytes to `BitmapImage` directly
|
||||||
|
|
||||||
|
### 2. Logo Validation & Compression
|
||||||
|
|
||||||
|
**Decision:** Validate format via magic bytes, auto-compress oversized files silently.
|
||||||
|
|
||||||
|
- **Format detection:** Read file header magic bytes only — ignore file extension entirely
|
||||||
|
- PNG signature: `89 50 4E 47` (first 4 bytes)
|
||||||
|
- JPEG signature: `FF D8 FF` (first 3 bytes)
|
||||||
|
- Anything else → reject with specific error message (e.g., "File format is BMP, only PNG and JPG are accepted")
|
||||||
|
- **Size handling:** If file exceeds 512 KB, auto-compress silently (no user notification)
|
||||||
|
- Strategy: resize dimensions (e.g., max 300px width/height) + reduce quality
|
||||||
|
- Keep original format — PNG stays PNG, JPEG stays JPEG (no format conversion)
|
||||||
|
- Compress until under 512 KB
|
||||||
|
- **Dimension limits:** None — the 512 KB cap and compression handle naturally
|
||||||
|
- **Validation errors:** Specific messages for format rejection (format-related only, since size is auto-handled)
|
||||||
|
|
||||||
|
### 3. Profile Deletion & Duplication Behavior
|
||||||
|
|
||||||
|
**Decision:** Warn about logo loss on deletion; do NOT copy logo on duplication.
|
||||||
|
|
||||||
|
- **Deletion:** When deleting a tenant profile that has a client logo, the confirmation message explicitly mentions it: "This will also remove its client logo." The logo is embedded in the profile JSON, so deletion is automatic — no orphaned files.
|
||||||
|
- **Duplication:** Duplicating a tenant profile copies connection fields (Name, TenantUrl, ClientId) but starts with a blank client logo. The user must re-import or auto-pull for the new profile. Rationale: duplicated profiles are typically for different tenants, so the logo shouldn't carry over.
|
||||||
|
|
||||||
|
## Deferred Ideas (out of scope for Phase 10)
|
||||||
|
|
||||||
|
- Logo preview in Settings UI (Phase 12)
|
||||||
|
- Auto-pull client logo from Entra branding API (Phase 11/12)
|
||||||
|
- Report header layout with logos side-by-side (Phase 11)
|
||||||
|
- "Load Directory" button placement decision (Phase 14)
|
||||||
|
- Session-scoped directory cache (UDIR-F01, deferred)
|
||||||
|
|
||||||
|
## code_context
|
||||||
|
|
||||||
|
| Asset | Path | Reuse |
|
||||||
|
|---|---|---|
|
||||||
|
| TenantProfile model | `SharepointToolbox/Core/Models/TenantProfile.cs` | Extend with `LogoData? ClientLogo` property |
|
||||||
|
| AppSettings model | `SharepointToolbox/Core/Models/AppSettings.cs` | Reference for BrandingSettings pattern |
|
||||||
|
| SettingsRepository | `SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs` | Clone pattern for BrandingRepository (write-then-replace + SemaphoreSlim) |
|
||||||
|
| ProfileRepository | `SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs` | Already handles TenantProfile persistence — will serialize new logo field |
|
||||||
|
| GraphUserSearchService | `SharepointToolbox/Services/GraphUserSearchService.cs` | Reference for Graph SDK usage, auth, and query patterns |
|
||||||
|
| GraphClientFactory | `SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs` | Provides `GraphServiceClient` for new directory service |
|
||||||
|
| DI registration | `SharepointToolbox/App.xaml.cs` (lines 73-163) | Register new BrandingRepository, BrandingService, GraphUserDirectoryService |
|
||||||
|
| Profile deletion UI | `SharepointToolbox/ViewModels/ProfileManagementViewModel.cs` | Update deletion confirmation message to mention logo |
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Requirements: SharePoint Toolbox v2.2
|
||||||
|
|
||||||
|
**Defined:** 2026-04-08
|
||||||
|
**Core Value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
|
||||||
|
|
||||||
|
## v2.2 Requirements
|
||||||
|
|
||||||
|
Requirements for v2.2 Report Branding & User Directory. Each maps to roadmap phases.
|
||||||
|
|
||||||
|
### Report Branding
|
||||||
|
|
||||||
|
- [x] **BRAND-01**: User can import an MSP logo in application settings (global, persisted across sessions)
|
||||||
|
- [ ] **BRAND-02**: User can preview the imported MSP logo in settings UI
|
||||||
|
- [x] **BRAND-03**: User can import a client logo per tenant profile
|
||||||
|
- [ ] **BRAND-04**: User can auto-pull client logo from tenant's Entra branding API
|
||||||
|
- [ ] **BRAND-05**: All five HTML report types display MSP and client logos in a consistent header
|
||||||
|
- [x] **BRAND-06**: Logo import validates format (PNG/JPG) and enforces 512 KB size limit
|
||||||
|
|
||||||
|
### User Directory
|
||||||
|
|
||||||
|
- [ ] **UDIR-01**: User can toggle between search mode and directory browse mode in user access audit tab
|
||||||
|
- [ ] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
|
||||||
|
- [ ] **UDIR-03**: User can filter directory by user type (member vs guest)
|
||||||
|
- [ ] **UDIR-04**: User can see department and job title columns in directory list
|
||||||
|
- [ ] **UDIR-05**: User can select one or more users from directory to run the access audit
|
||||||
|
|
||||||
|
## Future Requirements
|
||||||
|
|
||||||
|
### Report Branding (Deferred)
|
||||||
|
|
||||||
|
- **BRAND-F01**: PDF export with embedded logos
|
||||||
|
- **BRAND-F02**: Custom report title/footer text per tenant
|
||||||
|
|
||||||
|
### User Directory (Deferred)
|
||||||
|
|
||||||
|
- **UDIR-F01**: Session-scoped directory cache (avoid re-fetching on tab switch)
|
||||||
|
- **UDIR-F02**: Export user directory list to CSV
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| CSV report branding | CSV is data-only format; logos don't apply |
|
||||||
|
| Logo in application title bar | Not a report branding concern; separate UX decision |
|
||||||
|
| User directory as standalone tab | Directory browse is a mode within existing user access audit tab |
|
||||||
|
| Real-time directory sync | One-time load with manual refresh is sufficient for audit workflows |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Which phases cover which requirements. Updated during roadmap creation.
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| BRAND-01 | Phase 10 | Complete |
|
||||||
|
| BRAND-03 | Phase 10 | Complete |
|
||||||
|
| BRAND-06 | Phase 10 | Complete |
|
||||||
|
| BRAND-05 | Phase 11 | Pending |
|
||||||
|
| BRAND-04 | Phase 11 | Pending |
|
||||||
|
| BRAND-02 | Phase 12 | Pending |
|
||||||
|
| UDIR-01 | Phase 13 | Pending |
|
||||||
|
| UDIR-02 | Phase 13 | Pending |
|
||||||
|
| UDIR-03 | Phase 13 | Pending |
|
||||||
|
| UDIR-04 | Phase 13 | Pending |
|
||||||
|
| UDIR-05 | Phase 14 | Pending |
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- v2.2 requirements: 11 total
|
||||||
|
- Mapped to phases: 11
|
||||||
|
- Unmapped: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
*Requirements defined: 2026-04-08*
|
||||||
|
*Last updated: 2026-04-08 after roadmap creation — all 11 requirements mapped to Phases 10-14*
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
|
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
|
||||||
- ✅ **v1.1 Enhanced Reports** — Phases 6-9 (shipped 2026-04-08) — [archive](milestones/v1.1-ROADMAP.md)
|
- ✅ **v1.1 Enhanced Reports** — Phases 6-9 (shipped 2026-04-08) — [archive](milestones/v1.1-ROADMAP.md)
|
||||||
|
- 🔄 **v2.2 Report Branding & User Directory** — Phases 10-14 (active)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -28,9 +29,84 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### v2.2 Report Branding & User Directory (Phases 10-14)
|
||||||
|
|
||||||
|
- [x] **Phase 10: Branding Data Foundation** — Models, repository, and services for logo storage and user directory enumeration (completed 2026-04-08)
|
||||||
|
- [ ] **Phase 11: HTML Export Branding + ViewModel Integration** — Inject logos into all 5 HTML report types; wire branding into export-triggering ViewModels and logo management commands
|
||||||
|
- [ ] **Phase 12: Branding UI Views** — Settings and profile dialog logo sections with live preview; auto-pull client logo from Entra branding API
|
||||||
|
- [ ] **Phase 13: User Directory ViewModel** — Browse mode state, paginated directory load, member/guest filter, and department/job title columns
|
||||||
|
- [ ] **Phase 14: User Directory View** — Toggle panel in UserAccessAuditView, user selection to trigger existing audit pipeline
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 10: Branding Data Foundation
|
||||||
|
**Goal**: The application can store, validate, and retrieve MSP and client logos as portable base64 strings in JSON, and can enumerate a full tenant user list with pagination.
|
||||||
|
**Depends on**: Nothing (additive to existing infrastructure)
|
||||||
|
**Requirements**: BRAND-01, BRAND-03, BRAND-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. An MSP logo imported as a PNG or JPG file is persisted as a base64 string in `branding.json` and survives an application restart
|
||||||
|
2. A client logo imported per tenant profile is persisted as a base64 string inside the tenant's profile JSON and is not affected by other tenants' profiles
|
||||||
|
3. A file larger than 512 KB or not a valid PNG/JPG is rejected at import time with an error; no invalid data reaches the JSON store
|
||||||
|
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
|
||||||
|
**Plans**: 3 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression
|
||||||
|
- [ ] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination
|
||||||
|
- [ ] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate
|
||||||
|
|
||||||
|
### Phase 11: HTML Export Branding + ViewModel Integration
|
||||||
|
**Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
|
||||||
|
**Depends on**: Phase 10
|
||||||
|
**Requirements**: BRAND-05, BRAND-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Running any of the five HTML exports (Permissions, Storage, Search, Duplicates, User Access) produces an HTML file whose header contains the MSP logo `<img>` tag when an MSP logo is configured
|
||||||
|
2. When a client logo is configured for the active tenant, the same HTML export header contains both the MSP logo and the client logo side by side
|
||||||
|
3. When no logo is configured, the HTML export header contains no broken image placeholder and the report renders identically to the pre-branding output
|
||||||
|
4. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View
|
||||||
|
5. Auto-pulling the client logo from the tenant's Entra branding API stores the logo in the tenant profile and falls back silently when no Entra branding is configured
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 12: Branding UI Views
|
||||||
|
**Goal**: Administrators can see, import, preview, and clear logos directly in the Settings and profile management dialogs.
|
||||||
|
**Depends on**: Phase 11
|
||||||
|
**Requirements**: BRAND-02, BRAND-04 (view layer for Entra pull)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Opening Settings shows the MSP logo section: an import button, a live thumbnail preview of the current logo, and a clear button that removes the logo immediately
|
||||||
|
2. Opening a tenant profile dialog shows the client logo section with the same import/preview/clear controls
|
||||||
|
3. Importing a logo via the UI shows the thumbnail preview without requiring an application restart
|
||||||
|
4. Clicking "Pull from Entra" in the profile dialog fetches and displays the tenant's banner logo if one exists, and shows a clear user-facing message if none is configured
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 13: User Directory ViewModel
|
||||||
|
**Goal**: The UserAccessAuditViewModel supports a full directory browse mode with paginated load, member/guest filtering, and department/job title display, fully testable without the View.
|
||||||
|
**Depends on**: Phase 10
|
||||||
|
**Requirements**: UDIR-01, UDIR-02, UDIR-03, UDIR-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `UserAccessAuditViewModel` exposes a toggle property that switches between Search mode (existing people-picker behavior) and Browse mode (directory list behavior), with no regression to Search mode behavior
|
||||||
|
2. Invoking the load-directory command fetches all enabled member users via `PageIterator`, updates a progress observable with the running user count, and supports cancellation mid-load
|
||||||
|
3. A "Members only / Include guests" toggle filters the displayed list in-memory without issuing a new Graph request
|
||||||
|
4. Each user row in the observable collection exposes DisplayName, UPN, Department, and JobTitle; Department and JobTitle columns are visible and sortable in the ViewModel's `ICollectionView`
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 14: User Directory View
|
||||||
|
**Goal**: Administrators can toggle into directory browse mode from the user access audit tab, see the paginated user list with filters, and launch an access audit for a selected user.
|
||||||
|
**Depends on**: Phase 13
|
||||||
|
**Requirements**: UDIR-05, UDIR-01 (view layer)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The user access audit tab shows a mode toggle control (e.g., radio buttons or segmented control) that visibly switches the left panel between the existing people-picker and the directory browse panel
|
||||||
|
2. In browse mode, selecting a user from the directory list and clicking Run Audit (or equivalent) launches the existing audit pipeline for that user, producing the same results as if the user had been found via search
|
||||||
|
3. While the directory is loading, the panel shows a "Loading... X users" counter and an active cancel button; the load button is disabled to prevent concurrent requests
|
||||||
|
4. When the directory load is cancelled or fails, the panel returns to a ready state with a clear status message and no broken UI
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans | Status | Completed |
|
| Phase | Milestone | Plans | Status | Completed |
|
||||||
|-------|-----------|-------|--------|-----------|
|
|-------|-----------|-------|--------|-----------|
|
||||||
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
||||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||||
|
| 10. Branding Data Foundation | 3/3 | Complete | 2026-04-08 | — |
|
||||||
|
| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/? | Not started | — |
|
||||||
|
| 12. Branding UI Views | v2.2 | 0/? | Not started | — |
|
||||||
|
| 13. User Directory ViewModel | v2.2 | 0/? | Not started | — |
|
||||||
|
| 14. User Directory View | v2.2 | 0/? | Not started | — |
|
||||||
|
|||||||
+40
-22
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v2.2
|
milestone: v2.2
|
||||||
milestone_name: v2.2 Report Branding & User Directory
|
milestone_name: Report Branding & User Directory
|
||||||
status: defining-requirements
|
status: planning
|
||||||
stopped_at: Defining requirements
|
stopped_at: Completed 10-branding-data-foundation/10-03-PLAN.md
|
||||||
last_updated: "2026-04-08T00:00:00Z"
|
last_updated: "2026-04-08T10:40:19.677Z"
|
||||||
last_activity: 2026-04-08 — Milestone v2.2 started
|
last_activity: 2026-04-08 — Roadmap created for v2.2
|
||||||
progress:
|
progress:
|
||||||
total_phases: 0
|
total_phases: 5
|
||||||
completed_phases: 0
|
completed_phases: 1
|
||||||
total_plans: 0
|
total_plans: 3
|
||||||
completed_plans: 0
|
completed_plans: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -20,17 +20,17 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-04-08)
|
See: .planning/PROJECT.md (updated 2026-04-08)
|
||||||
|
|
||||||
**Core value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
|
**Core value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
|
||||||
**Current focus:** v2.2 Report Branding & User Directory — HTML report logos, user directory browse mode
|
**Current focus:** v2.2 Report Branding & User Directory — HTML report logos (Phases 10-12), user directory browse mode (Phases 13-14)
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: Not started (defining requirements)
|
Phase: 10 (not started)
|
||||||
Plan: —
|
Plan: —
|
||||||
Status: Defining requirements
|
Status: Roadmap ready — awaiting phase planning
|
||||||
Last activity: 2026-04-08 — Milestone v2.2 started
|
Last activity: 2026-04-08 — Roadmap created for v2.2
|
||||||
|
|
||||||
```
|
```
|
||||||
v2.2 Progress: [░░░░░░░░░░] 0%
|
v2.2 Progress: [░░░░░░░░░░] 0% (0/5 phases)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
@@ -39,15 +39,32 @@ v2.2 Progress: [░░░░░░░░░░] 0%
|
|||||||
|
|
||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
Decisions are logged in PROJECT.md Key Decisions table.
|
||||||
|
|
||||||
**v1.1 architectural notes:**
|
**v2.2 architectural decisions (locked at roadmap):**
|
||||||
- Global site selection (Phase 6) changes the toolbar; all tabs must bind to a shared `GlobalSiteSelectionViewModel` or equivalent. Use `WeakReferenceMessenger` for cross-tab site-changed notifications, consistent with v1.0 messenger usage.
|
- Logos stored as base64 strings in JSON (not file paths). `BrandingSettings.cs` holds MSP logo; `TenantProfile` holds client logo. File path is discarded after import. This decision is locked — all downstream phases depend on it.
|
||||||
- Per-tab override (SITE-02) means each `FeatureViewModelBase` subclass stores a nullable local site override; null means "use global".
|
- Client logo lives on `TenantProfile`, NOT in `BrandingSettings`. Per-tenant ownership; prevents serialization and deletion awkwardness.
|
||||||
- Storage Visualization (Phase 9) requires a WPF charting NuGet (LiveCharts2 recommended — actively maintained, WPF-native, self-contained friendly). Wire chart data binding to the existing storage scan result model.
|
- Export services use optional `ReportBranding? branding = null` parameter. All existing call sites compile unchanged. No new `IHtmlExportService` interface needed.
|
||||||
- Self-contained EXE constraint: charting library must not require runtime DLLs outside the publish output.
|
- `GraphUserDirectoryService` is a new service, separate from `GraphUserSearchService`. Different pagination model (`PageIterator`), different cancellation needs.
|
||||||
|
- Directory does NOT load automatically on tab open. Explicit "Load Directory" button required to avoid blocking UI on large tenants.
|
||||||
|
- SVG logo support: rejected. XSS risk in data-URIs. PNG/JPG only.
|
||||||
|
- No new NuGet packages for v2.2. All capabilities provided by existing stack (BCL, Microsoft.Graph 5.74.0, WPF PresentationCore).
|
||||||
|
|
||||||
|
**v1.1 architectural notes (carried forward):**
|
||||||
|
- Global site selection (Phase 6) changes the toolbar; all tabs bind to shared `GlobalSiteSelectionViewModel`. `WeakReferenceMessenger` for cross-tab site-changed notifications.
|
||||||
|
- Per-tab override (SITE-02): each `FeatureViewModelBase` subclass stores a nullable local site override; null means "use global".
|
||||||
|
- Storage Visualization (Phase 9): LiveCharts2, WPF-native, self-contained friendly.
|
||||||
|
- [Phase 10-branding-data-foundation]: No ConsistencyLevel header on equality filter for GetUsersAsync (unlike GraphUserSearchService startsWith which requires it)
|
||||||
|
- [Phase 10-branding-data-foundation]: MapUser extracted as internal static in GraphUserDirectoryService for direct unit testability without live Graph endpoint
|
||||||
|
- [Phase 10-branding-data-foundation]: Type alias AppGraphClientFactory used in GraphUserDirectoryService to disambiguate from Microsoft.Graph.GraphClientFactory
|
||||||
|
- [Phase 10-branding-data-foundation]: Used WPF PresentationCore (BitmapDecoder/TransformedBitmap/JpegBitmapEncoder) for image compression instead of System.Drawing.Bitmap — System.Drawing.Common is not available without a new NuGet package on .NET 10, and WPF PresentationCore is already in the stack
|
||||||
|
- [Phase 10-branding-data-foundation]: LogoData is a non-positional record with init properties (not positional constructor) to avoid System.Text.Json deserialization failure
|
||||||
|
- [Phase 10-branding-data-foundation]: No new using statements required for Phase 10 DI registrations — SharepointToolbox.Infrastructure.Persistence and SharepointToolbox.Services were already imported
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
None.
|
- Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning.
|
||||||
|
- Verify Entra `bannerLogo` stream endpoint returns empty body (not HTTP 404) when no tenant branding is configured — determines error handling branch for BRAND-04 auto-pull.
|
||||||
|
- Decide report header layout before Phase 11: logos side-by-side (current spec: `display: flex; gap: 16px`, MSP left + client right).
|
||||||
|
- Decide "Load Directory" button placement before Phase 14: inside browse panel (recommended) or tab-level toolbar.
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
@@ -55,6 +72,7 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-08
|
Last session: 2026-04-08T10:36:58.959Z
|
||||||
Stopped at: Milestone v2.2 started — defining requirements
|
Stopped at: Completed 10-branding-data-foundation/10-03-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
Next step: `/gsd:plan-phase 10`
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/LogoData.cs
|
||||||
|
- SharepointToolbox/Core/Models/BrandingSettings.cs
|
||||||
|
- SharepointToolbox/Core/Models/TenantProfile.cs
|
||||||
|
- SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs
|
||||||
|
- SharepointToolbox/Services/IBrandingService.cs
|
||||||
|
- SharepointToolbox/Services/BrandingService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingServiceTests.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "An MSP logo imported as PNG or JPG is persisted as base64 in branding.json and survives round-trip"
|
||||||
|
- "A client logo imported per tenant profile is persisted as base64 inside the profile JSON"
|
||||||
|
- "A file that is not PNG/JPG (e.g., BMP) is rejected with a descriptive error message"
|
||||||
|
- "A file larger than 512 KB is silently compressed to fit under the limit"
|
||||||
|
- "A file under 512 KB is stored without modification"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
provides: "Shared logo record with Base64 and MimeType properties"
|
||||||
|
contains: "record LogoData"
|
||||||
|
- path: "SharepointToolbox/Core/Models/BrandingSettings.cs"
|
||||||
|
provides: "MSP logo wrapper model"
|
||||||
|
contains: "LogoData? MspLogo"
|
||||||
|
- path: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
||||||
|
provides: "Client logo property on existing profile model"
|
||||||
|
contains: "LogoData? ClientLogo"
|
||||||
|
- path: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
provides: "JSON persistence for BrandingSettings with write-then-replace"
|
||||||
|
contains: "SemaphoreSlim"
|
||||||
|
- path: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
provides: "Logo import with magic byte validation and auto-compression"
|
||||||
|
exports: ["ImportLogoAsync"]
|
||||||
|
- path: "SharepointToolbox.Tests/Services/BrandingServiceTests.cs"
|
||||||
|
provides: "Unit tests for validation, compression, rejection"
|
||||||
|
min_lines: 60
|
||||||
|
- path: "SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs"
|
||||||
|
provides: "Unit tests for repository round-trip"
|
||||||
|
min_lines: 30
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "BrandingRepository"
|
||||||
|
- from: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "return type"
|
||||||
|
pattern: "LogoData"
|
||||||
|
- from: "SharepointToolbox/Core/Models/BrandingSettings.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "property type"
|
||||||
|
pattern: "LogoData\\? MspLogo"
|
||||||
|
- from: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
||||||
|
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||||
|
via: "property type"
|
||||||
|
pattern: "LogoData\\? ClientLogo"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the logo storage infrastructure: models, repository, and branding service with validation/compression.
|
||||||
|
|
||||||
|
Purpose: BRAND-01, BRAND-03, BRAND-06 require models for logo data, a repository for MSP branding persistence, extension of TenantProfile for client logos, and a service that validates format (magic bytes) and auto-compresses oversized files.
|
||||||
|
|
||||||
|
Output: LogoData record, BrandingSettings model, TenantProfile extension, BrandingRepository, BrandingService (with IBrandingService interface), and comprehensive unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing patterns the executor needs to follow exactly. -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/AppSettings.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
public string DataFolder { get; set; } = string.Empty;
|
||||||
|
public string Lang { get; set; } = "en";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/TenantProfile.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class SettingsRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public SettingsRepository(string filePath) { _filePath = filePath; }
|
||||||
|
public async Task<AppSettings> LoadAsync() { /* File.ReadAllTextAsync + JsonSerializer.Deserialize */ }
|
||||||
|
public async Task SaveAsync(AppSettings settings) { /* SemaphoreSlim + write-tmp + validate round-trip + File.Move */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox.Tests/Services/SettingsServiceTests.cs (test pattern):
|
||||||
|
```csharp
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class SettingsServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempFile;
|
||||||
|
public SettingsServiceTests() { _tempFile = Path.GetTempFileName(); File.Delete(_tempFile); }
|
||||||
|
public void Dispose() { if (File.Exists(_tempFile)) File.Delete(_tempFile); if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); }
|
||||||
|
private SettingsRepository CreateRepository() => new(_tempFile);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create logo models, BrandingRepository, and repository tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/LogoData.cs,
|
||||||
|
SharepointToolbox/Core/Models/BrandingSettings.cs,
|
||||||
|
SharepointToolbox/Core/Models/TenantProfile.cs,
|
||||||
|
SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs,
|
||||||
|
SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: BrandingRepository.LoadAsync returns default BrandingSettings (MspLogo=null) when file does not exist
|
||||||
|
- Test 2: BrandingRepository round-trips BrandingSettings with a non-null MspLogo (Base64 + MimeType preserved)
|
||||||
|
- Test 3: BrandingRepository.SaveAsync creates directory if it does not exist
|
||||||
|
- Test 4: TenantProfile with ClientLogo serializes to JSON with camelCase "clientLogo" key and deserializes back correctly (use System.Text.Json directly)
|
||||||
|
- Test 5: TenantProfile without ClientLogo (null) serializes with clientLogo absent or null and deserializes with ClientLogo=null (forward-compatible)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `LogoData.cs` as a non-positional record with `{ get; init; }` properties (NOT positional constructor) to avoid System.Text.Json deserialization pitfall (see RESEARCH Pitfall 3):
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `BrandingSettings.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Extend `TenantProfile.cs` — add ONE property: `public LogoData? ClientLogo { get; set; }`. Do NOT remove or rename any existing properties. This is additive only. ProfileRepository needs no code change — System.Text.Json handles the new nullable property automatically.
|
||||||
|
|
||||||
|
4. Create `BrandingRepository.cs` as an exact structural clone of `SettingsRepository.cs`, substituting `BrandingSettings` for `AppSettings`. Same pattern: `SemaphoreSlim(1,1)`, `File.ReadAllTextAsync`, `JsonSerializer.Deserialize<BrandingSettings>`, write-then-replace with `.tmp` file, `JsonDocument.Parse` validation, `File.Move(overwrite: true)`. Use `PropertyNameCaseInsensitive = true` for Load and `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` + `WriteIndented = true` for Save. Same error handling (InvalidDataException for IO/JSON errors).
|
||||||
|
|
||||||
|
5. Write `BrandingRepositoryTests.cs` following the `SettingsServiceTests` pattern: `IDisposable`, `Path.GetTempFileName()`, cleanup of `.tmp` files, `[Trait("Category", "Unit")]`. Tests for TenantProfile serialization use `JsonSerializer` directly (no repository needed — just confirm the model serializes/deserializes with the new property).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>LogoData record, BrandingSettings model, TenantProfile.ClientLogo property, and BrandingRepository all exist. Repository round-trips BrandingSettings with MspLogo. TenantProfile with ClientLogo serializes correctly. All tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Create BrandingService with validation, compression, and tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Services/IBrandingService.cs,
|
||||||
|
SharepointToolbox/Services/BrandingService.cs,
|
||||||
|
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: ImportLogoAsync with valid PNG bytes (magic: 0x89,0x50,0x4E,0x47 + minimal valid content) returns LogoData with MimeType="image/png" and correct Base64
|
||||||
|
- Test 2: ImportLogoAsync with valid JPEG bytes (magic: 0xFF,0xD8,0xFF + minimal content) returns LogoData with MimeType="image/jpeg"
|
||||||
|
- Test 3: ImportLogoAsync with BMP bytes (magic: 0x42,0x4D) throws InvalidDataException with message containing "PNG" and "JPG"
|
||||||
|
- Test 4: ImportLogoAsync with empty file throws InvalidDataException
|
||||||
|
- Test 5: ImportLogoAsync with file under 512 KB returns Base64 matching original bytes exactly (no compression)
|
||||||
|
- Test 6: ImportLogoAsync with file over 512 KB returns LogoData where decoded bytes are <= 512 KB (compressed)
|
||||||
|
- Test 7: SaveMspLogoAsync calls BrandingRepository.SaveAsync with the logo set on BrandingSettings.MspLogo
|
||||||
|
- Test 8: ClearMspLogoAsync saves BrandingSettings with MspLogo=null
|
||||||
|
- Test 9: GetMspLogoAsync returns null when no logo is configured
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `IBrandingService.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
public interface IBrandingService
|
||||||
|
{
|
||||||
|
Task<LogoData> ImportLogoAsync(string filePath);
|
||||||
|
Task SaveMspLogoAsync(LogoData logo);
|
||||||
|
Task ClearMspLogoAsync();
|
||||||
|
Task<LogoData?> GetMspLogoAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Note: `ImportLogoAsync` is a pure validation+encoding function. It reads the file, validates magic bytes, compresses if needed, and returns `LogoData`. It does NOT persist anything. The caller (ViewModel in Phase 11) decides whether to save as MSP logo or client logo.
|
||||||
|
|
||||||
|
2. Create `BrandingService.cs`:
|
||||||
|
- Constructor takes `BrandingRepository` (same pattern as `SettingsService` taking `SettingsRepository`).
|
||||||
|
- `ImportLogoAsync(string filePath)`:
|
||||||
|
a. Read all bytes via `File.ReadAllBytesAsync`.
|
||||||
|
b. Detect MIME type from magic bytes: PNG signature `0x89,0x50,0x4E,0x47` (first 4 bytes), JPEG signature `0xFF,0xD8,0xFF` (first 3 bytes). If neither matches, throw `InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.")`.
|
||||||
|
c. If bytes.Length > 512 * 1024, call `CompressToLimit(bytes, mimeType, 512 * 1024)`.
|
||||||
|
d. Return `new LogoData { Base64 = Convert.ToBase64String(bytes), MimeType = mimeType }`.
|
||||||
|
- `CompressToLimit` private static method: Use `System.Drawing.Bitmap` to resize to max 300x300px (proportional scaling) and re-encode at quality 75. Use `System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()` to find the codec matching the MIME type. Use `EncoderParameters` with `Encoder.Quality` set to 75L. If still over limit after first pass, reduce to 200x200 and quality 50. Return the compressed bytes.
|
||||||
|
- `SaveMspLogoAsync(LogoData logo)`: Load settings from repo, set `MspLogo = logo`, save back.
|
||||||
|
- `ClearMspLogoAsync()`: Load settings, set `MspLogo = null`, save back.
|
||||||
|
- `GetMspLogoAsync()`: Load settings, return `MspLogo` (may be null).
|
||||||
|
|
||||||
|
3. Create `BrandingServiceTests.cs`:
|
||||||
|
- Use `[Trait("Category", "Unit")]` and `IDisposable` pattern.
|
||||||
|
- For magic byte tests: create small byte arrays with correct headers. For PNG, use the 8-byte PNG signature (`0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A`) followed by minimal IHDR+IEND chunks to make a valid 1x1 PNG. For JPEG, use `0xFF,0xD8,0xFF,0xE0` + minimal JFIF header + `0xFF,0xD9` (EOI). Write these to temp files and call `ImportLogoAsync`.
|
||||||
|
- For compression test: generate a valid PNG/JPEG that exceeds 512 KB (e.g., create a 400x400 bitmap filled with random pixels, save as PNG to a temp file, verify it exceeds 512 KB, then call `ImportLogoAsync` and verify result decodes to <= 512 KB).
|
||||||
|
- For SaveMspLogoAsync/ClearMspLogoAsync/GetMspLogoAsync: use real `BrandingRepository` with temp file (same pattern as `SettingsServiceTests`).
|
||||||
|
- Do NOT mock BrandingRepository — the existing test pattern in this codebase uses real file I/O with temp files.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>BrandingService validates PNG/JPG via magic bytes, rejects other formats with descriptive error, auto-compresses files over 512 KB, and provides MSP logo CRUD. All tests pass including round-trip through repository.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test --filter "FullyQualifiedName~Branding" --no-build
|
||||||
|
dotnet test --filter "FullyQualifiedName~ProfileService" --no-build
|
||||||
|
```
|
||||||
|
All three commands must succeed with zero failures. The ProfileServiceTests confirm TenantProfile changes do not break existing profile persistence.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- LogoData record exists with Base64 and MimeType init properties
|
||||||
|
- BrandingSettings class exists with nullable MspLogo property
|
||||||
|
- TenantProfile has nullable ClientLogo property (additive, no breaking changes)
|
||||||
|
- BrandingRepository persists BrandingSettings to JSON with write-then-replace safety
|
||||||
|
- BrandingService validates magic bytes (PNG/JPG only), auto-compresses > 512 KB, and provides MSP logo CRUD
|
||||||
|
- All existing tests continue to pass (no regressions from TenantProfile extension)
|
||||||
|
- New tests cover: repository round-trip, format validation, compression, rejection, CRUD
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 01
|
||||||
|
subsystem: branding
|
||||||
|
tags: [logo, base64, json-persistence, wpf-imaging, magic-bytes, compression]
|
||||||
|
|
||||||
|
requires: []
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- LogoData record (Base64 + MimeType init properties) — shared model for all logo storage
|
||||||
|
- BrandingSettings class with nullable MspLogo — MSP-level branding persistence model
|
||||||
|
- TenantProfile.ClientLogo property — per-tenant client logo (additive, no breaking changes)
|
||||||
|
- BrandingRepository — JSON persistence with write-then-replace safety using SemaphoreSlim
|
||||||
|
- IBrandingService / BrandingService — magic byte validation, auto-compression, MSP logo CRUD
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- 10-02 (branding UI ViewModel will consume IBrandingService)
|
||||||
|
- 11-report-branding (HTML export will use LogoData from BrandingSettings and TenantProfile)
|
||||||
|
- Phase 13-14 (TenantProfile extended — profile serialization must stay compatible)
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- BrandingRepository mirrors SettingsRepository exactly (SemaphoreSlim write-then-replace, JsonDocument validation)
|
||||||
|
- LogoData as non-positional record with init properties (avoids System.Text.Json positional constructor pitfall)
|
||||||
|
- BrandingService uses WPF PresentationCore (BitmapDecoder/TransformedBitmap/BitmapEncoder) for compression — no new NuGet package required
|
||||||
|
- Magic byte detection (4 bytes PNG, 3 bytes JPEG) before extension check — format is determined by content, not filename
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- SharepointToolbox/Core/Models/LogoData.cs
|
||||||
|
- SharepointToolbox/Core/Models/BrandingSettings.cs
|
||||||
|
- SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs
|
||||||
|
- SharepointToolbox/Services/IBrandingService.cs
|
||||||
|
- SharepointToolbox/Services/BrandingService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
|
||||||
|
- SharepointToolbox.Tests/Services/BrandingServiceTests.cs
|
||||||
|
modified:
|
||||||
|
- SharepointToolbox/Core/Models/TenantProfile.cs
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Used WPF PresentationCore (BitmapDecoder/TransformedBitmap/JpegBitmapEncoder) for image compression instead of System.Drawing.Bitmap — System.Drawing.Common is not available without a new NuGet package on .NET 10, but WPF PresentationCore is already in the stack (net10.0-windows + UseWPF=true)"
|
||||||
|
- "LogoData is a non-positional record (init properties, not constructor parameters) — prevents System.Text.Json deserialization failure on records with positional constructors"
|
||||||
|
- "BrandingService.ImportLogoAsync is pure (no persistence) — caller decides where to store the LogoData; ViewModel in Phase 11 will call SaveMspLogoAsync or equivalent client logo save"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Repository pattern: BrandingRepository is structural clone of SettingsRepository — same SemaphoreSlim(1,1) write lock, write-tmp-then-validate-then-move safety protocol"
|
||||||
|
- "Magic byte validation: PNG checked with 4 bytes (0x89 0x50 0x4E 0x47), JPEG with 3 bytes (0xFF 0xD8 0xFF) — content-based not extension-based"
|
||||||
|
- "Compression two-pass: 300x300 quality 75 first, 200x200 quality 50 if still over limit"
|
||||||
|
- "Test pattern: IDisposable + Path.GetTempFileName() + Dispose cleanup of .tmp files — matches existing SettingsServiceTests"
|
||||||
|
|
||||||
|
requirements-completed:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
duration: 4min
|
||||||
|
completed: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Plan 01: Branding Data Foundation Summary
|
||||||
|
|
||||||
|
**LogoData record + BrandingRepository (write-then-replace JSON) + BrandingService with PNG/JPEG magic byte validation and WPF-based auto-compression to 512 KB limit**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~4 min
|
||||||
|
- **Started:** 2026-04-08T00:28:31Z
|
||||||
|
- **Completed:** 2026-04-08T00:32:26Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 8 (7 created, 1 modified)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- LogoData record, BrandingSettings model, and TenantProfile.ClientLogo property established as the shared data models for all logo storage across v2.2
|
||||||
|
- BrandingRepository persists BrandingSettings to branding.json with write-then-replace safety (SemaphoreSlim + tmp file + JsonDocument validation before move)
|
||||||
|
- BrandingService validates PNG/JPEG via magic bytes, rejects all other formats with descriptive error message mentioning PNG and JPG, auto-compresses files over 512 KB using WPF imaging in two passes
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Create logo models, BrandingRepository, and repository tests** - `2280f12` (feat)
|
||||||
|
2. **Task 2: Create BrandingService with validation, compression, and tests** - `1303866` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `SharepointToolbox/Core/Models/LogoData.cs` - Non-positional record with Base64 and MimeType init properties
|
||||||
|
- `SharepointToolbox/Core/Models/BrandingSettings.cs` - MSP logo wrapper with nullable MspLogo property
|
||||||
|
- `SharepointToolbox/Core/Models/TenantProfile.cs` - Extended with nullable ClientLogo property (additive only)
|
||||||
|
- `SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs` - JSON persistence mirroring SettingsRepository pattern
|
||||||
|
- `SharepointToolbox/Services/IBrandingService.cs` - Interface with ImportLogoAsync, Save/Clear/GetMspLogoAsync
|
||||||
|
- `SharepointToolbox/Services/BrandingService.cs` - Magic byte validation, WPF-based compression, MSP logo CRUD
|
||||||
|
- `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` - 5 tests: defaults, round-trip, dir creation, TenantProfile serialization
|
||||||
|
- `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` - 9 tests: PNG/JPEG acceptance, BMP rejection, empty file, no-compression, compression, CRUD
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Used WPF PresentationCore imaging (BitmapDecoder, TransformedBitmap, JpegBitmapEncoder) for compression — `System.Drawing.Common` is not available without a new NuGet package on .NET 10 and is not in the existing stack
|
||||||
|
- `ImportLogoAsync` is kept pure (no persistence side-effects) — caller decides where to store the returned `LogoData`, enabling reuse for both MSP logo and per-tenant client logo paths
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Used WPF PresentationCore instead of System.Drawing.Bitmap for compression**
|
||||||
|
- **Found during:** Task 2 (BrandingService implementation)
|
||||||
|
- **Issue:** Plan specified `System.Drawing.Bitmap` and `ImageCodecInfo`, but `System.Drawing.Common` is not in the project's package list and is not available on .NET 10 without an explicit NuGet package reference. Adding it would violate the v2.2 constraint ("No new NuGet packages")
|
||||||
|
- **Fix:** Implemented compression using `System.Windows.Media.Imaging` classes (BitmapDecoder, TransformedBitmap, JpegBitmapEncoder, PngBitmapEncoder) — fully available via WPF PresentationCore which is already in the stack
|
||||||
|
- **Files modified:** SharepointToolbox/Services/BrandingService.cs
|
||||||
|
- **Verification:** All 9 BrandingServiceTests pass including the compression test (400x400 random-pixel PNG over 512 KB compressed to under 512 KB)
|
||||||
|
- **Committed in:** 1303866 (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 — implementation approach)
|
||||||
|
**Impact on plan:** No scope change. Compression behavior is identical: proportional resize to 300x300 at quality 75, then 200x200 at quality 50 if still over limit. WPF APIs provide the same capability without a new dependency.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None — build and all tests passed first time after implementation.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- All logo storage models and infrastructure are ready for Phase 10 Plan 02 (branding UI ViewModel)
|
||||||
|
- BrandingService.ImportLogoAsync is the entry point for logo import flows in Phase 11
|
||||||
|
- TenantProfile.ClientLogo is ready; ProfileRepository requires no code changes (System.Text.Json handles the new nullable property automatically)
|
||||||
|
- 14 total Branding tests passing; 10 ProfileService tests confirm no regression from TenantProfile extension
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 10-branding-data-foundation*
|
||||||
|
*Completed: 2026-04-08*
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/GraphDirectoryUser.cs
|
||||||
|
- SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox/Services/GraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GetUsersAsync returns all enabled member users following @odata.nextLink until exhausted"
|
||||||
|
- "GetUsersAsync respects CancellationToken and stops iteration when cancelled"
|
||||||
|
- "Each returned user includes DisplayName, UserPrincipalName, Mail, Department, and JobTitle"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
|
||||||
|
provides: "Result record for directory enumeration"
|
||||||
|
contains: "record GraphDirectoryUser"
|
||||||
|
- path: "SharepointToolbox/Services/IGraphUserDirectoryService.cs"
|
||||||
|
provides: "Interface for directory enumeration"
|
||||||
|
exports: ["GetUsersAsync"]
|
||||||
|
- path: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
provides: "PageIterator-based Graph user enumeration"
|
||||||
|
contains: "PageIterator"
|
||||||
|
- path: "SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs"
|
||||||
|
provides: "Unit tests for directory service"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "GraphClientFactory"
|
||||||
|
- from: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
to: "Microsoft.Graph PageIterator"
|
||||||
|
via: "SDK pagination"
|
||||||
|
pattern: "PageIterator<User, UserCollectionResponse>"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the Graph user directory service for paginated tenant user enumeration.
|
||||||
|
|
||||||
|
Purpose: Phase 13 (User Directory ViewModel) needs a service that enumerates all enabled member users from a tenant via Microsoft Graph with pagination. This plan builds the infrastructure service and its tests.
|
||||||
|
|
||||||
|
Output: GraphDirectoryUser model, IGraphUserDirectoryService interface, GraphUserDirectoryService implementation with PageIterator, and unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing Graph service pattern to follow. -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/IGraphUserSearchService.cs:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public interface IGraphUserSearchService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
string query,
|
||||||
|
int maxResults = 10,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/GraphUserSearchService.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphUserSearchService : IGraphUserSearchService
|
||||||
|
{
|
||||||
|
private readonly GraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
|
public GraphUserSearchService(GraphClientFactory graphClientFactory)
|
||||||
|
{
|
||||||
|
_graphClientFactory = graphClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
|
||||||
|
string clientId, string query, int maxResults = 10, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
var response = await graphClient.Users.GetAsync(config =>
|
||||||
|
{
|
||||||
|
config.QueryParameters.Filter = $"startsWith(displayName,'{escapedQuery}')...";
|
||||||
|
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" };
|
||||||
|
config.QueryParameters.Top = maxResults;
|
||||||
|
config.Headers.Add("ConsistencyLevel", "eventual");
|
||||||
|
config.QueryParameters.Count = true;
|
||||||
|
}, ct);
|
||||||
|
// ...map response.Value to GraphUserResult list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
private readonly MsalClientFactory _msalFactory;
|
||||||
|
public GraphClientFactory(MsalClientFactory msalFactory) { _msalFactory = msalFactory; }
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct) { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create GraphDirectoryUser model and IGraphUserDirectoryService interface</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/GraphDirectoryUser.cs,
|
||||||
|
SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- GraphDirectoryUser is a positional record with DisplayName (string), UserPrincipalName (string), Mail (string?), Department (string?), JobTitle (string?)
|
||||||
|
- IGraphUserDirectoryService declares GetUsersAsync(string clientId, IProgress<int>? progress, CancellationToken ct) returning Task<IReadOnlyList<GraphDirectoryUser>>
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `GraphDirectoryUser.cs` in `Core/Models/`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle);
|
||||||
|
```
|
||||||
|
This is a positional record (fine here since it's never JSON-deserialized — it's only constructed in code from Graph SDK User objects).
|
||||||
|
|
||||||
|
2. Create `IGraphUserDirectoryService.cs` in `Services/`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
public interface IGraphUserDirectoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
IProgress<int>? progress = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `IProgress<int>` parameter reports the running count of users fetched so far — Phase 13's ViewModel will use this to show "Loading... X users" feedback. It's optional (null = no reporting).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||||
|
</verify>
|
||||||
|
<done>GraphDirectoryUser record and IGraphUserDirectoryService interface exist and compile without warnings.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Implement GraphUserDirectoryService with PageIterator and tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Services/GraphUserDirectoryService.cs,
|
||||||
|
SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: GetUsersAsync with mocked GraphClientFactory returns mapped GraphDirectoryUser records with all 5 fields
|
||||||
|
- Test 2: GetUsersAsync reports progress via IProgress<int> with incrementing user count
|
||||||
|
- Test 3: GetUsersAsync with cancelled token throws OperationCanceledException or returns partial results
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `GraphUserDirectoryService.cs`:
|
||||||
|
- Constructor takes `GraphClientFactory` (same pattern as `GraphUserSearchService`).
|
||||||
|
- `GetUsersAsync` implementation:
|
||||||
|
a. Get `GraphServiceClient` via `_graphClientFactory.CreateClientAsync(clientId, ct)`.
|
||||||
|
b. Call `graphClient.Users.GetAsync(config => { ... }, ct)` with:
|
||||||
|
- `config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'"` — standard equality filter, does NOT require ConsistencyLevel: eventual (unlike GraphUserSearchService which uses startsWith). Do NOT add ConsistencyLevel header. Do NOT add $count.
|
||||||
|
- `config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" }`
|
||||||
|
- `config.QueryParameters.Top = 999`
|
||||||
|
c. If response is null, return empty list.
|
||||||
|
d. Create `PageIterator<User, UserCollectionResponse>.CreatePageIterator(graphClient, response, callback)`.
|
||||||
|
e. In the callback:
|
||||||
|
- Check `ct.IsCancellationRequested` — if true, `return false` to stop iteration (see RESEARCH Pitfall 2).
|
||||||
|
- Map User to GraphDirectoryUser: `new GraphDirectoryUser(user.DisplayName ?? user.UserPrincipalName ?? string.Empty, user.UserPrincipalName ?? string.Empty, user.Mail, user.Department, user.JobTitle)`.
|
||||||
|
- Add to results list.
|
||||||
|
- Report progress: `progress?.Report(results.Count)`.
|
||||||
|
- Return true to continue.
|
||||||
|
f. Call `await pageIterator.IterateAsync(ct)`.
|
||||||
|
g. Return results as `IReadOnlyList<GraphDirectoryUser>`.
|
||||||
|
- Add a comment on the filter line: `// Pending real-tenant verification — see STATE.md pending todos`
|
||||||
|
|
||||||
|
2. Create `GraphUserDirectoryServiceTests.cs`:
|
||||||
|
- Use `[Trait("Category", "Unit")]`.
|
||||||
|
- Testing PageIterator with mocks is complex because `PageIterator` requires a real `GraphServiceClient`. Instead, test at a higher level:
|
||||||
|
a. Create a mock `GraphClientFactory` using Moq that returns a mock `GraphServiceClient`.
|
||||||
|
b. For the basic mapping test: mock `graphClient.Users.GetAsync()` to return a `UserCollectionResponse` with a list of test `User` objects (no `@odata.nextLink` = single page). Verify the returned `GraphDirectoryUser` list has correct field mapping.
|
||||||
|
c. For the progress test: same setup, verify `IProgress<int>.Report` is called with incrementing counts.
|
||||||
|
d. For cancellation: use a pre-cancelled `CancellationTokenSource`. The `GetAsync` call should throw `OperationCanceledException` or the callback should detect cancellation.
|
||||||
|
- If mocking `GraphServiceClient.Users.GetAsync` proves too complex with the Graph SDK's request builder pattern, mark the test with `[Fact(Skip = "Requires integration test with real Graph client")]` and add a comment explaining why. The critical thing is the test FILE exists with the intent documented.
|
||||||
|
- Focus on what IS testable without a real Graph endpoint: the mapping logic. Consider extracting a static `MapUser(User user)` method and testing that directly.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>GraphUserDirectoryService exists with PageIterator pagination, cancellation support via callback check, progress reporting, and correct filter (no ConsistencyLevel). Tests verify mapping logic and exist for pagination/cancellation scenarios.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test --filter "FullyQualifiedName~GraphUserDirectoryService" --no-build
|
||||||
|
```
|
||||||
|
Both commands must succeed. No warnings, no test failures.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- GraphDirectoryUser record has all 5 fields (DisplayName, UPN, Mail, Department, JobTitle)
|
||||||
|
- IGraphUserDirectoryService interface declares GetUsersAsync with clientId, progress, and cancellation
|
||||||
|
- GraphUserDirectoryService uses PageIterator for pagination, checks cancellation in callback, reports progress
|
||||||
|
- Filter is "accountEnabled eq true and userType eq 'Member'" WITHOUT ConsistencyLevel header
|
||||||
|
- Tests exist and pass for mapping logic; pagination/cancellation tests are either passing or skipped with clear justification
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: "02"
|
||||||
|
subsystem: api
|
||||||
|
tags: [microsoft-graph, graph-sdk, pagination, page-iterator, csharp, directory-service]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 10-branding-data-foundation-01
|
||||||
|
provides: "GraphClientFactory (existing) and project infrastructure"
|
||||||
|
provides:
|
||||||
|
- "GraphDirectoryUser record (DisplayName, UPN, Mail, Department, JobTitle)"
|
||||||
|
- "IGraphUserDirectoryService interface with GetUsersAsync(clientId, progress, ct)"
|
||||||
|
- "GraphUserDirectoryService implementation with PageIterator-based pagination"
|
||||||
|
- "MapUser static method testable without live Graph endpoint"
|
||||||
|
- "GraphUserDirectoryServiceTests with 5 unit tests for mapping logic"
|
||||||
|
affects:
|
||||||
|
- phase-13-user-directory-viewmodel
|
||||||
|
- phase-14-user-directory-ui
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "PageIterator<User, UserCollectionResponse> for multi-page Graph enumeration"
|
||||||
|
- "Cancellation-in-callback pattern: callback returns false when ct.IsCancellationRequested"
|
||||||
|
- "IProgress<int> reporting running count for ViewModel loading feedback"
|
||||||
|
- "AppGraphClientFactory alias to disambiguate SharepointToolbox.Infrastructure.Auth.GraphClientFactory from Microsoft.Graph.GraphClientFactory"
|
||||||
|
- "Extract MapUser as internal static for direct unit testability without live Graph"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- SharepointToolbox/Core/Models/GraphDirectoryUser.cs
|
||||||
|
- SharepointToolbox/Services/IGraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox/Services/GraphUserDirectoryService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs
|
||||||
|
modified: []
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "No ConsistencyLevel: eventual header on the directory filter (accountEnabled eq true and userType eq 'Member') — standard equality filter does not require it, unlike startsWith queries in GraphUserSearchService"
|
||||||
|
- "MapUser extracted as internal static method to decouple mapping logic from PageIterator, enabling direct unit tests without a live Graph client"
|
||||||
|
- "Integration tests for pagination/cancellation skipped with documented rationale — PageIterator uses internal GraphServiceClient internals not mockable via Moq"
|
||||||
|
- "Type alias AppGraphClientFactory used to resolve ambiguity with Microsoft.Graph.GraphClientFactory in the same namespace"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "IProgress<int> optional progress pattern: pass null for no reporting, non-null for ViewModel loading UX"
|
||||||
|
- "PageIterator cancellation: check ct.IsCancellationRequested inside callback, return false to stop"
|
||||||
|
|
||||||
|
requirements-completed:
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 4min
|
||||||
|
completed: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Plan 02: Graph User Directory Service Summary
|
||||||
|
|
||||||
|
**Graph SDK PageIterator service for full-tenant member enumeration with cancellation, progress reporting, and 5-field user mapping**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 4 min
|
||||||
|
- **Started:** 2026-04-08T10:28:36Z
|
||||||
|
- **Completed:** 2026-04-08T10:32:20Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4 created
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- GraphDirectoryUser record with all 5 required fields (DisplayName, UPN, Mail, Department, JobTitle)
|
||||||
|
- IGraphUserDirectoryService interface with IProgress<int> optional parameter for loading feedback
|
||||||
|
- GraphUserDirectoryService using PageIterator for transparent multi-page Graph enumeration with callback-based cancellation
|
||||||
|
- 5 unit tests covering all MapUser field-mapping scenarios including null fallback chains
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Create GraphDirectoryUser model and IGraphUserDirectoryService interface** - `5e56a96` (feat)
|
||||||
|
2. **Task 2: Implement GraphUserDirectoryService with PageIterator and tests** - `3ba5746` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `SharepointToolbox/Core/Models/GraphDirectoryUser.cs` - Positional record with 5 fields for directory enumeration results
|
||||||
|
- `SharepointToolbox/Services/IGraphUserDirectoryService.cs` - Interface with GetUsersAsync(clientId, IProgress<int>?, CancellationToken)
|
||||||
|
- `SharepointToolbox/Services/GraphUserDirectoryService.cs` - PageIterator implementation, cancellation in callback, progress reporting, no ConsistencyLevel header
|
||||||
|
- `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` - 5 MapUser unit tests + 4 integration tests skipped with documented rationale
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- No ConsistencyLevel header on the equality filter (different from GraphUserSearchService which uses startsWith and requires eventual consistency)
|
||||||
|
- MapUser extracted as internal static to allow direct unit testing of mapping logic without requiring PageIterator and a live Graph client
|
||||||
|
- Integration-level tests for pagination/cancellation documented as skipped: PageIterator's internal request execution is not mockable via Moq without a real GraphServiceClient
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Resolved ambiguous GraphClientFactory reference**
|
||||||
|
- **Found during:** Task 2 (GraphUserDirectoryService implementation)
|
||||||
|
- **Issue:** `using Microsoft.Graph;` combined with `using SharepointToolbox.Infrastructure.Auth;` created an ambiguous reference — both namespaces define `GraphClientFactory`. Build error CS0104.
|
||||||
|
- **Fix:** Added type alias `using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;` and removed the generic using for the auth namespace.
|
||||||
|
- **Files modified:** `SharepointToolbox/Services/GraphUserDirectoryService.cs`
|
||||||
|
- **Verification:** `dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -warnaserror` succeeds with 0 warnings, 0 errors.
|
||||||
|
- **Committed in:** `3ba5746` (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (Rule 1 - Bug)
|
||||||
|
**Impact on plan:** Fix necessary for compilation. No scope creep.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- Pre-existing `BrandingServiceTests.cs` (untracked) references `BrandingService` types not yet created (awaiting full plan 10-01 execution). This prevented `dotnet test` from running after rebuilding the test project. Tests were verified to compile via direct inspection; main project builds with zero warnings. Logged in `deferred-items.md`. Will be resolved when plan 10-01 is fully executed.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- GraphUserDirectoryService is ready for injection into Phase 13's User Directory ViewModel
|
||||||
|
- IProgress<int> parameter provides the running count hook Phase 13 needs for "Loading... X users" UX
|
||||||
|
- Pending real-tenant verification of the filter (noted in STATE.md and code comment)
|
||||||
|
- BrandingService (plan 10-01 remainder) must be completed to restore test project compilation
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 10-branding-data-foundation*
|
||||||
|
*Completed: 2026-04-08*
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 10-01
|
||||||
|
- 10-02
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/App.xaml.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "BrandingRepository, BrandingService, and GraphUserDirectoryService are resolved by DI without runtime errors"
|
||||||
|
- "The full test suite passes including all new and existing tests"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/App.xaml.cs"
|
||||||
|
provides: "DI registration for Phase 10 services"
|
||||||
|
contains: "BrandingRepository"
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
|
||||||
|
via: "AddSingleton registration"
|
||||||
|
pattern: "BrandingRepository.*branding\\.json"
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Services/BrandingService.cs"
|
||||||
|
via: "AddSingleton registration"
|
||||||
|
pattern: "AddSingleton<BrandingService>"
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "SharepointToolbox/Services/GraphUserDirectoryService.cs"
|
||||||
|
via: "AddTransient registration"
|
||||||
|
pattern: "IGraphUserDirectoryService.*GraphUserDirectoryService"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Register all Phase 10 services in the DI container and run the full test suite to confirm no regressions.
|
||||||
|
|
||||||
|
Purpose: Without DI registration, none of the new services are available at runtime. This plan wires BrandingRepository, BrandingService, and GraphUserDirectoryService into App.xaml.cs following established patterns.
|
||||||
|
|
||||||
|
Output: Updated App.xaml.cs with Phase 10 DI registrations. Full test suite green.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-01-SUMMARY.md
|
||||||
|
@.planning/phases/10-branding-data-foundation/10-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- DI registration pattern from App.xaml.cs (lines 73-163). -->
|
||||||
|
|
||||||
|
From SharepointToolbox/App.xaml.cs:
|
||||||
|
```csharp
|
||||||
|
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
|
||||||
|
{
|
||||||
|
var appData = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"SharepointToolbox");
|
||||||
|
services.AddSingleton(_ => new ProfileRepository(Path.Combine(appData, "profiles.json")));
|
||||||
|
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
|
||||||
|
services.AddSingleton<MsalClientFactory>();
|
||||||
|
services.AddSingleton<SessionManager>();
|
||||||
|
// ... more registrations ...
|
||||||
|
services.AddSingleton<GraphClientFactory>();
|
||||||
|
// ... more registrations ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From 10-RESEARCH.md Pattern 7:
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<IBrandingService, BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Register Phase 10 services in DI and run full test suite</name>
|
||||||
|
<files>SharepointToolbox/App.xaml.cs</files>
|
||||||
|
<action>
|
||||||
|
1. Open `SharepointToolbox/App.xaml.cs` and locate the `RegisterServices` method.
|
||||||
|
|
||||||
|
2. Add a new section comment and three registrations AFTER the existing `SettingsRepository` registration (around line 79) and BEFORE the `MsalClientFactory` line. Place them logically with the other repository/service registrations:
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<IBrandingService, BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the necessary `using` statements at the top of the file if not already present:
|
||||||
|
- `using SharepointToolbox.Infrastructure.Persistence;` (likely already present for ProfileRepository/SettingsRepository)
|
||||||
|
- `using SharepointToolbox.Services;` (likely already present for other service registrations)
|
||||||
|
|
||||||
|
4. Rationale for lifetimes per RESEARCH:
|
||||||
|
- `BrandingRepository`: Singleton — single file, shared SemaphoreSlim lock (same as ProfileRepository and SettingsRepository).
|
||||||
|
- `BrandingService` (as `IBrandingService`): Singleton — stateless after construction, depends on singleton repository.
|
||||||
|
- `GraphUserDirectoryService` (as `IGraphUserDirectoryService`): Transient — stateless, per-call usage, different tenants.
|
||||||
|
|
||||||
|
5. Build and run the full test suite to confirm zero regressions:
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore -warnaserror && dotnet test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>App.xaml.cs has Phase 10 DI registrations. Full build succeeds with zero warnings. Full test suite passes with zero failures.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
dotnet build --no-restore -warnaserror
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
Both must succeed. Zero warnings, zero test failures. This is the phase gate.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- App.xaml.cs registers BrandingRepository (Singleton, branding.json), IBrandingService/BrandingService (Singleton), IGraphUserDirectoryService/GraphUserDirectoryService (Transient)
|
||||||
|
- Full build passes with -warnaserror
|
||||||
|
- Full test suite passes (all existing + all new tests)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/10-branding-data-foundation/10-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
plan: "03"
|
||||||
|
subsystem: infra
|
||||||
|
tags: [di, dependency-injection, ioc-container, branding, graph-directory, wpf]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 10-branding-data-foundation-01
|
||||||
|
provides: "BrandingRepository, IBrandingService/BrandingService"
|
||||||
|
- phase: 10-branding-data-foundation-02
|
||||||
|
provides: "IGraphUserDirectoryService/GraphUserDirectoryService"
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- "BrandingRepository registered as Singleton in DI (branding.json path)"
|
||||||
|
- "IBrandingService/BrandingService registered as Singleton in DI"
|
||||||
|
- "IGraphUserDirectoryService/GraphUserDirectoryService registered as Transient in DI"
|
||||||
|
- "Phase 10 services fully wired — resolvable at runtime"
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- phase-11-report-branding
|
||||||
|
- phase-13-user-directory-viewmodel
|
||||||
|
- phase-14-user-directory-ui
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Phase 10 DI block placed after SettingsRepository, before MsalClientFactory — grouped with other repository/infrastructure singletons"
|
||||||
|
- "BrandingRepository: Singleton lifetime matching ProfileRepository/SettingsRepository (single file, shared SemaphoreSlim)"
|
||||||
|
- "IBrandingService: Singleton lifetime — stateless after construction, depends on singleton BrandingRepository"
|
||||||
|
- "IGraphUserDirectoryService: Transient lifetime — stateless, per-call, designed for multiple-tenant scenarios"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- SharepointToolbox/App.xaml.cs
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "No new using statements required — SharepointToolbox.Infrastructure.Persistence and SharepointToolbox.Services were already imported from prior phases"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Phase section comment pattern: each new phase block labeled with '// Phase N: Name' comment for orientation in RegisterServices"
|
||||||
|
|
||||||
|
requirements-completed:
|
||||||
|
- BRAND-01
|
||||||
|
- BRAND-03
|
||||||
|
- BRAND-06
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 5min
|
||||||
|
completed: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Plan 03: DI Registration Summary
|
||||||
|
|
||||||
|
**BrandingRepository (Singleton), IBrandingService (Singleton), and IGraphUserDirectoryService (Transient) wired into App.xaml.cs — 224 tests pass, zero regressions**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~5 min
|
||||||
|
- **Started:** 2026-04-08T10:34:43Z
|
||||||
|
- **Completed:** 2026-04-08T10:39:00Z
|
||||||
|
- **Tasks:** 1
|
||||||
|
- **Files modified:** 1
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- All three Phase 10 services registered in the application's DI container with correct lifetimes
|
||||||
|
- Main project builds with zero warnings under `-warnaserror`
|
||||||
|
- Full test suite: 224 passed, 26 skipped (integration tests requiring live Graph), 0 failed
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Register Phase 10 services in DI and run full test suite** - `7e8e228` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `SharepointToolbox/App.xaml.cs` - Added Phase 10 DI block: BrandingRepository (Singleton, branding.json), IBrandingService/BrandingService (Singleton), IGraphUserDirectoryService/GraphUserDirectoryService (Transient)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
None - followed plan as specified. The `using` directives for `SharepointToolbox.Infrastructure.Persistence` and `SharepointToolbox.Services` were already present, so no additional imports were needed.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
One flaky test failure (`CanExport_true_when_has_results`) occurred during the first full suite run. This test uses `WeakReferenceMessenger` with async ViewModel operations and is timing-sensitive. Re-running the specific test and then the full suite both passed. The failure was not caused by my DI changes (the test uses direct constructor injection with mocks — no DI container involved). The test passed on all subsequent runs.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- All Phase 10 services resolve at runtime without errors
|
||||||
|
- Phase 11 (report branding) can inject `IBrandingService` into export services and ViewModels
|
||||||
|
- Phase 13 (user directory ViewModel) can inject `IGraphUserDirectoryService`
|
||||||
|
- BrandingRepository will create `branding.json` on first write, in the existing AppData directory — no manual setup needed
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 10-branding-data-foundation*
|
||||||
|
*Completed: 2026-04-08*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: SharepointToolbox/App.xaml.cs (with Phase 10 registrations)
|
||||||
|
- FOUND: .planning/phases/10-branding-data-foundation/10-03-SUMMARY.md
|
||||||
|
- FOUND commit: 7e8e228 (feat(10-03): register Phase 10 services in DI container)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
phase: 10
|
||||||
|
title: Branding Data Foundation
|
||||||
|
status: ready-for-planning
|
||||||
|
created: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Context: Branding Data Foundation
|
||||||
|
|
||||||
|
## Decided Areas (from prior research + STATE.md)
|
||||||
|
|
||||||
|
These are locked — do not re-litigate during planning or execution.
|
||||||
|
|
||||||
|
| Decision | Value |
|
||||||
|
|---|---|
|
||||||
|
| Logo storage format | Base64 strings in JSON (not file paths) |
|
||||||
|
| MSP logo location | `BrandingSettings.cs` model → `branding.json` |
|
||||||
|
| Client logo location | On `TenantProfile` model (per-tenant) |
|
||||||
|
| File path after import | Discarded — only base64 persists |
|
||||||
|
| SVG support | Rejected (XSS risk) — PNG/JPG only |
|
||||||
|
| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` |
|
||||||
|
| Directory auto-load | No — explicit "Load Directory" button required |
|
||||||
|
| New NuGet packages | None — existing stack covers everything |
|
||||||
|
| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods |
|
||||||
|
|
||||||
|
## Discussed Areas
|
||||||
|
|
||||||
|
### 1. Logo Metadata Model
|
||||||
|
|
||||||
|
**Decision:** Store base64 + MIME type as separate fields in a shared `LogoData` record.
|
||||||
|
|
||||||
|
- Shared model: `LogoData { string Base64, string MimeType }` — used by both MSP logo (in `BrandingSettings`) and client logo (on `TenantProfile`)
|
||||||
|
- `MimeType` is `"image/png"` or `"image/jpeg"`, determined at import time from magic bytes
|
||||||
|
- No other metadata stored — no original filename, dimensions, or import date
|
||||||
|
- Export services concatenate `data:{MimeType};base64,{Base64}` for HTML `<img>` tags
|
||||||
|
- WPF preview converts `Base64` bytes to `BitmapImage` directly
|
||||||
|
|
||||||
|
### 2. Logo Validation & Compression
|
||||||
|
|
||||||
|
**Decision:** Validate format via magic bytes, auto-compress oversized files silently.
|
||||||
|
|
||||||
|
- **Format detection:** Read file header magic bytes only — ignore file extension entirely
|
||||||
|
- PNG signature: `89 50 4E 47` (first 4 bytes)
|
||||||
|
- JPEG signature: `FF D8 FF` (first 3 bytes)
|
||||||
|
- Anything else → reject with specific error message (e.g., "File format is BMP, only PNG and JPG are accepted")
|
||||||
|
- **Size handling:** If file exceeds 512 KB, auto-compress silently (no user notification)
|
||||||
|
- Strategy: resize dimensions (e.g., max 300px width/height) + reduce quality
|
||||||
|
- Keep original format — PNG stays PNG, JPEG stays JPEG (no format conversion)
|
||||||
|
- Compress until under 512 KB
|
||||||
|
- **Dimension limits:** None — the 512 KB cap and compression handle naturally
|
||||||
|
- **Validation errors:** Specific messages for format rejection (format-related only, since size is auto-handled)
|
||||||
|
|
||||||
|
### 3. Profile Deletion & Duplication Behavior
|
||||||
|
|
||||||
|
**Decision:** Warn about logo loss on deletion; do NOT copy logo on duplication.
|
||||||
|
|
||||||
|
- **Deletion:** When deleting a tenant profile that has a client logo, the confirmation message explicitly mentions it: "This will also remove its client logo." The logo is embedded in the profile JSON, so deletion is automatic — no orphaned files.
|
||||||
|
- **Duplication:** Duplicating a tenant profile copies connection fields (Name, TenantUrl, ClientId) but starts with a blank client logo. The user must re-import or auto-pull for the new profile. Rationale: duplicated profiles are typically for different tenants, so the logo shouldn't carry over.
|
||||||
|
|
||||||
|
## Deferred Ideas (out of scope for Phase 10)
|
||||||
|
|
||||||
|
- Logo preview in Settings UI (Phase 12)
|
||||||
|
- Auto-pull client logo from Entra branding API (Phase 11/12)
|
||||||
|
- Report header layout with logos side-by-side (Phase 11)
|
||||||
|
- "Load Directory" button placement decision (Phase 14)
|
||||||
|
- Session-scoped directory cache (UDIR-F01, deferred)
|
||||||
|
|
||||||
|
## code_context
|
||||||
|
|
||||||
|
| Asset | Path | Reuse |
|
||||||
|
|---|---|---|
|
||||||
|
| TenantProfile model | `SharepointToolbox/Core/Models/TenantProfile.cs` | Extend with `LogoData? ClientLogo` property |
|
||||||
|
| AppSettings model | `SharepointToolbox/Core/Models/AppSettings.cs` | Reference for BrandingSettings pattern |
|
||||||
|
| SettingsRepository | `SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs` | Clone pattern for BrandingRepository (write-then-replace + SemaphoreSlim) |
|
||||||
|
| ProfileRepository | `SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs` | Already handles TenantProfile persistence — will serialize new logo field |
|
||||||
|
| GraphUserSearchService | `SharepointToolbox/Services/GraphUserSearchService.cs` | Reference for Graph SDK usage, auth, and query patterns |
|
||||||
|
| GraphClientFactory | `SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs` | Provides `GraphServiceClient` for new directory service |
|
||||||
|
| DI registration | `SharepointToolbox/App.xaml.cs` (lines 73-163) | Register new BrandingRepository, BrandingService, GraphUserDirectoryService |
|
||||||
|
| Profile deletion UI | `SharepointToolbox/ViewModels/ProfileManagementViewModel.cs` | Update deletion confirmation message to mention logo |
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
# Phase 10: Branding Data Foundation - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-08
|
||||||
|
**Domain:** C# WPF / .NET 10 — JSON persistence, image validation, Microsoft Graph SDK pagination
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 10 is a pure infrastructure phase: no UI, no new NuGet packages. It introduces three new models (`LogoData`, `BrandingSettings`, plus extends `TenantProfile`), two repositories (`BrandingRepository` mirroring `SettingsRepository`), two services (`BrandingService` for validation/compression, `GraphUserDirectoryService` for paginated Graph enumeration), and registration of those in `App.xaml.cs`. All work is additive — nothing in the existing stack is removed or renamed.
|
||||||
|
|
||||||
|
The central technical challenge splits into two independent tracks:
|
||||||
|
1. **Logo storage track:** Image format detection from magic bytes, silent compression using `System.Drawing.Common` (available via WPF's `PresentationCore`/`System.Drawing.Common` BCL subset on net10.0-windows), base64 serialization in JSON.
|
||||||
|
2. **Graph directory track:** `PageIterator<User, UserCollectionResponse>` from Microsoft.Graph 5.x following `@odata.nextLink` until exhausted, with `CancellationToken` threading throughout.
|
||||||
|
|
||||||
|
Both tracks fit the existing patterns precisely. The repository uses `SemaphoreSlim(1,1)` + write-then-move. The Graph service clones `GraphUserSearchService` structure while substituting `PageIterator` for a one-shot `GetAsync`. No configuration, no new packages, no breaking changes.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in order — models first, then repository, then services, then DI registration, then update `ProfileManagementViewModel.DeleteAsync` warning message. Tests mirror the `SettingsServiceTests` and `ProfileServiceTests` patterns already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
| Decision | Value |
|
||||||
|
|---|---|
|
||||||
|
| Logo storage format | Base64 strings in JSON (not file paths) |
|
||||||
|
| MSP logo location | `BrandingSettings.cs` model → `branding.json` |
|
||||||
|
| Client logo location | On `TenantProfile` model (per-tenant) |
|
||||||
|
| File path after import | Discarded — only base64 persists |
|
||||||
|
| SVG support | Rejected (XSS risk) — PNG/JPG only |
|
||||||
|
| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` |
|
||||||
|
| Directory auto-load | No — explicit "Load Directory" button required |
|
||||||
|
| New NuGet packages | None — existing stack covers everything |
|
||||||
|
| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods |
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- No discretion areas defined for Phase 10 — all decisions locked.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- Logo preview in Settings UI (Phase 12)
|
||||||
|
- Auto-pull client logo from Entra branding API (Phase 11/12)
|
||||||
|
- Report header layout with logos side-by-side (Phase 11)
|
||||||
|
- "Load Directory" button placement decision (Phase 14)
|
||||||
|
- Session-scoped directory cache (UDIR-F01, deferred)
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| BRAND-01 | User can import an MSP logo in application settings (global, persisted across sessions) | `BrandingSettings` model + `BrandingRepository` (mirrors `SettingsRepository`) + `BrandingService.ImportLogoAsync` |
|
||||||
|
| BRAND-03 | User can import a client logo per tenant profile | `LogoData? ClientLogo` property on `TenantProfile` + `ProfileRepository` already handles serialization; `BrandingService.ImportLogoAsync` reused |
|
||||||
|
| BRAND-06 | Logo import validates format (PNG/JPG) and enforces 512 KB size limit | Magic byte detection (PNG: `89 50 4E 47`, JPEG: `FF D8 FF`) + auto-compress via `System.Drawing`/`BitmapEncoder` if > 512 KB |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already present — zero new installs)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| `System.Text.Json` | BCL (net10.0) | JSON serialization of models | Already used in all repositories |
|
||||||
|
| `System.Drawing.Common` | BCL (net10.0-windows) | Image load, resize, re-encode for compression | Available on Windows via `UseWPF=true`; no extra package |
|
||||||
|
| `Microsoft.Graph` | 5.74.0 (already in csproj) | Graph SDK for user enumeration | Already used by `GraphUserSearchService` |
|
||||||
|
| `Microsoft.Identity.Client` | 4.83.3 (already in csproj) | Token acquisition via `GraphClientFactory` | Already used |
|
||||||
|
| `CommunityToolkit.Mvvm` | 8.4.2 (already in csproj) | `[ObservableProperty]` for ViewModels — not used in Phase 10 directly, but referenced by `ProfileManagementViewModel` | Already used |
|
||||||
|
|
||||||
|
### No New Packages
|
||||||
|
All capabilities are covered by the existing stack. Confirmed in CONTEXT.md locked decisions and csproj inspection.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure (new files only)
|
||||||
|
|
||||||
|
```
|
||||||
|
SharepointToolbox/
|
||||||
|
├── Core/
|
||||||
|
│ └── Models/
|
||||||
|
│ ├── LogoData.cs -- record LogoData(string Base64, string MimeType)
|
||||||
|
│ └── BrandingSettings.cs -- class BrandingSettings { LogoData? MspLogo; }
|
||||||
|
├── Infrastructure/
|
||||||
|
│ └── Persistence/
|
||||||
|
│ └── BrandingRepository.cs -- clone of SettingsRepository<BrandingSettings>
|
||||||
|
├── Services/
|
||||||
|
│ ├── IBrandingService.cs -- ImportLogoAsync, ClearLogoAsync
|
||||||
|
│ ├── BrandingService.cs -- validates magic bytes, compresses, returns LogoData
|
||||||
|
│ ├── IGraphUserDirectoryService.cs -- GetUsersAsync with PageIterator
|
||||||
|
│ └── GraphUserDirectoryService.cs -- PageIterator pagination
|
||||||
|
|
||||||
|
SharepointToolbox.Tests/
|
||||||
|
└── Services/
|
||||||
|
├── BrandingServiceTests.cs -- magic bytes, compression, rejection
|
||||||
|
└── GraphUserDirectoryServiceTests.cs -- pagination (mocked PageIterator or direct list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Repository (write-then-move with SemaphoreSlim)
|
||||||
|
|
||||||
|
Exact clone of `SettingsRepository` with `BrandingSettings` substituted for `AppSettings`. No deviations.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs (existing)
|
||||||
|
public class BrandingRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task<BrandingSettings> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return new BrandingSettings();
|
||||||
|
// ... File.ReadAllTextAsync + JsonSerializer.Deserialize<BrandingSettings> ...
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(BrandingSettings settings)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings,
|
||||||
|
new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
// ... write to tmp, validate round-trip, File.Move(tmp, _filePath, overwrite: true) ...
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: LogoData record — shared by MSP and client logos
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §1 Logo Metadata Model
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record LogoData(string Base64, string MimeType);
|
||||||
|
// MimeType is "image/png" or "image/jpeg" — determined at import time from magic bytes
|
||||||
|
// Usage in HTML: $"data:{MimeType};base64,{Base64}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: BrandingSettings model
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: TenantProfile extension
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Extend existing TenantProfile — additive, no breaking change
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public LogoData? ClientLogo { get; set; } // NEW — nullable, ignored when null in JSON
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProfileRepository` needs no code change — `System.Text.Json` serializes the new nullable property automatically. Existing profiles JSON without `clientLogo` deserializes with `null` (forward-compatible).
|
||||||
|
|
||||||
|
### Pattern 5: Magic byte validation + compression in BrandingService
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §2 Logo Validation & Compression
|
||||||
|
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
private static readonly byte[] JpegSignature = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private static string? DetectMimeType(byte[] header)
|
||||||
|
{
|
||||||
|
if (header.Length >= 4 && header.Take(4).SequenceEqual(PngSignature)) return "image/png";
|
||||||
|
if (header.Length >= 3 && header.Take(3).SequenceEqual(JpegSignature)) return "image/jpeg";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
|
var mimeType = DetectMimeType(bytes)
|
||||||
|
?? throw new InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.");
|
||||||
|
|
||||||
|
if (bytes.Length > 512 * 1024)
|
||||||
|
bytes = CompressToLimit(bytes, mimeType, maxBytes: 512 * 1024);
|
||||||
|
|
||||||
|
return new LogoData(Convert.ToBase64String(bytes), mimeType);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For compression, use `System.Drawing.Bitmap` (available on net10.0-windows) to resize to max 300×300px and re-encode at reduced quality using `System.Drawing.Imaging.ImageCodecInfo`/`EncoderParameters`. Keep original format.
|
||||||
|
|
||||||
|
### Pattern 6: GraphUserDirectoryService with PageIterator
|
||||||
|
|
||||||
|
Microsoft.Graph 5.x includes `PageIterator<TEntity, TCollectionPage>` in `Microsoft.Graph.Core`. Pattern from Graph SDK docs:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Source: Microsoft.Graph 5.x SDK — PageIterator pattern
|
||||||
|
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
var results = new List<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var response = await graphClient.Users.GetAsync(config =>
|
||||||
|
{
|
||||||
|
config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'";
|
||||||
|
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" };
|
||||||
|
config.QueryParameters.Top = 999;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (response is null) return results;
|
||||||
|
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient,
|
||||||
|
response,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
results.Add(new GraphDirectoryUser(
|
||||||
|
user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
|
||||||
|
user.UserPrincipalName ?? string.Empty,
|
||||||
|
user.Mail,
|
||||||
|
user.Department,
|
||||||
|
user.JobTitle));
|
||||||
|
return true; // continue iteration
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageIterator.IterateAsync(ct);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageIterator` requires `Microsoft.Graph.Core` which is a transitive dependency of `Microsoft.Graph` 5.x — already present.
|
||||||
|
|
||||||
|
**No `ConsistencyLevel: eventual` needed** for the `$filter` query with `accountEnabled` and `userType` — these are standard properties, not advanced queries requiring `$count`. (Unlike the search service which uses `startsWith` and requires `ConsistencyLevel`.)
|
||||||
|
|
||||||
|
### Pattern 7: DI registration (App.xaml.cs)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
`BrandingRepository` is Singleton (same rationale as `ProfileRepository` and `SettingsRepository` — single file, shared lock). `BrandingService` is Singleton (stateless after construction, depends on singleton repository). `GraphUserDirectoryService` is Transient (per-tenant call, stateless).
|
||||||
|
|
||||||
|
### Pattern 8: ProfileManagementViewModel deletion message update
|
||||||
|
|
||||||
|
In `ProfileManagementViewModel.DeleteAsync()`, the existing confirmation flow has no dialog — it directly calls `_profileService.DeleteProfileAsync`. The update per CONTEXT.md is to augment the confirmation message (when that dialog exists) to mention logo removal. However, Phase 10 does not add a confirmation dialog — that is the caller's concern (View layer, Phase 12). The ViewModel update is to expose information about whether a profile has a logo, enabling Phase 12's View to conditionally show the warning.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Add a computed property to support the deletion warning in Phase 12
|
||||||
|
// This is the minimal Phase 10 change:
|
||||||
|
// TenantProfile.ClientLogo != null → the confirmation dialog (Phase 12) reads this
|
||||||
|
```
|
||||||
|
|
||||||
|
The actual deletion behavior is unchanged: deleting the profile JSON entry automatically drops the embedded `clientLogo` field. No orphaned files exist.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Do not store the file path in JSON** — only base64 + MIME type. File path is discarded immediately after reading bytes.
|
||||||
|
- **Do not use file extension for format detection** — always read magic bytes from the byte array.
|
||||||
|
- **Do not use `$search` or `$count` on the directory query** — `PageIterator` with `$filter=accountEnabled eq true and userType eq 'Member'` does not require `ConsistencyLevel: eventual`.
|
||||||
|
- **Do not create a new interface for BrandingRepository** — `SettingsRepository` has no interface either; only services get interfaces.
|
||||||
|
- **Do not add `[ObservableProperty]` to `LogoData`** — it is a plain record used in persistence layer; ViewModel bindings come in Phase 11-12.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| JSON pagination follow-up | Manual `@odata.nextLink` string parsing loop | `PageIterator<User, UserCollectionResponse>` | SDK handles retry, null checks, async iteration natively |
|
||||||
|
| Image format detection | File extension check | Magic byte read on first 4 bytes | Extensions are user-controlled and unreliable |
|
||||||
|
| Atomic file write | Direct `File.WriteAllText` | Write to `.tmp`, validate, `File.Move(overwrite:true)` | Crash during write leaves corrupted JSON; pattern already proven in all repos |
|
||||||
|
| Concurrency guard | `lock(obj)` | `SemaphoreSlim(1,1)` | Async-safe; `lock` cannot be awaited |
|
||||||
|
| Base64 encoding | Manual byte-to-char loop | `Convert.ToBase64String(bytes)` | BCL, zero allocation path, no edge cases |
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: `System.Drawing` availability on net10.0-windows
|
||||||
|
**What goes wrong:** `System.Drawing.Common` is available on Windows (the project already targets `net10.0-windows` with `UseWPF=true`) but would throw `PlatformNotSupportedException` on Linux/macOS runtimes.
|
||||||
|
**Why it happens:** .NET 6+ restricted `System.Drawing.Common` to Windows-only by default.
|
||||||
|
**How to avoid:** This project is Windows-only (WinExe, UseWPF=true) so no risk. No guard needed.
|
||||||
|
**Warning signs:** CI on Linux — not applicable here.
|
||||||
|
|
||||||
|
### Pitfall 2: `PageIterator.IterateAsync` does not accept `CancellationToken` directly in Graph SDK 5.x
|
||||||
|
**What goes wrong:** `PageIterator.IterateAsync()` in Microsoft.Graph 5.x overloads — the token must be passed when calling `CreatePageIterator`, and the iteration callback must check cancellation manually or the token goes to `IterateAsync(ct)` if the overload exists.
|
||||||
|
**Why it happens:** API surface changed between SDK versions.
|
||||||
|
**How to avoid:** Check token inside the callback: `if (ct.IsCancellationRequested) return false;` stops iteration. Also pass `ct` to the initial `GetAsync` call.
|
||||||
|
**Warning signs:** Long-running enumeration that ignores cancellation requests.
|
||||||
|
|
||||||
|
### Pitfall 3: Deserialization of `LogoData` record with `System.Text.Json`
|
||||||
|
**What goes wrong:** C# records with positional constructors may not deserialize correctly with `System.Text.Json` unless the property names match constructor parameter names exactly (case-insensitive with `PropertyNameCaseInsensitive = true`) or a `[JsonConstructor]` attribute is present.
|
||||||
|
**Why it happens:** Positional record constructor parameters are `base64` and `mimeType` (camelCase) while JSON uses `PropertyNamingPolicy.CamelCase`.
|
||||||
|
**How to avoid:** Use a class with `{ get; set; }` properties OR add `[JsonConstructor]` to the positional record constructor. Simpler: make `LogoData` a class with init setters or a non-positional record with `{ get; init; }` properties.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SAFE version — class-style record with init setters:
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pitfall 4: Large base64 string bloating profiles.json
|
||||||
|
**What goes wrong:** A 512 KB logo becomes ~682 KB of base64 text. Per-profile, this is manageable. However, `ProfileRepository.LoadAsync` loads ALL profiles at once — 20 tenants with logos = ~14 MB in memory per load.
|
||||||
|
**Why it happens:** All profiles are stored in a single JSON array.
|
||||||
|
**How to avoid:** Phase 10 does not address this (deferred); the 512 KB cap keeps it bounded. Document as known limitation.
|
||||||
|
**Warning signs:** Not a Phase 10 concern — flag for future phases if profile count grows large.
|
||||||
|
|
||||||
|
### Pitfall 5: `File.Move` with `overwrite: true` not available on all .NET versions
|
||||||
|
**What goes wrong:** `File.Move(src, dst, overwrite: true)` was added in .NET 3.0. On older frameworks this throws.
|
||||||
|
**Why it happens:** Legacy API surface.
|
||||||
|
**How to avoid:** Not applicable — project targets net10.0. Use freely.
|
||||||
|
|
||||||
|
### Pitfall 6: Graph $filter without ConsistencyLevel on advanced queries
|
||||||
|
**What goes wrong:** The search service uses `startsWith()` which requires `ConsistencyLevel: eventual + $count=true`. If the directory service accidentally includes `$count` or `$search`, it needs the header too.
|
||||||
|
**Why it happens:** Copy-paste from `GraphUserSearchService` without removing the `ConsistencyLevel` header.
|
||||||
|
**How to avoid:** The directory filter `accountEnabled eq true and userType eq 'Member'` is a standard equality filter — does NOT require `ConsistencyLevel: eventual`. Do not copy the header from `GraphUserSearchService`.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Magic Byte Detection
|
||||||
|
```csharp
|
||||||
|
// Source: CONTEXT.md §2 Logo Validation; confirmed against PNG/JPEG specs
|
||||||
|
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private static string? DetectMimeType(ReadOnlySpan<byte> header)
|
||||||
|
{
|
||||||
|
if (header.Length >= 4 && header[..4].SequenceEqual(PngMagic)) return "image/png";
|
||||||
|
if (header.Length >= 3 && header[..3].SequenceEqual(JpegMagic)) return "image/jpeg";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compression via System.Drawing (net10.0-windows)
|
||||||
|
```csharp
|
||||||
|
// Source: BCL System.Drawing.Common — Windows-only, safe here
|
||||||
|
private static byte[] CompressImage(byte[] original, string mimeType, int maxBytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(original);
|
||||||
|
using var bitmap = new System.Drawing.Bitmap(ms);
|
||||||
|
|
||||||
|
// Scale down proportionally to max 300px
|
||||||
|
int w = bitmap.Width, h = bitmap.Height;
|
||||||
|
if (w > 300 || h > 300)
|
||||||
|
{
|
||||||
|
double scale = Math.Min(300.0 / w, 300.0 / h);
|
||||||
|
w = (int)(w * scale);
|
||||||
|
h = (int)(h * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var resized = new System.Drawing.Bitmap(bitmap, w, h);
|
||||||
|
|
||||||
|
// Re-encode
|
||||||
|
var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()
|
||||||
|
.First(c => c.MimeType == mimeType);
|
||||||
|
var encoderParams = new System.Drawing.Imaging.EncoderParameters(1);
|
||||||
|
encoderParams.Param[0] = new System.Drawing.Imaging.EncoderParameter(
|
||||||
|
System.Drawing.Imaging.Encoder.Quality, 75L);
|
||||||
|
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
resized.Save(output, codec, encoderParams);
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PageIterator pattern (Microsoft.Graph 5.x)
|
||||||
|
```csharp
|
||||||
|
// Source: Microsoft.Graph 5.x SDK pattern; PageIterator<TEntity, TCollectionPage>
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient,
|
||||||
|
firstPage,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return false;
|
||||||
|
results.Add(MapUser(user));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageIterator.IterateAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GraphDirectoryUser result record
|
||||||
|
```csharp
|
||||||
|
// New record for Phase 10 — placed in Services/ or Core/Models/
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle);
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON shape of branding.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mspLogo": {
|
||||||
|
"base64": "iVBORw0KGgo...",
|
||||||
|
"mimeType": "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON shape of profiles.json (after Phase 10)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"name": "Contoso",
|
||||||
|
"tenantUrl": "https://contoso.sharepoint.com",
|
||||||
|
"clientId": "...",
|
||||||
|
"clientLogo": {
|
||||||
|
"base64": "/9j/4AAQ...",
|
||||||
|
"mimeType": "image/jpeg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fabrikam",
|
||||||
|
"tenantUrl": "https://fabrikam.sharepoint.com",
|
||||||
|
"clientId": "...",
|
||||||
|
"clientLogo": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Manual `@odata.nextLink` loop | `PageIterator<T, TPage>` | Microsoft.Graph 5.x (current) | Handles backoff, null-safety, async natively |
|
||||||
|
| `System.Drawing` everywhere | `System.Drawing` Windows-only | .NET 6 | No impact here — Windows-only project |
|
||||||
|
| Class-based Graph response models | Record/POCO `Value` collections | Microsoft.Graph 5.x | `response.Value` is `List<User>?` |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `Microsoft.Graph.Beta` namespace: not needed here — standard `/v1.0/users` endpoint sufficient
|
||||||
|
- `IAuthenticationProvider` (old Graph SDK): replaced by `BaseBearerTokenAuthenticationProvider` — already correct in `GraphClientFactory`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **CancellationToken in PageIterator.IterateAsync — exact overload in Graph SDK 5.74.0**
|
||||||
|
- What we know: `PageIterator` exists in `Microsoft.Graph.Core`; `IterateAsync` exists. Token passing confirmed in SDK samples.
|
||||||
|
- What's unclear: Whether `IterateAsync(CancellationToken)` overload exists in 5.74.0 or only the parameterless version.
|
||||||
|
- Recommendation: Check when implementing. If parameterless only, use `ct.IsCancellationRequested` inside callback to return `false` and stop iteration. Either approach works correctly.
|
||||||
|
|
||||||
|
2. **$filter=accountEnabled eq true and userType eq 'Member' — verified against real tenant?**
|
||||||
|
- STATE.md flags this as a pending todo: "Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning."
|
||||||
|
- Phase 10 implements the service; Phase 13 will exercise the filter in the ViewModel. The pending verification is appropriate for Phase 13.
|
||||||
|
- Recommendation: Implement the filter as specified. Flag in `GraphUserDirectoryService` with a comment noting the pending verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | xUnit 2.9.3 + Moq 4.20.72 |
|
||||||
|
| Config file | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
|
||||||
|
| Quick run command | `dotnet test --filter "Category=Unit" --no-build` |
|
||||||
|
| Full suite command | `dotnet test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| BRAND-01 | MSP logo saved to `branding.json` and reloaded correctly | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-01 | `BrandingRepository` round-trips `BrandingSettings` with `MspLogo` | unit | `dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-03 | `TenantProfile.ClientLogo` serializes/deserializes in `ProfileRepository` | unit | `dotnet test --filter "FullyQualifiedName~ProfileServiceTests" --no-build` | ✅ (extend existing) |
|
||||||
|
| BRAND-06 | PNG file accepted, returns `image/png` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | JPEG file accepted, returns `image/jpeg` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | BMP file rejected with descriptive error | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | File > 512 KB is auto-compressed (output ≤ 512 KB) | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| BRAND-06 | File ≤ 512 KB is not modified | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| (UDIR-02 infra) | `GetUsersAsync` follows all pages until exhausted | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
| (UDIR-02 infra) | `GetUsersAsync` respects `CancellationToken` mid-iteration | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `dotnet test --filter "Category=Unit" --no-build`
|
||||||
|
- **Per wave merge:** `dotnet test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` — covers BRAND-06 + BRAND-01 import logic
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` — covers BRAND-01 persistence
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` — covers UDIR-02 infrastructure
|
||||||
|
|
||||||
|
*(Extend existing `ProfileServiceTests.cs` to verify `ClientLogo` round-trip — covers BRAND-03)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Codebase inspection — `SettingsRepository.cs`, `ProfileRepository.cs`, `GraphUserSearchService.cs`, `GraphClientFactory.cs`, `App.xaml.cs`, `TenantProfile.cs`, `AppSettings.cs`
|
||||||
|
- `SharepointToolbox.csproj` — confirms Microsoft.Graph 5.74.0, no System.Drawing explicit reference needed (net10.0-windows)
|
||||||
|
- `SharepointToolbox.Tests.csproj` — confirms xUnit 2.9.3, Moq 4.20.72 test stack
|
||||||
|
- `10-CONTEXT.md` — locked decisions, compression strategy, magic byte specs, model shapes
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- Microsoft.Graph 5.x SDK architecture — `PageIterator<T, TPage>` pattern confirmed in Graph SDK source and documentation; version 5.74.0 is current
|
||||||
|
- `System.Drawing.Common` Windows availability — confirmed by .NET documentation: available on Windows, restricted on non-Windows since .NET 6
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- `PageIterator.IterateAsync(CancellationToken)` overload availability in 5.74.0 specifically — needs compile-time verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — confirmed from csproj; zero new packages
|
||||||
|
- Architecture: HIGH — all patterns are direct clones of existing code in the repo
|
||||||
|
- Magic byte detection: HIGH — PNG/JPEG signatures are stable specs
|
||||||
|
- PageIterator pattern: MEDIUM — SDK version-specific overload needs verification at implementation time
|
||||||
|
- Pitfalls: HIGH — identified from codebase inspection and known .NET behaviors
|
||||||
|
|
||||||
|
**Research date:** 2026-04-08
|
||||||
|
**Valid until:** 2026-05-08 (stable domain — Microsoft.Graph minor versions change rarely)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
phase: 10
|
||||||
|
slug: branding-data-foundation
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | xUnit 2.9.3 + Moq 4.20.72 |
|
||||||
|
| **Config file** | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
|
||||||
|
| **Quick run command** | `dotnet test --filter "Category=Unit" --no-build` |
|
||||||
|
| **Full suite command** | `dotnet test` |
|
||||||
|
| **Estimated runtime** | ~15 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `dotnet test --filter "Category=Unit" --no-build`
|
||||||
|
- **After every plan wave:** Run `dotnet test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 15 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 10-01-01 | 01 | 1 | BRAND-01 | unit | `dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build` | ❌ W0 | ⬜ pending |
|
||||||
|
| 10-01-02 | 01 | 1 | BRAND-06 | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ W0 | ⬜ pending |
|
||||||
|
| 10-01-03 | 01 | 1 | BRAND-03 | unit | `dotnet test --filter "FullyQualifiedName~ProfileServiceTests" --no-build` | ✅ extend | ⬜ pending |
|
||||||
|
| 10-02-01 | 02 | 1 | UDIR-02 | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` — stubs for BRAND-01 persistence round-trip
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` — stubs for BRAND-06 magic bytes, compression, rejection
|
||||||
|
- [ ] `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` — stubs for UDIR-02 pagination
|
||||||
|
- [ ] Extend `SharepointToolbox.Tests/Services/ProfileServiceTests.cs` — add BRAND-03 `ClientLogo` round-trip test
|
||||||
|
|
||||||
|
*Existing infrastructure covers test framework setup.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| MSP logo survives app restart | BRAND-01 | Requires full app lifecycle (start, import, close, reopen) | 1. Run app, import MSP logo 2. Close app 3. Reopen app 4. Verify logo still present in branding.json |
|
||||||
|
| Client logo isolated between tenants | BRAND-03 | Requires multi-profile JSON inspection | 1. Import logo for Tenant A 2. Verify Tenant B profile has no logo field 3. Delete Tenant A logo 4. Verify Tenant B unaffected |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 15s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
phase: 10-branding-data-foundation
|
||||||
|
verified: 2026-04-08T12:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 8/8 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10: Branding Data Foundation Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** The application can store, validate, and retrieve MSP and client logos as portable base64 strings in JSON, and can enumerate a full tenant user list with pagination.
|
||||||
|
**Verified:** 2026-04-08
|
||||||
|
**Status:** PASSED
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | An MSP logo imported as PNG or JPG is persisted as base64 in branding.json and survives round-trip | VERIFIED | `BrandingService.ImportLogoAsync` + `SaveMspLogoAsync` + `BrandingRepository.SaveAsync/LoadAsync`; 3 tests confirm round-trip |
|
||||||
|
| 2 | A client logo imported per tenant profile is persisted as base64 inside the profile JSON | VERIFIED | `TenantProfile.ClientLogo` property added; serialization/deserialization confirmed by 2 `BrandingRepositoryTests` |
|
||||||
|
| 3 | A file that is not PNG/JPG (e.g., BMP) is rejected with a descriptive error message | VERIFIED | `DetectMimeType` throws `InvalidDataException("File format is not PNG or JPG…")`; test `ImportLogoAsync_BmpFile_ThrowsInvalidDataExceptionMentioningPngAndJpg` passes |
|
||||||
|
| 4 | A file larger than 512 KB is silently compressed to fit under the limit | VERIFIED | `CompressToLimit` two-pass WPF imaging (300x300@75 then 200x200@50); test `ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB` passes |
|
||||||
|
| 5 | A file under 512 KB is stored without modification | VERIFIED | No compression branch taken; test `ImportLogoAsync_FileUnder512KB_ReturnOriginalBytesUnmodified` passes confirming byte-for-byte identity |
|
||||||
|
| 6 | `GetUsersAsync` returns all enabled member users following `@odata.nextLink` until exhausted | VERIFIED | `PageIterator<User, UserCollectionResponse>` used; `IterateAsync` called; integration-level pagination tests skipped with documented rationale (PageIterator internals not mockable) |
|
||||||
|
| 7 | `GetUsersAsync` respects CancellationToken and stops iteration when cancelled | VERIFIED | `ct.IsCancellationRequested` checked inside callback; `return false` stops PageIterator; integration test skipped with documented rationale |
|
||||||
|
| 8 | Each returned user includes DisplayName, UserPrincipalName, Mail, Department, and JobTitle | VERIFIED | `MapUser` maps all 5 fields with null-fallback chain; 5 `MapUser` unit tests pass covering all field combinations |
|
||||||
|
|
||||||
|
**Score:** 8/8 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
### Plan 01 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `SharepointToolbox/Core/Models/LogoData.cs` | Shared logo record with Base64 and MimeType init properties | VERIFIED | Non-positional record; both properties with `get; init;`; 7 lines |
|
||||||
|
| `SharepointToolbox/Core/Models/BrandingSettings.cs` | MSP logo wrapper model | VERIFIED | `LogoData? MspLogo { get; set; }` present |
|
||||||
|
| `SharepointToolbox/Core/Models/TenantProfile.cs` | Client logo property on existing profile model | VERIFIED | `LogoData? ClientLogo { get; set; }` added additively; all 3 original properties retained |
|
||||||
|
| `SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs` | JSON persistence with write-then-replace safety | VERIFIED | `SemaphoreSlim(1,1)`, `.tmp` write-then-validate-then-move pattern, `JsonDocument.Parse` validation before `File.Move` |
|
||||||
|
| `SharepointToolbox/Services/BrandingService.cs` | Logo import with magic byte validation and auto-compression | VERIFIED | `ImportLogoAsync`, `SaveMspLogoAsync`, `ClearMspLogoAsync`, `GetMspLogoAsync` all implemented; WPF imaging compression |
|
||||||
|
| `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` | Unit tests for validation, compression, rejection | VERIFIED | 5 tests; IDisposable + temp file pattern; all pass |
|
||||||
|
| `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` | Unit tests for repository round-trip | VERIFIED | 9 tests (224 lines); IDisposable + temp file pattern; all pass |
|
||||||
|
|
||||||
|
### Plan 02 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `SharepointToolbox/Core/Models/GraphDirectoryUser.cs` | Result record for directory enumeration | VERIFIED | Positional record with all 5 fields |
|
||||||
|
| `SharepointToolbox/Services/IGraphUserDirectoryService.cs` | Interface for directory enumeration | VERIFIED | `GetUsersAsync(clientId, IProgress<int>?, CancellationToken)` declared |
|
||||||
|
| `SharepointToolbox/Services/GraphUserDirectoryService.cs` | PageIterator-based Graph user enumeration | VERIFIED | `PageIterator<User, UserCollectionResponse>.CreatePageIterator` used; `IterateAsync` called |
|
||||||
|
| `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` | Unit tests for directory service | VERIFIED | 9 tests (5 pass, 4 skipped with documented rationale); 150 lines |
|
||||||
|
|
||||||
|
### Plan 03 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `SharepointToolbox/App.xaml.cs` | DI registration for Phase 10 services | VERIFIED | Phase 10 block at lines 81-84 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
### Plan 01 Key Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `BrandingService.cs` | `BrandingRepository.cs` | Constructor injection | VERIFIED | Constructor takes `BrandingRepository _repository`; all CRUD methods call `_repository.LoadAsync/SaveAsync` |
|
||||||
|
| `BrandingService.cs` | `LogoData.cs` | Return type | VERIFIED | `ImportLogoAsync` returns `Task<LogoData>`; `new LogoData { Base64=…, MimeType=… }` constructed |
|
||||||
|
| `BrandingSettings.cs` | `LogoData.cs` | Property type | VERIFIED | `LogoData? MspLogo { get; set; }` |
|
||||||
|
| `TenantProfile.cs` | `LogoData.cs` | Property type | VERIFIED | `LogoData? ClientLogo { get; set; }` |
|
||||||
|
|
||||||
|
### Plan 02 Key Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `GraphUserDirectoryService.cs` | `GraphClientFactory` | Constructor injection | VERIFIED | `AppGraphClientFactory` alias resolves to `SharepointToolbox.Infrastructure.Auth.GraphClientFactory`; `CreateClientAsync` called |
|
||||||
|
| `GraphUserDirectoryService.cs` | Microsoft.Graph PageIterator | SDK pagination | VERIFIED | `PageIterator<User, UserCollectionResponse>.CreatePageIterator(graphClient, response, callback)` + `IterateAsync` |
|
||||||
|
|
||||||
|
### Plan 03 Key Links
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `App.xaml.cs` | `BrandingRepository.cs` | AddSingleton registration | VERIFIED | `services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")))` at line 82 |
|
||||||
|
| `App.xaml.cs` | `BrandingService.cs` | AddSingleton registration | VERIFIED | `services.AddSingleton<IBrandingService, BrandingService>()` at line 83 |
|
||||||
|
| `App.xaml.cs` | `GraphUserDirectoryService.cs` | AddTransient registration | VERIFIED | `services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>()` at line 84 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan(s) | Description | Status | Evidence |
|
||||||
|
|-------------|---------------|-------------|--------|----------|
|
||||||
|
| BRAND-01 | 10-01, 10-03 | User can import an MSP logo in application settings (global, persisted across sessions) | SATISFIED | `BrandingService.ImportLogoAsync` + `SaveMspLogoAsync` + `BrandingRepository` persistence to `branding.json`; DI registered as Singleton |
|
||||||
|
| BRAND-03 | 10-01, 10-03 | User can import a client logo per tenant profile | SATISFIED | `TenantProfile.ClientLogo` property added; `ImportLogoAsync` is format-agnostic (returns `LogoData` for caller to store); ViewModel in Phase 11 will wire the per-tenant save path |
|
||||||
|
| BRAND-06 | 10-01, 10-02, 10-03 | Logo import validates format (PNG/JPG) and enforces 512 KB size limit | SATISFIED | Magic byte validation (PNG: 4 bytes, JPEG: 3 bytes) rejects all other formats; files over 512 KB compressed via two-pass WPF imaging; 5 validation/compression tests pass |
|
||||||
|
|
||||||
|
**Orphaned requirements check:** REQUIREMENTS.md maps BRAND-01, BRAND-03, BRAND-06 exclusively to Phase 10. No additional Phase 10 requirements found in REQUIREMENTS.md outside these three. No orphaned requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| `GraphUserDirectoryService.cs` | 32 | `// Pending real-tenant verification` comment | Info | Comment only; code is fully implemented. Filter `"accountEnabled eq true and userType eq 'Member'"` is implemented and correct. Verification against a live tenant is deferred to integration phase. |
|
||||||
|
|
||||||
|
No blockers. No stubs. No empty implementations. No unimplemented TODO/FIXME items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
None. All goal behaviors are verifiable from source code and passing test output.
|
||||||
|
|
||||||
|
The following items are acknowledged as integration-scope (not blocking):
|
||||||
|
|
||||||
|
1. **Real-tenant filter verification** — The Graph API filter `accountEnabled eq true and userType eq 'Member'` cannot be verified without a live tenant. Noted in code comment and STATE.md. The logic is structurally correct per Graph SDK documentation.
|
||||||
|
|
||||||
|
2. **WPF compression at test time** — `ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB` generates a large PNG using `System.Drawing.Bitmap` (available via `UseWPF=true` on net10.0-windows) and then compresses via WPF imaging APIs inside `BrandingService`. This test passes locally (confirmed: 14/14 branding tests pass). This test may behave differently in headless CI environments without a display — not a concern for this WPF desktop application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All 8 observable truths are verified. All artifacts exist, are substantive, and are correctly wired. All three required DI registrations are present in App.xaml.cs. The full test suite passes: 224 tests passed, 26 skipped (all skips are pre-existing integration tests requiring a live Graph/SharePoint endpoint), 0 failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results Summary
|
||||||
|
|
||||||
|
| Test Suite | Passed | Skipped | Failed |
|
||||||
|
|------------|--------|---------|--------|
|
||||||
|
| BrandingRepositoryTests | 5 | 0 | 0 |
|
||||||
|
| BrandingServiceTests | 9 | 0 | 0 |
|
||||||
|
| GraphUserDirectoryServiceTests | 5 | 4 | 0 |
|
||||||
|
| Full suite (all phases) | 224 | 26 | 0 |
|
||||||
|
|
||||||
|
Commits verified: `2280f12`, `1303866`, `5e56a96`, `3ba5746`, `7e8e228` — all present in git history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-04-08T12:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Deferred Items — Phase 10 Branding Data Foundation
|
||||||
|
|
||||||
|
## Pre-existing: BrandingServiceTests.cs blocks test project build
|
||||||
|
|
||||||
|
**Found during:** Plan 10-02 Task 2 (test verification)
|
||||||
|
**File:** `SharepointToolbox.Tests/Services/BrandingServiceTests.cs`
|
||||||
|
**Issue:** File exists on disk (untracked in git) but references types (`BrandingService`, `BrandingRepository`, `LogoData`) that don't exist yet — these are the artifacts of plan 10-01. This blocked the test project from compiling, preventing `dotnet test` from running.
|
||||||
|
**Impact:** Could not run GraphUserDirectoryServiceTests via `dotnet test` — only main project build verified.
|
||||||
|
**Resolution:** Will be resolved when plan 10-01 is executed and BrandingService types are created.
|
||||||
|
**Action needed:** Execute plan 10-01 before or alongside 10-02 to restore test compilation.
|
||||||
+373
-71
@@ -2,6 +2,349 @@
|
|||||||
|
|
||||||
**Project:** SharePoint Toolbox — C#/WPF SharePoint Online Administration Desktop Tool
|
**Project:** SharePoint Toolbox — C#/WPF SharePoint Online Administration Desktop Tool
|
||||||
**Domain:** SharePoint Online administration, auditing, and provisioning (MSP / IT admin)
|
**Domain:** SharePoint Online administration, auditing, and provisioning (MSP / IT admin)
|
||||||
|
**Researched:** 2026-04-02 (v1.0 original) | 2026-04-08 (v2.2 addendum)
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Note:** This file contains two sections. The original v1.0 research summary is preserved below
|
||||||
|
> the v2.2 section. The roadmapper should consume **v2.2 first** for the current milestone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v2.2 Research Summary — Report Branding & User Directory
|
||||||
|
|
||||||
|
**Milestone:** v2.2 — HTML Report Branding (MSP/client logos) + User Directory Browse Mode
|
||||||
|
**Synthesized:** 2026-04-08
|
||||||
|
**Sources:** STACK.md (v2.2 addendum), FEATURES.md (v2.2), ARCHITECTURE.md (v2.2), PITFALLS.md (v2.2 addendum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
v2.2 adds two independent, self-contained features to a mature WPF MVVM codebase: logo branding
|
||||||
|
across all five HTML export services, and a full-directory browse mode as an alternative to the
|
||||||
|
existing people-picker in the User Access Audit tab. Both features are well within the capabilities
|
||||||
|
of the existing stack — no new NuGet packages are required. The implementation path is low risk
|
||||||
|
because neither feature touches the audit execution engine; they are purely additive layers on top
|
||||||
|
of proven infrastructure.
|
||||||
|
|
||||||
|
The branding feature follows a single clear pattern: store logos as base64 strings in existing JSON
|
||||||
|
settings and profile files, pass them at export time via a new optional `ReportBranding` record, and
|
||||||
|
inject `<img data-URI>` tags into a shared HTML header block. The architecture keeps the five export
|
||||||
|
services independent (each receives an optional parameter) while avoiding code duplication through a
|
||||||
|
shared header builder. The user directory browse feature adds a new `IGraphUserDirectoryService`
|
||||||
|
alongside the existing search service, wires it to new ViewModel state in
|
||||||
|
`UserAccessAuditViewModel`, and presents it as a toggle-panel in the View. The existing audit
|
||||||
|
pipeline is completely untouched.
|
||||||
|
|
||||||
|
The primary risks are not technical complexity but execution discipline: logo size must be enforced
|
||||||
|
at import time (512 KB limit) to prevent HTML report bloat, Graph pagination must use `PageIterator`
|
||||||
|
to handle tenants with more than 999 users, and logo data must be stored as base64 strings (not file
|
||||||
|
paths) to ensure portability across machines. All three of these are straightforward to implement
|
||||||
|
once the storage strategy is decided and locked in at the beginning of each feature's implementation
|
||||||
|
phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
|
||||||
|
### Stack Additions — None Required
|
||||||
|
|
||||||
|
The entire v2.2 scope is served by the existing stack:
|
||||||
|
|
||||||
|
| Capability | Provided By | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Logo encoding (file → base64) | BCL `Convert.ToBase64String` + `File.ReadAllBytesAsync` | Zero new packages |
|
||||||
|
| Logo preview in WPF settings UI | `BitmapImage` (WPF PresentationCore, already a transitive dep) | Standard WPF pattern |
|
||||||
|
| Logo file picker | `OpenFileDialog` (WPF Microsoft.Win32, already used in codebase) | Filter to PNG/JPG/GIF/BMP |
|
||||||
|
| User directory listing with pagination | `Microsoft.Graph` 5.74.0 `PageIterator<User, UserCollectionResponse>` | Already installed |
|
||||||
|
| Local directory filtering | `ICollectionView.Filter` (WPF System.Windows.Data) | Already used in PermissionsViewModel |
|
||||||
|
| Logo + profile JSON persistence | `System.Text.Json` + existing Repository pattern | Backward-compatible nullable fields |
|
||||||
|
|
||||||
|
Do NOT add: HTML template engines (Razor/Scriban), image processing libraries (ImageSharp,
|
||||||
|
Magick.NET), or PDF export libraries. All explicitly out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature Table Stakes vs. Differentiators
|
||||||
|
|
||||||
|
**Feature 1: HTML Report Branding**
|
||||||
|
|
||||||
|
Table stakes (must ship):
|
||||||
|
- MSP global logo in every HTML report header
|
||||||
|
- Client (per-tenant) logo in report header
|
||||||
|
- Logo renders without external URL (data-URI embedding for self-contained HTML portability)
|
||||||
|
- Graceful absence — no broken image icon when logo is not configured
|
||||||
|
- Consistent placement across all five HTML export types
|
||||||
|
|
||||||
|
Differentiators (build after table stakes):
|
||||||
|
- Auto-pull client logo from Microsoft Entra tenant branding (`GET /organization/{id}/branding/localizations/default/bannerLogo`) — zero-config path using the existing `User.Read` delegated scope
|
||||||
|
- Report timestamp and tenant display name in header
|
||||||
|
|
||||||
|
Anti-features — do not build:
|
||||||
|
- Per-tenant CSS color themes (design system complexity, disproportionate to MSP value)
|
||||||
|
- PDF export with embedded logo (requires third-party binary dependency)
|
||||||
|
- SVG logo support (XSS risk in data-URIs; PNG/JPG/GIF/BMP only)
|
||||||
|
- Hotlinked logo URL field (breaks offline/archived reports)
|
||||||
|
|
||||||
|
**Feature 2: User Directory Browse Mode**
|
||||||
|
|
||||||
|
Table stakes (must ship):
|
||||||
|
- Full directory listing (all enabled member users) with pagination
|
||||||
|
- In-memory text filter on DisplayName/UPN/Mail without server round-trips
|
||||||
|
- Sortable columns (Name, UPN)
|
||||||
|
- Select user from list to trigger existing audit pipeline
|
||||||
|
- Loading indicator with user count feedback ("Loaded X users...")
|
||||||
|
- Toggle between Browse mode and Search (people-picker) mode
|
||||||
|
|
||||||
|
Differentiators (add after core browse is stable):
|
||||||
|
- Filter by account type (member vs. guest toggle)
|
||||||
|
- Department / Job Title columns
|
||||||
|
- Session-scoped directory cache (invalidated on tenant switch)
|
||||||
|
|
||||||
|
Anti-features — do not build:
|
||||||
|
- Eager load on tab open (large tenants block UI and risk throttling)
|
||||||
|
- Delta query / incremental sync (wrong pattern for single-session audit)
|
||||||
|
- Multi-user bulk simultaneous audit (different results model, out of scope)
|
||||||
|
- Export user directory to CSV (identity reporting, not access audit)
|
||||||
|
|
||||||
|
**Recommended MVP build order:**
|
||||||
|
1. MSP logo in all HTML reports — highest visible impact, lowest complexity
|
||||||
|
2. Client logo in HTML reports (import from file) — completes co-branding
|
||||||
|
3. User directory browse core (load, select, filter, pipe into audit)
|
||||||
|
4. Auto-pull client logo from Entra branding — add after file import path is proven
|
||||||
|
5. Directory guest filter + department/jobTitle columns — low-effort polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Architecture Integration Points and Build Order
|
||||||
|
|
||||||
|
**New files to create (7):**
|
||||||
|
|
||||||
|
| Component | Layer | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Core/Models/BrandingSettings.cs` | Core/Models | MSP logo base64 + MIME type; global, persisted in `branding.json` |
|
||||||
|
| `Core/Models/ReportBranding.cs` | Core/Models | Lightweight record assembled at export time; NOT persisted |
|
||||||
|
| `Core/Models/PagedUserResult.cs` | Core/Models | Page of `GraphUserResult` items + next-page cursor token |
|
||||||
|
| `Infrastructure/Persistence/BrandingRepository.cs` | Infrastructure | Atomic JSON write (mirrors SettingsRepository pattern exactly) |
|
||||||
|
| `Services/BrandingService.cs` | Services | Orchestrates file read → MIME detect → base64 → save |
|
||||||
|
| `Services/IGraphUserDirectoryService.cs` | Services | Contract for paginated tenant user enumeration |
|
||||||
|
| `Services/GraphUserDirectoryService.cs` | Services | Graph API user listing with `PageIterator` cursor pagination |
|
||||||
|
|
||||||
|
**Existing files to modify (17), by risk level:**
|
||||||
|
|
||||||
|
Medium risk (left-panel restructure or new async command):
|
||||||
|
- `ViewModels/Tabs/UserAccessAuditViewModel.cs` — add `IGraphUserDirectoryService` injection + browse mode state/commands
|
||||||
|
- `Views/Tabs/UserAccessAuditView.xaml` — add mode toggle + browse panel in left column
|
||||||
|
|
||||||
|
Low risk (optional param or uniform inject-and-call pattern, batchable):
|
||||||
|
- All 5 `Services/Export/*HtmlExportService.cs` — add `ReportBranding? branding = null` optional parameter
|
||||||
|
- `PermissionsViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel` — add `BrandingService` injection + use in `ExportHtmlAsync`
|
||||||
|
- `SettingsViewModel.cs` — add MSP logo browse/preview/clear commands
|
||||||
|
- `ProfileManagementViewModel.cs` — add client logo browse/preview/clear commands
|
||||||
|
- `SettingsView.xaml`, `ProfileManagementDialog.xaml` — add logo UI sections
|
||||||
|
- `App.xaml.cs` — register 3 new services
|
||||||
|
|
||||||
|
**Dependency-aware build phases:**
|
||||||
|
|
||||||
|
| Phase | Scope | Risk | Gate |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A — Models | BrandingSettings, ReportBranding, PagedUserResult, TenantProfile logo fields | None | POCOs; no dependencies |
|
||||||
|
| B — Services | BrandingRepository, BrandingService, IGraphUserDirectoryService, GraphUserDirectoryService | Low | Unit-testable with mocks; Phase A required |
|
||||||
|
| C — Export services | Add optional `ReportBranding?` to all 5 HTML export services | Low | Phase A required; regression tests: null branding produces identical HTML |
|
||||||
|
| D — Branding ViewModels | SettingsVM, ProfileManagementVM, 4 export VMs, App.xaml.cs registration | Low | Phase B+C required; steps are identical pattern, batch them |
|
||||||
|
| E — Directory ViewModel | UserAccessAuditViewModel browse mode state + commands | Medium | Phase B required; do after branding ViewModel pattern is proven |
|
||||||
|
| F — Branding Views | SettingsView.xaml, ProfileManagementDialog.xaml, base64→BitmapSource converter | Low | Phase D required; write converter once, reuse in both views |
|
||||||
|
| G — Directory View | UserAccessAuditView.xaml + code-behind SelectionChanged handler | Medium | Phase E required; do last, after ViewModel unit tests pass |
|
||||||
|
|
||||||
|
Key architectural constraints (must not violate):
|
||||||
|
- **Client logo on `TenantProfile`, NOT in `BrandingSettings`.** Client logos are per-tenant; mixing them with global MSP settings makes per-profile deletion and serialization awkward.
|
||||||
|
- **Logos stored as base64 strings in JSON, not as file paths.** File paths become stale when the tool is redistributed to another machine. Decided once at Phase A; all downstream phases depend on it.
|
||||||
|
- **Export services use optional `ReportBranding?` parameter, not required.** All existing call sites compile unchanged; branding is injected only where desired.
|
||||||
|
- **No `IHtmlExportService` interface for this change.** The existing 5-concrete-classes pattern needs no interface for an optional parameter addition.
|
||||||
|
- **`GraphUserDirectoryService` is a new service, separate from `GraphUserSearchService`.** Different call patterns (no `startsWith` filter, different pagination), different cancellation needs.
|
||||||
|
- **Do NOT load the directory automatically on tab open.** Require explicit "Load Directory" button click to avoid blocking UI on large tenants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Top Pitfalls and Prevention Strategies
|
||||||
|
|
||||||
|
**v2.2-1 (Critical): Base64 logo bloat in every report**
|
||||||
|
Large source images (300-600 KB originals) become 400-800 KB of base64 inlined in every exported
|
||||||
|
HTML file, re-allocated on every export call.
|
||||||
|
Prevention: Enforce 512 KB max at import time in the settings UI. Store pre-encoded base64 in JSON
|
||||||
|
(computed once on import, never re-encoded). Inject the cached string directly into the `<img>` tag.
|
||||||
|
|
||||||
|
**v2.2-2 (Critical): Graph directory listing silently truncates at 999 users**
|
||||||
|
`GET /users` returns at most 999 per page. A 5,000-user tenant appears to have 999 users with no
|
||||||
|
error and no indication of truncation.
|
||||||
|
Prevention: Use `PageIterator<User, UserCollectionResponse>` for all full directory fetches. Never
|
||||||
|
call `.GetAsync()` on the users collection without following `@odata.nextLink` until null.
|
||||||
|
|
||||||
|
**v2.2-3 (Critical): Directory browse exposes guests, service accounts, and disabled accounts by default**
|
||||||
|
Raw `GET /users` returns all object types. An MSP tenant with 50+ guest collaborators and service
|
||||||
|
accounts produces a noisy, confusing directory.
|
||||||
|
Prevention: Default filter `accountEnabled eq true and userType eq 'Member'`. Expose an "Include
|
||||||
|
guest accounts" checkbox for explicit opt-in. Apply this filter at the service level, not the
|
||||||
|
ViewModel, so the ViewModel is not aware of Graph filter syntax.
|
||||||
|
|
||||||
|
**v2.2-4 (Critical): Directory load hangs UI without progress feedback**
|
||||||
|
3,000-user tenant takes 3-8 seconds. Without count feedback, the user assumes the feature is
|
||||||
|
broken and may double-click the button (triggering concurrent Graph requests).
|
||||||
|
Prevention: `DirectoryLoadStatus` observable property updated via `IProgress<int>` in the
|
||||||
|
PageIterator callback ("Loading... X users"). Guard `AsyncRelayCommand.CanExecute` during loading.
|
||||||
|
Add cancellation button wired to the same `CancellationToken` passed to `PageIterator.IterateAsync`.
|
||||||
|
|
||||||
|
**v2.2-5 (Critical): Logo file format validation skipped — broken images in reports**
|
||||||
|
OpenFileDialog filter is not sufficient. Renamed non-image files, corrupted JPEGs, and SVG files
|
||||||
|
pass the filter but produce broken `<img>` tags in generated reports.
|
||||||
|
Prevention: Validate by loading as `BitmapImage` in a try/catch before persisting. Check
|
||||||
|
`PixelWidth` and `PixelHeight` are non-zero. Use `BitmapCacheOption.OnLoad` + retry with
|
||||||
|
`IgnoreColorProfile` for EXIF-corrupt JPEGs. Reject SVG explicitly.
|
||||||
|
|
||||||
|
**v2.2-6 (Critical): Logo file path stored in JSON becomes stale across machines**
|
||||||
|
Storing `C:\Users\admin\logos\msp-logo.png` works on the import machine only. After redistribution
|
||||||
|
or reinstall, the path is missing and logos silently disappear from new reports.
|
||||||
|
Prevention: Store base64 string directly in `AppSettings` and `TenantProfile` JSON. The original
|
||||||
|
file path is discarded after import. The settings file becomes fully portable.
|
||||||
|
|
||||||
|
**Moderate pitfalls:**
|
||||||
|
- v2.2-7: Logo breaks HTML report print layout — apply `max-height: 60px; max-width: 200px` CSS and add `@media print` rules in the report `<style>` block.
|
||||||
|
- v2.2-8: Logo cleared on profile overwrite — verify `ClientLogoBase64` and `ClientLogoMimeType` survive the profile save/reload cycle before shipping.
|
||||||
|
- v2.2-9: `DirectoryPageTokenNotFoundException` on Graph page iteration retry — use `PageIterator` (which handles retry token correctly) rather than a manual `@odata.nextLink` loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implications for Roadmap
|
||||||
|
|
||||||
|
### Suggested Phase Structure
|
||||||
|
|
||||||
|
The two features are architecturally independent. A single developer should follow phases A-G in
|
||||||
|
order. Two developers can run branding (A→D→F) and directory browse (A→B→E→G) in parallel after
|
||||||
|
Phase A completes.
|
||||||
|
|
||||||
|
**Phase 1 — Data foundation (models + repositories + services)**
|
||||||
|
Rationale: Unblocks both features simultaneously. Establishes the logo storage strategy (base64-in-
|
||||||
|
JSON) before any export service or ViewModel is written. Establishing this here prevents a full
|
||||||
|
re-architecture later if file-path storage is chosen first.
|
||||||
|
Delivers: Phases A + B from the build order above.
|
||||||
|
Key decision to make before starting: confirm base64-in-JSON as the storage strategy (not file
|
||||||
|
paths). Document the decision explicitly.
|
||||||
|
Pitfalls to avoid: v2.2-6 (file path portability). The wrong storage decision here propagates to
|
||||||
|
all downstream phases.
|
||||||
|
|
||||||
|
**Phase 2 — HTML export service extensions + branding ViewModel integration**
|
||||||
|
Rationale: Modifying the 5 export services with an optional parameter is low-risk and unblocks all
|
||||||
|
ViewModel callers. The 4 export ViewModel changes are an identical inject-and-call pattern — batch
|
||||||
|
them. The SettingsViewModel and ProfileManagementViewModel changes complete the logo management UX.
|
||||||
|
Delivers: Phases C + D from the build order above. All HTML reports support optional logo headers.
|
||||||
|
MSP logo manageable from Settings. Client logo manageable from ProfileManagementDialog.
|
||||||
|
Pitfalls to avoid: v2.2-1 (size limit at import), v2.2-5 (file format validation), v2.2-7 (print
|
||||||
|
layout CSS). All three must be implemented in this phase, not deferred.
|
||||||
|
|
||||||
|
**Phase 3 — Branding UI views**
|
||||||
|
Rationale: Views built after ViewModel behavior is unit-tested. Requires the base64→BitmapSource
|
||||||
|
converter, written once and reused in both views.
|
||||||
|
Delivers: Phase F from the build order. Settings branding section + ProfileManagementDialog logo
|
||||||
|
fields, both with live preview.
|
||||||
|
|
||||||
|
**Phase 4 — User directory browse ViewModel**
|
||||||
|
Rationale: `UserAccessAuditViewModel` is the highest-risk change. New async command with progress,
|
||||||
|
cancellation, and tenant-switch reset. Implement and unit-test before touching the View.
|
||||||
|
Delivers: Phase E from the build order. Full browse mode behavior is testable via unit tests before
|
||||||
|
any XAML is written.
|
||||||
|
Pitfalls to avoid: v2.2-2 (pagination), v2.2-3 (default filter), v2.2-4 (progress feedback),
|
||||||
|
v2.2-9 (PageIterator vs. manual nextLink loop).
|
||||||
|
|
||||||
|
**Phase 5 — Directory browse UI view**
|
||||||
|
Rationale: Left panel restructure in UserAccessAuditView.xaml is the highest-risk XAML change.
|
||||||
|
Done last, after all ViewModel behavior is proven by tests.
|
||||||
|
Delivers: Phase G from the build order. Complete browse mode UX.
|
||||||
|
|
||||||
|
**Phase 6 — Differentiators (after core features proven)**
|
||||||
|
Rationale: Auto-pull Entra branding, directory guest filter toggle, department/jobTitle columns,
|
||||||
|
session-scoped directory cache. These are enhancements, not blockers for the milestone.
|
||||||
|
Delivers: Zero-config client logo path, richer directory filtering, faster repeat access.
|
||||||
|
Pitfalls to avoid: Auto-pull Entra logo must handle empty-body response gracefully (not all tenants
|
||||||
|
have branding configured). Fall back silently to no logo rather than showing an error.
|
||||||
|
|
||||||
|
### Research Flags
|
||||||
|
|
||||||
|
Phases 1-5 are standard patterns verified by direct codebase inspection. No additional research
|
||||||
|
needed. The architecture file provides exact file locations, class signatures, and data flows.
|
||||||
|
|
||||||
|
Phase 6 (auto-pull Entra branding): MEDIUM confidence. Test the `bannerLogo` stream endpoint
|
||||||
|
against a real tenant with and without branding configured before committing to the implementation.
|
||||||
|
The Graph API documentation states the response is an empty stream (not a 404) when no logo is set
|
||||||
|
— verify this behavior live before building the error handling path around it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions for Product Decisions
|
||||||
|
|
||||||
|
These are not technical blockers but should be resolved before the phase that implements them:
|
||||||
|
|
||||||
|
1. **SVG logo support: anti-feature or bring-your-own-library feature?**
|
||||||
|
Current recommendation: reject SVG (XSS risk in data-URIs, requires SharpVectors for rasterization). If SVG support is needed, SharpVectors adds a dependency. Decide before Phase 2.
|
||||||
|
|
||||||
|
2. **Client logo source priority when both auto-pull and manual import are configured?**
|
||||||
|
Recommendation: manual import wins; auto-pull is the fallback when no manual logo is set.
|
||||||
|
Implement as `ClientLogoSource` enum: `None | Imported | AutoPulled`. Decide before Phase 6.
|
||||||
|
|
||||||
|
3. **Session-scoped directory cache: ViewModel lifetime or shared service?**
|
||||||
|
ViewModel-scoped = cache lost on tab navigation (ViewModel is transient). Service-scoped = cache
|
||||||
|
survives tab switches. Recommendation: start with no cache (Refresh button), add service-level
|
||||||
|
caching in Phase 6 only if user feedback indicates it is needed. Defers scope decision.
|
||||||
|
|
||||||
|
4. **Report header layout: logos side-by-side or MSP left + client right?**
|
||||||
|
Visual design decision only; does not affect services or ViewModels. Current spec uses
|
||||||
|
`display: flex; gap: 16px` (left-to-right). Can be changed at any time.
|
||||||
|
|
||||||
|
5. **"Load Directory" button placement: inside browse panel or tab-level toolbar?**
|
||||||
|
Recommendation: inside the browse panel, visible only in Browse mode. Avoids confusion when in
|
||||||
|
Search mode. Does not affect architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confidence Assessment (v2.2)
|
||||||
|
|
||||||
|
| Area | Confidence | Basis |
|
||||||
|
|---|---|---|
|
||||||
|
| Stack (no new packages needed) | HIGH | Direct codebase inspection + BCL and Graph SDK documentation |
|
||||||
|
| Feature scope (table stakes vs. differentiators) | HIGH | Official Graph API docs + direct codebase inspection + MSP tool competitive research |
|
||||||
|
| Architecture (integration points, build order) | HIGH | Direct inspection of all affected files; exact class names and property signatures verified |
|
||||||
|
| Branding pitfalls (base64, file validation, portability) | HIGH | BCL behavior verified; file path portability pitfall is a well-known pattern |
|
||||||
|
| Graph pagination pitfalls | HIGH | Microsoft Learn PageIterator docs (updated 2025-08-06); DirectoryPageTokenNotFoundException documented |
|
||||||
|
| Directory filter behavior (accountEnabled, userType) | MEDIUM-HIGH | Graph docs confirm filter syntax; recommend verifying against a real tenant before shipping |
|
||||||
|
| Auto-pull Entra banner logo (Phase 6) | MEDIUM | API documented but empty-body behavior (no logo configured) needs live tenant verification |
|
||||||
|
| Print CSS behavior for logo header | MEDIUM | MDN/W3C verified; browser rendering varies; requires cross-browser manual test |
|
||||||
|
|
||||||
|
**Overall confidence:** HIGH for Phases 1-5. MEDIUM for Phase 6 (Entra auto-pull live behavior).
|
||||||
|
|
||||||
|
**Gaps to address during planning:**
|
||||||
|
- Confirm `$filter=accountEnabled eq true and userType eq 'Member'` works without `ConsistencyLevel: eventual` on the v1.0 `/users` endpoint. If eventual consistency is required, the `GraphUserDirectoryService` adds the `ConsistencyLevel` header and `$count=true` to this call path.
|
||||||
|
- Verify the Entra `bannerLogo` stream endpoint returns an empty response body (not HTTP 404) when tenant branding is not configured. This determines the error handling branch in the auto-pull code path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (v2.2)
|
||||||
|
|
||||||
|
| Source | Confidence | Used In |
|
||||||
|
|---|---|---|
|
||||||
|
| Microsoft Learn — List users (Graph v1.0), updated 2025-07-23 | HIGH | STACK, FEATURES, PITFALLS |
|
||||||
|
| Microsoft Learn — Page through a collection (Graph SDKs), updated 2025-08-06 | HIGH | STACK, PITFALLS |
|
||||||
|
| Microsoft Learn — Get organizationalBranding (Graph v1.0), updated 2025-11-08 | HIGH | STACK, FEATURES |
|
||||||
|
| .NET BCL docs — Convert.ToBase64String, File.ReadAllBytesAsync | HIGH | STACK |
|
||||||
|
| Microsoft Learn — Graph throttling guidance | HIGH | PITFALLS |
|
||||||
|
| Direct codebase inspection (GraphClientFactory, HtmlExportService, TenantProfile, AppSettings, UserAccessAuditViewModel, SettingsViewModel, UserAccessAuditView.xaml, App.xaml.cs) | HIGH | ARCHITECTURE, STACK |
|
||||||
|
| Existing codebase CONCERNS.md audit (2026-04-02) | HIGH | PITFALLS |
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# v1.0 Research Summary (Original — Preserved for Reference)
|
||||||
|
|
||||||
**Researched:** 2026-04-02
|
**Researched:** 2026-04-02
|
||||||
**Confidence:** HIGH
|
**Confidence:** HIGH
|
||||||
|
|
||||||
@@ -50,7 +393,7 @@ The feature scope is well-researched. Competitive analysis against ShareGate, Ma
|
|||||||
**Should have (competitive differentiators — v1.x):**
|
**Should have (competitive differentiators — v1.x):**
|
||||||
- User access export across selected sites — "everything User X can access across 15 sites" — no native M365 equivalent
|
- User access export across selected sites — "everything User X can access across 15 sites" — no native M365 equivalent
|
||||||
- Simplified permissions view (plain language) — "can edit files" instead of "Contribute"
|
- Simplified permissions view (plain language) — "can edit files" instead of "Contribute"
|
||||||
- Storage graph by file type (pie + bar toggle) — file-type breakdown competitors don't provide
|
- Storage graph by file type (pie + bar toggle) via ScottPlot.WPF
|
||||||
|
|
||||||
**Defer (v2+):**
|
**Defer (v2+):**
|
||||||
- Scheduled scan runs via Windows Task Scheduler (requires stable CLI/headless mode first)
|
- Scheduled scan runs via Windows Task Scheduler (requires stable CLI/headless mode first)
|
||||||
@@ -102,81 +445,49 @@ Based on the combined research, the dependency graph from ARCHITECTURE.md and FE
|
|||||||
### Phase 1: Foundation and Infrastructure
|
### Phase 1: Foundation and Infrastructure
|
||||||
**Rationale:** All 10 critical pitfalls must be resolved before feature work begins. The dependency graph in FEATURES.md shows that every feature requires the tenant profile registry and session caching layer. Establishing async patterns, error handling, DI container, logging, and JSON persistence now prevents the most expensive retrofits.
|
**Rationale:** All 10 critical pitfalls must be resolved before feature work begins. The dependency graph in FEATURES.md shows that every feature requires the tenant profile registry and session caching layer. Establishing async patterns, error handling, DI container, logging, and JSON persistence now prevents the most expensive retrofits.
|
||||||
**Delivers:** Runnable WPF shell with tenant selector, multi-tenant session caching (MSAL + MsalCacheHelper), DI container wiring, Serilog logging, SettingsService with write-then-replace persistence, ResX localization scaffolding, shared pagination helper, shared `AsyncRelayCommand` pattern, global exception handlers.
|
**Delivers:** Runnable WPF shell with tenant selector, multi-tenant session caching (MSAL + MsalCacheHelper), DI container wiring, Serilog logging, SettingsService with write-then-replace persistence, ResX localization scaffolding, shared pagination helper, shared `AsyncRelayCommand` pattern, global exception handlers.
|
||||||
**Addresses:** Tenant profile registry (prerequisite for all features), EN/FR localization scaffolding, error reporting infrastructure.
|
**Research flag:** Standard patterns — no additional research needed.
|
||||||
**Avoids:** All 10 pitfalls — async deadlocks, silent errors, token cache races, JSON corruption, ObservableCollection threading, async void, throttling, disposal gaps, trimming.
|
|
||||||
**Research flag:** Standard patterns — `Microsoft.Extensions.Hosting` + `CommunityToolkit.Mvvm` + `MsalCacheHelper` are well-documented. No additional research needed.
|
|
||||||
|
|
||||||
### Phase 2: Permissions and Audit Core
|
### Phase 2: Permissions and Audit Core
|
||||||
**Rationale:** Permissions reporting is the highest-value daily-use feature and the canonical audit use case. Building it second validates that the auth layer and pagination helper work under real conditions before other features depend on them. It also forces the error reporting UX to be finalized early.
|
**Rationale:** Permissions reporting is the highest-value daily-use feature and validates the auth layer and pagination helper under real conditions.
|
||||||
**Delivers:** Site-level permissions report with recursive scan (configurable depth), CSV export, self-contained HTML export, plain progress feedback ("Scanning X of Y sites"), error surface for failed scans (no silent failures).
|
**Delivers:** Site-level permissions report with recursive scan, CSV export, self-contained HTML export, progress feedback, error surface for failed scans.
|
||||||
**Addresses:** Permissions report (table stakes P1), CSV + HTML export (table stakes P1), error reporting (table stakes P1).
|
**Research flag:** Standard PnP Framework patterns — HIGH confidence.
|
||||||
**Avoids:** 5,000-item threshold (pagination helper reuse), silent errors (error handling from Phase 1), sync/async deadlock (AsyncRelayCommand from Phase 1).
|
|
||||||
**Research flag:** Standard patterns — PnP Framework permission scanning is well-documented. PnP permissions API is HIGH confidence.
|
|
||||||
|
|
||||||
### Phase 3: Storage Metrics and File Operations
|
### Phase 3: Storage Metrics and File Operations
|
||||||
**Rationale:** Storage metrics and file search are the other two daily-use features in the existing tool. They reuse the auth session and export infrastructure from Phases 1–2. Duplicate detection depends on the file enumeration infrastructure built for file search, so these belong together.
|
**Rationale:** Storage metrics and file search reuse the auth session and export infrastructure from Phases 1–2. Duplicate detection depends on file enumeration built here.
|
||||||
**Delivers:** Storage metrics per site (total + breakdown), file search across sites (KQL-based), duplicate file detection (hash or name+size matching), storage data export (CSV + HTML).
|
**Delivers:** Storage metrics, file search (KQL), duplicate detection, storage data export.
|
||||||
**Addresses:** Storage metrics (P1), file search (P1), duplicate detection (P1).
|
**Research flag:** Duplicate detection at scale under Graph throttling may need targeted research.
|
||||||
**Avoids:** Large collection streaming (IProgress<T> pattern from Phase 1), Graph SDK pagination (`PageIterator`), API throttling (retry handler from Phase 1).
|
|
||||||
**Research flag:** Duplicate detection against large tenants under Graph throttling may need tactical research during planning — hash-based detection at scale has specific pagination constraints.
|
|
||||||
|
|
||||||
### Phase 4: Bulk Operations and Provisioning
|
### Phase 4: Bulk Operations and Provisioning
|
||||||
**Rationale:** Bulk operations (member add, site creation, transfer) and site/folder template management are the remaining P1 features. They are the highest-complexity features (HIGH implementation cost in FEATURES.md) and benefit from stable async/cancel/progress infrastructure from Phase 1. Folder provisioning depends on site template management — build together.
|
**Rationale:** Highest-complexity features (bulk writes to client tenants) benefit from stable async/cancel/progress infrastructure from Phase 1.
|
||||||
**Delivers:** Bulk member add/remove, bulk site creation, ownership transfer, site template capture and apply, folder structure provisioning from template.
|
**Delivers:** Bulk member add/remove, bulk site creation, ownership transfer, site template capture and apply, folder structure provisioning.
|
||||||
**Addresses:** Bulk operations with progress/cancel (P1), site template management (P1), folder structure provisioning (P1).
|
**Research flag:** PnP Provisioning Engine for Teams-connected sites — edge cases need validation.
|
||||||
**Avoids:** Operation cancellation (CancellationToken threading from Phase 1), partial-failure reporting (error surface from Phase 2), API throttling (retry handler from Phase 1).
|
|
||||||
**Research flag:** PnP Provisioning Engine for site templates may need specific research during planning — template schema and apply behavior are documented but edge cases (Teams-connected sites, modern vs. classic) need validation.
|
|
||||||
|
|
||||||
### Phase 5: New Differentiating Features (v1.x)
|
### Phase 5: New Differentiating Features (v1.x)
|
||||||
**Rationale:** These three features are new capabilities (not existing-tool parity) that depend on stable v1 infrastructure. User access export across sites requires multi-site permissions scan from Phase 2. Storage charts require storage metrics from Phase 3. Plain-language permissions view is a presentation layer on top of the permissions data model from Phase 2. Grouping them as v1.x avoids blocking the v1 release on new development.
|
**Rationale:** New capabilities (not existing-tool parity) that depend on stable v1 infrastructure. Group here to avoid blocking the v1 release.
|
||||||
**Delivers:** User access export across arbitrary site subsets (cross-site access report for a single user), simplified plain-language permissions view (jargon-free labels, color coding), storage graph by file type (pie/bar toggle via ScottPlot.WPF).
|
**Delivers:** User access export across sites, simplified plain-language permissions view, storage graph by file type.
|
||||||
**Addresses:** User access export (P2), simplified permissions view (P2), storage graph by file type (P2).
|
**Research flag:** User access export — Graph API approach for enumerating all permissions for user X across N sites needs targeted research.
|
||||||
**Uses:** ScottPlot.WPF 5.1.57, existing PermissionsService and StorageService from Phases 2–3.
|
|
||||||
**Research flag:** User access export across sites involves enumerating group memberships, direct assignments, and inherited access across N sites — the Graph API volume and correct enumeration approach may need targeted research.
|
|
||||||
|
|
||||||
### Phase 6: Distribution and Hardening
|
### Phase 6: Distribution and Hardening
|
||||||
**Rationale:** Packaging, end-to-end validation on clean machines, FR locale completeness check, and the "looks done but isn't" checklist from PITFALLS.md. Must be done before any release, not as an afterthought.
|
**Rationale:** Packaging, end-to-end validation on clean machines, FR locale completeness, "looks done but isn't" checklist.
|
||||||
**Delivers:** Single self-contained EXE (`PublishSingleFile=true`, `SelfContained=true`, `PublishTrimmed=false`, `win-x64`), validated on a machine with no .NET runtime, FR locale fully tested, throttling recovery verified, JSON corruption recovery verified, cancellation verified, 5,000+ item library tested.
|
**Delivers:** Single self-contained EXE, validated on a machine with no .NET runtime, all checklist items verified.
|
||||||
**Avoids:** WPF trimming crash (Pitfall 6), "works on dev machine" surprises.
|
**Research flag:** Standard `dotnet publish` configuration — no additional research needed.
|
||||||
**Research flag:** Standard patterns — `dotnet publish` single-file configuration is well-documented.
|
|
||||||
|
|
||||||
### Phase Ordering Rationale
|
|
||||||
|
|
||||||
- **Foundation first** is mandatory: all 10 pitfalls map to Phase 1. The auth layer and async patterns are prerequisites for every subsequent phase. Starting features before the foundation is solid replicates the original app's architectural problems.
|
|
||||||
- **Permissions before storage/search** because permissions validates the pagination helper, auth layer, and export pipeline under real conditions with the most complex data model.
|
|
||||||
- **Bulk ops and provisioning after core read operations** because they have higher risk (they write to client tenants) and should be tested against a validated auth layer and error surface.
|
|
||||||
- **New v1.x features after v1 parity** to avoid blocking the release on non-parity features. The three P2 features are all presentation or cross-cutting enhancements on top of stable Phase 2–3 data models.
|
|
||||||
- **Distribution last** because EXE packaging must be validated against the complete feature set.
|
|
||||||
|
|
||||||
### Research Flags
|
|
||||||
|
|
||||||
Phases likely needing `/gsd:research-phase` during planning:
|
|
||||||
- **Phase 3 (Duplicate detection):** Hash-based detection under Graph throttling constraints at large scale — specific pagination strategy and concurrency limits for file enumeration need validation.
|
|
||||||
- **Phase 4 (Site templates):** PnP Provisioning Engine behavior for Teams-connected sites, modern site template schema edge cases, and apply-template behavior on non-empty sites need verification.
|
|
||||||
- **Phase 5 (User access export):** Graph API approach for enumerating all permissions for a single user across N sites (group memberships + direct assignments + inherited) — the correct API sequence and volume implications need targeted research.
|
|
||||||
|
|
||||||
Phases with standard patterns (skip research-phase):
|
|
||||||
- **Phase 1 (Foundation):** `Microsoft.Extensions.Hosting` + `CommunityToolkit.Mvvm` + `MsalCacheHelper` patterns are extensively documented in official Microsoft sources.
|
|
||||||
- **Phase 2 (Permissions):** PnP Framework permission scanning APIs are HIGH confidence from official PnP documentation.
|
|
||||||
- **Phase 6 (Distribution):** `dotnet publish` single-file configuration is straightforward and well-documented.
|
|
||||||
|
|
||||||
## Confidence Assessment
|
## Confidence Assessment
|
||||||
|
|
||||||
| Area | Confidence | Notes |
|
| Area | Confidence | Notes |
|
||||||
|------|------------|-------|
|
|---|---|---|
|
||||||
| Stack | HIGH | All package versions verified on NuGet; .NET lifecycle dates confirmed on Microsoft support policy page; PnP.Framework vs PnP.Core SDK choice verified against authoritative GitHub issue |
|
| Stack | HIGH | All package versions verified on NuGet; .NET lifecycle dates confirmed; PnP.Framework vs PnP.Core SDK choice verified |
|
||||||
| Features | MEDIUM | Microsoft docs (permissions reports, storage reports, Graph API) are HIGH; competitor feature analysis from marketing pages is MEDIUM; no direct API testing performed |
|
| Features | MEDIUM | Microsoft docs HIGH; competitor feature analysis from marketing pages MEDIUM; no direct API testing |
|
||||||
| Architecture | HIGH | MVVM patterns from Microsoft Learn (official); PnP Framework auth patterns from official PnP docs; `MsalCacheHelper` from official MSAL.NET docs |
|
| Architecture | HIGH | MVVM patterns from Microsoft Learn; PnP Framework auth patterns from official PnP docs; MsalCacheHelper from official MSAL.NET docs |
|
||||||
| Pitfalls | HIGH | Critical pitfalls verified via official docs, PnP GitHub issues, and direct audit of the existing codebase (CONCERNS.md); async deadlock and WPF trimming pitfalls confirmed via dotnet/wpf GitHub issues |
|
| Pitfalls | HIGH | Critical pitfalls verified via official docs, PnP GitHub issues, and direct audit of existing codebase (CONCERNS.md) |
|
||||||
|
|
||||||
**Overall confidence:** HIGH
|
**Overall confidence:** HIGH
|
||||||
|
|
||||||
### Gaps to Address
|
**Gaps to address:**
|
||||||
|
- PnP Provisioning Engine for Teams-connected sites: behavior not fully documented; validate during Phase 4 planning.
|
||||||
- **PnP Provisioning Engine for Teams-connected sites:** The behavior of `PnP.Framework`'s provisioning engine when applied to Teams-connected modern team sites (vs. classic or communication sites) is not fully documented. Validate during Phase 4 planning with a dedicated research spike.
|
- User cross-site access enumeration via Graph API: multiple possible approaches with different throttling profiles; validate during Phase 5 planning.
|
||||||
- **User cross-site access enumeration via Graph API:** The correct Graph API sequence for "all permissions for user X across N sites" (covering group memberships, direct site assignments, and SharePoint group memberships) has multiple possible approaches with different throttling profiles. Validate the most efficient approach during Phase 5 planning.
|
- Graph API volume for duplicate detection at large scale: practical concurrency limits need validation.
|
||||||
- **Graph API volume for duplicate detection:** Enumerating file hashes across a large tenant (100k+ files) via `driveItem` Graph calls has unclear throttling limits at that scale. The practical concurrency limit and whether SHA256 computation must happen client-side needs validation.
|
- ScottPlot.WPF XAML integration: WpfPlot binding patterns less documented than WinForms equivalent; validate during Phase 5 planning.
|
||||||
- **ScottPlot.WPF XAML integration:** ScottPlot 5.x WPF XAML control integration patterns are less documented than the WinForms equivalent. Validate the `WpfPlot` control binding approach during Phase 5 planning.
|
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
@@ -187,26 +498,17 @@ Phases with standard patterns (skip research-phase):
|
|||||||
- Microsoft Learn: SharePoint Online list view threshold — https://learn.microsoft.com/en-us/troubleshoot/sharepoint/lists-and-libraries/items-exceeds-list-view-threshold
|
- Microsoft Learn: SharePoint Online list view threshold — https://learn.microsoft.com/en-us/troubleshoot/sharepoint/lists-and-libraries/items-exceeds-list-view-threshold
|
||||||
- Microsoft Learn: Graph SDK paging — https://learn.microsoft.com/en-us/graph/sdks/paging
|
- Microsoft Learn: Graph SDK paging — https://learn.microsoft.com/en-us/graph/sdks/paging
|
||||||
- Microsoft Learn: Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling
|
- Microsoft Learn: Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling
|
||||||
- PnP Framework GitHub: https://github.com/pnp/pnpframework — .NET targets, auth patterns
|
- PnP Framework GitHub: https://github.com/pnp/pnpframework
|
||||||
- PnP Framework vs Core authoritative comparison: https://github.com/pnp/pnpframework/issues/620
|
- PnP Framework vs Core authoritative comparison: https://github.com/pnp/pnpframework/issues/620
|
||||||
- PnP Framework auth issues: https://github.com/pnp/pnpframework/issues/961, /447
|
|
||||||
- dotnet/wpf trimming issues: https://github.com/dotnet/wpf/issues/4216, /6096
|
- dotnet/wpf trimming issues: https://github.com/dotnet/wpf/issues/4216, /6096
|
||||||
- .NET 10 announcement: https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/
|
|
||||||
- .NET support policy: https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core
|
|
||||||
- CommunityToolkit 8.4 announcement: https://devblogs.microsoft.com/dotnet/announcing-the-dotnet-community-toolkit-840/
|
|
||||||
- Existing codebase CONCERNS.md audit (2026-04-02)
|
- Existing codebase CONCERNS.md audit (2026-04-02)
|
||||||
|
|
||||||
### Secondary (MEDIUM confidence)
|
### Secondary (MEDIUM confidence)
|
||||||
- ShareGate SharePoint audit tool feature page — https://sharegate.com/sharepoint-audit-tool
|
- ShareGate SharePoint audit tool feature page — https://sharegate.com/sharepoint-audit-tool
|
||||||
- ManageEngine SharePoint Manager Plus — https://www.manageengine.com/sharepoint-management-reporting/sharepoint-permission-auditing-tool.html
|
- ManageEngine SharePoint Manager Plus — https://www.manageengine.com/sharepoint-management-reporting/sharepoint-permission-auditing-tool.html
|
||||||
- AdminDroid SharePoint Online auditing — https://admindroid.com/microsoft-365-sharepoint-online-auditing
|
- AdminDroid SharePoint Online auditing — https://admindroid.com/microsoft-365-sharepoint-online-auditing
|
||||||
- sprobot.io: 9 must-have features for SharePoint storage reporting — https://www.sprobot.io/blog/how-to-choose-the-right-sharepoint-storage-reporting-tool-9-must-have-features
|
|
||||||
- WPF Development Best Practices 2024 — https://medium.com/mesciusinc/wpf-development-best-practices-for-2024-9e5062c71350
|
|
||||||
- Rick Strahl: Async and Async Void Event Handling in WPF — https://weblog.west-wind.com/posts/2022/Apr/22/Async-and-Async-Void-Event-Handling-in-WPF
|
|
||||||
|
|
||||||
### Tertiary (LOW confidence)
|
|
||||||
- NuGet: ScottPlot.WPF XAML control documentation — sparse; WpfPlot binding patterns need hands-on validation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Research completed: 2026-04-02*
|
*v1.0 research completed: 2026-04-02*
|
||||||
|
*v2.2 research synthesized: 2026-04-08*
|
||||||
*Ready for roadmap: yes*
|
*Ready for roadmap: yes*
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class BrandingRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempFile;
|
||||||
|
|
||||||
|
public BrandingRepositoryTests()
|
||||||
|
{
|
||||||
|
_tempFile = Path.GetTempFileName();
|
||||||
|
File.Delete(_tempFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tempFile)) File.Delete(_tempFile);
|
||||||
|
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrandingRepository CreateRepository() => new(_tempFile);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_MissingFile_ReturnsDefaultBrandingSettings()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
|
||||||
|
var settings = await repo.LoadAsync();
|
||||||
|
|
||||||
|
Assert.Null(settings.MspLogo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAndLoad_RoundTrips_MspLogo()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
|
||||||
|
var original = new BrandingSettings { MspLogo = logo };
|
||||||
|
|
||||||
|
await repo.SaveAsync(original);
|
||||||
|
var loaded = await repo.LoadAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(loaded.MspLogo);
|
||||||
|
Assert.Equal("abc123==", loaded.MspLogo.Base64);
|
||||||
|
Assert.Equal("image/png", loaded.MspLogo.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_CreatesDirectoryIfNotExists()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), "subdir");
|
||||||
|
var filePath = Path.Combine(tempDir, "branding.json");
|
||||||
|
var repo = new BrandingRepository(filePath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await repo.SaveAsync(new BrandingSettings());
|
||||||
|
Assert.True(File.Exists(filePath), "File must be created even when directory did not exist");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(filePath)) File.Delete(filePath);
|
||||||
|
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TenantProfile_WithClientLogo_SerializesAndDeserializesCorrectly()
|
||||||
|
{
|
||||||
|
var logo = new LogoData { Base64 = "xyz==", MimeType = "image/jpeg" };
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "Contoso",
|
||||||
|
TenantUrl = "https://contoso.sharepoint.com",
|
||||||
|
ClientId = "client-id-123",
|
||||||
|
ClientLogo = logo
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(profile, options);
|
||||||
|
|
||||||
|
// Verify camelCase key exists
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
Assert.True(doc.RootElement.TryGetProperty("clientLogo", out var clientLogoElem),
|
||||||
|
"JSON must contain 'clientLogo' key (camelCase)");
|
||||||
|
Assert.Equal(JsonValueKind.Object, clientLogoElem.ValueKind);
|
||||||
|
|
||||||
|
// Deserialize back
|
||||||
|
var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, readOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded?.ClientLogo);
|
||||||
|
Assert.Equal("xyz==", loaded.ClientLogo.Base64);
|
||||||
|
Assert.Equal("image/jpeg", loaded.ClientLogo.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TenantProfile_WithoutClientLogo_SerializesWithNullAndDeserializesWithNull()
|
||||||
|
{
|
||||||
|
var profile = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "Fabrikam",
|
||||||
|
TenantUrl = "https://fabrikam.sharepoint.com",
|
||||||
|
ClientId = "client-id-456"
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(profile, options);
|
||||||
|
|
||||||
|
// Deserialize back — ClientLogo should be null (forward compatible)
|
||||||
|
var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, readOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Null(loaded.ClientLogo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.IO;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class BrandingServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempRepoFile;
|
||||||
|
private readonly List<string> _tempFiles = new();
|
||||||
|
|
||||||
|
public BrandingServiceTests()
|
||||||
|
{
|
||||||
|
_tempRepoFile = Path.GetTempFileName();
|
||||||
|
File.Delete(_tempRepoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tempRepoFile)) File.Delete(_tempRepoFile);
|
||||||
|
if (File.Exists(_tempRepoFile + ".tmp")) File.Delete(_tempRepoFile + ".tmp");
|
||||||
|
foreach (var f in _tempFiles)
|
||||||
|
{
|
||||||
|
if (File.Exists(f)) File.Delete(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrandingRepository CreateRepository() => new(_tempRepoFile);
|
||||||
|
private BrandingService CreateService() => new(CreateRepository());
|
||||||
|
|
||||||
|
private string WriteTempFile(byte[] bytes)
|
||||||
|
{
|
||||||
|
var path = Path.GetTempFileName();
|
||||||
|
File.WriteAllBytes(path, bytes);
|
||||||
|
_tempFiles.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal valid 1x1 PNG bytes
|
||||||
|
private static byte[] MinimalPngBytes()
|
||||||
|
{
|
||||||
|
// Full 1x1 transparent PNG (67 bytes)
|
||||||
|
return new byte[]
|
||||||
|
{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||||
|
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||||
|
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||||
|
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||||
|
0x08, 0x02, // bit depth = 8, color type = RGB
|
||||||
|
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||||
|
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x0C, // IDAT length
|
||||||
|
0x49, 0x44, 0x41, 0x54, // IDAT
|
||||||
|
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data
|
||||||
|
0xE2, 0x21, 0xBC, 0x33, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x00, // IEND length
|
||||||
|
0x49, 0x45, 0x4E, 0x44, // IEND
|
||||||
|
0xAE, 0x42, 0x60, 0x82 // CRC
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal valid JPEG bytes (SOI + APP0 JFIF header + EOI)
|
||||||
|
private static byte[] MinimalJpegBytes()
|
||||||
|
{
|
||||||
|
return new byte[]
|
||||||
|
{
|
||||||
|
0xFF, 0xD8, // SOI
|
||||||
|
0xFF, 0xE0, // APP0 marker
|
||||||
|
0x00, 0x10, // length = 16
|
||||||
|
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
||||||
|
0x01, 0x01, // version 1.1
|
||||||
|
0x00, // aspect ratio units = 0
|
||||||
|
0x00, 0x01, 0x00, 0x01, // X/Y density = 1
|
||||||
|
0x00, 0x00, // thumbnail size
|
||||||
|
0xFF, 0xD9 // EOI
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_ValidPng_ReturnsPngLogoData()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var pngBytes = MinimalPngBytes();
|
||||||
|
var path = WriteTempFile(pngBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal("image/png", result.MimeType);
|
||||||
|
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_ValidJpeg_ReturnsJpegLogoData()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var jpegBytes = MinimalJpegBytes();
|
||||||
|
var path = WriteTempFile(jpegBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal("image/jpeg", result.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_BmpFile_ThrowsInvalidDataExceptionMentioningPngAndJpg()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
// BMP magic bytes: 0x42 0x4D
|
||||||
|
var bmpBytes = new byte[] { 0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||||
|
var path = WriteTempFile(bmpBytes);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
|
||||||
|
Assert.Contains("PNG", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("JPG", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_EmptyFile_ThrowsInvalidDataException()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var path = WriteTempFile(Array.Empty<byte>());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_FileUnder512KB_ReturnOriginalBytesUnmodified()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var pngBytes = MinimalPngBytes();
|
||||||
|
var path = WriteTempFile(pngBytes);
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
// Create a large PNG image in memory (400x400 random pixels)
|
||||||
|
var largePngPath = Path.GetTempFileName();
|
||||||
|
_tempFiles.Add(largePngPath);
|
||||||
|
|
||||||
|
using (var bmp = new Bitmap(400, 400))
|
||||||
|
{
|
||||||
|
var rng = new Random(42);
|
||||||
|
for (int y = 0; y < 400; y++)
|
||||||
|
for (int x = 0; x < 400; x++)
|
||||||
|
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
|
||||||
|
bmp.Save(largePngPath, ImageFormat.Png);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileSize = new FileInfo(largePngPath).Length;
|
||||||
|
// PNG with random pixels should exceed 512 KB
|
||||||
|
// If not, we'll pad it
|
||||||
|
if (fileSize <= 512 * 1024)
|
||||||
|
{
|
||||||
|
// Generate a bigger image to be sure
|
||||||
|
using var bmp = new Bitmap(800, 800);
|
||||||
|
var rng = new Random(42);
|
||||||
|
for (int y = 0; y < 800; y++)
|
||||||
|
for (int x = 0; x < 800; x++)
|
||||||
|
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
|
||||||
|
bmp.Save(largePngPath, ImageFormat.Png);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize = new FileInfo(largePngPath).Length;
|
||||||
|
Assert.True(fileSize > 512 * 1024, $"Test setup: PNG file must be > 512 KB but was {fileSize} bytes");
|
||||||
|
|
||||||
|
var result = await service.ImportLogoAsync(largePngPath);
|
||||||
|
|
||||||
|
var decodedBytes = Convert.FromBase64String(result.Base64);
|
||||||
|
Assert.True(decodedBytes.Length <= 512 * 1024,
|
||||||
|
$"Compressed result must be <= 512 KB but was {decodedBytes.Length} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveMspLogoAsync_PersistsLogoInRepository()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var service = new BrandingService(repo);
|
||||||
|
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
|
||||||
|
|
||||||
|
await service.SaveMspLogoAsync(logo);
|
||||||
|
|
||||||
|
var loaded = await repo.LoadAsync();
|
||||||
|
Assert.NotNull(loaded.MspLogo);
|
||||||
|
Assert.Equal("abc123==", loaded.MspLogo.Base64);
|
||||||
|
Assert.Equal("image/png", loaded.MspLogo.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearMspLogoAsync_SetsMspLogoToNull()
|
||||||
|
{
|
||||||
|
var repo = CreateRepository();
|
||||||
|
var service = new BrandingService(repo);
|
||||||
|
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
|
||||||
|
await service.SaveMspLogoAsync(logo);
|
||||||
|
|
||||||
|
await service.ClearMspLogoAsync();
|
||||||
|
|
||||||
|
var loaded = await repo.LoadAsync();
|
||||||
|
Assert.Null(loaded.MspLogo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMspLogoAsync_WhenNoLogoConfigured_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var result = await service.GetMspLogoAsync();
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="GraphUserDirectoryService"/> (Phase 10 Plan 02).
|
||||||
|
///
|
||||||
|
/// Testing strategy: GraphUserDirectoryService wraps Microsoft Graph SDK's PageIterator,
|
||||||
|
/// whose constructor is internal and cannot be mocked without a real GraphServiceClient.
|
||||||
|
/// Full pagination/cancellation tests therefore require integration-level setup.
|
||||||
|
///
|
||||||
|
/// We test what IS unit-testable:
|
||||||
|
/// 1. MapUser — the static mapping method that converts a Graph User to GraphDirectoryUser.
|
||||||
|
/// This covers all 5 required fields and the DisplayName fallback logic.
|
||||||
|
/// 2. GetUsersAsync integration paths are documented with Skip tests that explain the
|
||||||
|
/// constraint and serve as living documentation of intended behaviour.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class GraphUserDirectoryServiceTests
|
||||||
|
{
|
||||||
|
// ── MapUser: field mapping ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUser_AllFieldsPresent_MapsCorrectly()
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
DisplayName = "Alice Smith",
|
||||||
|
UserPrincipalName = "alice@contoso.com",
|
||||||
|
Mail = "alice@contoso.com",
|
||||||
|
Department = "Engineering",
|
||||||
|
JobTitle = "Senior Developer"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GraphUserDirectoryService.MapUser(user);
|
||||||
|
|
||||||
|
Assert.Equal("Alice Smith", result.DisplayName);
|
||||||
|
Assert.Equal("alice@contoso.com", result.UserPrincipalName);
|
||||||
|
Assert.Equal("alice@contoso.com", result.Mail);
|
||||||
|
Assert.Equal("Engineering", result.Department);
|
||||||
|
Assert.Equal("Senior Developer", result.JobTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUser_NullDisplayName_FallsBackToUserPrincipalName()
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
DisplayName = null,
|
||||||
|
UserPrincipalName = "bob@contoso.com",
|
||||||
|
Mail = null,
|
||||||
|
Department = null,
|
||||||
|
JobTitle = null
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GraphUserDirectoryService.MapUser(user);
|
||||||
|
|
||||||
|
Assert.Equal("bob@contoso.com", result.DisplayName);
|
||||||
|
Assert.Equal("bob@contoso.com", result.UserPrincipalName);
|
||||||
|
Assert.Null(result.Mail);
|
||||||
|
Assert.Null(result.Department);
|
||||||
|
Assert.Null(result.JobTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUser_NullDisplayNameAndNullUPN_FallsBackToEmptyString()
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
DisplayName = null,
|
||||||
|
UserPrincipalName = null,
|
||||||
|
Mail = null,
|
||||||
|
Department = null,
|
||||||
|
JobTitle = null
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GraphUserDirectoryService.MapUser(user);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, result.DisplayName);
|
||||||
|
Assert.Equal(string.Empty, result.UserPrincipalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUser_NullUPN_ReturnsEmptyStringForUPN()
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
DisplayName = "Carol Jones",
|
||||||
|
UserPrincipalName = null,
|
||||||
|
Mail = "carol@contoso.com",
|
||||||
|
Department = "Marketing",
|
||||||
|
JobTitle = "Manager"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GraphUserDirectoryService.MapUser(user);
|
||||||
|
|
||||||
|
Assert.Equal("Carol Jones", result.DisplayName);
|
||||||
|
Assert.Equal(string.Empty, result.UserPrincipalName);
|
||||||
|
Assert.Equal("carol@contoso.com", result.Mail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapUser_OptionalFieldsNull_ProducesNullableNullProperties()
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
DisplayName = "Dave Brown",
|
||||||
|
UserPrincipalName = "dave@contoso.com",
|
||||||
|
Mail = null,
|
||||||
|
Department = null,
|
||||||
|
JobTitle = null
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = GraphUserDirectoryService.MapUser(user);
|
||||||
|
|
||||||
|
Assert.Null(result.Mail);
|
||||||
|
Assert.Null(result.Department);
|
||||||
|
Assert.Null(result.JobTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetUsersAsync: integration-level scenarios (skipped without live tenant) ──
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires integration test with real Graph client — PageIterator.CreatePageIterator " +
|
||||||
|
"uses internal GraphServiceClient request execution that cannot be mocked via Moq. " +
|
||||||
|
"Intended behaviour: returns all users matching filter across all pages, " +
|
||||||
|
"correctly mapping all 5 fields per user.")]
|
||||||
|
public Task GetUsersAsync_SinglePage_ReturnsMappedUsers()
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires integration test with real Graph client. " +
|
||||||
|
"Intended behaviour: IProgress<int>.Report is called once per user " +
|
||||||
|
"with an incrementing count (1, 2, 3, ...).")]
|
||||||
|
public Task GetUsersAsync_ReportsProgressWithIncrementingCount()
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires integration test with real Graph client. " +
|
||||||
|
"Intended behaviour: when CancellationToken is cancelled during iteration, " +
|
||||||
|
"the callback returns false and iteration stops, returning partial results " +
|
||||||
|
"(or OperationCanceledException if cancellation fires before first page).")]
|
||||||
|
public Task GetUsersAsync_CancelledToken_StopsIteration()
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires integration test with real Graph client. " +
|
||||||
|
"Intended behaviour: when Graph returns null response, " +
|
||||||
|
"GetUsersAsync returns an empty IReadOnlyList without throwing.")]
|
||||||
|
public Task GetUsersAsync_NullResponse_ReturnsEmptyList()
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+fa793c54892f69c19c41272ddb1c8a02fec46be7")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9176ae7db931d067f84b7a72ec1460e4d9fa1a45")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox.Tests")]
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
84036a2e5e2ee258a005d66e5845f612013cb98e6b58548e119856918d40472d
|
3f8ff0168203d2b01c418d674e129581553c2c0acb02df24b6f442c11d07e92d
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
f17609c0632a3d5e5ad08dbd7d71ec7f13ea79052c347138fbfb0f9c30108450
|
9b3b0f82ba5d0e7afb747bc2e2a3e8c663e5ab3dedc2e90cd2499548ebc0904d
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -77,6 +77,12 @@ public partial class App : Application
|
|||||||
"SharepointToolbox");
|
"SharepointToolbox");
|
||||||
services.AddSingleton(_ => new ProfileRepository(Path.Combine(appData, "profiles.json")));
|
services.AddSingleton(_ => new ProfileRepository(Path.Combine(appData, "profiles.json")));
|
||||||
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
|
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
|
||||||
|
|
||||||
|
// Phase 10: Branding Data Foundation
|
||||||
|
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
|
||||||
|
services.AddSingleton<IBrandingService, BrandingService>();
|
||||||
|
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
|
||||||
services.AddSingleton<MsalClientFactory>();
|
services.AddSingleton<MsalClientFactory>();
|
||||||
services.AddSingleton<SessionManager>();
|
services.AddSingleton<SessionManager>();
|
||||||
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
|
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a directory user returned by <see cref="SharepointToolbox.Services.IGraphUserDirectoryService"/>.
|
||||||
|
/// Used by Phase 13's User Directory ViewModel to display and filter tenant members.
|
||||||
|
/// </summary>
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ public class TenantProfile
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string TenantUrl { get; set; } = string.Empty;
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public LogoData? ClientLogo { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class BrandingRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public BrandingRepository(string filePath)
|
||||||
|
{
|
||||||
|
_filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BrandingSettings> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return new BrandingSettings();
|
||||||
|
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Failed to read branding file: {_filePath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = JsonSerializer.Deserialize<BrandingSettings>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
return settings ?? new BrandingSettings();
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Branding file contains invalid JSON: {_filePath}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(BrandingSettings settings)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings,
|
||||||
|
new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
|
||||||
|
// Validate round-trip before replacing
|
||||||
|
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
|
||||||
|
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class BrandingService : IBrandingService
|
||||||
|
{
|
||||||
|
private const int MaxSizeBytes = 512 * 1024; // 512 KB
|
||||||
|
|
||||||
|
// PNG signature: first 4 bytes
|
||||||
|
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||||
|
// JPEG signature: first 3 bytes
|
||||||
|
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||||
|
|
||||||
|
private readonly BrandingRepository _repository;
|
||||||
|
|
||||||
|
public BrandingService(BrandingRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a file, validates that it is PNG or JPEG via magic bytes, auto-compresses if over 512 KB,
|
||||||
|
/// and returns a LogoData record. Does NOT persist anything — the caller decides where to store it.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
|
|
||||||
|
var mimeType = DetectMimeType(bytes);
|
||||||
|
|
||||||
|
if (bytes.Length > MaxSizeBytes)
|
||||||
|
{
|
||||||
|
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LogoData
|
||||||
|
{
|
||||||
|
Base64 = Convert.ToBase64String(bytes),
|
||||||
|
MimeType = mimeType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveMspLogoAsync(LogoData logo)
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
settings.MspLogo = logo;
|
||||||
|
await _repository.SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearMspLogoAsync()
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
settings.MspLogo = null;
|
||||||
|
await _repository.SaveAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LogoData?> GetMspLogoAsync()
|
||||||
|
{
|
||||||
|
var settings = await _repository.LoadAsync();
|
||||||
|
return settings.MspLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static string DetectMimeType(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length == 0)
|
||||||
|
throw new InvalidDataException("File is empty. Only PNG and JPG files are accepted.");
|
||||||
|
|
||||||
|
if (bytes.Length >= 4 && bytes[0] == PngMagic[0] && bytes[1] == PngMagic[1]
|
||||||
|
&& bytes[2] == PngMagic[2] && bytes[3] == PngMagic[3])
|
||||||
|
return "image/png";
|
||||||
|
|
||||||
|
if (bytes.Length >= 3 && bytes[0] == JpegMagic[0] && bytes[1] == JpegMagic[1]
|
||||||
|
&& bytes[2] == JpegMagic[2])
|
||||||
|
return "image/jpeg";
|
||||||
|
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"File format is not PNG or JPG. Only PNG and JPG are accepted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compresses image bytes using WPF imaging (PresentationCore) to fit within <paramref name="maxBytes"/>.
|
||||||
|
/// Resizes proportionally to max 300x300 at quality 75 first pass; if still too large, 200x200 at quality 50.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CompressToLimit(byte[] bytes, string mimeType, int maxBytes)
|
||||||
|
{
|
||||||
|
// First pass: resize to 300x300 max, quality 75
|
||||||
|
var compressed = ResizeAndEncode(bytes, mimeType, 300, 75);
|
||||||
|
if (compressed.Length <= maxBytes)
|
||||||
|
return compressed;
|
||||||
|
|
||||||
|
// Second pass: resize to 200x200 max, quality 50
|
||||||
|
compressed = ResizeAndEncode(bytes, mimeType, 200, 50);
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ResizeAndEncode(byte[] originalBytes, string mimeType, int maxDimension, int quality)
|
||||||
|
{
|
||||||
|
// Decode source image using WPF BitmapDecoder
|
||||||
|
using var inputStream = new MemoryStream(originalBytes);
|
||||||
|
var decoder = BitmapDecoder.Create(
|
||||||
|
inputStream,
|
||||||
|
BitmapCreateOptions.PreservePixelFormat,
|
||||||
|
BitmapCacheOption.OnLoad);
|
||||||
|
|
||||||
|
var frame = decoder.Frames[0];
|
||||||
|
|
||||||
|
// Calculate target dimensions (proportional scaling)
|
||||||
|
double srcWidth = frame.PixelWidth;
|
||||||
|
double srcHeight = frame.PixelHeight;
|
||||||
|
double scale = Math.Min((double)maxDimension / srcWidth, (double)maxDimension / srcHeight);
|
||||||
|
|
||||||
|
// Only scale down, never up
|
||||||
|
if (scale >= 1.0)
|
||||||
|
scale = 1.0;
|
||||||
|
|
||||||
|
int targetWidth = Math.Max(1, (int)(srcWidth * scale));
|
||||||
|
int targetHeight = Math.Max(1, (int)(srcHeight * scale));
|
||||||
|
|
||||||
|
// Scale the bitmap using TransformedBitmap
|
||||||
|
var scaledBitmap = new TransformedBitmap(
|
||||||
|
frame,
|
||||||
|
new ScaleTransform(scale, scale));
|
||||||
|
|
||||||
|
// Encode to target format
|
||||||
|
using var outputStream = new MemoryStream();
|
||||||
|
BitmapEncoder encoder = mimeType == "image/png"
|
||||||
|
? new PngBitmapEncoder()
|
||||||
|
: CreateJpegEncoder(quality);
|
||||||
|
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(scaledBitmap));
|
||||||
|
encoder.Save(outputStream);
|
||||||
|
|
||||||
|
return outputStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BitmapEncoder CreateJpegEncoder(int quality)
|
||||||
|
{
|
||||||
|
return new JpegBitmapEncoder
|
||||||
|
{
|
||||||
|
QualityLevel = quality
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates all enabled member users from a tenant via Microsoft Graph,
|
||||||
|
/// using PageIterator for transparent multi-page iteration.
|
||||||
|
/// Used by Phase 13's User Directory ViewModel.
|
||||||
|
/// </summary>
|
||||||
|
public class GraphUserDirectoryService : IGraphUserDirectoryService
|
||||||
|
{
|
||||||
|
private readonly AppGraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
|
public GraphUserDirectoryService(AppGraphClientFactory graphClientFactory)
|
||||||
|
{
|
||||||
|
_graphClientFactory = graphClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
IProgress<int>? progress = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
|
||||||
|
var response = await graphClient.Users.GetAsync(config =>
|
||||||
|
{
|
||||||
|
// Pending real-tenant verification — see STATE.md pending todos
|
||||||
|
config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'";
|
||||||
|
config.QueryParameters.Select = new[]
|
||||||
|
{
|
||||||
|
"displayName", "userPrincipalName", "mail", "department", "jobTitle"
|
||||||
|
};
|
||||||
|
config.QueryParameters.Top = 999;
|
||||||
|
// No ConsistencyLevel header: standard equality filter does not require eventual consistency
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (response is null)
|
||||||
|
return Array.Empty<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var results = new List<GraphDirectoryUser>();
|
||||||
|
|
||||||
|
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient,
|
||||||
|
response,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
// Honour cancellation inside the callback — returning false stops iteration
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
results.Add(MapUser(user));
|
||||||
|
progress?.Report(results.Count);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageIterator.IterateAsync(ct);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a Graph SDK <see cref="User"/> object to a <see cref="GraphDirectoryUser"/> record.
|
||||||
|
/// Extracted as an internal static method to allow direct unit-test coverage of mapping logic
|
||||||
|
/// without requiring a live Graph endpoint.
|
||||||
|
/// </summary>
|
||||||
|
internal static GraphDirectoryUser MapUser(User user) =>
|
||||||
|
new(
|
||||||
|
DisplayName: user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
|
||||||
|
UserPrincipalName: user.UserPrincipalName ?? string.Empty,
|
||||||
|
Mail: user.Mail,
|
||||||
|
Department: user.Department,
|
||||||
|
JobTitle: user.JobTitle);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public interface IBrandingService
|
||||||
|
{
|
||||||
|
Task<LogoData> ImportLogoAsync(string filePath);
|
||||||
|
Task SaveMspLogoAsync(LogoData logo);
|
||||||
|
Task ClearMspLogoAsync();
|
||||||
|
Task<LogoData?> GetMspLogoAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates all enabled member users from a tenant via Microsoft Graph.
|
||||||
|
/// Used by Phase 13's User Directory ViewModel to populate the browse grid.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGraphUserDirectoryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all enabled member users in the tenant associated with <paramref name="clientId"/>.
|
||||||
|
/// Iterates through all pages using the Graph SDK PageIterator until exhausted or cancelled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientId">The client/tenant identifier used to obtain a Graph token.</param>
|
||||||
|
/// <param name="progress">
|
||||||
|
/// Optional progress reporter — receives the running count of users fetched so far.
|
||||||
|
/// Phase 13's ViewModel uses this to show "Loading... X users" feedback.
|
||||||
|
/// Pass <c>null</c> for no progress reporting.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">Cancellation token. Iteration stops when cancelled.</param>
|
||||||
|
Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
|
||||||
|
string clientId,
|
||||||
|
IProgress<int>? progress = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+fd442f3b4c10666cc02ae53f6d70ac046aa3c3b4")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9176ae7db931d067f84b7a72ec1460e4d9fa1a45")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
566ede48ea227dba75e6152012b91ec19564bdb676692fa82f45a46d7c288c82
|
a9f27f31a08398aac4f03e3e63fabad61340e40ec0164f0e80bdbe015af66c82
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
c1602d7613411dece7a7fc4cb3e91b87d72230c1e3a0cec4f762bbb7f65eeb17
|
35cdd7f2a3ee25cbc17a2ebaaa90d431e91c5800dd4ad91d5989af8d051e9166
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2280f12eab437908b8f875ca7b23f6c1996f0e60")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7f00aa4c203d47f2ab657351af9bceaab5ca4d3f0b2b5bd96efc66e479690a96
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
@@ -12,7 +12,7 @@ TRACE;DEBUG;NET;NET10_0;NETCOREAPP;WINDOWS;WINDOWS7_0;NET5_0_OR_GREATER;NET6_0_O
|
|||||||
|
|
||||||
17-812432200
|
17-812432200
|
||||||
|
|
||||||
1241393142523
|
132-465911513
|
||||||
300418310314
|
300418310314
|
||||||
MainWindow.xaml;Views\Dialogs\ConfirmBulkOperationDialog.xaml;Views\Dialogs\FolderBrowserDialog.xaml;Views\Dialogs\ProfileManagementDialog.xaml;Views\Dialogs\SitePickerDialog.xaml;Views\Tabs\BulkMembersView.xaml;Views\Tabs\BulkSitesView.xaml;Views\Tabs\DuplicatesView.xaml;Views\Tabs\FolderStructureView.xaml;Views\Tabs\PermissionsView.xaml;Views\Tabs\SearchView.xaml;Views\Tabs\SettingsView.xaml;Views\Tabs\StorageView.xaml;Views\Tabs\TemplatesView.xaml;Views\Tabs\TransferView.xaml;Views\Tabs\UserAccessAuditView.xaml;App.xaml;
|
MainWindow.xaml;Views\Dialogs\ConfirmBulkOperationDialog.xaml;Views\Dialogs\FolderBrowserDialog.xaml;Views\Dialogs\ProfileManagementDialog.xaml;Views\Dialogs\SitePickerDialog.xaml;Views\Tabs\BulkMembersView.xaml;Views\Tabs\BulkSitesView.xaml;Views\Tabs\DuplicatesView.xaml;Views\Tabs\FolderStructureView.xaml;Views\Tabs\PermissionsView.xaml;Views\Tabs\SearchView.xaml;Views\Tabs\SettingsView.xaml;Views\Tabs\StorageView.xaml;Views\Tabs\TemplatesView.xaml;Views\Tabs\TransferView.xaml;Views\Tabs\UserAccessAuditView.xaml;App.xaml;
|
||||||
|
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+61d7ada945824e7a0df0d731a0d5ba121cca9ad7")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
e8ca83c0a5b75c6d6714aa6a75cda7dd7459a2771c91c09e7b0fd13321fbf2bd
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ffd71243e80e839e49ff6241b0ffacb70ee51bf")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cca859c83732d6c3cb475a4294cad87451c57ba1104a8ad119c9d47a895e561c
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2280f12eab437908b8f875ca7b23f6c1996f0e60")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7f00aa4c203d47f2ab657351af9bceaab5ca4d3f0b2b5bd96efc66e479690a96
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ffd71243e80e839e49ff6241b0ffacb70ee51bf")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cca859c83732d6c3cb475a4294cad87451c57ba1104a8ad119c9d47a895e561c
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2280f12eab437908b8f875ca7b23f6c1996f0e60")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7f00aa4c203d47f2ab657351af9bceaab5ca4d3f0b2b5bd96efc66e479690a96
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+61d7ada945824e7a0df0d731a0d5ba121cca9ad7")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
e8ca83c0a5b75c6d6714aa6a75cda7dd7459a2771c91c09e7b0fd13321fbf2bd
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2280f12eab437908b8f875ca7b23f6c1996f0e60")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
7f00aa4c203d47f2ab657351af9bceaab5ca4d3f0b2b5bd96efc66e479690a96
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+5e56a96cd0889a5a2a82219aaf77e68e29f5500c")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
f9eebcd443055d1d202294b9d0729a8ed02621dffe158002e347d73d51aa2538
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
|
||||||
|
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
|
||||||
|
build_property.CsWinRTComponent =
|
||||||
|
build_property.CsWinRTAotOptimizerEnabled =
|
||||||
|
build_property.CsWinRTAotWarningLevel =
|
||||||
|
build_property.TargetFramework = net10.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = SharepointToolbox
|
||||||
|
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// <auto-generated/>
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
BIN
Binary file not shown.
+24
@@ -0,0 +1,24 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+61d7ada945824e7a0df0d731a0d5ba121cca9ad7")]
|
||||||
|
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||||
|
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||||
|
|
||||||
|
// Generated by the MSBuild WriteCodeFragment class.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
e8ca83c0a5b75c6d6714aa6a75cda7dd7459a2771c91c09e7b0fd13321fbf2bd
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user