Initial commit
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
@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.IOAuthFlowCache OAuthCache
|
||||
@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>
|
||||
|
||||
@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>
|
||||
|
||||
@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">Active</span>
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "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">+ New Profile</button>
|
||||
</div>
|
||||
|
||||
@if (_profiles.Count == 0 && !_showForm)
|
||||
{
|
||||
<div class="alert alert-info">No profiles configured. Create one to get started.</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">Tenant ID: @p.TenantId</div>
|
||||
<div class="text-muted">Client ID: @p.ClientId</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
@if (Session.CurrentProfile?.Id == p.Id)
|
||||
{
|
||||
<span class="chip chip-green">Active</span>
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card" style="border-color:#0078d4">
|
||||
<div class="card-title">@(_editing?.Id == null ? "New Profile" : "Edit Profile")</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" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tenant 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" />
|
||||
</div>
|
||||
|
||||
@* App registration section *@
|
||||
<div class="form-group">
|
||||
<label class="form-label">Client ID (App Registration)</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"
|
||||
style="flex:1" />
|
||||
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
||||
disabled="@(!CanRegister || _registering)"
|
||||
title="@(CanRegister ? "Register app in client Entra ID (requires Global Admin)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
||||
@(_registering ? "Redirecting…" : "Register in Entra")
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Click "Register in Entra" to auto-create the app registration in the client tenant — requires Global Admin credentials.
|
||||
Or enter an existing public client App Registration ID manually.
|
||||
</small>
|
||||
</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>
|
||||
</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 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 HandleRegResultAsync();
|
||||
await HandleConnectErrorAsync();
|
||||
}
|
||||
|
||||
private async Task HandleRegResultAsync()
|
||||
{
|
||||
var uri = new Uri(Nav.Uri);
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
|
||||
if (!query.TryGetValue("reg_result_key", out var key) || string.IsNullOrEmpty(key))
|
||||
return;
|
||||
|
||||
var result = OAuthCache.GetAndRemoveRegistrationResult(key!);
|
||||
if (result is not null)
|
||||
{
|
||||
_form = new TenantProfile
|
||||
{
|
||||
Name = result.TenantName,
|
||||
TenantUrl = result.TenantUrl,
|
||||
TenantId = result.TenantId,
|
||||
ClientId = result.ClientId,
|
||||
};
|
||||
_showForm = true;
|
||||
_formError = string.Empty;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||
}
|
||||
|
||||
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 };
|
||||
_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) return;
|
||||
_registering = true;
|
||||
StateHasChanged();
|
||||
|
||||
var returnUrl = Nav.Uri.Contains('?')
|
||||
? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?'))
|
||||
: Nav.Uri;
|
||||
|
||||
var url = $"/connect/register-initiate" +
|
||||
$"?tenantId={Uri.EscapeDataString(_form.TenantId)}" +
|
||||
$"&tenantName={Uri.EscapeDataString(_form.Name)}" +
|
||||
$"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" +
|
||||
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
|
||||
Nav.NavigateTo(url, forceLoad: true);
|
||||
}
|
||||
|
||||
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 (_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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user