diff --git a/src/Aguacongas.TheIdServer.BlazorApp/Aguacongas.TheIdServer.BlazorApp.csproj b/src/Aguacongas.TheIdServer.BlazorApp/Aguacongas.TheIdServer.BlazorApp.csproj index bcb0f3115..c22857710 100644 --- a/src/Aguacongas.TheIdServer.BlazorApp/Aguacongas.TheIdServer.BlazorApp.csproj +++ b/src/Aguacongas.TheIdServer.BlazorApp/Aguacongas.TheIdServer.BlazorApp.csproj @@ -1,4 +1,4 @@ - + net7.0 diff --git a/src/Aguacongas.TheIdServer.Duende/Localization-fr.json b/src/Aguacongas.TheIdServer.Duende/Localization-fr.json index 22f469f19..09a523822 100644 --- a/src/Aguacongas.TheIdServer.Duende/Localization-fr.json +++ b/src/Aguacongas.TheIdServer.Duende/Localization-fr.json @@ -1528,7 +1528,7 @@ "value": "La clé est requise." }, { - "key": "The key must be unique.", + "key": "The key '{0}' must be unique.", "value": "La clé doit être unique." }, { @@ -2011,10 +2011,6 @@ "key": "Below is the list of backchannel login requests awaiting your approbation.", "value": "Vous trouverez ci-dessous la liste des demandes de connexion backchannel en attente de votre approbation." }, - { - "key": "Below is the list of backchannel login requests awaiting your approbation.", - "value": "Vous trouverez ci-dessous la liste des demandes de connexion backchannel en attente de votre approbation." - }, { "key": "Sessions", "value": "Sessions" @@ -2023,10 +2019,6 @@ "key": "Renewed", "value": "Renouvelée" }, - { - "key": "Renewed", - "value": "Renouvelée" - }, { "key": "Created", "value": "Créé" @@ -2115,10 +2107,6 @@ "key": "ignore nested groups", "value": "ignorer les groupes imbriqués" }, - { - "key": "ignore nested groups", - "value": "ignorer les groupes imbriqués" - }, { "key": "claims cache absolute expiration", "value": "expiration absolue du cache des réclamations" @@ -2134,5 +2122,9 @@ { "key": "Are you sure you want to leave this page?", "value": "Voulez-vous vraiment quitter cette page ?" + }, + { + "key": "clone", + "value": "cloner" } ] \ No newline at end of file diff --git a/src/Aguacongas.TheIdServer.IS4/Localization-fr.json b/src/Aguacongas.TheIdServer.IS4/Localization-fr.json index a7ce94707..09a523822 100644 --- a/src/Aguacongas.TheIdServer.IS4/Localization-fr.json +++ b/src/Aguacongas.TheIdServer.IS4/Localization-fr.json @@ -85,7 +85,7 @@ }, { "key": "always include user claims in id token", - "value": "toujours inclure les revendications des utilisateurs dans le jeton d'identification" + "value": "toujours inclure les réclamations des utilisateurs dans le jeton d'identification" }, { "key": "always send claims", @@ -213,19 +213,19 @@ }, { "key": "claims mapping", - "value": "cartographie des revendications" + "value": "cartographie des réclamations" }, { "key": "Claims mapping", - "value": "Cartographie des revendications" + "value": "Cartographie des réclamations" }, { "key": "claims prefix", - "value": "préfixe de revendications" + "value": "préfixe de réclamations" }, { "key": "claims transformations", - "value": "transformations de revendications" + "value": "transformations de réclamations" }, { "key": "Claims transformations", @@ -789,7 +789,7 @@ }, { "key": "map default outbound JWT claim types", - "value": "mapper les types de revendication JWT sortants par défaut" + "value": "mapper les types de réclamations JWT sortants par défaut" }, { "key": "metadata address", @@ -1345,15 +1345,15 @@ }, { "key": "The claim prefix cannot exceed 250 char.", - "value": "Le préfixe de revendication ne peut pas dépasser 250 caractères." + "value": "Le préfixe de réclamations ne peut pas dépasser 250 caractères." }, { "key": "The claim type cannot exceed 2000 chars.", - "value": "Le type de revendication ne peut pas dépasser 2000 caractères." + "value": "Le type de réclamations ne peut pas dépasser 2000 caractères." }, { "key": "The claim type cannot exceed 250 chars.", - "value": "Le type de revendication ne peut pas dépasser 250 caractères." + "value": "Le type de réclamations ne peut pas dépasser 250 caractères." }, { "key": "The claim type is required.", @@ -1361,7 +1361,7 @@ }, { "key": "The claim type must be unique.", - "value": "Le type de revendication doit être unique." + "value": "Le type de réclamations doit être unique." }, { "key": "The claim value cannot exceed 2000 chars.", @@ -1453,11 +1453,11 @@ }, { "key": "The from claim type is required.", - "value": "Le type de revendication de est requis." + "value": "Le type de réclamations de est requis." }, { "key": "The from claim type must be unique.", - "value": "Le type de revendication de doit être unique." + "value": "Le type de réclamations de doit être unique." }, { "key": "The front channel logout url cannot exceed 2000 char.", @@ -1489,15 +1489,15 @@ }, { "key": "The identity claim type cannot exceed 2000 chars.", - "value": "Le type de revendication d'identité ne peut pas dépasser 2 000 caractères." + "value": "Le type de réclamations d'identité ne peut pas dépasser 2 000 caractères." }, { "key": "The identity claim type is required.", - "value": "Le type de revendication d'identité est requis." + "value": "Le type de réclamations d'identité est requis." }, { "key": "The identity claim type must be unique.", - "value": "Le type de revendication d'identité doit être unique." + "value": "Le type de réclamations d'identité doit être unique." }, { "key": "The identity property key cannot exceed 250 chars.", @@ -1521,14 +1521,14 @@ }, { "key": "The identity should provide at least one claim.", - "value": "L'identité doit fournir au moins une revendication." + "value": "L'identité doit fournir au moins une réclamations." }, { "key": "The key is required.", "value": "La clé est requise." }, { - "key": "The key must be unique.", + "key": "The key '{0}' must be unique.", "value": "La clé doit être unique." }, { @@ -1951,6 +1951,86 @@ "key": "You've successfully authenticated with {0}. Please enter an email address for this site below and click the Register button to finish logging in.", "value": "Vous vous êtes bien authentifié avec {0}. Veuillez entrer une adresse e-mail pour ce site ci-dessous et cliquez sur le bouton S'inscrire pour terminer la connexion." }, + { + "key": "{0} is requesting your permission", + "value": "{0} demande votre permission" + }, + { + "key": "Verify that this identifier matches what the client is displaying:", + "value": "Vérifiez que cette indentifiant correspond à ce que le client affiche :" + }, + { + "key": "Description or name of device", + "value": "Description ou nom de l'appareil" + }, + { + "key": "Will be available to these resource servers:", + "value": "Seront disponibles sur ces serveurs de ressources :" + }, + { + "key": "Binding Message", + "value": "Message contraignant" + }, + { + "key": "Pending Backchannel Login Requests", + "value": "Demandes de connexion backchannel en attente" + }, + { + "key": "No Pending Login Requests", + "value": "Aucune demande de connexion en attente" + }, + { + "key": "Invalid login request id.", + "value": "ID de demande de connexion non valide." + }, + { + "key": "SubjectIds don't match.", + "value": "Les ID de sujet ne correspondent pas." + }, + { + "key": "Backchannel login requests", + "value": "Demandes de connexion backchannel" + }, + { + "key": "ciba requests", + "value": "demandes ciba" + }, + { + "key": "ciba lifetime", + "value": "durée de vie ciba" + }, + { + "key": "polling interval", + "value": "intervalle d'interrogation" + }, + { + "key": "Below is the list of sessions you have opened.", + "value": "Vous trouverez ci-dessous la liste des sessions que vous avez ouvertes." + }, + { + "key": "Below is the list of backchannel login requests awaiting your approbation.", + "value": "Vous trouverez ci-dessous la liste des demandes de connexion backchannel en attente de votre approbation." + }, + { + "key": "Sessions", + "value": "Sessions" + }, + { + "key": "Renewed", + "value": "Renouvelée" + }, + { + "key": "Created", + "value": "Créé" + }, + { + "key": "Expires", + "value": "Expire" + }, + { + "key": "No session", + "value": "Pas de session" + }, { "key": "allowed identity token signing algorithms", "value": "algorithmes de signature de jeton d'identité autorisés" @@ -1979,6 +2059,10 @@ "key": "Please click here to log in", "value": "Veuillez cliquer ici pour vous connecter" }, + { + "key": "Register as a new user", + "value": "S'inscrire en tant que nouvel utilisateur" + }, { "key": "Resend email confirmation", "value": "Renvoyer la confirmation par e-mail" @@ -1988,15 +2072,59 @@ "value": "Renvoyer" }, { - "key": "Register as a new user", - "value": "S'inscrire en tant que nouvel utilisateur" + "key": "The machine account password is required.", + "value": "Le mot de passe du compte machine est requis." }, { - "key": "Resend email confirmation", - "value": "Renvoyer la confirmation par e-mail" + "key": "persist kerberos credentials", + "value": "conserver les informations d'identification kerberos" + }, + { + "key": "persist ntlm credentials", + "value": "conserver les informations d'identification ntlm" + }, + { + "key": "enable ldap", + "value": "activer ldap" + }, + { + "key": "domain", + "value": "domaine" + }, + { + "key": "machine account name", + "value": "nom du compte machine" + }, + { + "key": "machine account password", + "value": "mot de passe compte machine" + }, + { + "key": "enable ldap claim resolution", + "value": "activer la resolution des réclamations ldap" + }, + { + "key": "ignore nested groups", + "value": "ignorer les groupes imbriqués" + }, + { + "key": "claims cache absolute expiration", + "value": "expiration absolue du cache des réclamations" + }, + { + "key": "claims cache sliding expiration", + "value": "expiration du glissement du cache des réclamations" + }, + { + "key": "require resource indicator", + "value": "indicateur de resource requis" }, { "key": "Are you sure you want to leave this page?", "value": "Voulez-vous vraiment quitter cette page ?" + }, + { + "key": "clone", + "value": "cloner" } ] \ No newline at end of file diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/Aguacongas.TheIdServer.BlazorApp.Components.csproj b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/Aguacongas.TheIdServer.BlazorApp.Components.csproj index 61f60c65f..382ba4e4f 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/Aguacongas.TheIdServer.BlazorApp.Components.csproj +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/Aguacongas.TheIdServer.BlazorApp.Components.csproj @@ -32,10 +32,4 @@ - - - true - - - diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor new file mode 100644 index 000000000..421ebf1c5 --- /dev/null +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor @@ -0,0 +1,9 @@ +@inject IStringLocalizerAsync Localizer +@inject NavigationManager Navigation + + + + + \ No newline at end of file diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor.cs new file mode 100644 index 000000000..0c2ca913c --- /dev/null +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CloneButton.razor.cs @@ -0,0 +1,19 @@ +using Aguacongas.IdentityServer.Store.Entity; +using Aguacongas.TheIdServer.BlazorApp.Pages; +using Microsoft.AspNetCore.Components; +using System.Threading.Tasks; + +namespace Aguacongas.TheIdServer.BlazorApp.Components +{ + public partial class CloneButton + { + [Parameter] + public string CssClass { get; set; } + + private Task Clone() + { + Navigation.NavigateTo(Navigation.GetUriWithQueryParameter(nameof(EntityModel.Clone), true)); + return Task.CompletedTask; + } + } +} diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CultureInfos.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CultureInfos.razor.cs index e12ad9b72..3c89da5f7 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CultureInfos.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/CultureInfos.razor.cs @@ -11,7 +11,7 @@ namespace Aguacongas.TheIdServer.BlazorApp.Components { public partial class CultureInfos { - private IEnumerable _cultureInfos = CultureInfo.GetCultures(CultureTypes.AllCultures); + private readonly IEnumerable _cultureInfos = CultureInfo.GetCultures(CultureTypes.AllCultures); private IEnumerable _filterValues; protected override bool IsReadOnly => true; @@ -19,7 +19,7 @@ public partial class CultureInfos protected override Task> GetFilteredValues(string term, CancellationToken cancellationToken) { - term = term ?? string.Empty; + term ??= string.Empty; _filterValues = _cultureInfos .Where(c => c.Name.Contains(term, StringComparison.OrdinalIgnoreCase) || c.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase)) .OrderBy(c => c.Name) diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor similarity index 83% rename from src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor rename to src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor index d3db0fea1..b3d60a496 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor @@ -1,7 +1,7 @@ @inject Settings _settings @inject OneTimeTokenService _service @inject IJSRuntime _jsRuntime -@inject IStringLocalizerAsync Localizer +@inject IStringLocalizerAsync Localizer \ No newline at end of file + diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor.cs similarity index 97% rename from src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor.cs rename to src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor.cs index 2e8ec57b1..6c0130d4d 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButtom.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/ExportButton.razor.cs @@ -10,7 +10,7 @@ namespace Aguacongas.TheIdServer.BlazorApp.Components { - public partial class ExportButtom + public partial class ExportButton { [Parameter] public PageRequest Request { get; set; } diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/PageListHeader.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/PageListHeader.razor index be560469f..ad50137d0 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/PageListHeader.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Components/PageListHeader.razor @@ -7,7 +7,7 @@ @Localizer["Add"] - + diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Infrastructure/Abstraction/EntityModel.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Infrastructure/Abstraction/EntityModel.cs index bf18ffc7f..e9dae563f 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Infrastructure/Abstraction/EntityModel.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Infrastructure/Abstraction/EntityModel.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; @@ -49,6 +51,10 @@ namespace Aguacongas.TheIdServer.BlazorApp.Pages [Parameter] public string Id { get; set; } + [Parameter] + [SupplyParameterFromQuery] + public bool Clone { get; set; } + protected bool IsNew { get; private set; } protected T Model { get; private set; } @@ -59,15 +65,13 @@ namespace Aguacongas.TheIdServer.BlazorApp.Pages protected abstract bool NonEditable { get; } -#pragma warning disable CA1056 // Uri properties should not be strings. Nope, because it's used as parameter of NavigationManager.NavigateTo protected abstract string BackUrl { get; } -#pragma warning restore CA1056 // Uri properties should not be strings protected HandleModificationState HandleModificationState { get; private set; } protected string EntityPath => typeof(T).Name; - protected PageRequest ExportRequest => new PageRequest + protected PageRequest ExportRequest => new() { Filter = $"{nameof(IEntityId.Id)} eq '{Id}'", Expand = Expand @@ -92,9 +96,24 @@ protected override async Task OnInitializedAsync() HandleModificationState = new HandleModificationState(Logger); HandleModificationState.OnStateChange += HandleModificationState_OnStateChange; - if (Id == null) + _registration ??= NavigationManager.RegisterLocationChangingHandler(async context => + { + if (!EditContext.IsModified() && !Clone) + { + return; + } + + var isConfirmed = await JSRuntime.InvokeAsync("window.confirm", Localizer["Are you sure you want to leave this page?"]?.ToString()) + .ConfigureAwait(false); + + if (!isConfirmed) + { + context.PreventNavigation(); + } + }); + + if (Id is null) { - IsNew = true; var newModel = await Create().ConfigureAwait(false); CreateEditContext(newModel); EntityCreated(Model); @@ -104,23 +123,24 @@ protected override async Task OnInitializedAsync() var model = await GetModelAsync() .ConfigureAwait(false); - CreateEditContext(model); + CreateEditContext(model); + } - _registration ??= NavigationManager.RegisterLocationChangingHandler(async context => + protected override void OnParametersSet() + { + if (Clone && Id is not null) { - if (!EditContext.IsModified()) + Id += "-clone"; + HandleModificationState.Changes.Clear(); + EntityCreated(Model); + if (Model is IEntityId entityId) { - return; + entityId.Id = Id; } + OnCloning(); + } - var isConfirmed = await JSRuntime.InvokeAsync("window.confirm", Localizer["Are you sure you want to leave this page?"]?.ToString()) - .ConfigureAwait(false); - - if (!isConfirmed) - { - context.PreventNavigation(); - } - }); + IsNew = Id is null || Clone; } protected async Task HandleValidSubmit() @@ -141,9 +161,10 @@ await Notifier.NotifyAsync(new Models.Notification }).ConfigureAwait(false); return; } - + Id = GetModelId(Model); IsNew = false; + Clone = false; CreateEditContext(Model.Clone()); var keys = changes.Keys @@ -159,7 +180,7 @@ await HandleMoficationList(key, changes[key]) await Notifier.NotifyAsync(new Models.Notification { - Header = Id, + Header = GetNotiticationHeader(), Message = Localizer["Saved"] }).ConfigureAwait(false); @@ -180,9 +201,11 @@ await Notifier.NotifyAsync(new Models.Notification changes.Clear(); } - await InvokeAsync(StateHasChanged).ConfigureAwait(false); + await InvokeAsync(StateHasChanged).ConfigureAwait(false); } + protected virtual string GetNotiticationHeader() => Id; + protected void EntityCreated(TEntity entity) where TEntity : class { HandleModificationState.EntityCreated(entity); @@ -211,6 +234,9 @@ await Notifier.NotifyAsync(new Models.Notification Message = Localizer["Deleted"] }).ConfigureAwait(false); + EditContext.MarkAsUnmodified(); + HandleModificationState.Changes.Clear(); + NavigationManager.NavigateTo(BackUrl); } catch (Exception e) @@ -301,6 +327,10 @@ protected virtual void OnEntityUpdated(Type entityType, IEntityId entityModel) HandleModificationState.EntityUpdated(entityType, entityModel); } + protected virtual void OnCloning() + { + } + protected abstract Task Create(); protected abstract void RemoveNavigationProperty(TEntity entity); @@ -424,6 +454,8 @@ private Task StoreAsync(Type entityType, object entity, Func } } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Api/Api.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Api/Api.razor.cs index 89d2b0606..785b498c7 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Api/Api.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Api/Api.razor.cs @@ -76,6 +76,11 @@ protected override void SanetizeEntityToSaved(TEntity entity) } } + protected override void OnCloning() + { + Model.DisplayName = Localizer["Clone of {0}", Model.DisplayName]; + } + private static ApiSecret CreateSecret() => new() { diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor index c82d12930..cb3524a9e 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor @@ -34,7 +34,11 @@ else } } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor.cs index 4a1fa40f8..774cc199d 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ApiScope/ApiScope.razor.cs @@ -12,23 +12,20 @@ public partial class ApiScope protected override bool NonEditable => false; - protected override string BackUrl => "scopes"; + protected override string BackUrl => "apiscopes"; protected override async Task OnInitializedAsync() { await base.OnInitializedAsync().ConfigureAwait(false); } - protected override Task Create() - { - return Task.FromResult(new Entity.ApiScope + protected override Task Create() =>Task.FromResult(new Entity.ApiScope { Enabled = true, ApiScopeClaims = new List(), Properties = new List(), Resources = new List() - }); - } + }); protected override void RemoveNavigationProperty(TEntity entity) { @@ -52,6 +49,11 @@ protected override void SanetizeEntityToSaved(TEntity entity) } } + protected override void OnCloning() + { + Model.DisplayName = Localizer["Clone of {0}", Model.DisplayName]; + } + private static Entity.ApiScopeProperty CreateProperty() => new(); diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor index 48a0b12ce..dacaeb661 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor @@ -32,7 +32,11 @@ else } } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor.cs index 7cfcb877e..fbbcac276 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Client/Client.razor.cs @@ -128,6 +128,11 @@ protected override Task CreateAsync(Type entityType, object entity) return base.CreateAsync(entityType, entity); } + protected override void OnCloning() + { + Model.ClientName = Localizer["Clone of {0}", Model.ClientName]; + } + private void FilterFocusChanged(bool hasFocus) { if (hasFocus) diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor index 3451c79ca..163ef53e9 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor @@ -32,7 +32,11 @@ else { } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor.cs index 0b2e95028..77a42f033 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Culture.razor.cs @@ -76,6 +76,13 @@ protected override void RemoveNavigationProperty(TEntity entity) } } + protected override void OnCloning() + { + Model.Id = null; + _cultureInfo = CultureInfo.InvariantCulture; + StateHasChanged(); + } + private void HandleModificationState_OnFilterChange(string term) { StateHasChanged(); diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Validators/LocalizedResourceValidator.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Validators/LocalizedResourceValidator.cs index dd091c931..882cc4496 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Validators/LocalizedResourceValidator.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Culture/Validators/LocalizedResourceValidator.cs @@ -11,7 +11,7 @@ public class LocalizedResourceValidator : AbstractValidator public LocalizedResourceValidator(Culture culture, IStringLocalizer localizer) { RuleFor(m => m.Key).NotEmpty().WithMessage(localizer["The key is required."]); - RuleFor(m => m.Key).IsUnique(culture.Resources).WithMessage(localizer["The key must be unique."]); + RuleFor(m => m.Key).IsUnique(culture.Resources).WithMessage(r => localizer["The key '{0}' must be unique.", r.Key]); } } } \ No newline at end of file diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor index 9859f43ec..ffb4c70fd 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor @@ -25,7 +25,7 @@ else

@Id

}
-
+
@Localizer["Save"] @@ -34,7 +34,11 @@ else { } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor.cs index ca30ecd90..bfc01c627 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.ExternalProvider/ExternalProvider.razor.cs @@ -62,6 +62,11 @@ protected override void OnEntityUpdated(Type entityType, IEntityId entityModel) } } + protected override void OnCloning() + { + Model.DisplayName = Localizer["Clone of {0}", Model.DisplayName]; + } + private ExternalClaimTransformation CreateTransformation() { return new ExternalClaimTransformation diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor index bf93cfda0..631f06ff8 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor @@ -34,7 +34,11 @@ else } } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor.cs index 12dd51c2e..67ff421d9 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Identity/Identity.razor.cs @@ -52,6 +52,11 @@ protected override void SanetizeEntityToSaved(TEntity entity) } } + protected override void OnCloning() + { + Model.DisplayName = Localizer["Clone of {0}", Model.DisplayName]; + } + private static IdentityProperty CreateProperty() => new(); diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor index 1b1b84336..c67807c8b 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor @@ -34,7 +34,11 @@ else } } - + @if (!IsNew) + { + + + }
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor.cs index 1032e6b21..8ba9fb3d6 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.RelyingParty/RelyingParty.razor.cs @@ -153,7 +153,7 @@ protected override void SanetizeEntityToSaved(TEntity entity) subEntity.RelyingPartyId = Model.Id; } } - + private async Task SetCertificateAsync(InputFileChangeEventArgs e) { using var stream = e.File.OpenReadStream(); @@ -191,6 +191,11 @@ private async Task SetThrumprint(byte[] content) } } + protected override void OnCloning() + { + Model.Description = Localizer["Clone of {0}", Model.Description]; + } + private void RemoveCertificate() { _thumbprint = null; diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor index 89cbabfe1..f034ea344 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor @@ -27,8 +27,9 @@ else @if (!IsNew) { + + } -
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor.cs index 6dc09cac0..101d26468 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.Role/Role.razor.cs @@ -1,7 +1,7 @@ // Project: Aguafrommars/TheIdServer // Copyright (c) 2022 @Olivier Lefebvre -using Aguacongas.IdentityServer.Store; using Aguacongas.TheIdServer.BlazorApp.Services; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,7 +11,7 @@ namespace Aguacongas.TheIdServer.BlazorApp.Pages.Role { public partial class Role { - private readonly GridState _gridState = new GridState(); + private readonly GridState _gridState = new(); private IEnumerable Claims => Model.Claims.Where(c => c.Id == null || (c.ClaimType != null && c.ClaimType.Contains(HandleModificationState.FilterTerm)) || (c.ClaimValue != null && c.ClaimValue.Contains(HandleModificationState.FilterTerm))); @@ -41,7 +41,8 @@ private void HandleModificationState_OnFilterChange(string obj) protected override Task Create() { return Task.FromResult(new Models.Role - { + { + Id = Guid.NewGuid().ToString(), Claims =new List() }); } @@ -59,8 +60,15 @@ protected override void RemoveNavigationProperty(TEntity entity) } } - private static Entity.RoleClaim CreateClaim() - => new(); + protected override void OnCloning() + { + Model.Id = Guid.NewGuid().ToString(); + Model.Name = Localizer["Clone of {0}", Model.Name]; + } + + protected override string GetNotiticationHeader() => Model.Name; + + private static Entity.RoleClaim CreateClaim() => new(); private void OnDeleteClaimClicked(Entity.RoleClaim claim) { diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor index 0224f4b15..437cd957c 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor @@ -21,8 +21,9 @@ else @if (!IsNew) { + + } -
diff --git a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor.cs b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor.cs index 19e4cbc09..d9af71255 100644 --- a/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor.cs +++ b/src/BlazorApp/Aguacongas.TheIdServer.BlazorApp.Pages.User/User.razor.cs @@ -22,6 +22,7 @@ public partial class User { return Task.FromResult(new Models.User { + Id = Guid.NewGuid().ToString(), Claims = new List(), Consents = new List(), Logins = new List(), @@ -138,6 +139,14 @@ protected override Task DeleteAsync(Type entityType, object entity) return base.DeleteAsync(entityType, entity); } + protected override void OnCloning() + { + Model.Id = Guid.NewGuid().ToString(); + Model.UserName = Localizer["Clone of {0}", Model.UserName]; + } + + protected override string GetNotiticationHeader() => Model.UserName; + private static EntityNS.UserClaim CreateClaim() => new() { diff --git a/test/Shared/Aguacongas.IdentityServer.Admin.Test.Shared/Services/TokenCleanerHostTest.cs b/test/Shared/Aguacongas.IdentityServer.Admin.Test.Shared/Services/TokenCleanerHostTest.cs index c60f7a83e..02917531b 100644 --- a/test/Shared/Aguacongas.IdentityServer.Admin.Test.Shared/Services/TokenCleanerHostTest.cs +++ b/test/Shared/Aguacongas.IdentityServer.Admin.Test.Shared/Services/TokenCleanerHostTest.cs @@ -103,7 +103,7 @@ public async Task StartAsync_should_exit_on_cancel() resetEvent.WaitOne(); } - Assert.True(resetEvent.WaitOne(TimeSpan.FromSeconds(2))); + Assert.True(resetEvent.WaitOne(TimeSpan.FromSeconds(10))); } } } diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiScopeTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiScopeTest.cs index 8296d3497..92fede44c 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiScopeTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiScopeTest.cs @@ -118,11 +118,11 @@ public async Task OnFilterChanged_should_filter_properties_and_claims() [Fact] public async Task SaveClick_should_update_entity() { - string identityId = await CreateEntity(); + string apiScopeId = await CreateEntity(); var component = CreateComponent("Alice Smith", SharedConstants.WRITERPOLICY, - identityId); + apiScopeId); var input = component.Find("#displayName"); @@ -144,11 +144,26 @@ await input.ChangeAsync(new ChangeEventArgs await DbActionAsync(async context => { - var apiScope = await context.ApiScopes.FirstOrDefaultAsync(a => a.Id == identityId); + var apiScope = await context.ApiScopes.FirstOrDefaultAsync(a => a.Id == apiScopeId); Assert.Equal(expected, apiScope?.DisplayName); }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + string apiScopeId = await CreateEntity(); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + apiScopeId, + true); + + var input = component.Find("#displayName"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {apiScopeId}"); + } + private async Task CreateEntity() { var apiScopeId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiTest.cs index 202156df0..2eee53c75 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ApiTest.cs @@ -238,6 +238,21 @@ public async Task ClickAddRemoveClaims_should_not_throw() await divs.Last().ClickAsync(new MouseEventArgs()).ConfigureAwait(false); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + var apiId = await CreateApi(); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + apiId, + true); + + var input = component.Find("#displayName"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {apiId}"); + } + private async Task CreateApi() { var apiId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ClientTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ClientTest.cs index 516225e0a..436ce8771 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ClientTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ClientTest.cs @@ -610,6 +610,21 @@ await DbActionAsync(async context => }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + string clientId = await CreateClient("authorization_code"); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + clientId, + true); + + var input = WaitForNode(component, "#name"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {clientId}"); + } + private async Task CreateClient(string grantType = "hybrid", bool allowOfflineAccess = false) { var clientId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/EntityPageTestBase.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/EntityPageTestBase.cs index 3a4fd455d..c76a68ed8 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/EntityPageTestBase.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/EntityPageTestBase.cs @@ -51,7 +51,8 @@ public void WhenWriter_should_enable_inputs() protected IRenderedComponent CreateComponent(string userName, string role, - string? id) + string? id, + bool clone = false) { Factory.ConfigureTestContext(userName, new Claim[] @@ -63,7 +64,7 @@ protected IRenderedComponent CreateComponent(string userName, }, this); - var component = RenderComponent(ComponentParameter.CreateParameter("Id", id)); + var component = RenderComponent(ComponentParameter.CreateParameter("Id", id), ComponentParameter.CreateParameter("Clone", clone)); component.WaitForState(() => !component.Markup.Contains("Loading..."), TimeSpan.FromMinutes(1)); return component; } diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ExternalProviderTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ExternalProviderTest.cs index b66c0aa26..186de37fe 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ExternalProviderTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/ExternalProviderTest.cs @@ -360,6 +360,20 @@ await c.Providers.AddAsync(new ExternalProvider Assert.Throws(() => component.Find("li.validation-message")); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + var providerId = await CreateProvider(); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + providerId, + true); + + var input = component.Find("#displayName"); + + Assert.NotNull(input); + } private async Task CreateProvider() { var providerId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/IdentityTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/IdentityTest.cs index 849ed309a..a4a8b7768 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/IdentityTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/IdentityTest.cs @@ -148,6 +148,21 @@ await DbActionAsync(async context => }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + string identityId = await CreateEntity(); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + identityId, + true); + + var input = component.Find("#displayName"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {identityId}"); + } + private async Task CreateEntity() { var identityId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RelyingPartyTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RelyingPartyTest.cs index 04e0ddc18..0914c1ebb 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RelyingPartyTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RelyingPartyTest.cs @@ -119,6 +119,21 @@ await DbActionAsync(async context => }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + string relyingPartyId = await CreateEntity(null); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + relyingPartyId, + true); + + var input = component.Find("#description"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {relyingPartyId}"); + } + private async Task CreateEntity(byte[]? certificate) { var relyingPartyId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RoleTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RoleTest.cs index b1662c6a1..efb356df9 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RoleTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/RoleTest.cs @@ -218,6 +218,21 @@ await DbActionAsync(async context => }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + string roleId = await CreateRole(); + + var component = CreateComponent("Alice Smith", + SharedConstants.WRITERPOLICY, + roleId, + true); + + var input = WaitForNode(component, "#name"); + + Assert.Contains(input.Attributes, a => a.Value == $"Clone of {roleId}"); + } + private async Task CreateRole() { var roleId = GenerateId(); diff --git a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/UserTest.cs b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/UserTest.cs index d1e340e05..0d621d92e 100644 --- a/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/UserTest.cs +++ b/test/Shared/Aguacongas.TheIdServer.IntegrationTest.Shared/BlazorApp/Pages/UserTest.cs @@ -448,8 +448,18 @@ await DbActionAsync(async context => }); } + [Fact] + public async Task WhenWriter_should_be_able_to_clone_entity() + { + var tuple = await SetupPage(); + var component = tuple.Item2; + + var input = WaitForNode(component, "#name"); + + Assert.NotNull(input); + } - private async Task>> SetupPage() + private async Task>> SetupPage(bool clone = false) { var userId = GenerateId(); await CreateTestEntity(userId); @@ -471,7 +481,8 @@ private async Task>> SetupPage() Assert.True(result.Succeeded); var component = CreateComponent("Alice Smith", SharedConstants.WRITERPOLICY, - userId); + userId, + clone); return new Tuple>(userId, component); }