docs(07): create phase plan - 8 plans across 5 waves

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-07 12:32:39 +02:00
parent 91058bc2e4
commit 19e4c3852d
9 changed files with 2053 additions and 3 deletions

View File

@@ -45,7 +45,7 @@ Plans:
- [ ] 06-05-PLAN.md — Unit tests for global site selection flow
### Phase 7: User Access Audit
**Goal**: Administrators can audit every permission a specific user holds across selected sites and export the results
**Goal**: Administrators can audit every permission a specific user holds across selected sites, distinguish access types (direct/group/inherited), and export results to CSV or HTML
**Depends on**: Phase 6
**Requirements**: UACC-01, UACC-02
**Success Criteria** (what must be TRUE):
@@ -53,7 +53,16 @@ Plans:
2. Running the audit returns a list of all access entries the user holds across the selected sites
3. Results distinguish between direct role assignments, SharePoint group memberships, and inherited access
4. Results can be exported to CSV or HTML in the same format established by v1.0 export patterns
**Plans**: TBD
**Plans:** 8 plans
Plans:
- [ ] 07-01-PLAN.md — UserAccessEntry model + service interfaces (Wave 1)
- [ ] 07-02-PLAN.md — UserAccessAuditService implementation (Wave 2)
- [ ] 07-03-PLAN.md — GraphUserSearchService implementation (Wave 2)
- [ ] 07-04-PLAN.md — UserAccessAuditViewModel (Wave 3)
- [ ] 07-05-PLAN.md — UserAccessAuditView XAML layout (Wave 4)
- [ ] 07-06-PLAN.md — CSV + HTML export services (Wave 2)
- [ ] 07-07-PLAN.md — Tab wiring, DI, localization (Wave 4)
- [ ] 07-08-PLAN.md — Unit tests (Wave 5)
### Phase 8: Simplified Permissions
**Goal**: Permissions reports are readable by non-technical users through plain-language labels, color coding, and a configurable detail level
@@ -87,6 +96,6 @@ Plans:
| 4. Bulk Operations and Provisioning | v1.0 | 10/10 | Complete | 2026-04-03 |
| 5. Distribution and Hardening | v1.0 | 3/3 | Complete | 2026-04-03 |
| 6. Global Site Selection | 5/5 | Complete | 2026-04-07 | - |
| 7. User Access Audit | v1.1 | 0/? | Not started | - |
| 7. User Access Audit | v1.1 | 0/8 | Planned | - |
| 8. Simplified Permissions | v1.1 | 0/? | Not started | - |
| 9. Storage Visualization | v1.1 | 0/? | Not started | - |

View File

@@ -0,0 +1,232 @@
---
phase: 07-user-access-audit
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/Core/Models/UserAccessEntry.cs
- SharepointToolbox/Services/IUserAccessAuditService.cs
- SharepointToolbox/Services/IGraphUserSearchService.cs
autonomous: true
requirements:
- UACC-01
- UACC-02
must_haves:
truths:
- "UserAccessEntry record exists with all fields needed for audit results display and export"
- "IUserAccessAuditService interface defines the contract for scanning permissions filtered by user"
- "IGraphUserSearchService interface defines the contract for Graph API people-picker autocomplete"
- "AccessType enum distinguishes Direct, Group, and Inherited access"
artifacts:
- path: "SharepointToolbox/Core/Models/UserAccessEntry.cs"
provides: "Data model for user-centric audit results"
contains: "record UserAccessEntry"
- path: "SharepointToolbox/Services/IUserAccessAuditService.cs"
provides: "Service contract for user access auditing"
contains: "interface IUserAccessAuditService"
- path: "SharepointToolbox/Services/IGraphUserSearchService.cs"
provides: "Service contract for Graph API user search"
contains: "interface IGraphUserSearchService"
key_links: []
---
<objective>
Define the data models and service interfaces that all subsequent plans depend on. This is the Wave 0 contract layer: UserAccessEntry record, AccessType enum, IUserAccessAuditService, and IGraphUserSearchService.
Purpose: Every other plan in this phase imports these types. Defining them first prevents circular dependencies and gives executors concrete contracts.
Output: UserAccessEntry.cs, IUserAccessAuditService.cs, IGraphUserSearchService.cs
</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/07-user-access-audit/07-CONTEXT.md
<interfaces>
<!-- Existing models this builds alongside -->
From SharepointToolbox/Core/Models/PermissionEntry.cs:
```csharp
namespace SharepointToolbox.Core.Models;
public record PermissionEntry(
string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
string Title,
string Url,
bool HasUniquePermissions,
string Users, // Semicolon-joined display names
string UserLogins, // Semicolon-joined login names
string PermissionLevels, // Semicolon-joined role names
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType // "SharePointGroup" | "User" | "External User"
);
```
From SharepointToolbox/Core/Models/SiteInfo.cs:
```csharp
namespace SharepointToolbox.Core.Models;
public record SiteInfo(string Url, string Title);
```
From SharepointToolbox/Core/Models/ScanOptions.cs (inferred from usage):
```csharp
public record ScanOptions(bool IncludeInherited, bool ScanFolders, int FolderDepth, bool IncludeSubsites);
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create UserAccessEntry model and AccessType enum</name>
<files>SharepointToolbox/Core/Models/UserAccessEntry.cs</files>
<action>
Create `SharepointToolbox/Core/Models/UserAccessEntry.cs` with:
```csharp
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classifies how a user received a permission assignment.
/// </summary>
public enum AccessType
{
/// <summary>User is directly assigned a role on the object.</summary>
Direct,
/// <summary>User is a member of a SharePoint group that has the role.</summary>
Group,
/// <summary>Permission is inherited from a parent object (not unique).</summary>
Inherited
}
/// <summary>
/// One row in the User Access Audit results grid.
/// Represents a single permission that a specific user holds on a specific object.
/// </summary>
public record UserAccessEntry(
string UserDisplayName, // e.g. "Alice Smith"
string UserLogin, // e.g. "alice@contoso.com" or "i:0#.f|membership|alice@contoso.com"
string SiteUrl, // The site collection URL where this permission exists
string SiteTitle, // The site collection title
string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
string ObjectTitle, // Name of the list/folder/site
string ObjectUrl, // URL of the specific object
string PermissionLevel, // e.g. "Full Control", "Contribute"
AccessType AccessType, // Direct | Group | Inherited
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc.
bool IsHighPrivilege, // True for Full Control, Site Collection Administrator
bool IsExternalUser // True if login contains #EXT#
);
```
Design notes:
- Each row is one user + one object + one permission level (fully denormalized for DataGrid binding)
- IsHighPrivilege pre-computed during scan for warning icon display without re-evaluation
- IsExternalUser pre-computed using PermissionEntryHelper.IsExternalUser pattern
- SiteUrl + SiteTitle included so results can group by site across multi-site scans
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessEntry.cs and AccessType enum exist in Core/Models/, compile without errors, contain all 12 fields.</done>
</task>
<task type="auto">
<name>Task 2: Create IUserAccessAuditService and IGraphUserSearchService interfaces</name>
<files>SharepointToolbox/Services/IUserAccessAuditService.cs, SharepointToolbox/Services/IGraphUserSearchService.cs</files>
<action>
Create `SharepointToolbox/Services/IUserAccessAuditService.cs`:
```csharp
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Scans permissions across selected sites and filters results to show
/// only what specific user(s) can access.
/// </summary>
public interface IUserAccessAuditService
{
/// <summary>
/// Scans all selected sites for permissions, then filters results to entries
/// matching the specified user logins. Returns a flat list of UserAccessEntry
/// records suitable for DataGrid binding and export.
/// </summary>
/// <param name="sessionManager">Session manager for creating authenticated contexts.</param>
/// <param name="targetUserLogins">Login names (emails) of users to audit.</param>
/// <param name="sites">Sites to scan.</param>
/// <param name="options">Scan depth options (inherited, folders, subsites).</param>
/// <param name="progress">Progress reporter.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Flat list of access entries for the target users.</returns>
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
Create `SharepointToolbox/Services/IGraphUserSearchService.cs`:
```csharp
namespace SharepointToolbox.Services;
/// <summary>
/// Searches tenant users via Microsoft Graph API for the people-picker autocomplete.
/// </summary>
public interface IGraphUserSearchService
{
/// <summary>
/// Searches for users in the tenant whose display name or email matches the query.
/// Returns up to <paramref name="maxResults"/> matches.
/// </summary>
/// <param name="clientId">The Azure AD app client ID for Graph authentication.</param>
/// <param name="query">Partial name or email to search for.</param>
/// <param name="maxResults">Maximum number of results to return (default 10).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of (DisplayName, Email/UPN) tuples.</returns>
Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
string clientId,
string query,
int maxResults = 10,
CancellationToken ct = default);
}
/// <summary>
/// Represents a user returned by the Graph API people search.
/// </summary>
public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
```
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>Both interface files exist in Services/, compile without errors, IUserAccessAuditService.AuditUsersAsync and IGraphUserSearchService.SearchUsersAsync are defined with correct signatures.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- UserAccessEntry.cs contains record with 12 fields and AccessType enum
- IUserAccessAuditService.cs contains AuditUsersAsync method signature
- IGraphUserSearchService.cs contains SearchUsersAsync method signature and GraphUserResult record
</verification>
<success_criteria>
All three files compile cleanly. The contracts are established: downstream plans (07-02 through 07-08) can import UserAccessEntry, AccessType, IUserAccessAuditService, IGraphUserSearchService, and GraphUserResult without ambiguity.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,303 @@
---
phase: 07-user-access-audit
plan: 02
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
- SharepointToolbox/Services/UserAccessAuditService.cs
autonomous: true
requirements:
- UACC-01
- UACC-02
must_haves:
truths:
- "UserAccessAuditService scans permissions via PermissionsService.ScanSiteAsync and filters by target user logins"
- "Each PermissionEntry is correctly classified as Direct, Group, or Inherited AccessType"
- "High-privilege entries (Full Control, Site Collection Administrator) are flagged"
- "External users (#EXT#) are detected via PermissionEntryHelper.IsExternalUser"
- "Multi-user semicolon-delimited PermissionEntry rows are correctly split into per-user UserAccessEntry rows"
artifacts:
- path: "SharepointToolbox/Services/UserAccessAuditService.cs"
provides: "Implementation of IUserAccessAuditService"
contains: "class UserAccessAuditService"
key_links:
- from: "SharepointToolbox/Services/UserAccessAuditService.cs"
to: "SharepointToolbox/Services/IPermissionsService.cs"
via: "Constructor injection + ScanSiteAsync call"
pattern: "ScanSiteAsync"
- from: "SharepointToolbox/Services/UserAccessAuditService.cs"
to: "SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs"
via: "IsExternalUser for guest detection"
pattern: "IsExternalUser"
---
<objective>
Implement UserAccessAuditService that scans sites via PermissionsService and transforms the results into user-centric UserAccessEntry records with access type classification.
Purpose: Core business logic — takes raw PermissionEntry results and produces the user-centric audit view that the UI and exports consume.
Output: UserAccessAuditService.cs
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-01-SUMMARY.md
<interfaces>
<!-- From 07-01: Models and interfaces this plan implements -->
From SharepointToolbox/Core/Models/UserAccessEntry.cs:
```csharp
public enum AccessType { Direct, Group, Inherited }
public record UserAccessEntry(
string UserDisplayName, string UserLogin,
string SiteUrl, string SiteTitle,
string ObjectType, string ObjectTitle, string ObjectUrl,
string PermissionLevel,
AccessType AccessType, string GrantedThrough,
bool IsHighPrivilege, bool IsExternalUser);
```
From SharepointToolbox/Services/IUserAccessAuditService.cs:
```csharp
public interface IUserAccessAuditService
{
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
<!-- Existing services this depends on -->
From SharepointToolbox/Services/IPermissionsService.cs:
```csharp
public interface IPermissionsService
{
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
}
```
From SharepointToolbox/Core/Models/PermissionEntry.cs:
```csharp
public record PermissionEntry(
string ObjectType, string Title, string Url,
bool HasUniquePermissions,
string Users, string UserLogins, string PermissionLevels,
string GrantedThrough, string PrincipalType);
```
From SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs:
```csharp
public static bool IsExternalUser(string loginName) => loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
```
From SharepointToolbox/Services/ISessionManager.cs (usage pattern):
```csharp
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement UserAccessAuditService</name>
<files>SharepointToolbox/Services/UserAccessAuditService.cs</files>
<action>
Create `SharepointToolbox/Services/UserAccessAuditService.cs`:
```csharp
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Scans permissions across multiple sites via PermissionsService,
/// then filters and transforms results into user-centric UserAccessEntry records.
/// </summary>
public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control",
"Site Collection Administrator"
};
public UserAccessAuditService(IPermissionsService permissionsService)
{
_permissionsService = permissionsService;
}
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Normalize target logins for case-insensitive matching.
// Users may be identified by email ("alice@contoso.com") or full claim
// ("i:0#.f|membership|alice@contoso.com"), so we match on "contains".
var targets = targetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0)
.ToHashSet();
if (targets.Count == 0)
return Array.Empty<UserAccessEntry>();
var allEntries = new List<UserAccessEntry>();
for (int i = 0; i < sites.Count; i++)
{
ct.ThrowIfCancellationRequested();
var site = sites[i];
progress.Report(new OperationProgress(i, sites.Count,
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
var profile = new TenantProfile
{
TenantUrl = site.Url,
ClientId = string.Empty, // Will be set by SessionManager from cached session
Name = site.Title
};
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
var userEntries = TransformEntries(permEntries, targets, site);
allEntries.AddRange(userEntries);
}
progress.Report(new OperationProgress(sites.Count, sites.Count,
$"Audit complete: {allEntries.Count} access entries found."));
return allEntries;
}
/// <summary>
/// Transforms PermissionEntry list into UserAccessEntry list,
/// filtering to only entries that match target user logins.
/// </summary>
private static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries,
HashSet<string> targets,
SiteInfo site)
{
foreach (var entry in permEntries)
{
// Split semicolon-delimited Users and UserLogins
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
// Split semicolon-delimited PermissionLevels
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int u = 0; u < logins.Length; u++)
{
var login = logins[u].Trim();
var loginLower = login.ToLowerInvariant();
var displayName = u < names.Length ? names[u].Trim() : login;
// Check if this login matches any target user.
// Match by "contains" because SharePoint claims may wrap the email:
// "i:0#.f|membership|alice@contoso.com" contains "alice@contoso.com"
bool isTarget = targets.Any(t =>
loginLower.Contains(t) || t.Contains(loginLower));
if (!isTarget) continue;
// Determine access type
var accessType = ClassifyAccessType(entry);
// Emit one UserAccessEntry per permission level
foreach (var level in permLevels)
{
var trimmedLevel = level.Trim();
if (string.IsNullOrEmpty(trimmedLevel)) continue;
yield return new UserAccessEntry(
UserDisplayName: displayName,
UserLogin: login,
SiteUrl: site.Url,
SiteTitle: site.Title,
ObjectType: entry.ObjectType,
ObjectTitle: entry.Title,
ObjectUrl: entry.Url,
PermissionLevel: trimmedLevel,
AccessType: accessType,
GrantedThrough: entry.GrantedThrough,
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
IsExternalUser: PermissionEntryHelper.IsExternalUser(login));
}
}
}
}
/// <summary>
/// Classifies a PermissionEntry into Direct, Group, or Inherited access type.
/// </summary>
private static AccessType ClassifyAccessType(PermissionEntry entry)
{
// Inherited: object does not have unique permissions
if (!entry.HasUniquePermissions)
return AccessType.Inherited;
// Group: GrantedThrough starts with "SharePoint Group:"
if (entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase))
return AccessType.Group;
// Direct: unique permissions, granted directly
return AccessType.Direct;
}
}
```
Key design decisions:
- Reuses PermissionsService.ScanSiteAsync entirely (no CSOM calls) -- filters results post-scan
- User matching uses case-insensitive "contains" to handle both plain emails and SharePoint claim format
- Each PermissionEntry row with semicolon-delimited users is split into individual UserAccessEntry rows
- Each semicolon-delimited permission level becomes a separate row (fully denormalized for grid display)
- AccessType classification: !HasUniquePermissions = Inherited, GrantedThrough contains "SharePoint Group:" = Group, else Direct
- SessionManager profile construction follows PermissionsViewModel pattern (TenantUrl = site URL)
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessAuditService.cs compiles, implements IUserAccessAuditService, scans via IPermissionsService, filters by user login, classifies access types, flags high-privilege and external users.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- UserAccessAuditService implements IUserAccessAuditService interface
- TransformEntries correctly splits semicolon-delimited logins/names/levels
- ClassifyAccessType maps HasUniquePermissions + GrantedThrough to AccessType enum
- HighPrivilegeLevels includes "Full Control" and "Site Collection Administrator"
</verification>
<success_criteria>
The audit engine is implemented: given a list of user logins and sites, it produces a flat list of UserAccessEntry records with correct access type classification, high-privilege detection, and external user flagging. Ready for ViewModel consumption in 07-04.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,167 @@
---
phase: 07-user-access-audit
plan: 03
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
- SharepointToolbox/Services/GraphUserSearchService.cs
autonomous: true
requirements:
- UACC-01
must_haves:
truths:
- "GraphUserSearchService queries Microsoft Graph /users endpoint with $filter for displayName/mail startsWith"
- "Service returns GraphUserResult records with DisplayName, UPN, and Mail"
- "Service handles empty queries and returns empty list"
- "Service uses existing GraphClientFactory for authentication"
artifacts:
- path: "SharepointToolbox/Services/GraphUserSearchService.cs"
provides: "Implementation of IGraphUserSearchService for people-picker autocomplete"
contains: "class GraphUserSearchService"
key_links:
- from: "SharepointToolbox/Services/GraphUserSearchService.cs"
to: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs"
via: "Constructor injection, CreateClientAsync call"
pattern: "CreateClientAsync"
---
<objective>
Implement GraphUserSearchService that queries Microsoft Graph API to search tenant users by name or email. Powers the people-picker autocomplete in the audit tab.
Purpose: Enables administrators to find and select tenant users by typing partial names/emails, rather than typing exact login names manually.
Output: GraphUserSearchService.cs
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-01-SUMMARY.md
<interfaces>
<!-- From 07-01: Interface to implement -->
From SharepointToolbox/Services/IGraphUserSearchService.cs:
```csharp
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);
```
<!-- Existing auth infrastructure -->
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
```csharp
public class GraphClientFactory
{
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement GraphUserSearchService</name>
<files>SharepointToolbox/Services/GraphUserSearchService.cs</files>
<action>
Create `SharepointToolbox/Services/GraphUserSearchService.cs`:
```csharp
using SharepointToolbox.Infrastructure.Auth;
namespace SharepointToolbox.Services;
/// <summary>
/// Searches tenant users via Microsoft Graph API.
/// Used by the people-picker autocomplete in the User Access Audit tab.
/// </summary>
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)
{
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
return Array.Empty<GraphUserResult>();
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
// Use $filter with startsWith on displayName and mail.
// Graph API requires ConsistencyLevel=eventual for advanced queries.
var escapedQuery = query.Replace("'", "''");
var response = await graphClient.Users.GetAsync(config =>
{
config.QueryParameters.Filter =
$"startsWith(displayName,'{escapedQuery}') or startsWith(mail,'{escapedQuery}') or startsWith(userPrincipalName,'{escapedQuery}')";
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" };
config.QueryParameters.Top = maxResults;
config.QueryParameters.Orderby = new[] { "displayName" };
config.Headers.Add("ConsistencyLevel", "eventual");
config.QueryParameters.Count = true;
}, ct);
if (response?.Value is null)
return Array.Empty<GraphUserResult>();
return response.Value
.Select(u => new GraphUserResult(
DisplayName: u.DisplayName ?? u.UserPrincipalName ?? "Unknown",
UserPrincipalName: u.UserPrincipalName ?? string.Empty,
Mail: u.Mail))
.ToList();
}
}
```
Design notes:
- Minimum 2 characters before searching (prevents overly broad queries)
- Uses startsWith filter on displayName, mail, and UPN for broad matching
- Single quotes in query are escaped to prevent OData injection
- ConsistencyLevel=eventual header required for startsWith filter on directory objects
- Count=true is required alongside ConsistencyLevel=eventual
- Returns max 10 results by default (people picker dropdown)
- Uses existing GraphClientFactory which handles MSAL token acquisition
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>GraphUserSearchService.cs compiles, implements IGraphUserSearchService, uses GraphClientFactory for auth, queries Graph /users with startsWith filter, returns GraphUserResult list.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- GraphUserSearchService implements IGraphUserSearchService
- Uses GraphClientFactory.CreateClientAsync (not raw HTTP)
- Handles empty/short queries gracefully (returns empty list)
- Filter uses startsWith on displayName, mail, and UPN
</verification>
<success_criteria>
The Graph people search service is implemented: given a partial name/email query, it returns matching tenant users via Microsoft Graph API. Ready for ViewModel consumption in 07-04 (people picker debounced autocomplete).
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,215 @@
---
phase: 07-user-access-audit
plan: 04
type: execute
wave: 3
depends_on: ["07-01", "07-02", "07-03"]
files_modified:
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
autonomous: true
requirements:
- UACC-01
- UACC-02
must_haves:
truths:
- "ViewModel extends FeatureViewModelBase with RunOperationAsync that calls IUserAccessAuditService.AuditUsersAsync"
- "People picker search is debounced (300ms) and calls IGraphUserSearchService.SearchUsersAsync"
- "Selected users are stored in an ObservableCollection<GraphUserResult>"
- "Results are ObservableCollection<UserAccessEntry> with CollectionViewSource for grouping toggle"
- "ExportCsvCommand and ExportHtmlCommand follow PermissionsViewModel pattern"
- "Site selection follows _hasLocalSiteOverride + OnGlobalSitesChanged pattern from PermissionsViewModel"
- "Per-user summary banner properties (TotalAccesses, SitesCount, HighPrivilegeCount) are computed from results"
- "FilterText property filters the CollectionView in real-time"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
provides: "Tab ViewModel for User Access Audit"
contains: "class UserAccessAuditViewModel"
key_links:
- from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
to: "SharepointToolbox/Services/IUserAccessAuditService.cs"
via: "Constructor injection, AuditUsersAsync call in RunOperationAsync"
pattern: "AuditUsersAsync"
- from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
to: "SharepointToolbox/Services/IGraphUserSearchService.cs"
via: "Constructor injection, SearchUsersAsync call in debounced search"
pattern: "SearchUsersAsync"
- from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
to: "SharepointToolbox/ViewModels/FeatureViewModelBase.cs"
via: "Extends base class, overrides RunOperationAsync and OnGlobalSitesChanged"
pattern: "FeatureViewModelBase"
---
<objective>
Implement UserAccessAuditViewModel — the tab ViewModel that orchestrates people picker search, site selection, audit execution, result grouping/filtering, summary banner, and export commands.
Purpose: Central coordinator between UI and services. This is the largest single file in the phase, connecting all pieces.
Output: UserAccessAuditViewModel.cs
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-01-SUMMARY.md
@.planning/phases/07-user-access-audit/07-02-SUMMARY.md
@.planning/phases/07-user-access-audit/07-03-SUMMARY.md
<interfaces>
<!-- From 07-01: Models -->
From SharepointToolbox/Core/Models/UserAccessEntry.cs:
```csharp
public enum AccessType { Direct, Group, Inherited }
public record UserAccessEntry(
string UserDisplayName, string UserLogin,
string SiteUrl, string SiteTitle,
string ObjectType, string ObjectTitle, string ObjectUrl,
string PermissionLevel,
AccessType AccessType, string GrantedThrough,
bool IsHighPrivilege, bool IsExternalUser);
```
From SharepointToolbox/Services/IGraphUserSearchService.cs:
```csharp
public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
public interface IGraphUserSearchService
{
Task<IReadOnlyList<GraphUserResult>> SearchUsersAsync(
string clientId, string query, int maxResults = 10, CancellationToken ct = default);
}
```
From SharepointToolbox/Services/IUserAccessAuditService.cs:
```csharp
public interface IUserAccessAuditService
{
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
<!-- Base class pattern (from FeatureViewModelBase.cs) -->
```csharp
public abstract partial class FeatureViewModelBase : ObservableRecipient
{
protected IReadOnlyList<SiteInfo> GlobalSites { get; private set; }
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
protected virtual void OnTenantSwitched(TenantProfile profile) { }
protected virtual void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites) { }
// RunCommand, CancelCommand, IsRunning, StatusMessage, ProgressValue auto-provided
}
```
<!-- PermissionsViewModel pattern for site picker + export (reference) -->
From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs:
```csharp
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
private bool _hasLocalSiteOverride;
public Func<Window>? OpenSitePickerDialog { get; set; }
internal TenantProfile? _currentProfile;
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement UserAccessAuditViewModel</name>
<files>SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs</files>
<action>
Create `SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs`. This is a substantial file (~350 lines). Follow the PermissionsViewModel pattern exactly for site selection, tenant switching, export commands, and dialog factories.
Structure:
1. **Fields**: inject IUserAccessAuditService, IGraphUserSearchService, ISessionManager, export services, logger
2. **Observable properties**:
- `SearchQuery` (string) — people picker text input, triggers debounced search on change
- `SearchResults` (ObservableCollection<GraphUserResult>) — autocomplete dropdown items
- `SelectedUsers` (ObservableCollection<GraphUserResult>) — users added for audit
- `Results` (ObservableCollection<UserAccessEntry>) — audit output
- `FilterText` (string) — real-time filter on results grid
- `IsGroupByUser` (bool, default true) — toggle between group-by-user and group-by-site
- `IncludeInherited` (bool) — scan option
- `ScanFolders` (bool, default true) — scan option
- `IncludeSubsites` (bool) — scan option
- `IsSearching` (bool) — shows spinner during Graph search
3. **Summary properties** (computed, not stored):
- `TotalAccessCount` => Results.Count
- `SitesCount` => Results.Select(r => r.SiteUrl).Distinct().Count()
- `HighPrivilegeCount` => Results.Count(r => r.IsHighPrivilege)
- `SelectedUsersLabel` => e.g. "2 user(s) selected"
4. **Commands**:
- `ExportCsvCommand` (AsyncRelayCommand, CanExport)
- `ExportHtmlCommand` (AsyncRelayCommand, CanExport)
- `OpenSitePickerCommand` (RelayCommand)
- `AddUserCommand` (RelayCommand<GraphUserResult>) — adds to SelectedUsers
- `RemoveUserCommand` (RelayCommand<GraphUserResult>) — removes from SelectedUsers
5. **Site picker**: SelectedSites, _hasLocalSiteOverride, OpenSitePickerDialog factory, SitesSelectedLabel — identical pattern to PermissionsViewModel
6. **People picker debounce**: Use a CancellationTokenSource that is cancelled/recreated each time SearchQuery changes. Delay 300ms before calling SearchUsersAsync. Set IsSearching during search.
7. **RunOperationAsync**: Build ScanOptions, call AuditUsersAsync with SelectedUsers UPNs + effective sites, update Results on UI thread, notify summary properties and export CanExecute.
8. **CollectionViewSource**: Create a ResultsView (ICollectionView) backed by Results. When IsGroupByUser changes, update GroupDescriptions (group by UserLogin or SiteUrl). When FilterText changes, apply filter predicate.
9. **Constructors**: Full DI constructor + internal test constructor (omit export services) — same dual-constructor pattern as PermissionsViewModel.
10. **Tenant switching**: Reset all state (results, selected users, search, sites) in OnTenantSwitched.
Important implementation details:
- The debounced search should use `Task.Delay(300, ct)` pattern with a field `_searchCts` that gets cancelled on each new keystroke
- partial void OnSearchQueryChanged(string value) triggers the debounced search
- partial void OnFilterTextChanged(string value) triggers ResultsView.Refresh()
- partial void OnIsGroupByUserChanged(bool value) triggers re-grouping of ResultsView
- Export CSV/HTML: use SaveFileDialog pattern from PermissionsViewModel, calling the audit-specific export services (UserAccessCsvExportService, UserAccessHtmlExportService) that will be created in plan 07-06
- Export services are typed as object references (UserAccessCsvExportService? and UserAccessHtmlExportService?) since they haven't been created yet — the plan 07-06 export service files will be the concrete types
- For the test constructor, pass null for export services
The ViewModel needs these `using` statements:
```
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
```
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessAuditViewModel.cs compiles and extends FeatureViewModelBase. It has: people picker with debounced Graph search, site selection with override pattern, RunOperationAsync calling AuditUsersAsync, Results with CollectionViewSource grouping and filtering, summary properties, dual constructors, export commands, tenant switching reset.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- UserAccessAuditViewModel extends FeatureViewModelBase
- Has ObservableProperty for SearchQuery, SelectedUsers, Results, FilterText, IsGroupByUser
- Has ExportCsvCommand, ExportHtmlCommand, OpenSitePickerCommand, AddUserCommand, RemoveUserCommand
- RunOperationAsync calls IUserAccessAuditService.AuditUsersAsync
- OnSearchQueryChanged triggers debounced IGraphUserSearchService.SearchUsersAsync
- ResultsView ICollectionView supports group-by toggle and text filter
</verification>
<success_criteria>
The ViewModel is the orchestration hub for the audit tab. All UI interactions (search users, select sites, run audit, filter results, toggle grouping, export) are wired to service calls and observable state. Ready for View binding in 07-05 and export service implementation in 07-06.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,268 @@
---
phase: 07-user-access-audit
plan: 05
type: execute
wave: 4
depends_on: ["07-04"]
files_modified:
- SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml
- SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs
autonomous: true
requirements:
- UACC-01
- UACC-02
must_haves:
truths:
- "View has left panel with people picker (TextBox + autocomplete Popup), site picker button, scan options, run/cancel/export buttons"
- "View has right panel with summary banner (total accesses, sites, high-privilege) and DataGrid"
- "DataGrid columns: User, Site, Object, Permission Level, Access Type, Granted Through"
- "Access type rows are color-coded: Direct (blue tint), Group (green tint), Inherited (gray tint)"
- "High-privilege entries show warning icon, external users show guest badge"
- "Group-by toggle switches DataGrid GroupStyle between user and site"
- "Filter TextBox filters results in real-time"
- "People picker shows autocomplete Popup with search results below the search TextBox"
artifacts:
- path: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"
provides: "XAML layout for User Access Audit tab"
contains: "UserAccessAuditView"
- path: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs"
provides: "Code-behind for dialog factory wiring"
contains: "UserAccessAuditView"
key_links:
- from: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"
to: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
via: "DataContext binding to ViewModel properties"
pattern: "Binding"
---
<objective>
Create the XAML view for the User Access Audit tab with people picker autocomplete, site picker, scan options, summary banner, color-coded DataGrid with grouping, filter, and export buttons.
Purpose: The visual interface for the audit feature. Follows the established PermissionsView two-panel layout pattern.
Output: UserAccessAuditView.xaml + UserAccessAuditView.xaml.cs
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-04-SUMMARY.md
<interfaces>
<!-- ViewModel properties the View binds to -->
From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs (expected):
```csharp
// People picker
[ObservableProperty] string SearchQuery;
[ObservableProperty] ObservableCollection<GraphUserResult> SearchResults;
[ObservableProperty] ObservableCollection<GraphUserResult> SelectedUsers;
[ObservableProperty] bool IsSearching;
RelayCommand<GraphUserResult> AddUserCommand;
RelayCommand<GraphUserResult> RemoveUserCommand;
string SelectedUsersLabel { get; }
// Site selection
ObservableCollection<SiteInfo> SelectedSites;
RelayCommand OpenSitePickerCommand;
string SitesSelectedLabel { get; }
// Scan options
[ObservableProperty] bool IncludeInherited;
[ObservableProperty] bool ScanFolders;
[ObservableProperty] bool IncludeSubsites;
// Results
[ObservableProperty] ObservableCollection<UserAccessEntry> Results;
ICollectionView ResultsView { get; }
[ObservableProperty] string FilterText;
[ObservableProperty] bool IsGroupByUser;
// Summary
int TotalAccessCount { get; }
int SitesCount { get; }
int HighPrivilegeCount { get; }
// Commands (from base + this VM)
IAsyncRelayCommand RunCommand; // from base
RelayCommand CancelCommand; // from base
IAsyncRelayCommand ExportCsvCommand;
IAsyncRelayCommand ExportHtmlCommand;
// State from base
bool IsRunning;
string StatusMessage;
int ProgressValue;
```
<!-- Existing View pattern to follow -->
PermissionsView.xaml: Left panel (290px) + Right panel (*) + Bottom StatusBar
Localization: {Binding Source={x:Static loc:TranslationSource.Instance}, Path=[key]}
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create UserAccessAuditView XAML layout</name>
<files>SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml</files>
<action>
Create `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml` following the PermissionsView.xaml pattern (left panel config + right panel DataGrid + bottom status bar).
Layout structure:
1. **Left panel (290px)** in DockPanel:
a. **People Picker GroupBox** ("Select Users"):
- TextBox bound to SearchQuery (UpdateSourceTrigger=PropertyChanged)
- Below TextBox: a Popup (IsOpen bound to SearchResults.Count > 0 and IsSearching or has results) containing a ListBox of SearchResults. Each item shows DisplayName + Mail. Clicking an item fires AddUserCommand.
- Below Popup: ItemsControl showing SelectedUsers as removable chips/pills. Each pill has user name + X button (RemoveUserCommand).
- TextBlock showing SelectedUsersLabel
b. **Site Selection GroupBox** ("Target Sites"):
- Button "Select Sites" bound to OpenSitePickerCommand
- TextBlock showing SitesSelectedLabel
c. **Scan Options GroupBox**:
- CheckBox "Include inherited" bound to IncludeInherited
- CheckBox "Scan folders" bound to ScanFolders
- CheckBox "Include subsites" bound to IncludeSubsites
d. **Action buttons**:
- Run Audit / Cancel row
- Export CSV / Export HTML row
2. **Right panel** in Grid:
a. **Summary banner** (StackPanel, horizontal, at top):
- Three stat cards (Border with background): Total Accesses, Sites, High Privilege
- Each shows the count value and label
b. **Toolbar row**:
- Filter TextBox bound to FilterText
- ToggleButton "Group by User" / "Group by Site" bound to IsGroupByUser
c. **DataGrid** bound to ResultsView (ICollectionView):
- Columns: User (DisplayName), Site (SiteTitle), Object (ObjectTitle), Permission Level, Access Type, Granted Through
- Row style with DataTriggers for color coding:
- AccessType.Direct: light blue background (#EBF5FB)
- AccessType.Group: light green background (#EAFAF1)
- AccessType.Inherited: light gray background (#F4F6F6)
- DataTemplate for Access Type column: TextBlock with icon (Unicode chars: Direct = key icon, Group = people icon, Inherited = arrow-down icon)
- DataTrigger for IsHighPrivilege=true: bold text + warning icon (Unicode shield)
- DataTrigger for IsExternalUser=true: guest badge styling
- GroupStyle with expander header showing group name + count
d. **DataGrid GroupStyle**: Expander with header template showing group key (user name or site title) and item count
3. **Bottom StatusBar** spanning both columns: ProgressBar + StatusMessage (same as PermissionsView)
Color-coding approach:
- Use Style with DataTriggers on the DataGrid Row, binding to AccessType property
- Access type icons: use Unicode characters that render in Segoe UI Symbol:
- Direct: "\uE192" (key) or plain text "Direct" with blue foreground
- Group: "\uE125" (people) or plain text "Group" with green foreground
- Inherited: "\uE19C" (hierarchy) or plain text "Inherited" with gray foreground
- High privilege warning: "\u26A0" (warning triangle) prepended to permission level
- External user badge: orange-tinted pill in user column
The people picker Popup approach:
- Use a Popup element positioned below the SearchQuery TextBox
- Popup.IsOpen bound to a computed property (HasSearchResults) or use a MultiBinding
- Popup contains a ListBox with ItemTemplate showing DisplayName and Mail
- Clicking a ListBox item invokes AddUserCommand via EventTrigger or by binding SelectedItem
- Simpler alternative: Use a ListBox directly below the TextBox (not a Popup) that is visible when SearchResults.Count > 0. This avoids Popup complexity.
For the autocomplete, the simplest WPF approach is:
- ListBox below TextBox, Visibility collapsed when SearchResults is empty
- ListBox.ItemTemplate shows "{DisplayName} ({Mail})"
- On SelectionChanged or mouse click, add user to SelectedUsers via AddUserCommand
Localization keys to use (will be added in 07-07):
- audit.grp.users, audit.grp.sites, audit.grp.options
- audit.search.placeholder, audit.btn.run, audit.btn.exportCsv, audit.btn.exportHtml
- audit.summary.total, audit.summary.sites, audit.summary.highPriv
- audit.toggle.byUser, audit.toggle.bySite
- audit.filter.placeholder
- btn.cancel (existing key)
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessAuditView.xaml compiles. Layout has: people picker with autocomplete list + removable user pills, site picker button, scan option checkboxes, run/cancel/export buttons, summary banner with 3 stats, filter TextBox, group-by toggle, color-coded DataGrid with access type icons and group headers, status bar.</done>
</task>
<task type="auto">
<name>Task 2: Create UserAccessAuditView code-behind</name>
<files>SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs</files>
<action>
Create `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs`:
```csharp
using System.Windows.Controls;
namespace SharepointToolbox.Views.Tabs;
public partial class UserAccessAuditView : UserControl
{
public UserAccessAuditView(ViewModels.Tabs.UserAccessAuditViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
// Wire site picker dialog factory (same pattern as PermissionsView)
viewModel.OpenSitePickerDialog = () =>
{
if (viewModel.CurrentProfile is null) return null!;
var factory = new Views.Dialogs.SitePickerDialog(
App.Current.MainWindow is MainWindow mw
? ((IServiceProvider)mw.GetType().GetField("_serviceProvider",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.GetValue(mw)!).GetService(typeof(Services.ISiteListService)) as Services.ISiteListService
: null!,
viewModel.CurrentProfile);
return factory;
};
}
}
```
IMPORTANT: The actual dialog factory wiring will be cleaner — it will be done from MainWindow.xaml.cs in plan 07-07 (same pattern as PermissionsView where the View's constructor receives the ViewModel from DI, and MainWindow sets the dialog factory after creating the View). So keep the code-behind minimal:
```csharp
using System.Windows.Controls;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Views.Tabs;
public partial class UserAccessAuditView : UserControl
{
public UserAccessAuditView(UserAccessAuditViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
```
The dialog factory wiring for the site picker will be handled in 07-07 from MainWindow.xaml.cs, following the same pattern where MainWindow wires dialog factories after resolving Views from DI.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessAuditView.xaml.cs compiles, receives UserAccessAuditViewModel via constructor injection, sets DataContext.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- UserAccessAuditView.xaml + .cs compile as a UserControl
- XAML has two-panel layout with all required UI elements
- DataGrid has color-coded rows via DataTriggers on AccessType
- Summary banner shows three computed stats
- People picker has search TextBox + results list + selected user pills
</verification>
<success_criteria>
The complete audit tab UI is rendered: administrators see a people picker, site selector, scan options, and a rich DataGrid with color-coded access types, grouping toggle, filter, summary banner, and export buttons. All bound to ViewModel properties from 07-04.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,332 @@
---
phase: 07-user-access-audit
plan: 06
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
- SharepointToolbox/Services/Export/UserAccessCsvExportService.cs
- SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs
autonomous: true
requirements:
- UACC-02
must_haves:
truths:
- "CSV export produces one file per audited user with summary section at top and flat data rows"
- "CSV filenames include user email and date (e.g. audit_alice@contoso.com_2026-04-07.csv)"
- "HTML export produces a single self-contained report with collapsible groups, sortable columns, search filter"
- "HTML report has both group-by-user and group-by-site views togglable via tab/button in header"
- "HTML report shows per-user summary stats and risk highlights (high-privilege, external users)"
- "Both exports follow established patterns: UTF-8+BOM for CSV, inline CSS/JS for HTML"
artifacts:
- path: "SharepointToolbox/Services/Export/UserAccessCsvExportService.cs"
provides: "CSV export for user access audit results"
contains: "class UserAccessCsvExportService"
- path: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs"
provides: "HTML export for user access audit results"
contains: "class UserAccessHtmlExportService"
key_links:
- from: "SharepointToolbox/Services/Export/UserAccessCsvExportService.cs"
to: "SharepointToolbox/Core/Models/UserAccessEntry.cs"
via: "Takes IReadOnlyList<UserAccessEntry> as input"
pattern: "UserAccessEntry"
- from: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs"
to: "SharepointToolbox/Core/Models/UserAccessEntry.cs"
via: "Takes IReadOnlyList<UserAccessEntry> as input"
pattern: "UserAccessEntry"
---
<objective>
Implement the two export services for User Access Audit: per-user CSV files with summary headers, and a single interactive HTML report with dual-view toggle, collapsible groups, and risk highlighting.
Purpose: Audit results must be exportable for compliance documentation and sharing with stakeholders.
Output: UserAccessCsvExportService.cs, UserAccessHtmlExportService.cs
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-01-SUMMARY.md
<interfaces>
<!-- From 07-01: Data model for export -->
From SharepointToolbox/Core/Models/UserAccessEntry.cs:
```csharp
public enum AccessType { Direct, Group, Inherited }
public record UserAccessEntry(
string UserDisplayName, string UserLogin,
string SiteUrl, string SiteTitle,
string ObjectType, string ObjectTitle, string ObjectUrl,
string PermissionLevel,
AccessType AccessType, string GrantedThrough,
bool IsHighPrivilege, bool IsExternalUser);
```
<!-- Existing export patterns to follow -->
From SharepointToolbox/Services/Export/CsvExportService.cs:
```csharp
public class CsvExportService
{
public string BuildCsv(IReadOnlyList<PermissionEntry> entries) { ... }
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value) { /* RFC 4180 escaping */ }
}
```
From SharepointToolbox/Services/Export/HtmlExportService.cs:
```csharp
public class HtmlExportService
{
public string BuildHtml(IReadOnlyList<PermissionEntry> entries) { ... }
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{
var html = BuildHtml(entries);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
}
// Pattern: stats cards, filter input, table, inline JS for filter, inline CSS, badges, user pills
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement UserAccessCsvExportService</name>
<files>SharepointToolbox/Services/Export/UserAccessCsvExportService.cs</files>
<action>
Create `SharepointToolbox/Services/Export/UserAccessCsvExportService.cs`:
```csharp
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports user access audit results to CSV format.
/// Produces one CSV file per audited user with a summary section at the top.
/// </summary>
public class UserAccessCsvExportService
{
private const string DataHeader =
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
/// <summary>
/// Builds a CSV string for a single user's access entries.
/// Includes a summary section at the top followed by data rows.
/// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{
var sb = new StringBuilder();
// Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data
// Data rows
sb.AppendLine(DataHeader);
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
{
Csv(entry.SiteTitle),
Csv(entry.ObjectType),
Csv(entry.ObjectTitle),
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
}));
}
return sb.ToString();
}
/// <summary>
/// Writes one CSV file per user to the specified directory.
/// File names: audit_{email}_{date}.csv
/// </summary>
public async Task WriteAsync(
IReadOnlyList<UserAccessEntry> allEntries,
string directoryPath,
CancellationToken ct)
{
Directory.CreateDirectory(directoryPath);
var dateStr = DateTime.Now.ToString("yyyy-MM-dd");
// Group by user
var byUser = allEntries.GroupBy(e => e.UserLogin);
foreach (var group in byUser)
{
ct.ThrowIfCancellationRequested();
var userLogin = group.Key;
var displayName = group.First().UserDisplayName;
var entries = group.ToList();
// Sanitize email for filename (replace @ and other invalid chars)
var safeLogin = SanitizeFileName(userLogin);
var fileName = $"audit_{safeLogin}_{dateStr}.csv";
var filePath = Path.Combine(directoryPath, fileName);
var csv = BuildCsv(displayName, userLogin, entries);
await File.WriteAllTextAsync(filePath, csv,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
}
/// <summary>
/// Writes all entries to a single CSV file (alternative for single-file export).
/// Used when the ViewModel export command picks a single file path.
/// </summary>
public async Task WriteSingleFileAsync(
IReadOnlyList<UserAccessEntry> entries,
string filePath,
CancellationToken ct)
{
var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
// Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
sb.AppendLine(fullHeader);
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
{
Csv(entry.UserDisplayName),
Csv(entry.UserLogin),
Csv(entry.SiteTitle),
Csv(entry.ObjectType),
Csv(entry.ObjectTitle),
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
}));
}
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(name.Length);
foreach (var c in name)
sb.Append(invalid.Contains(c) ? '_' : c);
return sb.ToString();
}
}
```
Design notes:
- Two write modes: WriteAsync (per-user files to directory) and WriteSingleFileAsync (all in one file)
- The ViewModel will use WriteSingleFileAsync for the SaveFileDialog export (simpler UX)
- WriteAsync with per-user files available for batch export scenarios
- Summary section at top of each file per CONTEXT.md decision
- RFC 4180 CSV escaping following existing CsvExportService.Csv() pattern
- UTF-8 with BOM for Excel compatibility (same as existing exports)
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessCsvExportService.cs compiles, has BuildCsv for per-user CSV, WriteAsync for per-user files, WriteSingleFileAsync for combined export, RFC 4180 escaping, UTF-8+BOM encoding.</done>
</task>
<task type="auto">
<name>Task 2: Implement UserAccessHtmlExportService</name>
<files>SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs</files>
<action>
Create `SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs`. Follow the HtmlExportService pattern (self-contained HTML with inline CSS/JS, stats cards, filter, table).
The HTML report must include:
1. **Title**: "User Access Audit Report"
2. **Stats cards** row: Total Accesses, Users Audited, Sites Scanned, High Privilege Count, External Users Count
3. **Per-user summary section**: For each user, show a card with their name, total accesses, sites count, high-privilege count. Highlight if user has Site Collection Admin access.
4. **View toggle**: Two buttons "By User" / "By Site" that show/hide the corresponding grouped table (JavaScript toggle, no page reload)
5. **Filter input**: Text filter that searches across all visible rows
6. **Table (By User view)**: Grouped by user (collapsible sections). Each group header shows user name + count. Rows: Site, Object Type, Object, Permission Level, Access Type badge, Granted Through
7. **Table (By Site view)**: Grouped by site (collapsible sections). Each group header shows site title + count. Rows: User, Object Type, Object, Permission Level, Access Type badge, Granted Through
8. **Access Type badges**: Colored badges — Direct (blue), Group (green), Inherited (gray)
9. **High-privilege rows**: Warning icon + bold text
10. **External user badge**: Orange "Guest" pill next to user name
11. **Inline JS**:
- `toggleView(view)`: Shows "by-user" or "by-site" div, updates active button state
- `filterTable()`: Filters visible rows in the active view
- `toggleGroup(id)`: Collapses/expands a group section
- `sortTable(col)`: Sorts rows within groups by column
The HTML should be ~300-400 lines of generated content. Use StringBuilder like the existing HtmlExportService.
Follow the exact same CSS style as HtmlExportService (same font-family, stat-card styles, table styles, badge styles) with additions for:
- `.access-direct { background: #dbeafe; color: #1e40af; }` (blue)
- `.access-group { background: #dcfce7; color: #166534; }` (green)
- `.access-inherited { background: #f3f4f6; color: #374151; }` (gray)
- `.high-priv { font-weight: 700; }` + warning icon
- `.guest-badge { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }` (reuse external-user style)
- `.view-toggle button.active { background: #1a1a2e; color: #fff; }`
- `.group-header { cursor: pointer; background: #f0f0f0; padding: 10px; font-weight: 600; }`
The service should have:
- `BuildHtml(IReadOnlyList<UserAccessEntry> entries)` — returns full HTML string
- `WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct)` — writes to file (UTF-8 without BOM, same as HtmlExportService)
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>UserAccessHtmlExportService.cs compiles, produces self-contained HTML with: stats cards, per-user summary, dual-view toggle (by-user/by-site), collapsible groups, filter input, sortable columns, color-coded access type badges, high-privilege warnings, external user badges, inline CSS/JS.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- UserAccessCsvExportService has BuildCsv + WriteAsync + WriteSingleFileAsync
- UserAccessHtmlExportService has BuildHtml + WriteAsync
- HTML output contains inline CSS and JS (no external dependencies)
- CSV uses RFC 4180 escaping and UTF-8+BOM
</verification>
<success_criteria>
Both export services compile and follow established patterns. CSV produces per-user files with summary headers. HTML produces an interactive report with dual-view toggle, collapsible groups, color-coded badges, and risk highlighting. Ready for ViewModel export commands in 07-04.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-06-SUMMARY.md`
</output>

View File

@@ -0,0 +1,312 @@
---
phase: 07-user-access-audit
plan: 07
type: execute
wave: 4
depends_on: ["07-04", "07-05", "07-06"]
files_modified:
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
autonomous: true
requirements:
- UACC-01
must_haves:
truths:
- "User Access Audit tab appears in MainWindow TabControl"
- "Tab content is wired to DI-resolved UserAccessAuditView"
- "All new services (IUserAccessAuditService, IGraphUserSearchService, export services) are registered in DI"
- "UserAccessAuditViewModel and UserAccessAuditView are registered in DI"
- "All localization keys used in UserAccessAuditView.xaml exist in both Strings.resx and Strings.fr.resx"
- "Site picker dialog factory is wired from MainWindow.xaml.cs"
artifacts:
- path: "SharepointToolbox/MainWindow.xaml"
provides: "New TabItem for User Access Audit"
contains: "UserAccessAuditTabItem"
- path: "SharepointToolbox/MainWindow.xaml.cs"
provides: "DI wiring for audit tab content and dialog factory"
contains: "UserAccessAuditView"
- path: "SharepointToolbox/App.xaml.cs"
provides: "DI registrations for all Phase 7 services and ViewModels"
contains: "UserAccessAuditService"
- path: "SharepointToolbox/Localization/Strings.resx"
provides: "English localization keys for audit tab"
contains: "tab.userAccessAudit"
- path: "SharepointToolbox/Localization/Strings.fr.resx"
provides: "French localization keys for audit tab"
contains: "tab.userAccessAudit"
key_links:
- from: "SharepointToolbox/MainWindow.xaml"
to: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"
via: "TabItem.Content set from code-behind"
pattern: "UserAccessAuditTabItem"
- from: "SharepointToolbox/App.xaml.cs"
to: "SharepointToolbox/Services/UserAccessAuditService.cs"
via: "DI registration AddTransient<IUserAccessAuditService, UserAccessAuditService>"
pattern: "UserAccessAuditService"
---
<objective>
Wire the User Access Audit tab into the application: add TabItem to MainWindow, register all Phase 7 services in DI, set up dialog factories, and add all localization keys in English and French.
Purpose: Integration glue that makes all Phase 7 pieces discoverable and functional at runtime.
Output: Modified MainWindow.xaml, MainWindow.xaml.cs, App.xaml.cs, Strings.resx, Strings.fr.resx
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-04-SUMMARY.md
@.planning/phases/07-user-access-audit/07-05-SUMMARY.md
@.planning/phases/07-user-access-audit/07-06-SUMMARY.md
<interfaces>
<!-- Current MainWindow.xaml TabControl (add new TabItem before SettingsTabItem) -->
From SharepointToolbox/MainWindow.xaml (existing tabs):
```xml
<TabItem x:Name="TemplatesTabItem" Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.templates]}" />
<!-- Settings tab: content set from code-behind via DI-resolved SettingsView -->
<TabItem x:Name="SettingsTabItem" Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.settings]}" />
```
<!-- Current MainWindow.xaml.cs wiring pattern -->
From SharepointToolbox/MainWindow.xaml.cs:
```csharp
PermissionsTabItem.Content = serviceProvider.GetRequiredService<PermissionsView>();
StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();
// ... etc
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();
```
<!-- Current App.xaml.cs DI registration pattern -->
From SharepointToolbox/App.xaml.cs:
```csharp
// Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<CsvExportService>();
services.AddTransient<HtmlExportService>();
services.AddTransient<PermissionsViewModel>();
services.AddTransient<PermissionsView>();
```
<!-- Types to register -->
Services: IUserAccessAuditService -> UserAccessAuditService, IGraphUserSearchService -> GraphUserSearchService
Export: UserAccessCsvExportService, UserAccessHtmlExportService
ViewModel: UserAccessAuditViewModel
View: UserAccessAuditView
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add DI registrations in App.xaml.cs</name>
<files>SharepointToolbox/App.xaml.cs</files>
<action>
In `App.xaml.cs`, add a new section in `RegisterServices` after the existing Phase 4 registrations and before `services.AddSingleton<MainWindow>()`:
```csharp
// Phase 7: User Access Audit
services.AddTransient<IUserAccessAuditService, UserAccessAuditService>();
services.AddTransient<IGraphUserSearchService, GraphUserSearchService>();
services.AddTransient<UserAccessCsvExportService>();
services.AddTransient<UserAccessHtmlExportService>();
services.AddTransient<UserAccessAuditViewModel>();
services.AddTransient<UserAccessAuditView>();
```
Add the necessary using statement at the top if not already present (Services.Export namespace is already imported via existing export services).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>App.xaml.cs registers all Phase 7 services, ViewModel, and View in the DI container.</done>
</task>
<task type="auto">
<name>Task 2: Add TabItem to MainWindow.xaml and wire in MainWindow.xaml.cs</name>
<files>SharepointToolbox/MainWindow.xaml, SharepointToolbox/MainWindow.xaml.cs</files>
<action>
**MainWindow.xaml**: Add a new TabItem before SettingsTabItem (after TemplatesTabItem):
```xml
<TabItem x:Name="UserAccessAuditTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.userAccessAudit]}">
</TabItem>
```
**MainWindow.xaml.cs**: Add tab content wiring after the existing tab assignments, before SettingsTabItem:
```csharp
// Phase 7: User Access Audit
var auditView = serviceProvider.GetRequiredService<UserAccessAuditView>();
UserAccessAuditTabItem.Content = auditView;
// Wire site picker dialog factory for audit tab (same pattern as Permissions)
if (auditView.DataContext is UserAccessAuditViewModel auditVm)
{
auditVm.OpenSitePickerDialog = () =>
{
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
return factory(auditVm.CurrentProfile ?? new TenantProfile());
};
}
```
Add `using SharepointToolbox.ViewModels.Tabs;` to MainWindow.xaml.cs if not already present (it should be via existing tab wiring, but the UserAccessAuditViewModel type needs to be resolved).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>MainWindow.xaml has UserAccessAuditTabItem. MainWindow.xaml.cs wires UserAccessAuditView content and site picker dialog factory.</done>
</task>
<task type="auto">
<name>Task 3: Add localization keys to Strings.resx and Strings.fr.resx</name>
<files>SharepointToolbox/Localization/Strings.resx, SharepointToolbox/Localization/Strings.fr.resx</files>
<action>
Add the following keys to both resx files. Add them at the end of the existing data entries, before the closing `</root>` tag.
**Strings.resx (English):**
```xml
<data name="tab.userAccessAudit" xml:space="preserve">
<value>User Access Audit</value>
</data>
<data name="audit.grp.users" xml:space="preserve">
<value>Select Users</value>
</data>
<data name="audit.grp.sites" xml:space="preserve">
<value>Target Sites</value>
</data>
<data name="audit.grp.options" xml:space="preserve">
<value>Scan Options</value>
</data>
<data name="audit.search.placeholder" xml:space="preserve">
<value>Search users by name or email...</value>
</data>
<data name="audit.users.selected" xml:space="preserve">
<value>{0} user(s) selected</value>
</data>
<data name="audit.btn.run" xml:space="preserve">
<value>Run Audit</value>
</data>
<data name="audit.btn.exportCsv" xml:space="preserve">
<value>Export CSV</value>
</data>
<data name="audit.btn.exportHtml" xml:space="preserve">
<value>Export HTML</value>
</data>
<data name="audit.summary.total" xml:space="preserve">
<value>Total Accesses</value>
</data>
<data name="audit.summary.sites" xml:space="preserve">
<value>Sites</value>
</data>
<data name="audit.summary.highPriv" xml:space="preserve">
<value>High Privilege</value>
</data>
<data name="audit.toggle.byUser" xml:space="preserve">
<value>By User</value>
</data>
<data name="audit.toggle.bySite" xml:space="preserve">
<value>By Site</value>
</data>
<data name="audit.filter.placeholder" xml:space="preserve">
<value>Filter results...</value>
</data>
<data name="audit.noUsers" xml:space="preserve">
<value>Select at least one user to audit.</value>
</data>
<data name="audit.noSites" xml:space="preserve">
<value>Select at least one site to scan.</value>
</data>
```
**Strings.fr.resx (French):**
```xml
<data name="tab.userAccessAudit" xml:space="preserve">
<value>Audit des acces utilisateur</value>
</data>
<data name="audit.grp.users" xml:space="preserve">
<value>Selectionner les utilisateurs</value>
</data>
<data name="audit.grp.sites" xml:space="preserve">
<value>Sites cibles</value>
</data>
<data name="audit.grp.options" xml:space="preserve">
<value>Options d'analyse</value>
</data>
<data name="audit.search.placeholder" xml:space="preserve">
<value>Rechercher par nom ou email...</value>
</data>
<data name="audit.users.selected" xml:space="preserve">
<value>{0} utilisateur(s) selectionne(s)</value>
</data>
<data name="audit.btn.run" xml:space="preserve">
<value>Lancer l'audit</value>
</data>
<data name="audit.btn.exportCsv" xml:space="preserve">
<value>Exporter CSV</value>
</data>
<data name="audit.btn.exportHtml" xml:space="preserve">
<value>Exporter HTML</value>
</data>
<data name="audit.summary.total" xml:space="preserve">
<value>Total des acces</value>
</data>
<data name="audit.summary.sites" xml:space="preserve">
<value>Sites</value>
</data>
<data name="audit.summary.highPriv" xml:space="preserve">
<value>Privileges eleves</value>
</data>
<data name="audit.toggle.byUser" xml:space="preserve">
<value>Par utilisateur</value>
</data>
<data name="audit.toggle.bySite" xml:space="preserve">
<value>Par site</value>
</data>
<data name="audit.filter.placeholder" xml:space="preserve">
<value>Filtrer les resultats...</value>
</data>
<data name="audit.noUsers" xml:space="preserve">
<value>Selectionnez au moins un utilisateur.</value>
</data>
<data name="audit.noSites" xml:space="preserve">
<value>Selectionnez au moins un site.</value>
</data>
```
Note: French accented characters (e with accent) should use proper Unicode characters in the actual file. Use the existing file's encoding pattern.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>Both Strings.resx and Strings.fr.resx contain all audit-related localization keys. Keys match those referenced in UserAccessAuditView.xaml.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- MainWindow shows User Access Audit tab in the TabControl
- App.xaml.cs has DI registrations for all Phase 7 types
- All localization keys used in XAML exist in both resx files
- Site picker dialog factory is wired for the audit ViewModel
</verification>
<success_criteria>
The User Access Audit feature is fully integrated into the application. The tab appears in MainWindow, all services resolve from DI, dialog factories work, and UI text is localized in both English and French.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-07-SUMMARY.md`
</output>

View File

@@ -0,0 +1,212 @@
---
phase: 07-user-access-audit
plan: 08
type: execute
wave: 5
depends_on: ["07-02", "07-03", "07-04", "07-06"]
files_modified:
- SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
- SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs
- SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs
autonomous: true
requirements:
- UACC-01
- UACC-02
must_haves:
truths:
- "UserAccessAuditService tests verify: user filtering, access type classification, high-privilege detection, external user detection, multi-user splitting"
- "CSV export tests verify: summary section presence, correct column count, RFC 4180 escaping, per-user file naming"
- "HTML export tests verify: contains stats cards, both view sections, access type badges, filter script"
- "ViewModel tests verify: debounced search triggers service, run audit populates results, tenant switch resets state, global sites override pattern"
artifacts:
- path: "SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs"
provides: "Unit tests for audit service business logic"
contains: "UserAccessAuditServiceTests"
- path: "SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs"
provides: "Unit tests for CSV export"
contains: "UserAccessCsvExportServiceTests"
- path: "SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs"
provides: "Unit tests for HTML export"
contains: "UserAccessHtmlExportServiceTests"
- path: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs"
provides: "Unit tests for ViewModel logic"
contains: "UserAccessAuditViewModelTests"
key_links:
- from: "SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs"
to: "SharepointToolbox/Services/UserAccessAuditService.cs"
via: "Tests TransformEntries logic with mock IPermissionsService"
pattern: "AuditUsersAsync"
---
<objective>
Write unit tests for the core Phase 7 business logic: UserAccessAuditService (filtering, classification), export services (CSV/HTML output), and ViewModel (search, audit, state management).
Purpose: Verify the critical behavior of user filtering, access type classification, export formatting, and ViewModel orchestration.
Output: 4 test files covering services, exports, and ViewModel
</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/phases/07-user-access-audit/07-CONTEXT.md
@.planning/phases/07-user-access-audit/07-02-SUMMARY.md
@.planning/phases/07-user-access-audit/07-04-SUMMARY.md
@.planning/phases/07-user-access-audit/07-06-SUMMARY.md
<interfaces>
<!-- From 07-01/07-02: Service under test -->
From SharepointToolbox/Services/UserAccessAuditService.cs:
```csharp
public class UserAccessAuditService : IUserAccessAuditService
{
public UserAccessAuditService(IPermissionsService permissionsService) { }
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
ISessionManager sessionManager,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
<!-- From 07-06: Export services under test -->
From SharepointToolbox/Services/Export/UserAccessCsvExportService.cs:
```csharp
public class UserAccessCsvExportService
{
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries);
public async Task WriteSingleFileAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct);
}
```
From SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs:
```csharp
public class UserAccessHtmlExportService
{
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries);
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct);
}
```
<!-- Existing test patterns -->
From SharepointToolbox.Tests (uses xUnit + NSubstitute):
```csharp
using NSubstitute;
using Xunit;
```
<!-- Mock patterns for IPermissionsService, ISessionManager -->
```csharp
var mockPermService = Substitute.For<IPermissionsService>();
var mockSessionMgr = Substitute.For<ISessionManager>();
mockSessionMgr.GetOrCreateContextAsync(Arg.Any<TenantProfile>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<ClientContext>(null!)); // service creates context, tests mock it
mockPermService.ScanSiteAsync(Arg.Any<ClientContext>(), Arg.Any<ScanOptions>(), Arg.Any<IProgress<OperationProgress>>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<PermissionEntry>>(testEntries));
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Write UserAccessAuditService unit tests</name>
<files>SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs</files>
<action>
Create `SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs` with xUnit + NSubstitute.
Test cases for AuditUsersAsync:
1. **Filters_by_target_user_login**: Mock IPermissionsService returning entries for 3 users. Audit for 1 user. Assert only that user's entries returned.
2. **Matches_user_by_email_in_claim_format**: PermissionEntry.UserLogins = "i:0#.f|membership|alice@contoso.com". Target = "alice@contoso.com". Assert match found.
3. **Classifies_direct_access**: Entry with HasUniquePermissions=true, GrantedThrough="Direct Permissions". Assert AccessType.Direct.
4. **Classifies_group_access**: Entry with HasUniquePermissions=true, GrantedThrough="SharePoint Group: Members". Assert AccessType.Group.
5. **Classifies_inherited_access**: Entry with HasUniquePermissions=false. Assert AccessType.Inherited.
6. **Detects_high_privilege**: Entry with PermissionLevels="Full Control". Assert IsHighPrivilege=true.
7. **Detects_high_privilege_site_admin**: Entry with PermissionLevels="Site Collection Administrator". Assert IsHighPrivilege=true.
8. **Flags_external_user**: Entry with UserLogins containing "#EXT#". Assert IsExternalUser=true.
9. **Splits_semicolon_users**: Entry with Users="Alice;Bob", UserLogins="alice@x.com;bob@x.com". Target both. Assert 2 separate UserAccessEntry rows per permission level.
10. **Splits_semicolon_permission_levels**: Entry with PermissionLevels="Read;Contribute". Assert 2 UserAccessEntry rows (one per level).
11. **Empty_targets_returns_empty**: Pass empty targetUserLogins. Assert empty result.
12. **Scans_multiple_sites**: Pass 2 sites. Assert both site entries appear in results.
Mock setup pattern:
```csharp
private static PermissionEntry MakeEntry(
string users = "Alice", string logins = "alice@contoso.com",
string levels = "Read", string grantedThrough = "Direct Permissions",
bool hasUnique = true, string objectType = "List", string title = "Docs",
string url = "https://contoso.sharepoint.com/Docs",
string principalType = "User") =>
new(objectType, title, url, hasUnique, users, logins, levels, grantedThrough, principalType);
```
For the SessionManager mock, the service passes TenantProfile objects to GetOrCreateContextAsync. The mock should return null for ClientContext since the PermissionsService is also mocked (it never actually uses the context in tests).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~UserAccessAuditServiceTests" --no-build 2>&1 | tail -10</automated>
</verify>
<done>All UserAccessAuditService tests pass: user filtering, claim format matching, access type classification (Direct/Group/Inherited), high-privilege detection, external user flagging, semicolon splitting, multi-site scanning.</done>
</task>
<task type="auto">
<name>Task 2: Write export service and ViewModel tests</name>
<files>SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs, SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs, SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs</files>
<action>
**UserAccessCsvExportServiceTests.cs**:
1. **BuildCsv_includes_summary_section**: Assert output starts with "User Access Audit Report" and includes user name, total, sites count.
2. **BuildCsv_includes_data_header**: Assert DataHeader line present after summary.
3. **BuildCsv_escapes_quotes**: Entry with title containing double quotes. Assert RFC 4180 escaping.
4. **BuildCsv_correct_column_count**: Assert each data row has 7 comma-separated fields.
5. **WriteSingleFileAsync_includes_all_users**: Pass entries for 2 users. Assert both appear in output.
**UserAccessHtmlExportServiceTests.cs**:
1. **BuildHtml_contains_doctype**: Assert starts with "<!DOCTYPE html>".
2. **BuildHtml_has_stats_cards**: Assert contains "Total Accesses" and stat-card CSS class.
3. **BuildHtml_has_both_views**: Assert contains "by-user" and "by-site" div/section identifiers.
4. **BuildHtml_has_access_type_badges**: Assert contains "access-direct", "access-group", "access-inherited" CSS classes.
5. **BuildHtml_has_filter_script**: Assert contains "filterTable" JS function.
6. **BuildHtml_has_toggle_script**: Assert contains "toggleView" JS function.
7. **BuildHtml_encodes_html_entities**: Entry with title containing "<script>". Assert encoded as "&lt;script&gt;".
**UserAccessAuditViewModelTests.cs** (use test constructor, mock services):
1. **RunOperation_calls_AuditUsersAsync**: Mock IUserAccessAuditService, add selected user + site, run. Assert AuditUsersAsync was called.
2. **RunOperation_populates_results**: Mock returns entries. Assert Results.Count matches.
3. **RunOperation_updates_summary_properties**: Assert TotalAccessCount, SitesCount, HighPrivilegeCount computed correctly.
4. **OnTenantSwitched_resets_state**: Set results and selected users, switch tenant. Assert all cleared.
5. **OnGlobalSitesChanged_updates_selected_sites**: Send GlobalSitesChangedMessage. Assert SelectedSites updated.
6. **OnGlobalSitesChanged_skipped_when_override**: Set _hasLocalSiteOverride. Send message. Assert SelectedSites unchanged.
7. **CanExport_false_when_no_results**: Assert ExportCsvCommand.CanExecute is false when Results is empty.
8. **CanExport_true_when_has_results**: Add results. Assert ExportCsvCommand.CanExecute is true.
For ViewModel tests, use the internal test constructor (no export services). Mock IUserAccessAuditService, IGraphUserSearchService, ISessionManager. Use NSubstitute.
Note: ViewModel tests that call RunOperationAsync should use the internal TestRunOperationAsync pattern from PermissionsViewModel (if exposed), or invoke RunCommand.ExecuteAsync directly.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~UserAccess" 2>&1 | tail -15</automated>
</verify>
<done>All Phase 7 tests pass: 12 audit service tests, 7 CSV export tests, 7 HTML export tests, 8 ViewModel tests. Total ~34 tests covering core business logic, export formatting, and ViewModel orchestration.</done>
</task>
</tasks>
<verification>
- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~UserAccess"` — all pass
- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` — no regressions in existing tests
- Test coverage: user filtering, access classification, export format, ViewModel lifecycle
</verification>
<success_criteria>
All Phase 7 unit tests pass. Critical business logic is verified: user login matching (including claim format), access type classification, high-privilege/external detection, CSV/HTML export format, and ViewModel state management. No regressions in existing tests.
</success_criteria>
<output>
After completion, create `.planning/phases/07-user-access-audit/07-08-SUMMARY.md`
</output>