Files
SharepointToolbox-Web/Components/Pages/Profiles.razor
T

323 lines
13 KiB
Plaintext

@page "/profiles"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject ISessionCredentialStore CredStore
@inject NavigationManager Nav
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T
@rendermode InteractiveServer
@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:#0078d4;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:#0078d4;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:#0078d4">
<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>
<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.
private const string RegistrationScope =
"https://graph.microsoft.com/Application.ReadWrite.All " +
"https://graph.microsoft.com/DelegatedPermissionGrant.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 };
_showForm = true;
_formError = _pageError = 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();
var clientId = await AppRegService.CreateAsync(
adminAccessToken: adminToken,
tenantName: _form.Name,
redirectUri: ConnectOpts.Value.RedirectUri,
ct: _regCts.Token);
_form.ClientId = clientId;
_regStatus = T["profiles.reg.registered"];
}
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);
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
}
}