6d9c79ad5a
Feature work: - Certificate (app-only) auth per profile: cert store, context/Graph client factories, automated app-registration provisioning (delegated + application permissions, admin consent), and a SessionManager seam that resolves the auth model per profile. - Scheduled reports: repositories, hosted service/runner/coordinator, report pages, and email delivery (app-only Mail.Send). - Tenant-wide user-access audit when no site is selected. Audit fixes: - Site enumeration: app-only discovery used Graph getAllSites (needs Graph Sites.Read.All the cert app lacks) and silently returned empty. Switched to the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth models now share one enumeration path. - Group expansion: the scan records a SharePoint group as a single principal, so user-centric audits found nothing for group-granted access. Resolve group membership (shared by audit + scheduler) and attribute it to the target user. - M365 group claims: the resolver only recognized AAD security groups (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim. - Provision Directory.Read.All as an application permission so M365/AAD group expansion works under the cert identity. Also: ignore data/appcerts/ (encrypted certificate key material). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
493 lines
21 KiB
Plaintext
493 lines
21 KiB
Plaintext
@page "/profiles"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@inject IUserSessionService Session
|
|
@inject IUserContextAccessor UserContext
|
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
|
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyCertStore CertStore
|
|
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnlyFactory
|
|
@inject ISessionCredentialStore CredStore
|
|
@inject NavigationManager Nav
|
|
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
|
|
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
|
|
@inject SharepointToolbox.Web.Services.Auth.ICertProvisioningService CertProvisioning
|
|
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
|
|
@inject TranslationSource T
|
|
@rendermode InteractiveServer
|
|
@using Microsoft.AspNetCore.Components.Forms
|
|
@using Microsoft.AspNetCore.WebUtilities
|
|
@using SharepointToolbox.Web.Core.Models
|
|
@using SharepointToolbox.Web.Services.Session
|
|
|
|
<h1 class="page-title">@T["profiles.title"]</h1>
|
|
<p class="page-subtitle">@T["profiles.subtitle"]</p>
|
|
|
|
@if (UserContext.Role != UserRole.Admin)
|
|
{
|
|
@* Non-admins can only select a profile, not create/edit/delete *@
|
|
<div class="alert alert-info">@T["profiles.restricted"]</div>
|
|
|
|
@foreach (var p in _profiles)
|
|
{
|
|
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
|
|
<div class="flex-row">
|
|
<div>
|
|
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
|
<div class="text-muted">@p.TenantUrl</div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
@if (Session.CurrentProfile?.Id == p.Id)
|
|
{
|
|
<span class="chip chip-green">@T["profiles.active"]</span>
|
|
}
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
|
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
return;
|
|
}
|
|
|
|
@* Admin view — full CRUD *@
|
|
|
|
@if (!string.IsNullOrEmpty(_pageError))
|
|
{
|
|
<div class="alert alert-error" style="margin-bottom:12px">@_pageError</div>
|
|
}
|
|
|
|
<div class="flex-row" style="margin-bottom:16px">
|
|
<button class="btn btn-primary" @onclick="AddNew">@T["profiles.new"]</button>
|
|
</div>
|
|
|
|
@if (_profiles.Count == 0 && !_showForm)
|
|
{
|
|
<div class="alert alert-info">@T["profiles.empty"]</div>
|
|
}
|
|
|
|
@foreach (var p in _profiles)
|
|
{
|
|
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
|
|
<div class="flex-row">
|
|
<div>
|
|
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
|
<div class="text-muted">@p.TenantUrl</div>
|
|
<div class="text-muted">@T["profiles.tenantid.label"] @p.TenantId</div>
|
|
<div class="text-muted">@T["profiles.clientid.label"] @p.ClientId</div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
@if (Session.CurrentProfile?.Id == p.Id)
|
|
{
|
|
<span class="chip chip-green">@T["profiles.active"]</span>
|
|
}
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
|
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => EditProfile(p)">@T["profiles.edit"]</button>
|
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteProfile(p)">@T["profile.delete"]</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (_showForm)
|
|
{
|
|
<div class="card" style="border-color:var(--accent)">
|
|
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div>
|
|
@if (!string.IsNullOrEmpty(_formError))
|
|
{
|
|
<div class="alert alert-error">@_formError</div>
|
|
}
|
|
<div class="form-group">
|
|
<label class="form-label">@T["profiles.form.name"]</label>
|
|
<input class="form-input" @bind="_form.Name" placeholder="@T["profiles.form.name.ph"]" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">@T["profiles.form.url"]</label>
|
|
<input class="form-input" @bind="_form.TenantUrl" placeholder="https://contoso.sharepoint.com" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">@T["profiles.form.tenantid"]</label>
|
|
<input class="form-input" @bind="_form.TenantId" placeholder="@T["profiles.form.tenantid.ph"]" />
|
|
</div>
|
|
|
|
@* App registration section *@
|
|
<div class="form-group">
|
|
<label class="form-label">@T["profiles.form.clientid"]</label>
|
|
<div class="flex-row" style="gap:8px;align-items:center">
|
|
<input class="form-input" @bind="_form.ClientId"
|
|
placeholder="@T["profiles.form.clientid.ph"]"
|
|
style="flex:1" />
|
|
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
|
disabled="@(!CanRegister || _registering)"
|
|
title="@(CanRegister ? T["profiles.register.tooltip.ready"] : T["profiles.register.tooltip.disabled"])">
|
|
@(_registering ? T["profiles.register.waiting"] : T["profiles.register.btn"])
|
|
</button>
|
|
</div>
|
|
<small class="text-muted">
|
|
@T["profiles.register.hint"]
|
|
</small>
|
|
|
|
@if (_deviceCode is not null)
|
|
{
|
|
<div class="alert alert-info" style="margin-top:10px">
|
|
<div style="margin-bottom:6px">@T["profiles.devicecode.intro.pre"] <strong>@T["profiles.devicecode.intro.tenant"]</strong> @T["profiles.devicecode.intro.post"]</div>
|
|
<ol style="margin:0 0 8px 18px;padding:0;line-height:1.7">
|
|
<li>@T["profiles.devicecode.step.open"] <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
|
|
<li>@T["profiles.devicecode.step.code"]
|
|
<code style="font-size:16px;font-weight:700;letter-spacing:1px;background:#fff;padding:2px 8px;border-radius:4px;border:1px solid var(--border)">@_deviceCode.UserCode</code>
|
|
</li>
|
|
<li>@T["profiles.devicecode.step.approve"]</li>
|
|
</ol>
|
|
<div class="flex-row" style="gap:8px">
|
|
<span class="text-muted">@_regStatus</span>
|
|
<button class="btn btn-secondary btn-sm" @onclick="CancelRegistration">@T["btn.cancel"]</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (!string.IsNullOrEmpty(_regStatus))
|
|
{
|
|
<div class="text-muted" style="margin-top:8px">@_regStatus</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">@T["profiles.form.logo"]</label>
|
|
<small class="text-muted d-block" style="margin-bottom:6px">@T["profiles.form.logo.hint"]</small>
|
|
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
|
|
</div>
|
|
|
|
@* ── App-only credentials for scheduled (unattended) reports ── *@
|
|
<div class="form-group" style="border-top:1px solid var(--border);padding-top:14px;margin-top:10px">
|
|
<label class="form-label" style="font-size:14px;font-weight:600">Certificate auth (app identity)</label>
|
|
@if (_editing is null)
|
|
{
|
|
<div class="alert alert-info">Save this client first, then re-open it to configure certificate credentials.</div>
|
|
}
|
|
else
|
|
{
|
|
<small class="text-muted d-block" style="margin-bottom:8px">
|
|
When enabled, this client uses a certificate-based app registration with
|
|
<strong>application</strong> permissions (Sites.FullControl.All, admin-consented) for
|
|
<strong>both</strong> interactive work and scheduled reports. Technicians never sign in
|
|
to SharePoint per profile. The <em>Register app</em> button provisions the certificate
|
|
and consent automatically; the fields below are for manual setup.
|
|
</small>
|
|
|
|
<label style="display:block;margin-bottom:8px">
|
|
<input type="checkbox" @bind="_form.AppOnlyEnabled" /> Use certificate auth for this client (no per-profile sign-in)
|
|
</label>
|
|
|
|
<label class="form-label">App-only client (application) ID</label>
|
|
<input class="form-input" @bind="_form.AppOnlyClientId"
|
|
placeholder="GUID of the app registration used for app-only auth" />
|
|
|
|
<label class="form-label mt-8">Certificate (.pfx)</label>
|
|
@if (_certPresent)
|
|
{
|
|
<div class="flex-row" style="gap:8px;align-items:center">
|
|
<span class="chip chip-green">Certificate stored</span>
|
|
@if (!string.IsNullOrEmpty(_form.AppOnlyCertThumbprint))
|
|
{
|
|
<span class="text-muted" style="font-size:12px">@_form.AppOnlyCertThumbprint</span>
|
|
}
|
|
<button class="btn btn-secondary btn-sm" @onclick="RemoveCertAsync">Remove</button>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="flex-row" style="gap:8px;align-items:center;flex-wrap:wrap">
|
|
<InputFile OnChange="OnCertSelected" accept=".pfx,.p12" />
|
|
<input class="form-input" type="password" @bind="_certPassword"
|
|
placeholder="PFX password (if any)" style="max-width:220px" />
|
|
<button class="btn btn-secondary btn-sm" @onclick="UploadCertAsync" disabled="@(_pfxBytes is null)">
|
|
Upload certificate
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
<div class="flex-row mt-8" style="gap:8px;align-items:center">
|
|
<button class="btn btn-secondary btn-sm" @onclick="TestAppOnlyAsync" disabled="@_appOnlyTesting">
|
|
@(_appOnlyTesting ? "Testing…" : "Test connection")
|
|
</button>
|
|
@if (!string.IsNullOrEmpty(_appOnlyStatus)) { <span class="text-muted" style="font-size:12px">@_appOnlyStatus</span> }
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex-row mt-8">
|
|
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
|
|
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private List<TenantProfile> _profiles = new();
|
|
private bool _showForm;
|
|
private bool _registering;
|
|
private TenantProfile? _editing;
|
|
private TenantProfile _form = new();
|
|
private string _formError = string.Empty;
|
|
private string _pageError = string.Empty;
|
|
|
|
private SharepointToolbox.Web.Services.OAuth.DeviceCodeStart? _deviceCode;
|
|
private string _regStatus = string.Empty;
|
|
private CancellationTokenSource? _regCts;
|
|
|
|
// Graph delegated scopes the admin must consent to so we can create the app registration,
|
|
// attach the certificate, and grant application-permission (app-role) admin consent.
|
|
private const string RegistrationScope =
|
|
"https://graph.microsoft.com/Application.ReadWrite.All " +
|
|
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
|
"https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " +
|
|
"https://graph.microsoft.com/Directory.Read.All " +
|
|
"openid offline_access";
|
|
|
|
private bool CanRegister =>
|
|
!string.IsNullOrWhiteSpace(_form.Name) &&
|
|
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
|
|
!string.IsNullOrWhiteSpace(_form.TenantId);
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_profiles = (await ProfileRepo.LoadAsync()).ToList();
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (!firstRender) return;
|
|
await HandleConnectErrorAsync();
|
|
}
|
|
|
|
private async Task HandleConnectErrorAsync()
|
|
{
|
|
var uri = new Uri(Nav.Uri);
|
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
|
|
|
if (!query.TryGetValue("connect_error", out var err) || string.IsNullOrEmpty(err))
|
|
return;
|
|
|
|
_pageError = err!;
|
|
await InvokeAsync(StateHasChanged);
|
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
|
}
|
|
|
|
private void AddNew()
|
|
{
|
|
_editing = null;
|
|
_form = new TenantProfile();
|
|
_showForm = true;
|
|
_formError = string.Empty;
|
|
_pageError = string.Empty;
|
|
}
|
|
|
|
private void EditProfile(TenantProfile p)
|
|
{
|
|
_editing = p;
|
|
_form = new TenantProfile
|
|
{
|
|
Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId,
|
|
ClientId = p.ClientId, ClientLogo = p.ClientLogo,
|
|
AppOnlyEnabled = p.AppOnlyEnabled, AppOnlyClientId = p.AppOnlyClientId,
|
|
AppOnlyCertThumbprint = p.AppOnlyCertThumbprint
|
|
};
|
|
_showForm = true;
|
|
_formError = _pageError = string.Empty;
|
|
_certPresent = CertStore.Exists(p.Id);
|
|
_pfxBytes = null;
|
|
_certPassword = string.Empty;
|
|
_appOnlyStatus = string.Empty;
|
|
}
|
|
|
|
private void CancelForm() { _showForm = false; _editing = null; }
|
|
|
|
private void SelectProfile(TenantProfile p)
|
|
{
|
|
Session.SetProfile(p);
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task RegisterAppAsync()
|
|
{
|
|
if (!CanRegister || _registering) return;
|
|
|
|
_registering = true;
|
|
_formError = string.Empty;
|
|
_regStatus = T["profiles.reg.requesting"];
|
|
_deviceCode = null;
|
|
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
// Secretless bootstrap: device code flow against the client tenant.
|
|
_deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token);
|
|
_regStatus = T["profiles.reg.waitingsignin"];
|
|
StateHasChanged();
|
|
|
|
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
|
|
|
|
_deviceCode = null;
|
|
_regStatus = T["profiles.reg.creating"];
|
|
StateHasChanged();
|
|
|
|
// Generate + store the app-only certificate before creating the registration so its
|
|
// public key can be attached as a sign-in credential. Technicians then operate under
|
|
// the app identity and never sign in to SharePoint per profile.
|
|
var cert = await CertProvisioning.GenerateAndStoreAsync(_form.Id, $"SP Toolbox — {_form.Name}", _regCts.Token);
|
|
|
|
var clientId = await AppRegService.CreateAsync(
|
|
adminAccessToken: adminToken,
|
|
tenantName: _form.Name,
|
|
redirectUri: ConnectOpts.Value.RedirectUri,
|
|
appOnlyCert: cert,
|
|
ct: _regCts.Token);
|
|
|
|
_form.ClientId = clientId;
|
|
_form.AppOnlyClientId = clientId;
|
|
_form.AppOnlyEnabled = true;
|
|
_form.AppOnlyCertThumbprint = cert.Thumbprint;
|
|
_certPresent = true;
|
|
|
|
// Cert key credential + app-role consent take time to propagate through Entra;
|
|
// wait it out so the profile is usable immediately instead of 401ing on first use.
|
|
_regStatus = T["profiles.reg.propagating"];
|
|
StateHasChanged();
|
|
var notReady = await AppOnlyFactory.WaitUntilReadyAsync(_form, TimeSpan.FromSeconds(90), _regCts.Token);
|
|
_regStatus = notReady is null
|
|
? T["profiles.reg.registered"]
|
|
: string.Format(T["profiles.reg.notready"], notReady);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_regStatus = T["profiles.reg.cancelled"];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = string.Format(T["profiles.reg.failed"], ex.Message);
|
|
_regStatus = string.Empty;
|
|
}
|
|
finally
|
|
{
|
|
_deviceCode = null;
|
|
_registering = false;
|
|
_regCts?.Dispose();
|
|
_regCts = null;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private void CancelRegistration()
|
|
{
|
|
_regCts?.Cancel();
|
|
_deviceCode = null;
|
|
_regStatus = T["profiles.reg.cancelled"];
|
|
}
|
|
|
|
private async Task SaveProfile()
|
|
{
|
|
_formError = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = T["profiles.err.name_required"]; return; }
|
|
if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = T["profiles.err.url_required"]; return; }
|
|
if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = T["profiles.err.clientid_required"]; return; }
|
|
if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = T["profiles.err.tenantid_required"]; return; }
|
|
|
|
if (_editing == null)
|
|
{
|
|
_form.Id = Guid.NewGuid().ToString();
|
|
_profiles.Add(_form);
|
|
}
|
|
else
|
|
{
|
|
var idx = _profiles.FindIndex(p => p.Id == _editing.Id);
|
|
if (idx >= 0) _profiles[idx] = _form;
|
|
}
|
|
await ProfileRepo.SaveAsync(_profiles);
|
|
_showForm = false; _editing = null;
|
|
}
|
|
|
|
private async Task DeleteProfile(TenantProfile p)
|
|
{
|
|
_profiles.RemoveAll(x => x.Id == p.Id);
|
|
await ProfileRepo.SaveAsync(_profiles);
|
|
CertStore.Delete(p.Id);
|
|
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
|
|
}
|
|
|
|
// ── App-only credential handlers ───────────────────────────────────────────
|
|
private const long MaxCertBytes = 256 * 1024;
|
|
private byte[]? _pfxBytes;
|
|
private string _certPassword = string.Empty;
|
|
private bool _certPresent;
|
|
private bool _appOnlyTesting;
|
|
private string _appOnlyStatus = string.Empty;
|
|
|
|
private async Task OnCertSelected(InputFileChangeEventArgs e)
|
|
{
|
|
_appOnlyStatus = string.Empty;
|
|
var file = e.File;
|
|
if (file is null) return;
|
|
if (file.Size > MaxCertBytes) { _appOnlyStatus = $"Certificate too large (max {MaxCertBytes / 1024} KB)."; _pfxBytes = null; return; }
|
|
try
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await file.OpenReadStream(MaxCertBytes).CopyToAsync(ms);
|
|
_pfxBytes = ms.ToArray();
|
|
}
|
|
catch (Exception ex) { _appOnlyStatus = $"Failed to read certificate: {ex.Message}"; _pfxBytes = null; }
|
|
}
|
|
|
|
private async Task UploadCertAsync()
|
|
{
|
|
if (_pfxBytes is null || _editing is null) return;
|
|
try
|
|
{
|
|
var thumbprint = await CertStore.SaveAsync(_form.Id, _pfxBytes, string.IsNullOrEmpty(_certPassword) ? null : _certPassword);
|
|
_form.AppOnlyCertThumbprint = thumbprint;
|
|
_certPresent = true;
|
|
_pfxBytes = null;
|
|
_certPassword = string.Empty;
|
|
await PersistFormAsync();
|
|
_appOnlyStatus = "Certificate stored.";
|
|
}
|
|
catch (Exception ex) { _appOnlyStatus = $"Certificate rejected: {ex.Message}"; }
|
|
}
|
|
|
|
private async Task RemoveCertAsync()
|
|
{
|
|
if (_editing is null) return;
|
|
CertStore.Delete(_form.Id);
|
|
_certPresent = false;
|
|
_form.AppOnlyCertThumbprint = string.Empty;
|
|
await PersistFormAsync();
|
|
_appOnlyStatus = "Certificate removed.";
|
|
}
|
|
|
|
private async Task TestAppOnlyAsync()
|
|
{
|
|
if (_editing is null) return;
|
|
_appOnlyTesting = true; _appOnlyStatus = string.Empty;
|
|
try
|
|
{
|
|
// Persist current field edits first so the test uses what the admin sees.
|
|
await PersistFormAsync();
|
|
var probe = new TenantProfile
|
|
{
|
|
Id = _form.Id, Name = _form.Name, TenantUrl = _form.TenantUrl, TenantId = _form.TenantId,
|
|
AppOnlyEnabled = true, AppOnlyClientId = _form.AppOnlyClientId
|
|
};
|
|
var error = await AppOnlyFactory.TestConnectionAsync(probe);
|
|
_appOnlyStatus = error is null ? "✓ Connected successfully." : $"✗ {error}";
|
|
}
|
|
catch (Exception ex) { _appOnlyStatus = $"✗ {ex.Message}"; }
|
|
finally { _appOnlyTesting = false; }
|
|
}
|
|
|
|
// Upserts the in-progress form into the profile list and saves, without closing the form.
|
|
private async Task PersistFormAsync()
|
|
{
|
|
var idx = _profiles.FindIndex(p => p.Id == _form.Id);
|
|
if (idx >= 0) _profiles[idx] = _form; else _profiles.Add(_form);
|
|
await ProfileRepo.SaveAsync(_profiles);
|
|
}
|
|
}
|