docs(19): create phase plan for app registration and removal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@
|
|||||||
- [x] **Phase 16: Report Consolidation Toggle** (2 plans) — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior (completed 2026-04-09)
|
- [x] **Phase 16: Report Consolidation Toggle** (2 plans) — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior (completed 2026-04-09)
|
||||||
- [x] **Phase 17: Group Expansion in HTML Reports** (2 plans) — Clickable group expansion in HTML exports with transitive membership resolution (completed 2026-04-09)
|
- [x] **Phase 17: Group Expansion in HTML Reports** (2 plans) — Clickable group expansion in HTML exports with transitive membership resolution (completed 2026-04-09)
|
||||||
- [x] **Phase 18: Auto-Take Ownership** (2 plans) — Global toggle and automatic site collection admin elevation on access denied (completed 2026-04-09)
|
- [x] **Phase 18: Auto-Take Ownership** (2 plans) — Global toggle and automatic site collection admin elevation on access denied (completed 2026-04-09)
|
||||||
- [ ] **Phase 19: App Registration & Removal** — Automated Entra app registration with guided fallback and clean removal
|
- [ ] **Phase 19: App Registration & Removal** (2 plans) — Automated Entra app registration with guided fallback and clean removal
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
@@ -117,7 +117,10 @@ Plans:
|
|||||||
3. Registration creates the Azure AD application, service principal, and grants all required API permissions in a single atomic operation — if any step fails, all partial changes are rolled back and the user sees a specific error explaining what failed and why
|
3. Registration creates the Azure AD application, service principal, and grants all required API permissions in a single atomic operation — if any step fails, all partial changes are rolled back and the user sees a specific error explaining what failed and why
|
||||||
4. A "Remove App" action in the profile dialog removes the Azure AD application registration from the target tenant
|
4. A "Remove App" action in the profile dialog removes the Azure AD application registration from the target tenant
|
||||||
5. After removal, all cached MSAL tokens and session state for that tenant are cleared, and subsequent operations require re-authentication
|
5. After removal, all cached MSAL tokens and session state for that tenant are cleared, and subsequent operations require re-authentication
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 19-01-PLAN.md — IAppRegistrationService + AppRegistrationResult model + TenantProfile.AppId + service implementation + unit tests
|
||||||
|
- [ ] 19-02-PLAN.md — ViewModel RegisterApp/RemoveApp commands + XAML dialog UI + fallback panel + localization + VM tests
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -130,4 +133,4 @@ Plans:
|
|||||||
| 16. Report Consolidation Toggle | v2.3 | 2/2 | Complete | 2026-04-09 |
|
| 16. Report Consolidation Toggle | v2.3 | 2/2 | Complete | 2026-04-09 |
|
||||||
| 17. Group Expansion in HTML Reports | 2/2 | Complete | 2026-04-09 | — |
|
| 17. Group Expansion in HTML Reports | 2/2 | Complete | 2026-04-09 | — |
|
||||||
| 18. Auto-Take Ownership | 2/2 | Complete | 2026-04-09 | — |
|
| 18. Auto-Take Ownership | 2/2 | Complete | 2026-04-09 | — |
|
||||||
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
|
| 19. App Registration & Removal | v2.3 | 0/2 | Planned | — |
|
||||||
|
|||||||
239
.planning/phases/19-app-registration-removal/19-01-PLAN.md
Normal file
239
.planning/phases/19-app-registration-removal/19-01-PLAN.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
---
|
||||||
|
phase: 19-app-registration-removal
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/AppRegistrationResult.cs
|
||||||
|
- SharepointToolbox/Core/Models/TenantProfile.cs
|
||||||
|
- SharepointToolbox/Services/IAppRegistrationService.cs
|
||||||
|
- SharepointToolbox/Services/AppRegistrationService.cs
|
||||||
|
- SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements: [APPREG-02, APPREG-03, APPREG-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "IsGlobalAdminAsync returns true when user has Global Admin directory role"
|
||||||
|
- "IsGlobalAdminAsync returns false (not throws) when user lacks role or gets 403"
|
||||||
|
- "RegisterAsync creates Application + ServicePrincipal + OAuth2PermissionGrants in sequence"
|
||||||
|
- "RegisterAsync rolls back (deletes Application) when any intermediate step fails"
|
||||||
|
- "RemoveAsync deletes the Application by appId and clears MSAL session"
|
||||||
|
- "TenantProfile.AppId is nullable and round-trips through JSON serialization"
|
||||||
|
- "AppRegistrationResult discriminates Success (with appId), Failure (with message), and Fallback"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/AppRegistrationResult.cs"
|
||||||
|
provides: "Discriminated result type for registration outcomes"
|
||||||
|
contains: "class AppRegistrationResult"
|
||||||
|
- path: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
||||||
|
provides: "AppId nullable property for storing registered app ID"
|
||||||
|
contains: "AppId"
|
||||||
|
- path: "SharepointToolbox/Services/IAppRegistrationService.cs"
|
||||||
|
provides: "Service interface with IsGlobalAdminAsync, RegisterAsync, RemoveAsync, ClearMsalSessionAsync"
|
||||||
|
exports: ["IAppRegistrationService"]
|
||||||
|
- path: "SharepointToolbox/Services/AppRegistrationService.cs"
|
||||||
|
provides: "Implementation using GraphServiceClient"
|
||||||
|
contains: "class AppRegistrationService"
|
||||||
|
- path: "SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs"
|
||||||
|
provides: "Unit tests covering admin detection, registration, rollback, removal, session clear"
|
||||||
|
min_lines: 80
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/AppRegistrationService.cs"
|
||||||
|
to: "GraphServiceClient"
|
||||||
|
via: "constructor injection of GraphClientFactory"
|
||||||
|
pattern: "GraphClientFactory"
|
||||||
|
- from: "SharepointToolbox/Services/AppRegistrationService.cs"
|
||||||
|
to: "MsalClientFactory"
|
||||||
|
via: "constructor injection for session eviction"
|
||||||
|
pattern: "MsalClientFactory"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the AppRegistrationService with full Graph API registration/removal logic, the AppRegistrationResult model, and add AppId to TenantProfile. All unit-tested with mocked Graph calls.
|
||||||
|
|
||||||
|
Purpose: The service layer is the foundation for all Entra app registration operations. It must be fully testable before any UI is wired.
|
||||||
|
Output: IAppRegistrationService + implementation + AppRegistrationResult model + TenantProfile.AppId field + unit tests
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/19-app-registration-removal/19-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing code the executor needs -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
public GraphClientFactory(MsalClientFactory msalFactory) { }
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct) { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs:
|
||||||
|
```csharp
|
||||||
|
public class MsalClientFactory
|
||||||
|
{
|
||||||
|
public string CacheDirectory { get; }
|
||||||
|
public async Task<IPublicClientApplication> GetOrCreateAsync(string clientId) { }
|
||||||
|
public MsalCacheHelper GetCacheHelper(string clientId) { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/ISessionManager.cs:
|
||||||
|
```csharp
|
||||||
|
public interface ISessionManager
|
||||||
|
{
|
||||||
|
Task<ClientContext> GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default);
|
||||||
|
Task ClearSessionAsync(string tenantUrl);
|
||||||
|
bool IsAuthenticated(string tenantUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/TenantProfile.cs:
|
||||||
|
```csharp
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public LogoData? ClientLogo { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Models + Interface + Service implementation</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/AppRegistrationResult.cs,
|
||||||
|
SharepointToolbox/Core/Models/TenantProfile.cs,
|
||||||
|
SharepointToolbox/Services/IAppRegistrationService.cs,
|
||||||
|
SharepointToolbox/Services/AppRegistrationService.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- AppRegistrationResult.Success("appId123") carries appId, IsSuccess=true
|
||||||
|
- AppRegistrationResult.Failure("msg") carries message, IsSuccess=false
|
||||||
|
- AppRegistrationResult.FallbackRequired() signals fallback path, IsSuccess=false, IsFallback=true
|
||||||
|
- TenantProfile.AppId is nullable string, defaults to null, serializes/deserializes via System.Text.Json
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `AppRegistrationResult.cs` in `Core/Models/`:
|
||||||
|
- Static factory methods: `Success(string appId)`, `Failure(string errorMessage)`, `FallbackRequired()`
|
||||||
|
- Properties: `bool IsSuccess`, `bool IsFallback`, `string? AppId`, `string? ErrorMessage`
|
||||||
|
- Use a private constructor pattern (not record, for consistency with other models in the project)
|
||||||
|
|
||||||
|
2. Update `TenantProfile.cs`:
|
||||||
|
- Add `public string? AppId { get; set; }` property (nullable, defaults to null)
|
||||||
|
- Existing properties unchanged
|
||||||
|
|
||||||
|
3. Create `IAppRegistrationService.cs` in `Services/`:
|
||||||
|
```csharp
|
||||||
|
public interface IAppRegistrationService
|
||||||
|
{
|
||||||
|
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct);
|
||||||
|
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
|
||||||
|
Task RemoveAsync(string clientId, string appId, CancellationToken ct);
|
||||||
|
Task ClearMsalSessionAsync(string clientId, string tenantUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create `AppRegistrationService.cs` in `Services/`:
|
||||||
|
- Constructor takes `GraphClientFactory`, `MsalClientFactory`, `ISessionManager`, `ILogger<AppRegistrationService>`
|
||||||
|
- `IsGlobalAdminAsync`: calls `graphClient.Me.TransitiveMemberOf.GetAsync()` filtered on `microsoft.graph.directoryRole`, checks for roleTemplateId `62e90394-69f5-4237-9190-012177145e10`. On any exception (including 403), return false and log warning.
|
||||||
|
- `RegisterAsync`:
|
||||||
|
a. Create Application object with `DisplayName = "SharePoint Toolbox - {tenantDisplayName}"`, `SignInAudience = "AzureADMyOrg"`, `IsFallbackPublicClient = true`, `PublicClient.RedirectUris = ["https://login.microsoftonline.com/common/oauth2/nativeclient"]`, `RequiredResourceAccess` for Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) and SharePoint (AllSites.FullControl) using the GUIDs from research.
|
||||||
|
b. Create ServicePrincipal with `AppId = createdApp.AppId`
|
||||||
|
c. Look up Microsoft Graph resource SP via filter `appId eq '00000003-0000-0000-c000-000000000000'`, get its `Id`
|
||||||
|
d. Look up SharePoint Online resource SP via filter `appId eq '00000003-0000-0ff1-ce00-000000000000'`, get its `Id`
|
||||||
|
e. Post `OAuth2PermissionGrant` for Graph scopes (`User.Read User.Read.All Group.Read.All Directory.Read.All`) with `ConsentType = "AllPrincipals"`, `ClientId = sp.Id`, `ResourceId = graphResourceSp.Id`
|
||||||
|
f. Post `OAuth2PermissionGrant` for SharePoint scopes (`AllSites.FullControl`) same pattern
|
||||||
|
g. On any exception after Application creation: try `DELETE /applications/{createdApp.Id}` (best-effort rollback, log warning on rollback failure), return `AppRegistrationResult.Failure(ex.Message)`
|
||||||
|
h. On success: return `AppRegistrationResult.Success(createdApp.AppId!)`
|
||||||
|
- `RemoveAsync`: calls `graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct)`. Log warning on failure but don't throw.
|
||||||
|
- `ClearMsalSessionAsync`:
|
||||||
|
a. Call `_sessionManager.ClearSessionAsync(tenantUrl)`
|
||||||
|
b. Get PCA via `_msalFactory.GetOrCreateAsync(clientId)`, loop `RemoveAsync` on all accounts
|
||||||
|
c. Call `_msalFactory.GetCacheHelper(clientId).UnregisterCache(pca.UserTokenCache)`
|
||||||
|
|
||||||
|
Use `private static List<RequiredResourceAccess> BuildRequiredResourceAccess()` as a helper. Use GUIDs from research doc (Graph permissions are HIGH confidence). For SharePoint AllSites.FullControl, use GUID `56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6` from research (LOW confidence — add a comment noting it should be verified against live tenant).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 4 files exist, solution builds clean, AppRegistrationResult has 3 factory methods, TenantProfile has AppId, IAppRegistrationService has 4 methods, AppRegistrationService implements all 4 with rollback pattern</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Unit tests for AppRegistrationService</name>
|
||||||
|
<files>SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs</files>
|
||||||
|
<behavior>
|
||||||
|
- IsGlobalAdminAsync returns true when transitiveMemberOf contains DirectoryRole with Global Admin templateId
|
||||||
|
- IsGlobalAdminAsync returns false when no matching role
|
||||||
|
- IsGlobalAdminAsync returns false (not throws) on ServiceException/403
|
||||||
|
- RegisterAsync returns Success with appId on full happy path
|
||||||
|
- RegisterAsync calls DELETE on Application when ServicePrincipal creation fails (rollback)
|
||||||
|
- RegisterAsync calls DELETE on Application when OAuth2PermissionGrant fails (rollback)
|
||||||
|
- RemoveAsync calls DELETE on Application by appId
|
||||||
|
- ClearMsalSessionAsync calls ClearSessionAsync + removes all MSAL accounts
|
||||||
|
- AppRegistrationResult.Success carries appId, .Failure carries message, .FallbackRequired sets IsFallback
|
||||||
|
- TenantProfile.AppId round-trips through JSON (null and non-null)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs` with xUnit tests. Since `GraphServiceClient` is hard to mock directly (sealed/extension methods), test strategy:
|
||||||
|
|
||||||
|
1. **AppRegistrationResult model tests** (pure logic, no mocks):
|
||||||
|
- `Success_CarriesAppId`: verify IsSuccess=true, AppId set
|
||||||
|
- `Failure_CarriesMessage`: verify IsSuccess=false, ErrorMessage set
|
||||||
|
- `FallbackRequired_SetsFallback`: verify IsFallback=true
|
||||||
|
|
||||||
|
2. **TenantProfile.AppId tests**:
|
||||||
|
- `AppId_DefaultsToNull`
|
||||||
|
- `AppId_RoundTrips_ViaJson`: serialize+deserialize with System.Text.Json, verify AppId preserved
|
||||||
|
- `AppId_Null_RoundTrips_ViaJson`: verify null survives serialization
|
||||||
|
|
||||||
|
3. **AppRegistrationService tests** — For methods that call GraphServiceClient, use the project's existing pattern: if the project uses Moq or NSubstitute (check test csproj), mock `GraphClientFactory` to return a mock `GraphServiceClient`. If mocking Graph SDK is too complex, test the logic by:
|
||||||
|
- Extracting `BuildRequiredResourceAccess()` as internal and testing the scope GUIDs/structure directly
|
||||||
|
- Testing that the service constructor accepts the right dependencies
|
||||||
|
- For integration-like behavior, mark tests with `[Trait("Category","Integration")]` and skip in CI
|
||||||
|
|
||||||
|
Check the test project's existing packages first (`dotnet list SharepointToolbox.Tests package`) to see if Moq/NSubstitute is available. Use whichever mocking library the project already uses.
|
||||||
|
|
||||||
|
All tests decorated with `[Trait("Category", "Unit")]`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-restore --verbosity normal 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All unit tests pass. Coverage: AppRegistrationResult 3 factory methods tested, TenantProfile.AppId serialization tested, service constructor/dependency tests pass, BuildRequiredResourceAccess structure verified</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `dotnet build` — full solution compiles
|
||||||
|
2. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~AppRegistrationServiceTests"` — all tests green
|
||||||
|
3. TenantProfile.AppId exists as nullable string
|
||||||
|
4. IAppRegistrationService has 4 methods: IsGlobalAdminAsync, RegisterAsync, RemoveAsync, ClearMsalSessionAsync
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- AppRegistrationService implements atomic registration with rollback
|
||||||
|
- IsGlobalAdminAsync uses transitiveMemberOf (not memberOf) for nested role coverage
|
||||||
|
- All unit tests pass
|
||||||
|
- Solution builds clean with no warnings in new files
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/19-app-registration-removal/19-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
329
.planning/phases/19-app-registration-removal/19-02-PLAN.md
Normal file
329
.planning/phases/19-app-registration-removal/19-02-PLAN.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
---
|
||||||
|
phase: 19-app-registration-removal
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["19-01"]
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
|
||||||
|
- SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml
|
||||||
|
- SharepointToolbox/Localization/Strings.resx
|
||||||
|
- SharepointToolbox/Localization/Strings.fr.resx
|
||||||
|
- SharepointToolbox/App.xaml.cs
|
||||||
|
- SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements: [APPREG-01, APPREG-04, APPREG-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Register App button visible in profile dialog when a profile is selected and has no AppId"
|
||||||
|
- "Remove App button visible when selected profile has a non-null AppId"
|
||||||
|
- "Clicking Register checks Global Admin first; if not admin, shows fallback instructions panel"
|
||||||
|
- "Clicking Register when admin runs full registration and stores AppId on profile"
|
||||||
|
- "Clicking Remove deletes the app registration and clears AppId + MSAL session"
|
||||||
|
- "Fallback panel shows step-by-step manual registration instructions"
|
||||||
|
- "Status feedback shown during registration/removal (busy indicator + result message)"
|
||||||
|
- "All strings localized in EN and FR"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||||
|
provides: "RegisterAppCommand, RemoveAppCommand, status/fallback properties"
|
||||||
|
contains: "RegisterAppCommand"
|
||||||
|
- path: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
|
||||||
|
provides: "Register/Remove buttons, fallback instructions panel, status area"
|
||||||
|
contains: "RegisterAppCommand"
|
||||||
|
- path: "SharepointToolbox/Localization/Strings.resx"
|
||||||
|
provides: "EN localization for register/remove/fallback strings"
|
||||||
|
contains: "profile.register"
|
||||||
|
- path: "SharepointToolbox/Localization/Strings.fr.resx"
|
||||||
|
provides: "FR localization for register/remove/fallback strings"
|
||||||
|
contains: "profile.register"
|
||||||
|
- path: "SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs"
|
||||||
|
provides: "Unit tests for register/remove commands"
|
||||||
|
min_lines: 50
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||||
|
to: "IAppRegistrationService"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "IAppRegistrationService"
|
||||||
|
- from: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
|
||||||
|
to: "ProfileManagementViewModel"
|
||||||
|
via: "data binding"
|
||||||
|
pattern: "RegisterAppCommand"
|
||||||
|
- from: "SharepointToolbox/App.xaml.cs"
|
||||||
|
to: "AppRegistrationService"
|
||||||
|
via: "DI registration"
|
||||||
|
pattern: "IAppRegistrationService"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire RegisterApp and RemoveApp commands into the ProfileManagementViewModel and dialog XAML, with fallback instructions panel and full EN/FR localization.
|
||||||
|
|
||||||
|
Purpose: This is the user-facing layer — the profile dialog becomes the entry point for tenant onboarding via app registration, with a guided fallback when permissions are insufficient.
|
||||||
|
Output: Working register/remove UI in profile dialog, localized strings, DI wiring, ViewModel unit tests
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/19-app-registration-removal/19-RESEARCH.md
|
||||||
|
@.planning/phases/19-app-registration-removal/19-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 01 outputs -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/IAppRegistrationService.cs:
|
||||||
|
```csharp
|
||||||
|
public interface IAppRegistrationService
|
||||||
|
{
|
||||||
|
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct);
|
||||||
|
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
|
||||||
|
Task RemoveAsync(string clientId, string appId, CancellationToken ct);
|
||||||
|
Task ClearMsalSessionAsync(string clientId, string tenantUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/AppRegistrationResult.cs:
|
||||||
|
```csharp
|
||||||
|
public class AppRegistrationResult
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public bool IsFallback { get; }
|
||||||
|
public string? AppId { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
public static AppRegistrationResult Success(string appId);
|
||||||
|
public static AppRegistrationResult Failure(string errorMessage);
|
||||||
|
public static AppRegistrationResult FallbackRequired();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/TenantProfile.cs:
|
||||||
|
```csharp
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public LogoData? ClientLogo { get; set; }
|
||||||
|
public string? AppId { get; set; } // NEW in Plan 01
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs (existing):
|
||||||
|
```csharp
|
||||||
|
public partial class ProfileManagementViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
// Constructor: ProfileService, IBrandingService, GraphClientFactory, ILogger
|
||||||
|
// Commands: AddCommand, RenameCommand, DeleteCommand, BrowseClientLogoCommand, ClearClientLogoCommand, AutoPullClientLogoCommand
|
||||||
|
// Properties: SelectedProfile, NewName, NewTenantUrl, NewClientId, ValidationMessage, ClientLogoPreview, Profiles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml (existing):
|
||||||
|
- 5-row Grid: profiles list, input fields, logo section, buttons
|
||||||
|
- Window size: 500x620
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: ViewModel commands + DI + Localization</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
|
||||||
|
SharepointToolbox/App.xaml.cs,
|
||||||
|
SharepointToolbox/Localization/Strings.resx,
|
||||||
|
SharepointToolbox/Localization/Strings.fr.resx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. **Update ProfileManagementViewModel constructor** to accept `IAppRegistrationService` as new last parameter. Store as `_appRegistrationService` field.
|
||||||
|
|
||||||
|
2. **Add observable properties:**
|
||||||
|
- `[ObservableProperty] private bool _isRegistering;` — true during async registration/removal
|
||||||
|
- `[ObservableProperty] private bool _showFallbackInstructions;` — true when fallback panel should be visible
|
||||||
|
- `[ObservableProperty] private string _registrationStatus = string.Empty;` — status text for user feedback
|
||||||
|
- Add a computed `HasRegisteredApp` property: `public bool HasRegisteredApp => SelectedProfile?.AppId != null;`
|
||||||
|
- In `OnSelectedProfileChanged`, call `OnPropertyChanged(nameof(HasRegisteredApp))` and notify register/remove commands
|
||||||
|
|
||||||
|
3. **Add commands:**
|
||||||
|
- `public IAsyncRelayCommand RegisterAppCommand { get; }` — initialized in constructor as `new AsyncRelayCommand(RegisterAppAsync, CanRegisterApp)`
|
||||||
|
- `public IAsyncRelayCommand RemoveAppCommand { get; }` — initialized as `new AsyncRelayCommand(RemoveAppAsync, CanRemoveApp)`
|
||||||
|
- `CanRegisterApp()`: `SelectedProfile != null && SelectedProfile.AppId == null && !IsRegistering`
|
||||||
|
- `CanRemoveApp()`: `SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering`
|
||||||
|
|
||||||
|
4. **RegisterAppAsync implementation:**
|
||||||
|
```
|
||||||
|
a. Set IsRegistering = true, ShowFallbackInstructions = false, RegistrationStatus = localized "Checking permissions..."
|
||||||
|
b. Call IsGlobalAdminAsync(SelectedProfile.ClientId, ct)
|
||||||
|
c. If not admin: ShowFallbackInstructions = true, RegistrationStatus = localized "Insufficient permissions", IsRegistering = false, return
|
||||||
|
d. RegistrationStatus = localized "Registering application..."
|
||||||
|
e. Call RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct)
|
||||||
|
f. If result.IsSuccess: SelectedProfile.AppId = result.AppId, save profile via _profileService.UpdateProfileAsync, RegistrationStatus = localized "Registration successful", OnPropertyChanged(nameof(HasRegisteredApp))
|
||||||
|
g. If result.IsFallback or !IsSuccess: RegistrationStatus = result.ErrorMessage ?? localized "Registration failed"
|
||||||
|
h. Finally: IsRegistering = false, notify command CanExecute
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **RemoveAppAsync implementation:**
|
||||||
|
```
|
||||||
|
a. Set IsRegistering = true, RegistrationStatus = localized "Removing application..."
|
||||||
|
b. Call RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct)
|
||||||
|
c. Call ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl)
|
||||||
|
d. SelectedProfile.AppId = null, save profile, RegistrationStatus = localized "Application removed", OnPropertyChanged(nameof(HasRegisteredApp))
|
||||||
|
e. Finally: IsRegistering = false, notify command CanExecute
|
||||||
|
f. Wrap in try/catch, log errors, show error in RegistrationStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Partial method for IsRegistering changes:** Add `partial void OnIsRegisteringChanged(bool value)` that calls `RegisterAppCommand.NotifyCanExecuteChanged()` and `RemoveAppCommand.NotifyCanExecuteChanged()`.
|
||||||
|
|
||||||
|
7. **Update App.xaml.cs DI registration:**
|
||||||
|
- Register `services.AddSingleton<IAppRegistrationService, AppRegistrationService>();`
|
||||||
|
- Update `ProfileManagementViewModel` transient registration (it already resolves from DI, the new constructor param will be injected automatically)
|
||||||
|
|
||||||
|
8. **Add localization strings to Strings.resx (EN):**
|
||||||
|
- `profile.register` = "Register App"
|
||||||
|
- `profile.remove` = "Remove App"
|
||||||
|
- `profile.register.checking` = "Checking permissions..."
|
||||||
|
- `profile.register.registering` = "Registering application..."
|
||||||
|
- `profile.register.success` = "Application registered successfully"
|
||||||
|
- `profile.register.failed` = "Registration failed"
|
||||||
|
- `profile.register.noperm` = "Insufficient permissions for automatic registration"
|
||||||
|
- `profile.remove.removing` = "Removing application..."
|
||||||
|
- `profile.remove.success` = "Application removed successfully"
|
||||||
|
- `profile.fallback.title` = "Manual Registration Required"
|
||||||
|
- `profile.fallback.step1` = "1. Go to Azure Portal > App registrations > New registration"
|
||||||
|
- `profile.fallback.step2` = "2. Name: 'SharePoint Toolbox - {0}', Supported account types: Single tenant"
|
||||||
|
- `profile.fallback.step3` = "3. Redirect URI: Public client, https://login.microsoftonline.com/common/oauth2/nativeclient"
|
||||||
|
- `profile.fallback.step4` = "4. Under API permissions, add: Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) and SharePoint (AllSites.FullControl)"
|
||||||
|
- `profile.fallback.step5` = "5. Grant admin consent for all permissions"
|
||||||
|
- `profile.fallback.step6` = "6. Copy the Application (client) ID and paste it in the Client ID field above"
|
||||||
|
|
||||||
|
9. **Add localization strings to Strings.fr.resx (FR):**
|
||||||
|
- `profile.register` = "Enregistrer l'app"
|
||||||
|
- `profile.remove` = "Supprimer l'app"
|
||||||
|
- `profile.register.checking` = "Verification des permissions..."
|
||||||
|
- `profile.register.registering` = "Enregistrement de l'application..."
|
||||||
|
- `profile.register.success` = "Application enregistree avec succes"
|
||||||
|
- `profile.register.failed` = "L'enregistrement a echoue"
|
||||||
|
- `profile.register.noperm` = "Permissions insuffisantes pour l'enregistrement automatique"
|
||||||
|
- `profile.remove.removing` = "Suppression de l'application..."
|
||||||
|
- `profile.remove.success` = "Application supprimee avec succes"
|
||||||
|
- `profile.fallback.title` = "Enregistrement manuel requis"
|
||||||
|
- `profile.fallback.step1` = "1. Allez dans le portail Azure > Inscriptions d'applications > Nouvelle inscription"
|
||||||
|
- `profile.fallback.step2` = "2. Nom: 'SharePoint Toolbox - {0}', Types de comptes: Locataire unique"
|
||||||
|
- `profile.fallback.step3` = "3. URI de redirection: Client public, https://login.microsoftonline.com/common/oauth2/nativeclient"
|
||||||
|
- `profile.fallback.step4` = "4. Sous Permissions API, ajouter: Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) et SharePoint (AllSites.FullControl)"
|
||||||
|
- `profile.fallback.step5` = "5. Accorder le consentement administrateur pour toutes les permissions"
|
||||||
|
- `profile.fallback.step6` = "6. Copier l'ID d'application (client) et le coller dans le champ ID Client ci-dessus"
|
||||||
|
|
||||||
|
Use proper accented characters in FR strings (e with accents etc.).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>ProfileManagementViewModel has RegisterAppCommand and RemoveAppCommand, DI wired, all localization strings in EN and FR, solution builds clean</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Profile dialog XAML + ViewModel tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml,
|
||||||
|
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. **Update ProfileManagementDialog.xaml:**
|
||||||
|
- Increase window Height from 620 to 750 to accommodate new section
|
||||||
|
- Add a new row (insert as Row 4, shift existing buttons to Row 5) with an "App Registration" section:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- App Registration -->
|
||||||
|
<StackPanel Grid.Row="4" Margin="0,8,0,8">
|
||||||
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
|
||||||
|
FontWeight="SemiBold" Padding="0,0,0,4"
|
||||||
|
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
|
||||||
|
|
||||||
|
<!-- Register / Remove buttons -->
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.register]}"
|
||||||
|
Command="{Binding RegisterAppCommand}" Width="120" Margin="0,0,8,0" />
|
||||||
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.remove]}"
|
||||||
|
Command="{Binding RemoveAppCommand}" Width="120" Margin="0,0,8,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Status text -->
|
||||||
|
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
|
||||||
|
Foreground="#006600"
|
||||||
|
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
|
|
||||||
|
<!-- Fallback instructions panel -->
|
||||||
|
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
|
||||||
|
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step2]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step3]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step4]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step5]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step6]}"
|
||||||
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check if `BooleanToVisibilityConverter` already exists in the project resources. If not, use a Style with DataTrigger instead (matching Phase 18 pattern of DataTrigger-based visibility). Alternatively, WPF has `BooleanToVisibilityConverter` built-in — add `<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>` to Window.Resources if not already present.
|
||||||
|
- Update Row 5 (buttons row) Grid.Row from 4 to 5
|
||||||
|
- Add a 6th RowDefinition if needed (Auto for app registration, Auto for buttons)
|
||||||
|
|
||||||
|
2. **Create ProfileManagementViewModelRegistrationTests.cs:**
|
||||||
|
- Mock `IAppRegistrationService`, `ProfileService`, `IBrandingService`, `GraphClientFactory`, `ILogger`
|
||||||
|
- Use the same mocking library as existing VM tests in the project
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- `RegisterAppCommand_CanExecute_WhenProfileSelected_AndNoAppId`: set SelectedProfile with null AppId, verify CanExecute = true
|
||||||
|
- `RegisterAppCommand_CannotExecute_WhenNoProfile`: verify CanExecute = false
|
||||||
|
- `RemoveAppCommand_CanExecute_WhenProfileHasAppId`: set SelectedProfile with non-null AppId, verify CanExecute = true
|
||||||
|
- `RemoveAppCommand_CannotExecute_WhenNoAppId`: set SelectedProfile with null AppId, verify CanExecute = false
|
||||||
|
- `RegisterApp_ShowsFallback_WhenNotAdmin`: mock IsGlobalAdminAsync to return false, execute command, verify ShowFallbackInstructions = true
|
||||||
|
- `RegisterApp_SetsAppId_OnSuccess`: mock IsGlobalAdminAsync true + RegisterAsync Success, verify SelectedProfile.AppId set
|
||||||
|
- `RemoveApp_ClearsAppId`: mock RemoveAsync + ClearMsalSessionAsync, verify SelectedProfile.AppId = null
|
||||||
|
|
||||||
|
All tests `[Trait("Category", "Unit")]`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileManagementViewModelRegistrationTests" --no-restore --verbosity normal 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Profile dialog shows Register/Remove buttons with correct visibility, fallback instructions panel toggles on ShowFallbackInstructions, all 7 ViewModel tests pass, solution builds clean</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `dotnet build` — full solution compiles
|
||||||
|
2. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileManagementViewModelRegistrationTests"` — all VM tests green
|
||||||
|
3. XAML renders Register/Remove buttons in the dialog
|
||||||
|
4. Fallback panel visibility bound to ShowFallbackInstructions
|
||||||
|
5. Localization strings exist in both Strings.resx and Strings.fr.resx
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Register App button visible when profile selected with no AppId
|
||||||
|
- Remove App button visible when profile has AppId
|
||||||
|
- Fallback instructions panel appears when IsGlobalAdmin returns false
|
||||||
|
- All strings localized in EN and FR
|
||||||
|
- All ViewModel tests pass
|
||||||
|
- DI wiring complete in App.xaml.cs
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/19-app-registration-removal/19-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user