Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web
This commit is contained in:
@@ -8,18 +8,19 @@
|
||||
@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">Client Profiles</h1>
|
||||
<p class="page-subtitle">Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</p>
|
||||
<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">Profile management is restricted to Admins. Select a profile below to work on a client.</div>
|
||||
<div class="alert alert-info">@T["profiles.restricted"]</div>
|
||||
|
||||
@foreach (var p in _profiles)
|
||||
{
|
||||
@@ -32,10 +33,10 @@
|
||||
<div class="spacer"></div>
|
||||
@if (Session.CurrentProfile?.Id == p.Id)
|
||||
{
|
||||
<span class="chip chip-green">Active</span>
|
||||
<span class="chip chip-green">@T["profiles.active"]</span>
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
|
||||
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,12 +52,12 @@
|
||||
}
|
||||
|
||||
<div class="flex-row" style="margin-bottom:16px">
|
||||
<button class="btn btn-primary" @onclick="AddNew">+ New Profile</button>
|
||||
<button class="btn btn-primary" @onclick="AddNew">@T["profiles.new"]</button>
|
||||
</div>
|
||||
|
||||
@if (_profiles.Count == 0 && !_showForm)
|
||||
{
|
||||
<div class="alert alert-info">No profiles configured. Create one to get started.</div>
|
||||
<div class="alert alert-info">@T["profiles.empty"]</div>
|
||||
}
|
||||
|
||||
@foreach (var p in _profiles)
|
||||
@@ -66,19 +67,19 @@
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||
<div class="text-muted">@p.TenantUrl</div>
|
||||
<div class="text-muted">Tenant ID: @p.TenantId</div>
|
||||
<div class="text-muted">Client ID: @p.ClientId</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">Active</span>
|
||||
<span class="chip chip-green">@T["profiles.active"]</span>
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
|
||||
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => EditProfile(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @onclick="() => DeleteProfile(p)">Delete</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>
|
||||
}
|
||||
@@ -86,57 +87,55 @@
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card" style="border-color:#0078d4">
|
||||
<div class="card-title">@(_editing?.Id == null ? "New Profile" : "Edit Profile")</div>
|
||||
<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">Profile Name *</label>
|
||||
<input class="form-input" @bind="_form.Name" placeholder="e.g. Contoso Production" />
|
||||
<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">Tenant URL *</label>
|
||||
<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">Tenant ID (GUID or domain) *</label>
|
||||
<input class="form-input" @bind="_form.TenantId" placeholder="contoso.onmicrosoft.com or GUID" />
|
||||
<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">Client ID (App Registration)</label>
|
||||
<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="Auto-filled after registration, or enter manually"
|
||||
placeholder="@T["profiles.form.clientid.ph"]"
|
||||
style="flex:1" />
|
||||
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
||||
disabled="@(!CanRegister || _registering)"
|
||||
title="@(CanRegister ? "Register app in client Entra ID (requires an admin who can create app registrations)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
||||
@(_registering ? "Waiting…" : "Register in Entra")
|
||||
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">
|
||||
Click "Register in Entra" to auto-create the app registration in the client tenant.
|
||||
You'll sign in with a client admin account — no secrets, no pre-existing app needed.
|
||||
Or enter an existing public client App Registration ID manually.
|
||||
@T["profiles.register.hint"]
|
||||
</small>
|
||||
|
||||
@if (_deviceCode is not null)
|
||||
{
|
||||
<div class="alert alert-info" style="margin-top:10px">
|
||||
<div style="margin-bottom:6px">Sign in to the <strong>client tenant</strong> to authorize app creation:</div>
|
||||
<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>Open <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
|
||||
<li>Enter code:
|
||||
<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>Approve the requested permissions with an admin account.</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">Cancel</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelRegistration">@T["btn.cancel"]</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -147,14 +146,14 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client logo (optional)</label>
|
||||
<small class="text-muted d-block" style="margin-bottom:6px">Shown top-right on exported reports for this client.</small>
|
||||
<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">Save</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -239,7 +238,7 @@
|
||||
|
||||
_registering = true;
|
||||
_formError = string.Empty;
|
||||
_regStatus = "Requesting a sign-in code…";
|
||||
_regStatus = T["profiles.reg.requesting"];
|
||||
_deviceCode = null;
|
||||
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
|
||||
StateHasChanged();
|
||||
@@ -248,13 +247,13 @@
|
||||
{
|
||||
// Secretless bootstrap: device code flow against the client tenant.
|
||||
_deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token);
|
||||
_regStatus = "Waiting for sign-in to complete…";
|
||||
_regStatus = T["profiles.reg.waitingsignin"];
|
||||
StateHasChanged();
|
||||
|
||||
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
|
||||
|
||||
_deviceCode = null;
|
||||
_regStatus = "Creating the app registration…";
|
||||
_regStatus = T["profiles.reg.creating"];
|
||||
StateHasChanged();
|
||||
|
||||
var clientId = await AppRegService.CreateAsync(
|
||||
@@ -264,15 +263,15 @@
|
||||
ct: _regCts.Token);
|
||||
|
||||
_form.ClientId = clientId;
|
||||
_regStatus = "App registered. Review and Save the profile.";
|
||||
_regStatus = T["profiles.reg.registered"];
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_regStatus = "Registration cancelled.";
|
||||
_regStatus = T["profiles.reg.cancelled"];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Registration failed: {ex.Message}";
|
||||
_formError = string.Format(T["profiles.reg.failed"], ex.Message);
|
||||
_regStatus = string.Empty;
|
||||
}
|
||||
finally
|
||||
@@ -289,16 +288,16 @@
|
||||
{
|
||||
_regCts?.Cancel();
|
||||
_deviceCode = null;
|
||||
_regStatus = "Registration cancelled.";
|
||||
_regStatus = T["profiles.reg.cancelled"];
|
||||
}
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
_formError = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = "Tenant URL is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = "Client ID is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = "Tenant ID is required."; return; }
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user