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:
Dev
2026-04-09 14:48:49 +02:00
parent 0d087ae4cd
commit 7d200ecf3f
3 changed files with 574 additions and 3 deletions

View File

@@ -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 | — |

View 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>

View 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>