docs(08-simplified-permissions): create phase plan (6 plans, 5 waves)

Plans cover plain-language permission labels, risk-level color coding,
summary counts, detail-level toggle, export integration, and unit tests.
PermissionEntry record is NOT modified — uses wrapper pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-07 14:00:08 +02:00
parent dcdbd8662d
commit c871effa87
7 changed files with 2209 additions and 2 deletions

View File

@@ -0,0 +1,404 @@
---
phase: 08-simplified-permissions
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/Core/Models/RiskLevel.cs
- SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs
- SharepointToolbox/Core/Models/PermissionSummary.cs
- SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs
autonomous: true
requirements:
- SIMP-01
- SIMP-02
must_haves:
truths:
- "RiskLevel enum distinguishes High, Medium, Low, and ReadOnly access tiers"
- "PermissionLevelMapping maps all standard SharePoint role names to plain-language labels and risk levels"
- "SimplifiedPermissionEntry wraps PermissionEntry with computed simplified labels and risk level without modifying the original record"
- "PermissionSummary groups permission entries by risk level with counts"
- "Unknown/custom role names fall back to the raw name with a Medium risk level"
artifacts:
- path: "SharepointToolbox/Core/Models/RiskLevel.cs"
provides: "Risk level classification enum"
contains: "enum RiskLevel"
- path: "SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs"
provides: "Static mapping from SP role names to plain-language labels"
contains: "class PermissionLevelMapping"
- path: "SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs"
provides: "Presentation wrapper for PermissionEntry with simplified fields"
contains: "class SimplifiedPermissionEntry"
- path: "SharepointToolbox/Core/Models/PermissionSummary.cs"
provides: "Aggregation model for summary counts by risk level"
contains: "record PermissionSummary"
key_links:
- from: "SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs"
to: "SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs"
via: "Static method call to resolve labels and risk level"
pattern: "PermissionLevelMapping\\.Get"
- from: "SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs"
to: "SharepointToolbox/Core/Models/PermissionEntry.cs"
via: "Wraps original entry as Inner property"
pattern: "PermissionEntry Inner"
---
<objective>
Define the data models and mapping layer for simplified permissions: RiskLevel enum, PermissionLevelMapping helper, SimplifiedPermissionEntry wrapper, and PermissionSummary aggregation model.
Purpose: All subsequent plans import these types. The mapping layer is the core of SIMP-01 (plain-language labels) and SIMP-02 (risk level color coding). PermissionEntry is immutable and NOT modified — SimplifiedPermissionEntry wraps it as a presentation concern.
Output: RiskLevel.cs, PermissionLevelMapping.cs, SimplifiedPermissionEntry.cs, PermissionSummary.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
<interfaces>
<!-- PermissionEntry is READ-ONLY — do NOT modify this record -->
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 (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType // "SharePointGroup" | "User" | "External User"
);
```
From SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs:
```csharp
public static class PermissionEntryHelper
{
public static bool IsExternalUser(string loginName);
public static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels);
public static bool IsSharingLinksGroup(string loginName);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create RiskLevel enum and PermissionLevelMapping helper</name>
<files>SharepointToolbox/Core/Models/RiskLevel.cs, SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs</files>
<action>
Create `SharepointToolbox/Core/Models/RiskLevel.cs`:
```csharp
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classifies a SharePoint permission level by its access risk.
/// Used for color coding in both WPF DataGrid and HTML export.
/// </summary>
public enum RiskLevel
{
/// <summary>Full Control, Site Collection Administrator — can delete site, manage permissions.</summary>
High,
/// <summary>Contribute, Edit, Design — can modify content.</summary>
Medium,
/// <summary>Read, Restricted View — can view but not modify.</summary>
Low,
/// <summary>View Only — most restricted legitimate access.</summary>
ReadOnly
}
```
Create `SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs`:
```csharp
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Maps SharePoint built-in permission level names to human-readable labels and risk levels.
/// Used by SimplifiedPermissionEntry and export services to translate raw role names
/// into plain-language descriptions that non-technical users can understand.
/// </summary>
public static class PermissionLevelMapping
{
/// <summary>
/// Result of looking up a SharePoint role name.
/// </summary>
public record MappingResult(string Label, RiskLevel RiskLevel);
/// <summary>
/// Known SharePoint built-in permission level mappings.
/// Keys are case-insensitive via the dictionary comparer.
/// </summary>
private static readonly Dictionary<string, MappingResult> Mappings = new(StringComparer.OrdinalIgnoreCase)
{
// High risk — full administrative access
["Full Control"] = new("Full control (can manage everything)", RiskLevel.High),
["Site Collection Administrator"] = new("Site collection admin (full control)", RiskLevel.High),
// Medium risk — can modify content
["Contribute"] = new("Can edit files and list items", RiskLevel.Medium),
["Edit"] = new("Can edit files, lists, and pages", RiskLevel.Medium),
["Design"] = new("Can edit pages and use design tools", RiskLevel.Medium),
["Approve"] = new("Can approve content and list items", RiskLevel.Medium),
["Manage Hierarchy"] = new("Can create sites and manage pages", RiskLevel.Medium),
// Low risk — read access
["Read"] = new("Can view files and pages", RiskLevel.Low),
["Restricted Read"] = new("Can view pages only (no download)", RiskLevel.Low),
// Read-only — most restricted
["View Only"] = new("Can view files in browser only", RiskLevel.ReadOnly),
["Restricted View"] = new("Restricted view access", RiskLevel.ReadOnly),
};
/// <summary>
/// Gets the human-readable label and risk level for a SharePoint role name.
/// Returns the mapped result for known roles; for unknown/custom roles,
/// returns the raw name as-is with Medium risk level.
/// </summary>
public static MappingResult GetMapping(string roleName)
{
if (string.IsNullOrWhiteSpace(roleName))
return new MappingResult(roleName, RiskLevel.Low);
return Mappings.TryGetValue(roleName.Trim(), out var result)
? result
: new MappingResult(roleName.Trim(), RiskLevel.Medium);
}
/// <summary>
/// Resolves a semicolon-delimited PermissionLevels string into individual mapping results.
/// This handles the PermissionEntry.PermissionLevels format (e.g. "Full Control; Contribute").
/// </summary>
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels)
{
if (string.IsNullOrWhiteSpace(permissionLevels))
return Array.Empty<MappingResult>();
return permissionLevels
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(GetMapping)
.ToList();
}
/// <summary>
/// Returns the highest (most dangerous) risk level from a semicolon-delimited permission levels string.
/// Used for row-level color coding when an entry has multiple roles.
/// </summary>
public static RiskLevel GetHighestRisk(string permissionLevels)
{
var mappings = GetMappings(permissionLevels);
if (mappings.Count == 0) return RiskLevel.Low;
// High < Medium < Low < ReadOnly in enum order — Min gives highest risk
return mappings.Min(m => m.RiskLevel);
}
/// <summary>
/// Converts a semicolon-delimited PermissionLevels string into a simplified labels string.
/// E.g. "Full Control; Contribute" becomes "Full control (can manage everything); Can edit files and list items"
/// </summary>
public static string GetSimplifiedLabels(string permissionLevels)
{
var mappings = GetMappings(permissionLevels);
return string.Join("; ", mappings.Select(m => m.Label));
}
}
```
Design notes:
- Case-insensitive lookup handles variations in SharePoint role name casing
- Unknown/custom roles default to Medium (conservative — forces admin review)
- GetHighestRisk uses enum ordering (High=0 is most dangerous) for row-level color
- Semicolon-split methods handle the PermissionEntry.PermissionLevels format directly
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>RiskLevel.cs contains 4-value enum (High, Medium, Low, ReadOnly). PermissionLevelMapping.cs has GetMapping, GetMappings, GetHighestRisk, and GetSimplifiedLabels. All standard SP roles mapped. Unknown roles fallback to Medium. Project compiles.</done>
</task>
<task type="auto">
<name>Task 2: Create SimplifiedPermissionEntry wrapper and PermissionSummary model</name>
<files>SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs, SharepointToolbox/Core/Models/PermissionSummary.cs</files>
<action>
Create `SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs`:
```csharp
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Presentation wrapper around PermissionEntry that adds simplified labels
/// and risk level classification without modifying the immutable source record.
/// Used as the DataGrid ItemsSource when simplified mode is active.
/// </summary>
public class SimplifiedPermissionEntry
{
/// <summary>The original immutable PermissionEntry.</summary>
public PermissionEntry Inner { get; }
/// <summary>
/// Human-readable labels for the permission levels.
/// E.g. "Can edit files and list items" instead of "Contribute".
/// </summary>
public string SimplifiedLabels { get; }
/// <summary>
/// The highest risk level across all permission levels on this entry.
/// Used for row-level color coding.
/// </summary>
public RiskLevel RiskLevel { get; }
/// <summary>
/// Individual mapping results for each permission level in the entry.
/// Used when detailed breakdown per-role is needed.
/// </summary>
public IReadOnlyList<PermissionLevelMapping.MappingResult> Mappings { get; }
// ── Passthrough properties for DataGrid binding ──
public string ObjectType => Inner.ObjectType;
public string Title => Inner.Title;
public string Url => Inner.Url;
public bool HasUniquePermissions => Inner.HasUniquePermissions;
public string Users => Inner.Users;
public string UserLogins => Inner.UserLogins;
public string PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType;
public SimplifiedPermissionEntry(PermissionEntry entry)
{
Inner = entry;
Mappings = PermissionLevelMapping.GetMappings(entry.PermissionLevels);
SimplifiedLabels = PermissionLevelMapping.GetSimplifiedLabels(entry.PermissionLevels);
RiskLevel = PermissionLevelMapping.GetHighestRisk(entry.PermissionLevels);
}
/// <summary>
/// Creates SimplifiedPermissionEntry wrappers for a collection of entries.
/// </summary>
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(
IEnumerable<PermissionEntry> entries)
{
return entries.Select(e => new SimplifiedPermissionEntry(e)).ToList();
}
}
```
Create `SharepointToolbox/Core/Models/PermissionSummary.cs`:
```csharp
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Summary counts of permission entries grouped by risk level.
/// Displayed in the summary panel when simplified mode is active.
/// </summary>
public record PermissionSummary(
/// <summary>Label for this group (e.g. "High Risk", "Read Only").</summary>
string Label,
/// <summary>The risk level this group represents.</summary>
RiskLevel RiskLevel,
/// <summary>Number of permission entries at this risk level.</summary>
int Count,
/// <summary>Number of distinct users at this risk level.</summary>
int DistinctUsers
);
/// <summary>
/// Computes PermissionSummary groups from SimplifiedPermissionEntry collections.
/// </summary>
public static class PermissionSummaryBuilder
{
/// <summary>
/// Risk level display labels.
/// </summary>
private static readonly Dictionary<RiskLevel, string> Labels = new()
{
[RiskLevel.High] = "High Risk",
[RiskLevel.Medium] = "Medium Risk",
[RiskLevel.Low] = "Low Risk",
[RiskLevel.ReadOnly] = "Read Only",
};
/// <summary>
/// Builds summary counts grouped by risk level from a collection of simplified entries.
/// Always returns all 4 risk levels, even if count is 0, for consistent UI binding.
/// </summary>
public static IReadOnlyList<PermissionSummary> Build(
IEnumerable<SimplifiedPermissionEntry> entries)
{
var grouped = entries
.GroupBy(e => e.RiskLevel)
.ToDictionary(g => g.Key, g => g.ToList());
return Enum.GetValues<RiskLevel>()
.Select(level =>
{
var items = grouped.GetValueOrDefault(level, new List<SimplifiedPermissionEntry>());
var distinctUsers = items
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
return new PermissionSummary(
Label: Labels[level],
RiskLevel: level,
Count: items.Count,
DistinctUsers: distinctUsers);
})
.ToList();
}
}
```
Design notes:
- SimplifiedPermissionEntry is a class (not record) so it can have passthrough properties for DataGrid binding
- All original PermissionEntry fields are exposed as passthrough properties — DataGrid columns bind identically
- SimplifiedLabels and RiskLevel are computed once at construction — no per-render cost
- PermissionSummaryBuilder.Build always returns 4 entries (one per RiskLevel) for consistent summary panel layout
- DistinctUsers uses case-insensitive comparison for login deduplication
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>SimplifiedPermissionEntry wraps PermissionEntry with SimplifiedLabels, RiskLevel, Mappings, and all passthrough properties. PermissionSummary + PermissionSummaryBuilder provide grouped counts. Project compiles cleanly. PermissionEntry.cs is NOT modified.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- RiskLevel.cs has High, Medium, Low, ReadOnly values
- PermissionLevelMapping has 11 known role mappings with labels and risk levels
- SimplifiedPermissionEntry wraps PermissionEntry (Inner property) without modifying it
- PermissionSummaryBuilder.Build returns 4 summary entries (one per risk level)
- No changes to PermissionEntry.cs
</verification>
<success_criteria>
All 4 files compile cleanly. The mapping and wrapper layer is complete: downstream plans (08-02 through 08-05) can import RiskLevel, PermissionLevelMapping, SimplifiedPermissionEntry, and PermissionSummary without ambiguity. PermissionEntry remains immutable and unmodified.
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,265 @@
---
phase: 08-simplified-permissions
plan: 02
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
- SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
autonomous: true
requirements:
- SIMP-01
- SIMP-02
- SIMP-03
must_haves:
truths:
- "IsSimplifiedMode toggle switches between raw and simplified permission labels in the DataGrid"
- "IsDetailView toggle controls whether individual rows are shown or collapsed into summary rows"
- "Toggling modes does NOT re-run the scan — it re-renders from existing Results data"
- "Summary counts per risk level are available as observable properties when simplified mode is on"
- "SimplifiedResults collection is computed from Results whenever Results changes or mode toggles"
- "ActiveItemsSource provides the correct collection for DataGrid binding depending on current mode"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
provides: "Extended PermissionsViewModel with simplified mode, detail toggle, and summary"
contains: "IsSimplifiedMode"
key_links:
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
to: "SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs"
via: "SimplifiedPermissionEntry.WrapAll uses PermissionLevelMapping internally"
pattern: "SimplifiedPermissionEntry\\.WrapAll"
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
to: "SharepointToolbox/Core/Models/PermissionSummary.cs"
via: "PermissionSummaryBuilder.Build computes summary from simplified entries"
pattern: "PermissionSummaryBuilder\\.Build"
---
<objective>
Extend PermissionsViewModel with IsSimplifiedMode toggle, IsDetailView toggle, SimplifiedResults collection, summary statistics, and an ActiveItemsSource that the DataGrid binds to. All toggles re-render from cached data — no re-scan required.
Purpose: This is the ViewModel logic for all three SIMP requirements. The View (08-03) binds to these new properties.
Output: Updated PermissionsViewModel.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/08-simplified-permissions/08-01-SUMMARY.md
<interfaces>
<!-- From 08-01: New types this plan consumes -->
From SharepointToolbox/Core/Models/RiskLevel.cs:
```csharp
public enum RiskLevel { High, Medium, Low, ReadOnly }
```
From SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs:
```csharp
public static class PermissionLevelMapping
{
public record MappingResult(string Label, RiskLevel RiskLevel);
public static MappingResult GetMapping(string roleName);
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels);
public static RiskLevel GetHighestRisk(string permissionLevels);
public static string GetSimplifiedLabels(string permissionLevels);
}
```
From SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs:
```csharp
public class SimplifiedPermissionEntry
{
public PermissionEntry Inner { get; }
public string SimplifiedLabels { get; }
public RiskLevel RiskLevel { get; }
public IReadOnlyList<PermissionLevelMapping.MappingResult> Mappings { get; }
// Passthrough: ObjectType, Title, Url, HasUniquePermissions, Users, UserLogins,
// PermissionLevels, GrantedThrough, PrincipalType
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(IEnumerable<PermissionEntry> entries);
}
```
From SharepointToolbox/Core/Models/PermissionSummary.cs:
```csharp
public record PermissionSummary(string Label, RiskLevel RiskLevel, int Count, int DistinctUsers);
public static class PermissionSummaryBuilder
{
public static IReadOnlyList<PermissionSummary> Build(IEnumerable<SimplifiedPermissionEntry> entries);
}
```
<!-- Current PermissionsViewModel — the file being modified -->
From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs:
```csharp
public partial class PermissionsViewModel : FeatureViewModelBase
{
// Existing fields and services — unchanged
[ObservableProperty] private ObservableCollection<PermissionEntry> _results = new();
// Existing commands — unchanged
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand OpenSitePickerCommand { get; }
// Full constructor and test constructor (internal)
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add simplified mode properties and summary computation to PermissionsViewModel</name>
<files>SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs</files>
<action>
Modify `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs` to add simplified mode support. Add the following new using statements at the top:
```csharp
using SharepointToolbox.Core.Helpers;
```
Add these new observable properties to the class (in the "Observable properties" section):
```csharp
/// <summary>
/// When true, displays simplified plain-language labels instead of raw SharePoint role names.
/// Toggling does not re-run the scan.
/// </summary>
[ObservableProperty]
private bool _isSimplifiedMode;
/// <summary>
/// When true, shows individual item-level rows (detailed view).
/// When false, shows only summary rows grouped by risk level (simple view).
/// Only meaningful when IsSimplifiedMode is true.
/// </summary>
[ObservableProperty]
private bool _isDetailView = true;
```
Add these computed collection properties (NOT ObservableProperty — manually raised):
```csharp
/// <summary>
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
/// </summary>
private IReadOnlyList<SimplifiedPermissionEntry> _simplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
public IReadOnlyList<SimplifiedPermissionEntry> SimplifiedResults
{
get => _simplifiedResults;
private set => SetProperty(ref _simplifiedResults, value);
}
/// <summary>
/// Summary counts grouped by risk level. Rebuilt when SimplifiedResults changes.
/// </summary>
private IReadOnlyList<PermissionSummary> _summaries = Array.Empty<PermissionSummary>();
public IReadOnlyList<PermissionSummary> Summaries
{
get => _summaries;
private set => SetProperty(ref _summaries, value);
}
/// <summary>
/// The collection the DataGrid actually binds to. Returns:
/// - Results (raw) when simplified mode is OFF
/// - SimplifiedResults when simplified mode is ON and detail view is ON
/// - (View handles summary display separately via Summaries property)
/// </summary>
public object ActiveItemsSource => IsSimplifiedMode
? (object)SimplifiedResults
: Results;
```
Add partial methods triggered by property changes:
```csharp
partial void OnIsSimplifiedModeChanged(bool value)
{
if (value && Results.Count > 0)
RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
}
partial void OnIsDetailViewChanged(bool value)
{
OnPropertyChanged(nameof(ActiveItemsSource));
}
```
Add a private method to rebuild simplified data from existing Results:
```csharp
/// <summary>
/// Recomputes SimplifiedResults and Summaries from the current Results collection.
/// Called when Results changes or when simplified mode is toggled on.
/// </summary>
private void RebuildSimplifiedData()
{
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results);
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
}
```
Modify the existing `RunOperationAsync` method: after the line that sets `Results = new ObservableCollection<PermissionEntry>(allEntries);` (both in the dispatcher branch and the else branch), add:
```csharp
if (IsSimplifiedMode)
RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
```
So the end of RunOperationAsync becomes (both branches):
```csharp
Results = new ObservableCollection<PermissionEntry>(allEntries);
if (IsSimplifiedMode)
RebuildSimplifiedData();
OnPropertyChanged(nameof(ActiveItemsSource));
```
Modify `OnTenantSwitched` to also reset simplified state:
After `Results = new ObservableCollection<PermissionEntry>();` add:
```csharp
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
Summaries = Array.Empty<PermissionSummary>();
OnPropertyChanged(nameof(ActiveItemsSource));
```
Do NOT change:
- Constructor signatures (both full and test constructors remain unchanged)
- Existing properties (SiteUrl, IncludeInherited, ScanFolders, etc.)
- ExportCsvCommand and ExportHtmlCommand implementations (export updates are in plan 08-04)
- OpenSitePickerCommand
- _hasLocalSiteOverride / OnGlobalSitesChanged logic
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>PermissionsViewModel has IsSimplifiedMode, IsDetailView, SimplifiedResults, Summaries, and ActiveItemsSource properties. Toggling IsSimplifiedMode rebuilds simplified data from cached Results without re-scanning. Toggling IsDetailView triggers ActiveItemsSource change notification. Existing tests still compile (no constructor changes).</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- `dotnet test SharepointToolbox.Tests/ --filter PermissionsViewModelTests` passes (no constructor changes)
- PermissionsViewModel has IsSimplifiedMode, IsDetailView, SimplifiedResults, Summaries, ActiveItemsSource
- Toggling IsSimplifiedMode calls RebuildSimplifiedData + raises ActiveItemsSource changed
- RunOperationAsync calls RebuildSimplifiedData when IsSimplifiedMode is true
- OnTenantSwitched resets SimplifiedResults and Summaries
</verification>
<success_criteria>
The ViewModel is the orchestration layer for SIMP-01/02/03. All mode toggles re-render from cached data. The View (08-03) can bind to IsSimplifiedMode, IsDetailView, ActiveItemsSource, and Summaries. Export services (08-04) can access SimplifiedResults and IsSimplifiedMode.
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,464 @@
---
phase: 08-simplified-permissions
plan: 03
type: execute
wave: 3
depends_on: ["08-02"]
files_modified:
- SharepointToolbox/Views/Tabs/PermissionsView.xaml
autonomous: true
requirements:
- SIMP-01
- SIMP-02
- SIMP-03
must_haves:
truths:
- "A Simplified Mode toggle checkbox appears in the left panel scan options"
- "A Detail Level selector (Simple/Detailed) appears when simplified mode is on"
- "When simplified mode is on, the Permission Levels column shows plain-language labels instead of raw role names"
- "Permission level cells are color-coded by risk level (red=High, orange=Medium, green=Low, blue=ReadOnly)"
- "A summary panel shows counts per risk level with color indicators above the DataGrid"
- "When detail level is Simple, the DataGrid is hidden and only the summary panel is visible"
- "When detail level is Detailed, both summary panel and DataGrid rows are visible"
artifacts:
- path: "SharepointToolbox/Views/Tabs/PermissionsView.xaml"
provides: "Updated permissions view with toggles, color coding, and summary panel"
contains: "IsSimplifiedMode"
key_links:
- from: "SharepointToolbox/Views/Tabs/PermissionsView.xaml"
to: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
via: "DataBinding to IsSimplifiedMode, IsDetailView, ActiveItemsSource, Summaries"
pattern: "Binding IsSimplifiedMode"
---
<objective>
Update PermissionsView.xaml to add the simplified mode toggle, detail level selector, color-coded permission cells, and summary panel with risk level counts.
Purpose: This is the visual layer for SIMP-01 (plain labels), SIMP-02 (color-coded summary), and SIMP-03 (detail level toggle). Binds to ViewModel properties created in 08-02.
Output: Updated PermissionsView.xaml
</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/08-simplified-permissions/08-01-SUMMARY.md
@.planning/phases/08-simplified-permissions/08-02-SUMMARY.md
<interfaces>
<!-- ViewModel properties the View binds to (from 08-02) -->
From PermissionsViewModel (updated):
```csharp
// New toggle properties
[ObservableProperty] private bool _isSimplifiedMode;
[ObservableProperty] private bool _isDetailView = true;
// Computed collections
public IReadOnlyList<SimplifiedPermissionEntry> SimplifiedResults { get; }
public IReadOnlyList<PermissionSummary> Summaries { get; }
public object ActiveItemsSource { get; } // Switches between Results and SimplifiedResults
// Existing (unchanged)
public ObservableCollection<PermissionEntry> Results { get; }
```
From SimplifiedPermissionEntry:
```csharp
public string ObjectType { get; }
public string Title { get; }
public string Url { get; }
public bool HasUniquePermissions { get; }
public string Users { get; }
public string PermissionLevels { get; } // Raw role names
public string SimplifiedLabels { get; } // Plain-language labels
public RiskLevel RiskLevel { get; } // High/Medium/Low/ReadOnly
public string GrantedThrough { get; }
public string PrincipalType { get; }
```
From PermissionSummary:
```csharp
public record PermissionSummary(string Label, RiskLevel RiskLevel, int Count, int DistinctUsers);
```
From RiskLevel:
```csharp
public enum RiskLevel { High, Medium, Low, ReadOnly }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add toggles, summary panel, and color-coded DataGrid to PermissionsView.xaml</name>
<files>SharepointToolbox/Views/Tabs/PermissionsView.xaml</files>
<action>
Replace the entire content of `SharepointToolbox/Views/Tabs/PermissionsView.xaml` with the updated XAML below. Key changes from the original:
1. Added `xmlns:models` namespace for RiskLevel enum reference in DataTriggers
2. Added "Display Options" GroupBox in left panel with Simplified Mode toggle and Detail Level radio buttons
3. Added summary panel (ItemsControl bound to Summaries) between left panel and DataGrid
4. DataGrid now binds to `ActiveItemsSource` instead of `Results`
5. Added "Simplified Labels" column visible only in simplified mode (via DataTrigger on Visibility)
6. Permission Levels column cells are color-coded by RiskLevel using DataTrigger
7. DataGrid visibility controlled by IsDetailView when in simplified mode
8. Summary panel visibility controlled by IsSimplifiedMode
```xml
<UserControl x:Class="SharepointToolbox.Views.Tabs.PermissionsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:models="clr-namespace:SharepointToolbox.Core.Models">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</UserControl.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="290" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Left panel: Scan configuration -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
<!-- Scan Options GroupBox -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<!-- Site URL -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.site.url]}"
Margin="0,0,0,2" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,6" />
<!-- View Sites + selected label -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.or.select]}"
Margin="0,0,0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
Command="{Binding OpenSitePickerCommand}"
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
<TextBlock Text="{Binding SitesSelectedLabel}"
FontStyle="Italic" Foreground="Gray" Margin="0,0,0,8" />
<!-- Checkboxes -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" />
<!-- Folder depth -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
Margin="0,0,0,2" />
<TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
Width="60" HorizontalAlignment="Left" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth, Mode=TwoWay}"
Margin="0,0,0,0" />
</StackPanel>
</GroupBox>
<!-- Display Options GroupBox (NEW for Phase 8) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.display.opts]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<!-- Simplified Mode toggle -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.simplified.mode]}"
IsChecked="{Binding IsSimplifiedMode}" Margin="0,0,0,8" />
<!-- Detail Level radio buttons (only enabled when simplified mode is on) -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.detail.level]}"
Margin="0,0,0,4"
IsEnabled="{Binding IsSimplifiedMode}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSimplifiedMode}" Value="False">
<Setter Property="Foreground" Value="Gray" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.detail.simple]}"
IsChecked="{Binding IsDetailView, Converter={StaticResource InvertBoolConverter}, Mode=TwoWay}"
IsEnabled="{Binding IsSimplifiedMode}"
Margin="0,0,0,2" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.detail.detailed]}"
IsChecked="{Binding IsDetailView}"
IsEnabled="{Binding IsSimplifiedMode}"
Margin="0,0,0,0" />
</StackPanel>
</GroupBox>
<!-- Action buttons -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.perms]}"
Command="{Binding RunCommand}"
Margin="0,0,4,4" Padding="6,3" />
<Button Grid.Column="1"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}"
Margin="0,0,0,4" Padding="6,3" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.csv.perms]}"
Command="{Binding ExportCsvCommand}"
Margin="0,0,4,0" Padding="6,3" />
<Button Grid.Column="1"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.html.perms]}"
Command="{Binding ExportHtmlCommand}"
Margin="0,0,0,0" Padding="6,3" />
</Grid>
</StackPanel>
</DockPanel>
<!-- Right panel: Summary + Results -->
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Summary panel (visible only in simplified mode) -->
<ItemsControl Grid.Row="0" ItemsSource="{Binding Summaries}"
Margin="0,0,0,8">
<ItemsControl.Style>
<Style TargetType="ItemsControl">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSimplifiedMode}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</ItemsControl.Style>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="6" Padding="14,10" Margin="0,0,10,4" MinWidth="140">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#F3F4F6" />
<Style.Triggers>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
<Setter Property="Background" Value="#FEE2E2" />
<Setter Property="BorderBrush" Value="#FECACA" />
<Setter Property="BorderThickness" Value="1" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
<Setter Property="Background" Value="#FEF3C7" />
<Setter Property="BorderBrush" Value="#FDE68A" />
<Setter Property="BorderThickness" Value="1" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
<Setter Property="Background" Value="#D1FAE5" />
<Setter Property="BorderBrush" Value="#A7F3D0" />
<Setter Property="BorderThickness" Value="1" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
<Setter Property="Background" Value="#DBEAFE" />
<Setter Property="BorderBrush" Value="#BFDBFE" />
<Setter Property="BorderThickness" Value="1" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<TextBlock Text="{Binding Count}" FontSize="22" FontWeight="Bold" />
<TextBlock Text="{Binding Label}" FontSize="11" Foreground="#555" />
<TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0">
<Run Text="{Binding DistinctUsers, Mode=OneWay}" />
<Run Text=" user(s)" />
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Results DataGrid -->
<DataGrid Grid.Row="1"
ItemsSource="{Binding ActiveItemsSource}"
AutoGenerateColumns="False"
IsReadOnly="True"
VirtualizingPanel.IsVirtualizing="True"
EnableRowVirtualization="True">
<DataGrid.Style>
<Style TargetType="DataGrid">
<Style.Triggers>
<!-- Hide DataGrid when simplified mode is on but detail view is off -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsSimplifiedMode}" Value="True" />
<Condition Binding="{Binding IsDetailView}" Value="False" />
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Collapsed" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
</DataGrid.Style>
<!-- Row style: color-code by RiskLevel when in simplified mode -->
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
<Setter Property="Background" Value="#FEF2F2" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
<Setter Property="Background" Value="#FFFBEB" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
<Setter Property="Background" Value="#ECFDF5" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
<Setter Property="Background" Value="#EFF6FF" />
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="100" />
<DataGridTextColumn Header="Title" Binding="{Binding Title}" Width="140" />
<DataGridTextColumn Header="URL" Binding="{Binding Url}" Width="200" />
<DataGridTextColumn Header="Unique Perms" Binding="{Binding HasUniquePermissions}" Width="90" />
<DataGridTextColumn Header="Users" Binding="{Binding Users}" Width="140" />
<DataGridTextColumn Header="Permission Levels" Binding="{Binding PermissionLevels}" Width="140" />
<!-- Simplified Labels column (only visible in simplified mode) -->
<DataGridTextColumn Header="Simplified" Binding="{Binding SimplifiedLabels}" Width="200">
<DataGridTextColumn.Visibility>
<Binding Path="DataContext.IsSimplifiedMode"
RelativeSource="{RelativeSource AncestorType=DataGrid}"
Converter="{StaticResource BoolToVis}" />
</DataGridTextColumn.Visibility>
</DataGridTextColumn>
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" />
<DataGridTextColumn Header="Principal Type" Binding="{Binding PrincipalType}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Grid>
<!-- Bottom: status bar spanning both columns -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem>
<ProgressBar Width="150" Height="14"
Value="{Binding ProgressValue}"
Minimum="0" Maximum="100" />
</StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar>
</Grid>
</UserControl>
```
IMPORTANT implementation notes:
1. **InvertBoolConverter** — The "Simple" radio button needs an inverted bool converter to bind to `IsDetailView` (Simple = !IsDetailView). Add this converter to the UserControl.Resources:
```xml
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<local:InvertBoolConverter x:Key="InvertBoolConverter" />
</UserControl.Resources>
```
You will need to create a simple `InvertBoolConverter` class. Add it as a nested helper or in a new file `SharepointToolbox/Core/Converters/InvertBoolConverter.cs`:
```csharp
using System.Globalization;
using System.Windows.Data;
namespace SharepointToolbox.Core.Converters;
/// <summary>
/// Inverts a boolean value. Used for radio button binding where
/// one option is the inverse of the bound property.
/// </summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InvertBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : value;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : value;
}
```
Add the namespace to the XAML header:
```xml
xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters"
```
And update the resource to use `converters:InvertBoolConverter`.
2. **Row color DataTriggers** — The RiskLevel-based row coloring only takes effect when ActiveItemsSource contains SimplifiedPermissionEntry objects (which have RiskLevel). When binding to raw PermissionEntry (simplified mode off), the triggers simply don't match and rows use default background.
3. **SimplifiedLabels column** — Uses BooleanToVisibilityConverter bound to the DataGrid's DataContext.IsSimplifiedMode. When simplified mode is off, the column is Collapsed.
4. **Summary card "user(s)" text** — Uses `<Run>` elements inside TextBlock for inline binding. The hardcoded "user(s)" text will be replaced with a localization key in plan 08-05.
5. **DataGrid hides when simplified + not detailed** — MultiDataTrigger on IsSimplifiedMode=True AND IsDetailView=False collapses the DataGrid, showing only the summary cards.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>PermissionsView.xaml has: Display Options GroupBox with Simplified Mode checkbox and Simple/Detailed radio buttons. Summary panel with 4 risk-level cards (color-coded). DataGrid binds to ActiveItemsSource with RiskLevel-based row colors. Simplified Labels column appears only in simplified mode. DataGrid hides in Simple mode. InvertBoolConverter created.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- PermissionsView.xaml contains bindings for IsSimplifiedMode, IsDetailView, ActiveItemsSource, Summaries
- InvertBoolConverter.cs exists and compiles
- Summary panel uses DataTrigger on RiskLevel for color coding
- DataGrid row style uses DataTrigger on RiskLevel for row background colors
- SimplifiedLabels column visibility bound to IsSimplifiedMode via BoolToVis converter
</verification>
<success_criteria>
The permissions tab visually supports all three SIMP requirements: simplified labels appear alongside raw names (SIMP-01), summary cards show color-coded counts by risk level (SIMP-02), and the Simple/Detailed toggle controls row visibility without re-scanning (SIMP-03). Ready for export integration (08-04) and localization (08-05).
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,392 @@
---
phase: 08-simplified-permissions
plan: 04
type: execute
wave: 3
depends_on: ["08-01"]
files_modified:
- SharepointToolbox/Services/Export/HtmlExportService.cs
- SharepointToolbox/Services/Export/CsvExportService.cs
autonomous: true
requirements:
- SIMP-01
- SIMP-02
must_haves:
truths:
- "HTML export includes a Simplified Labels column and color-coded permission cells when simplified entries are provided"
- "HTML summary section shows risk level counts with color indicators"
- "CSV export includes a Simplified Labels column after the raw Permission Levels column"
- "Both export services accept SimplifiedPermissionEntry via overloaded methods — original PermissionEntry methods remain unchanged"
artifacts:
- path: "SharepointToolbox/Services/Export/HtmlExportService.cs"
provides: "HTML export with simplified labels and risk-level color coding"
contains: "BuildHtml.*SimplifiedPermissionEntry"
- path: "SharepointToolbox/Services/Export/CsvExportService.cs"
provides: "CSV export with simplified labels column"
contains: "BuildCsv.*SimplifiedPermissionEntry"
key_links:
- from: "SharepointToolbox/Services/Export/HtmlExportService.cs"
to: "SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs"
via: "Overloaded BuildHtml and WriteAsync methods"
pattern: "SimplifiedPermissionEntry"
- from: "SharepointToolbox/Services/Export/CsvExportService.cs"
to: "SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs"
via: "Overloaded BuildCsv and WriteAsync methods"
pattern: "SimplifiedPermissionEntry"
---
<objective>
Add simplified-mode export support to HtmlExportService and CsvExportService. Both services get new overloaded methods that accept SimplifiedPermissionEntry and include plain-language labels and risk-level color coding. Original PermissionEntry methods are NOT modified.
Purpose: Exports reflect the simplified view (SIMP-01 labels, SIMP-02 colors) so exported reports match what the user sees in the UI.
Output: Updated HtmlExportService.cs, Updated CsvExportService.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/08-simplified-permissions/08-01-SUMMARY.md
<interfaces>
<!-- From 08-01: Types used by export services -->
From SharepointToolbox/Core/Models/RiskLevel.cs:
```csharp
public enum RiskLevel { High, Medium, Low, ReadOnly }
```
From SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs:
```csharp
public class SimplifiedPermissionEntry
{
public PermissionEntry Inner { get; }
public string SimplifiedLabels { get; }
public RiskLevel RiskLevel { get; }
// Passthrough: ObjectType, Title, Url, HasUniquePermissions, Users, UserLogins,
// PermissionLevels, GrantedThrough, PrincipalType
}
```
From SharepointToolbox/Core/Models/PermissionSummary.cs:
```csharp
public record PermissionSummary(string Label, RiskLevel RiskLevel, int Count, int DistinctUsers);
public static class PermissionSummaryBuilder
{
public static IReadOnlyList<PermissionSummary> Build(IEnumerable<SimplifiedPermissionEntry> entries);
}
```
<!-- Current export service signatures -->
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);
}
```
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);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add simplified export overloads to CsvExportService</name>
<files>SharepointToolbox/Services/Export/CsvExportService.cs</files>
<action>
Modify `SharepointToolbox/Services/Export/CsvExportService.cs`. Add `using SharepointToolbox.Core.Models;` if not already present (it is). Keep ALL existing methods unchanged. Add these new overloaded methods:
```csharp
/// <summary>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary>
private const string SimplifiedHeader =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
/// <summary>
/// Builds a CSV string from simplified permission entries.
/// Includes SimplifiedLabels and RiskLevel columns after raw Permissions.
/// Uses the same merge logic as the standard BuildCsv.
/// </summary>
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(SimplifiedHeader);
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
.Select(g => new
{
ObjectType = g.First().ObjectType,
Title = string.Join(" | ", g.Select(e => e.Title).Distinct()),
Url = string.Join(" | ", g.Select(e => e.Url).Distinct()),
HasUnique = g.First().HasUniquePermissions,
Users = g.Key.Users,
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(),
GrantedThrough = g.Key.GrantedThrough
});
foreach (var row in merged)
sb.AppendLine(string.Join(",", new[]
{
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
Csv(row.RiskLevel), Csv(row.GrantedThrough)
}));
return sb.ToString();
}
/// <summary>
/// Writes simplified CSV to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
```
Do NOT modify the existing `BuildCsv(IReadOnlyList<PermissionEntry>)` or `WriteAsync(IReadOnlyList<PermissionEntry>, ...)` methods. The new methods are overloads (same name, different parameter type).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>CsvExportService has overloaded BuildCsv and WriteAsync accepting SimplifiedPermissionEntry. CSV includes SimplifiedLabels and RiskLevel columns. Original PermissionEntry methods unchanged.</done>
</task>
<task type="auto">
<name>Task 2: Add simplified export overloads to HtmlExportService</name>
<files>SharepointToolbox/Services/Export/HtmlExportService.cs</files>
<action>
Modify `SharepointToolbox/Services/Export/HtmlExportService.cs`. Keep ALL existing methods unchanged. Add these new overloaded methods and helpers:
Add to the class a risk-level-to-CSS-color mapping method:
```csharp
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
};
```
Add the simplified BuildHtml overload. This is a full method — include the complete implementation. It extends the existing HTML template with:
- Risk-level summary cards (instead of just stats)
- A "Simplified Labels" column in the table
- Color-coded risk badges on each row
```csharp
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(0,0,0,.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries)
{
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the simplified HTML report to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
{
var html = BuildHtml(entries);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
```
Add the required using statements at the top of the file:
```csharp
using SharepointToolbox.Core.Models; // Already present
```
Note: PermissionSummaryBuilder is in the SharepointToolbox.Core.Models namespace so no additional using is needed.
Do NOT modify the existing `BuildHtml(IReadOnlyList<PermissionEntry>)` or `WriteAsync(IReadOnlyList<PermissionEntry>, ...)` methods. The new methods are overloads.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>HtmlExportService has overloaded BuildHtml and WriteAsync accepting SimplifiedPermissionEntry. HTML includes risk-level summary cards, Simplified column, and color-coded Risk badges. CsvExportService has overloaded methods with SimplifiedLabels and RiskLevel columns. Original methods for PermissionEntry remain unchanged.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- HtmlExportService has both `BuildHtml(IReadOnlyList<PermissionEntry>)` and `BuildHtml(IReadOnlyList<SimplifiedPermissionEntry>)`
- CsvExportService has both `BuildCsv(IReadOnlyList<PermissionEntry>)` and `BuildCsv(IReadOnlyList<SimplifiedPermissionEntry>)`
- Simplified HTML output includes risk-card section and Risk column
- Simplified CSV output includes SimplifiedLabels and RiskLevel headers
</verification>
<success_criteria>
Both export services support simplified mode. The PermissionsViewModel export commands (which will be updated to pass SimplifiedResults when IsSimplifiedMode is true — wired in plan 08-05) can produce exports that match the simplified UI view. Original export paths for non-simplified mode remain untouched.
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,222 @@
---
phase: 08-simplified-permissions
plan: 05
type: execute
wave: 4
depends_on: ["08-02", "08-03", "08-04"]
files_modified:
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
autonomous: true
requirements:
- SIMP-01
- SIMP-02
- SIMP-03
must_haves:
truths:
- "All new UI strings have EN and FR localization keys"
- "Export commands pass SimplifiedResults when IsSimplifiedMode is true"
- "PermissionsView.xaml display options GroupBox uses localization keys not hardcoded strings"
artifacts:
- path: "SharepointToolbox/Localization/Strings.resx"
provides: "EN localization keys for simplified permissions UI"
contains: "grp.display.opts"
- path: "SharepointToolbox/Localization/Strings.fr.resx"
provides: "FR localization keys for simplified permissions UI"
contains: "grp.display.opts"
key_links:
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
to: "SharepointToolbox/Services/Export/CsvExportService.cs"
via: "ExportCsvAsync calls simplified overload when IsSimplifiedMode"
pattern: "SimplifiedResults"
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
to: "SharepointToolbox/Services/Export/HtmlExportService.cs"
via: "ExportHtmlAsync calls simplified overload when IsSimplifiedMode"
pattern: "SimplifiedResults"
---
<objective>
Wire the simplified export paths, add all EN/FR localization keys, and finalize the integration between ViewModel export commands and the simplified export service overloads.
Purpose: Completes the integration: export commands use simplified data when mode is active, and all UI strings are properly localized in both languages.
Output: Updated Strings.resx, Strings.fr.resx, updated PermissionsViewModel export methods
</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/08-simplified-permissions/08-01-SUMMARY.md
@.planning/phases/08-simplified-permissions/08-02-SUMMARY.md
@.planning/phases/08-simplified-permissions/08-03-SUMMARY.md
@.planning/phases/08-simplified-permissions/08-04-SUMMARY.md
<interfaces>
<!-- PermissionsViewModel export methods to be updated -->
From PermissionsViewModel (current ExportCsvAsync):
```csharp
private async Task ExportCsvAsync()
{
if (_csvExportService == null || Results.Count == 0) return;
// ... SaveFileDialog ...
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
}
```
From PermissionsViewModel (current ExportHtmlAsync):
```csharp
private async Task ExportHtmlAsync()
{
if (_htmlExportService == null || Results.Count == 0) return;
// ... SaveFileDialog ...
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
}
```
<!-- Export service overloads (from 08-04) -->
From CsvExportService:
```csharp
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct);
```
From HtmlExportService:
```csharp
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct);
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add EN and FR localization keys for simplified permissions</name>
<files>SharepointToolbox/Localization/Strings.resx, SharepointToolbox/Localization/Strings.fr.resx</files>
<action>
Add the following keys to `SharepointToolbox/Localization/Strings.resx` (EN). Insert them in alphabetical order among existing keys, following the existing `<data>` element format:
```xml
<data name="chk.simplified.mode" xml:space="preserve"><value>Simplified mode</value></data>
<data name="grp.display.opts" xml:space="preserve"><value>Display Options</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
<data name="rad.detail.detailed" xml:space="preserve"><value>Detailed (all rows)</value></data>
<data name="rad.detail.simple" xml:space="preserve"><value>Simple (summary only)</value></data>
<data name="lbl.summary.users" xml:space="preserve"><value>user(s)</value></data>
```
Add the corresponding French translations to `SharepointToolbox/Localization/Strings.fr.resx`:
```xml
<data name="chk.simplified.mode" xml:space="preserve"><value>Mode simplifie</value></data>
<data name="grp.display.opts" xml:space="preserve"><value>Options d'affichage</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de detail :</value></data>
<data name="rad.detail.detailed" xml:space="preserve"><value>Detaille (toutes les lignes)</value></data>
<data name="rad.detail.simple" xml:space="preserve"><value>Simple (resume uniquement)</value></data>
<data name="lbl.summary.users" xml:space="preserve"><value>utilisateur(s)</value></data>
```
Note: French accented characters (e, a with accents) should be used if the resx file supports it. Check existing FR entries for the pattern — if they use plain ASCII, match that convention.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>6 new localization keys added to both Strings.resx and Strings.fr.resx. Keys match the binding paths used in PermissionsView.xaml (grp.display.opts, chk.simplified.mode, lbl.detail.level, rad.detail.simple, rad.detail.detailed, lbl.summary.users).</done>
</task>
<task type="auto">
<name>Task 2: Wire export commands to use simplified overloads and update summary card text</name>
<files>SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs</files>
<action>
Modify `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs` to update the export commands to pass simplified data when IsSimplifiedMode is active.
Update `ExportCsvAsync`:
```csharp
private async Task ExportCsvAsync()
{
if (_csvExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export permissions to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "permissions"
};
if (dialog.ShowDialog() != true) return;
try
{
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
else
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "CSV export failed.");
}
}
```
Update `ExportHtmlAsync`:
```csharp
private async Task ExportHtmlAsync()
{
if (_htmlExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export permissions to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "permissions"
};
if (dialog.ShowDialog() != true) return;
try
{
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
else
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "HTML export failed.");
}
}
```
Note: `SimplifiedResults.ToList()` converts `IReadOnlyList<SimplifiedPermissionEntry>` to `List<SimplifiedPermissionEntry>` which satisfies the `IReadOnlyList<SimplifiedPermissionEntry>` parameter. This is needed because the field type is `IReadOnlyList` but the service expects `IReadOnlyList`.
Also add `using System.Linq;` if not already present (it likely is via global using or existing code).
Do NOT change constructor signatures, RunOperationAsync, or any other method besides ExportCsvAsync and ExportHtmlAsync.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>ExportCsvAsync and ExportHtmlAsync check IsSimplifiedMode and pass SimplifiedResults to the overloaded WriteAsync when active. Standard PermissionEntry path unchanged when simplified mode is off.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors
- Strings.resx contains keys: grp.display.opts, chk.simplified.mode, lbl.detail.level, rad.detail.simple, rad.detail.detailed, lbl.summary.users
- Strings.fr.resx contains the same keys with French values
- ExportCsvAsync branches on IsSimplifiedMode to call the simplified overload
- ExportHtmlAsync branches on IsSimplifiedMode to call the simplified overload
</verification>
<success_criteria>
The full pipeline is wired: UI toggles -> ViewModel mode -> simplified data -> export services. All new UI text has EN/FR localization. Exports produce simplified output when the user has simplified mode active.
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,453 @@
---
phase: 08-simplified-permissions
plan: 06
type: execute
wave: 5
depends_on: ["08-05"]
files_modified:
- SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs
- SharepointToolbox.Tests/Models/PermissionSummaryBuilderTests.cs
- SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
autonomous: true
requirements:
- SIMP-01
- SIMP-02
- SIMP-03
must_haves:
truths:
- "PermissionLevelMapping maps all known role names correctly and handles unknown roles"
- "PermissionSummaryBuilder produces 4 risk-level groups with correct counts"
- "PermissionsViewModel toggle behavior is verified: IsSimplifiedMode rebuilds data, IsDetailView switches without re-scan"
- "SimplifiedPermissionEntry wraps PermissionEntry correctly with computed labels and risk levels"
artifacts:
- path: "SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs"
provides: "Unit tests for permission level mapping"
contains: "class PermissionLevelMappingTests"
- path: "SharepointToolbox.Tests/Models/PermissionSummaryBuilderTests.cs"
provides: "Unit tests for summary aggregation"
contains: "class PermissionSummaryBuilderTests"
- path: "SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs"
provides: "Extended ViewModel tests for simplified mode"
contains: "IsSimplifiedMode"
key_links:
- from: "SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs"
to: "SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs"
via: "Direct static method calls"
pattern: "PermissionLevelMapping\\.Get"
- from: "SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs"
to: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
via: "Test constructor + property assertions"
pattern: "IsSimplifiedMode"
---
<objective>
Add unit tests for the simplified permissions feature: PermissionLevelMapping, PermissionSummaryBuilder, SimplifiedPermissionEntry wrapping, and PermissionsViewModel toggle behavior.
Purpose: Validates the core logic of all three SIMP requirements. Mapping correctness (SIMP-01), summary aggregation (SIMP-02), and toggle-without-rescan behavior (SIMP-03).
Output: PermissionLevelMappingTests.cs, PermissionSummaryBuilderTests.cs, updated PermissionsViewModelTests.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/08-simplified-permissions/08-01-SUMMARY.md
@.planning/phases/08-simplified-permissions/08-02-SUMMARY.md
<interfaces>
<!-- Types under test -->
From PermissionLevelMapping:
```csharp
public static class PermissionLevelMapping
{
public record MappingResult(string Label, RiskLevel RiskLevel);
public static MappingResult GetMapping(string roleName);
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels);
public static RiskLevel GetHighestRisk(string permissionLevels);
public static string GetSimplifiedLabels(string permissionLevels);
}
```
From PermissionSummaryBuilder:
```csharp
public static class PermissionSummaryBuilder
{
public static IReadOnlyList<PermissionSummary> Build(IEnumerable<SimplifiedPermissionEntry> entries);
}
```
From SimplifiedPermissionEntry:
```csharp
public class SimplifiedPermissionEntry
{
public PermissionEntry Inner { get; }
public string SimplifiedLabels { get; }
public RiskLevel RiskLevel { get; }
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(IEnumerable<PermissionEntry> entries);
}
```
<!-- Existing test pattern (from PermissionsViewModelTests.cs) -->
```csharp
public class PermissionsViewModelTests
{
[Fact]
public async Task StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl()
{
var vm = new PermissionsViewModel(
mockPermissionsService.Object,
mockSiteListService.Object,
mockSessionManager.Object,
new NullLogger<FeatureViewModelBase>());
// ... test ...
}
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create PermissionLevelMapping and PermissionSummaryBuilder tests</name>
<files>SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs, SharepointToolbox.Tests/Models/PermissionSummaryBuilderTests.cs</files>
<action>
Create `SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs`:
```csharp
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Helpers;
public class PermissionLevelMappingTests
{
[Theory]
[InlineData("Full Control", RiskLevel.High)]
[InlineData("Site Collection Administrator", RiskLevel.High)]
[InlineData("Contribute", RiskLevel.Medium)]
[InlineData("Edit", RiskLevel.Medium)]
[InlineData("Design", RiskLevel.Medium)]
[InlineData("Approve", RiskLevel.Medium)]
[InlineData("Manage Hierarchy", RiskLevel.Medium)]
[InlineData("Read", RiskLevel.Low)]
[InlineData("Restricted Read", RiskLevel.Low)]
[InlineData("View Only", RiskLevel.ReadOnly)]
[InlineData("Restricted View", RiskLevel.ReadOnly)]
public void GetMapping_KnownRoles_ReturnsCorrectRiskLevel(string roleName, RiskLevel expected)
{
var result = PermissionLevelMapping.GetMapping(roleName);
Assert.Equal(expected, result.RiskLevel);
Assert.NotEmpty(result.Label);
}
[Fact]
public void GetMapping_UnknownRole_ReturnsMediumRiskWithRawName()
{
var result = PermissionLevelMapping.GetMapping("Custom Permission Level");
Assert.Equal(RiskLevel.Medium, result.RiskLevel);
Assert.Equal("Custom Permission Level", result.Label);
}
[Fact]
public void GetMapping_CaseInsensitive()
{
var lower = PermissionLevelMapping.GetMapping("full control");
var upper = PermissionLevelMapping.GetMapping("FULL CONTROL");
Assert.Equal(RiskLevel.High, lower.RiskLevel);
Assert.Equal(RiskLevel.High, upper.RiskLevel);
}
[Fact]
public void GetMappings_SemicolonDelimited_SplitsAndMaps()
{
var results = PermissionLevelMapping.GetMappings("Full Control; Read");
Assert.Equal(2, results.Count);
Assert.Equal(RiskLevel.High, results[0].RiskLevel);
Assert.Equal(RiskLevel.Low, results[1].RiskLevel);
}
[Fact]
public void GetMappings_EmptyString_ReturnsEmpty()
{
var results = PermissionLevelMapping.GetMappings("");
Assert.Empty(results);
}
[Fact]
public void GetHighestRisk_MultipleLevels_ReturnsHighest()
{
// Full Control (High) + Read (Low) => High
var risk = PermissionLevelMapping.GetHighestRisk("Full Control; Read");
Assert.Equal(RiskLevel.High, risk);
}
[Fact]
public void GetHighestRisk_SingleReadOnly_ReturnsReadOnly()
{
var risk = PermissionLevelMapping.GetHighestRisk("View Only");
Assert.Equal(RiskLevel.ReadOnly, risk);
}
[Fact]
public void GetSimplifiedLabels_JoinsLabels()
{
var labels = PermissionLevelMapping.GetSimplifiedLabels("Contribute; Read");
Assert.Contains("Can edit files and list items", labels);
Assert.Contains("Can view files and pages", labels);
}
}
```
Create `SharepointToolbox.Tests/Models/PermissionSummaryBuilderTests.cs`:
```csharp
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Models;
public class PermissionSummaryBuilderTests
{
private static PermissionEntry MakeEntry(string permLevels, string users = "User1", string logins = "user1@test.com") =>
new PermissionEntry(
ObjectType: "Site",
Title: "Test",
Url: "https://test.sharepoint.com",
HasUniquePermissions: true,
Users: users,
UserLogins: logins,
PermissionLevels: permLevels,
GrantedThrough: "Direct Permissions",
PrincipalType: "User");
[Fact]
public void Build_ReturnsAllFourRiskLevels()
{
var entries = SimplifiedPermissionEntry.WrapAll(new[]
{
MakeEntry("Full Control"),
MakeEntry("Contribute"),
MakeEntry("Read"),
MakeEntry("View Only")
});
var summaries = PermissionSummaryBuilder.Build(entries);
Assert.Equal(4, summaries.Count);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.High && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.Medium && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.Low && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.ReadOnly && s.Count == 1);
}
[Fact]
public void Build_EmptyCollection_ReturnsZeroCounts()
{
var summaries = PermissionSummaryBuilder.Build(Array.Empty<SimplifiedPermissionEntry>());
Assert.Equal(4, summaries.Count);
Assert.All(summaries, s => Assert.Equal(0, s.Count));
}
[Fact]
public void Build_CountsDistinctUsers()
{
var entries = SimplifiedPermissionEntry.WrapAll(new[]
{
MakeEntry("Full Control", "Alice", "alice@test.com"),
MakeEntry("Full Control", "Bob", "bob@test.com"),
MakeEntry("Full Control", "Alice", "alice@test.com"), // duplicate user
});
var summaries = PermissionSummaryBuilder.Build(entries);
var high = summaries.Single(s => s.RiskLevel == RiskLevel.High);
Assert.Equal(3, high.Count); // 3 entries
Assert.Equal(2, high.DistinctUsers); // 2 distinct users
}
[Fact]
public void SimplifiedPermissionEntry_WrapAll_PreservesInner()
{
var original = MakeEntry("Contribute");
var wrapped = SimplifiedPermissionEntry.WrapAll(new[] { original });
Assert.Single(wrapped);
Assert.Same(original, wrapped[0].Inner);
Assert.Equal("Contribute", wrapped[0].PermissionLevels);
Assert.Equal(RiskLevel.Medium, wrapped[0].RiskLevel);
Assert.Contains("Can edit", wrapped[0].SimplifiedLabels);
}
}
```
Create the `SharepointToolbox.Tests/Helpers/` and `SharepointToolbox.Tests/Models/` directories if they don't exist.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/ --filter "PermissionLevelMappingTests|PermissionSummaryBuilderTests" --no-restore 2>&1 | tail -10</automated>
</verify>
<done>PermissionLevelMappingTests covers: all 11 known roles, unknown role fallback, case insensitivity, semicolon splitting, highest risk, simplified labels. PermissionSummaryBuilderTests covers: 4 risk levels, empty input, distinct user counting, SimplifiedPermissionEntry wrapping. All tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Add simplified mode tests to PermissionsViewModelTests</name>
<files>SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs</files>
<action>
Add the following test methods to the existing `PermissionsViewModelTests` class in `SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs`. Add any needed using statements at the top:
```csharp
using CommunityToolkit.Mvvm.Messaging;
```
Add a helper method and new tests after the existing test:
```csharp
/// <summary>
/// Creates a PermissionsViewModel with mocked services and pre-populated results.
/// </summary>
private static PermissionsViewModel CreateViewModelWithResults(IReadOnlyList<PermissionEntry> results)
{
var mockPermissionsService = new Mock<IPermissionsService>();
mockPermissionsService
.Setup(s => s.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(results.ToList());
var mockSiteListService = new Mock<ISiteListService>();
var mockSessionManager = new Mock<ISessionManager>();
mockSessionManager
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var vm = new PermissionsViewModel(
mockPermissionsService.Object,
mockSiteListService.Object,
mockSessionManager.Object,
new NullLogger<FeatureViewModelBase>());
return vm;
}
[Fact]
public void IsSimplifiedMode_Default_IsFalse()
{
WeakReferenceMessenger.Default.Reset();
var vm = CreateViewModelWithResults(Array.Empty<PermissionEntry>());
Assert.False(vm.IsSimplifiedMode);
}
[Fact]
public async Task IsSimplifiedMode_WhenToggled_RebuildSimplifiedResults()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "Test", "https://test.sharepoint.com", true, "User1", "user1@test.com", "Full Control", "Direct Permissions", "User"),
new("List", "Docs", "https://test.sharepoint.com/docs", false, "User2", "user2@test.com", "Read", "Direct Permissions", "User"),
};
var vm = CreateViewModelWithResults(entries);
// Simulate scan completing
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
// Before toggle: simplified results empty
Assert.Empty(vm.SimplifiedResults);
// Toggle on
vm.IsSimplifiedMode = true;
// After toggle: simplified results populated
Assert.Equal(2, vm.SimplifiedResults.Count);
Assert.Equal(4, vm.Summaries.Count);
}
[Fact]
public async Task IsDetailView_Toggle_DoesNotChangeCounts()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "Test", "https://test.sharepoint.com", true, "User1", "user1@test.com", "Contribute", "Direct Permissions", "User"),
};
var vm = CreateViewModelWithResults(entries);
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
vm.IsSimplifiedMode = true;
var countBefore = vm.SimplifiedResults.Count;
vm.IsDetailView = false;
Assert.Equal(countBefore, vm.SimplifiedResults.Count); // No re-computation
vm.IsDetailView = true;
Assert.Equal(countBefore, vm.SimplifiedResults.Count); // Still the same
}
[Fact]
public async Task Summaries_ContainsCorrectRiskBreakdown()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "S1", "https://s1", true, "Admin", "admin@t.com", "Full Control", "Direct", "User"),
new("Site", "S2", "https://s2", true, "Editor", "ed@t.com", "Contribute", "Direct", "User"),
new("List", "L1", "https://l1", false, "Reader", "read@t.com", "Read", "Direct", "User"),
};
var vm = CreateViewModelWithResults(entries);
vm.SelectedSites.Add(new SiteInfo("https://s1", "S1"));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://s1", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
vm.IsSimplifiedMode = true;
var high = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.High);
var medium = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.Medium);
var low = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.Low);
Assert.Equal(1, high.Count);
Assert.Equal(1, medium.Count);
Assert.Equal(1, low.Count);
}
```
Add the RiskLevel using statement:
```csharp
using SharepointToolbox.Core.Models; // Already present (for PermissionEntry)
```
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/ --filter "PermissionsViewModelTests" --no-restore 2>&1 | tail -10</automated>
</verify>
<done>PermissionsViewModelTests has 5 tests total (1 existing + 4 new). Tests verify: IsSimplifiedMode default false, toggle rebuilds SimplifiedResults, IsDetailView toggle doesn't re-compute, Summaries has correct risk breakdown. All tests pass.</done>
</task>
</tasks>
<verification>
- `dotnet test SharepointToolbox.Tests/ --no-restore` passes all tests
- PermissionLevelMappingTests: 9 test methods covering known roles, unknown fallback, case insensitivity, splitting, risk ranking
- PermissionSummaryBuilderTests: 4 test methods covering risk levels, empty input, distinct users, wrapping
- PermissionsViewModelTests: 5 test methods (1 existing + 4 new) covering simplified mode toggle, detail toggle, summary breakdown
</verification>
<success_criteria>
All simplified permissions logic is covered by automated tests. Mapping correctness (SIMP-01), summary aggregation (SIMP-02), and toggle-without-rescan behavior (SIMP-03) are all verified. The test suite catches regressions in the core mapping layer and ViewModel behavior.
</success_criteria>
<output>
After completion, create `.planning/phases/08-simplified-permissions/08-06-SUMMARY.md`
</output>